Phase 18: Auto timezone detection and ability to change timezone #21
+18
-5
@@ -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 |
|
| 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 |
|
| 16. CI Dep Audit, Sec & Img Hyg | v1.1 | 6/6 | Complete | 2026-06-13 |
|
||||||
| 17. UI Optimization & Polish | v1.1 | 0/? | Not started | - |
|
| 17. UI Optimization & Polish | v1.1 | 0/? | Not started | - |
|
||||||
|
| 18. Auto Timezone Detection | v1.1 | 4/4 | Complete | 2026-06-14 |
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
@@ -633,11 +634,23 @@ Plans:
|
|||||||
|
|
||||||
### Phase 18: Auto timezone detection and ability to change timezone
|
### Phase 18: Auto timezone detection and ability to change timezone
|
||||||
|
|
||||||
**Goal:** [To be planned]
|
**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.
|
||||||
**Requirements**: TBD
|
**Requirements**: TBD (decision contract D-01..D-07 from 18-CONTEXT.md)
|
||||||
**Depends on:** Phase 17
|
**Depends on:** Phase 10 (admin role + `/admin` Settings + `app_config`); Phase 11 (all-day reminder computation this rewires). Independent of Phase 17. Phase 12 (setup wizard) not required — seeding is self-contained.
|
||||||
**Plans:** 0 plans
|
**Plans:** 4/4 plans complete
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
|
**Wave 1**
|
||||||
|
|
||||||
- [ ] TBD (run /gsd-plan-phase 18 to break down)
|
- [x] 18-01-PLAN.md — TDD: getHouseholdTimezone(db) accessor + isValidIanaTimezone (D-05/D-06)
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1 completion)*
|
||||||
|
|
||||||
|
- [x] 18-02-PLAN.md — TDD: admin GET/PUT/seed timezone endpoints on adminRouter, requireAdmin + IANA validation + no-overwrite seed (D-01/D-02/D-03/D-04)
|
||||||
|
- [x] 18-03-PLAN.md — TDD: route all-day reminder TZ at reminderScheduler:247 + outboxWorker:501,607 through the accessor (D-05/D-06/D-07)
|
||||||
|
|
||||||
|
**Wave 3** *(blocked on Wave 2 completion)*
|
||||||
|
|
||||||
|
- [x] 18-04-PLAN.md — PWA Timezone section in /admin Settings (searchable IANA picker + detected-zone seed) + client fns (D-02/D-04)
|
||||||
|
|
||||||
|
**UI hint**: yes
|
||||||
|
|||||||
+18
-13
@@ -2,15 +2,15 @@
|
|||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v1.1
|
milestone: v1.1
|
||||||
milestone_name: Operability & Polish
|
milestone_name: Operability & Polish
|
||||||
status: executing
|
status: "Phase 18 shipped — PR #21"
|
||||||
stopped_at: Completed 11-04-PLAN.md
|
stopped_at: Phase 18 Plan 03 complete — broker rewire done; plan 4 of 4 is next
|
||||||
last_updated: "2026-06-14T12:31:06.011Z"
|
last_updated: "2026-06-15T13:15:22.076Z"
|
||||||
last_activity: 2026-06-14
|
last_activity: 2026-06-15
|
||||||
progress:
|
progress:
|
||||||
total_phases: 23
|
total_phases: 23
|
||||||
completed_phases: 9
|
completed_phases: 9
|
||||||
total_plans: 32
|
total_plans: 37
|
||||||
completed_plans: 32
|
completed_plans: 36
|
||||||
percent: 39
|
percent: 39
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -21,14 +21,14 @@ progress:
|
|||||||
See: .planning/PROJECT.md (updated 2026-06-10)
|
See: .planning/PROJECT.md (updated 2026-06-10)
|
||||||
|
|
||||||
**Core value:** One color-coded family calendar (shared + personal) and shared lists from a single low-friction PWA — cross-ecosystem, no app store
|
**Core value:** One color-coded family calendar (shared + personal) and shared lists from a single low-friction PWA — cross-ecosystem, no app store
|
||||||
**Current focus:** Phase 11 — per-event-reminders
|
**Current focus:** Phase 18 — auto-timezone-detection-and-ability-to-change-timezone
|
||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 13
|
Phase: 18 — COMPLETE
|
||||||
Plan: Not started
|
Plan: 4 of 4
|
||||||
Status: Ready to execute
|
Status: Phase 18 shipped — PR #21
|
||||||
Last activity: 2026-06-14
|
Last activity: 2026-06-15
|
||||||
|
|
||||||
### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
|
### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
|
||||||
|
|
||||||
@@ -107,6 +107,10 @@ _Updated after each plan completion_
|
|||||||
| Phase 10-admin-role-settings P03 | 720 | 3 tasks | 6 files |
|
| Phase 10-admin-role-settings P03 | 720 | 3 tasks | 6 files |
|
||||||
| Phase 10-admin-role-settings P04 | 1315 | 3 tasks | 8 files |
|
| Phase 10-admin-role-settings P04 | 1315 | 3 tasks | 8 files |
|
||||||
| Phase 11-per-event-reminders P11-04 | 60 | 3 tasks | 4 files |
|
| Phase 11-per-event-reminders P11-04 | 60 | 3 tasks | 4 files |
|
||||||
|
| Phase 18-auto-timezone-detection-and-ability-to-change-timezone P01 | 2 | 2 tasks | 2 files |
|
||||||
|
| Phase 18 P02 | 3 | 2 tasks | 2 files |
|
||||||
|
| Phase 18 P03 | 28 | 2 tasks | 4 files |
|
||||||
|
| Phase 18 P04 | 15 | 3 tasks | 3 files |
|
||||||
|
|
||||||
## Accumulated Context
|
## Accumulated Context
|
||||||
|
|
||||||
@@ -184,6 +188,7 @@ Recent decisions affecting current work:
|
|||||||
- [Phase ?]: D-CLIENT-TYPES: reminderLeadMinutes required on CalendarOccurrence, optional on CreateEventPayload (absent=no-change D-08)
|
- [Phase ?]: D-CLIENT-TYPES: reminderLeadMinutes required on CalendarOccurrence, optional on CreateEventPayload (absent=no-change D-08)
|
||||||
- [Phase ?]: D-PAYLOAD-ABSENT: __custom__ unchanged → field omitted from payload; server hasOwnProperty check preserves original VALARM (D-08)
|
- [Phase ?]: D-PAYLOAD-ABSENT: __custom__ unchanged → field omitted from payload; server hasOwnProperty check preserves original VALARM (D-08)
|
||||||
- [Phase ?]: D-NULL-FALLBACK: occurrence.reminderLeadMinutes===null mapped to None; occurrence cannot distinguish absolute/multi-VALARM from no-reminder; rely on server-side preserve (absent payload)
|
- [Phase ?]: D-NULL-FALLBACK: occurrence.reminderLeadMinutes===null mapped to None; occurrence cannot distinguish absolute/multi-VALARM from no-reminder; rely on server-side preserve (absent payload)
|
||||||
|
- [Phase ?]: D-05/18-03: three all-day broker sites now route through getHouseholdTimezone(db)
|
||||||
|
|
||||||
### Roadmap Evolution
|
### Roadmap Evolution
|
||||||
|
|
||||||
@@ -252,8 +257,8 @@ Recent decisions affecting current work:
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-06-14T10:57:47.923Z
|
Last session: 2026-06-15T02:46:09.800Z
|
||||||
Stopped at: Completed 11-04-PLAN.md
|
Stopped at: Phase 18 Plan 03 complete — broker rewire done; plan 4 of 4 is next
|
||||||
Resume file: None
|
Resume file: None
|
||||||
|
|
||||||
## Operator Next Steps
|
## Operator Next Steps
|
||||||
|
|||||||
@@ -0,0 +1,511 @@
|
|||||||
|
---
|
||||||
|
phase: 12
|
||||||
|
slug: initial-setup-wizard
|
||||||
|
status: draft
|
||||||
|
shadcn_initialized: false
|
||||||
|
preset: none
|
||||||
|
created: 2026-06-14
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 12 — UI Design Contract: Initial Setup Wizard
|
||||||
|
|
||||||
|
> Visual and interaction contract for the first-run setup wizard.
|
||||||
|
> Generated by gsd-ui-researcher. Verified by gsd-ui-checker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context & Audience
|
||||||
|
|
||||||
|
This wizard is **operator-facing, not end-user-facing**. The operator is the person standing up
|
||||||
|
the self-hosted FamilySync instance on Unraid — technical enough to edit `docker-compose.yml`,
|
||||||
|
but not necessarily a developer. The wizard renders for an **unauthenticated visitor** (it is the
|
||||||
|
one screen in the app shown outside the Authelia/OIDC guard). It must be calm, legible, and
|
||||||
|
unintimidating.
|
||||||
|
|
||||||
|
This is the **only full-page standalone UI** in the app. It does NOT render inside the existing
|
||||||
|
AppNav + BottomTabBar shell. It owns its entire viewport.
|
||||||
|
|
||||||
|
All design tokens are inherited from the existing system (`apps/pwa/src/styles/tokens.css`).
|
||||||
|
No new tokens are introduced.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Tool | none (existing CSS custom properties) |
|
||||||
|
| Preset | not applicable |
|
||||||
|
| Component library | none (hand-rolled inline styles, project convention) |
|
||||||
|
| Icon library | lucide-react (already installed — `KeyRound`, `CheckCircle`, `AlertCircle`, `Loader2`, `Copy`, `Check`, `ShieldCheck`) |
|
||||||
|
| Font | system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif (var(--font-family-base)) |
|
||||||
|
|
||||||
|
Source: `apps/pwa/src/styles/tokens.css` — pre-populated from existing codebase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spacing Scale
|
||||||
|
|
||||||
|
Uses the existing 4px-based scale. No new tokens. Values from `tokens.css`:
|
||||||
|
|
||||||
|
| Token | Value | Usage |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| --space-1 | 4px | Icon gaps, label-to-input gap |
|
||||||
|
| --space-2 | 8px | Compact element spacing, badge gap |
|
||||||
|
| --space-3 | 12px | Input padding (vertical), row gaps |
|
||||||
|
| --space-4 | 16px | Default element spacing, card padding, input padding (horizontal) |
|
||||||
|
| --space-6 | 24px | Sheet/card padding, section gap |
|
||||||
|
| --space-8 | 32px | Between major sections |
|
||||||
|
| --space-12 | 48px | Page top/bottom padding (AdminPage pattern) |
|
||||||
|
|
||||||
|
Exceptions:
|
||||||
|
- Wizard card max-width: 540px (slightly wider than CredentialSheet 480px to accommodate
|
||||||
|
multi-field steps and generated-secret blocks).
|
||||||
|
- Step indicator touch targets: 44px minimum (accessibility).
|
||||||
|
- Copy-to-clipboard button: 36px height is acceptable since it is paired with an adjacent
|
||||||
|
textarea (which itself is large enough), but the copy button must have `minWidth: 44px`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
All values from `tokens.css`. No new sizes or weights.
|
||||||
|
|
||||||
|
| Role | Size | Weight | Line Height | Variable |
|
||||||
|
|------|------|--------|-------------|----------|
|
||||||
|
| Body | 15px | 400 | 1.5 | var(--text-body-size) / var(--text-body-weight) / var(--text-body-line-height) |
|
||||||
|
| Label | 13px | 400 | 1.4 | var(--text-label-size) / var(--text-label-weight) / var(--text-label-line-height) |
|
||||||
|
| Heading | 18px | 600 | 1.25 | var(--text-heading-size) / var(--text-heading-weight) / var(--text-heading-line-height) |
|
||||||
|
| Display | 24px | 600 | 1.2 | var(--text-display-size) / var(--text-display-weight) / var(--text-display-line-height) |
|
||||||
|
|
||||||
|
Usage in this phase:
|
||||||
|
- Wizard page title ("FamilySync Setup"): Display (24px/600/1.2)
|
||||||
|
- Step heading (e.g. "Generate Secrets"): Heading (18px/600/1.25)
|
||||||
|
- Step description / helper text: Body (15px/400/1.5)
|
||||||
|
- Field labels, step counter, status badges: Label (13px/400/1.4) — labels use weight 600
|
||||||
|
- Generated secret value (monospace block): 13px/400/1.4 with `font-family: monospace` override
|
||||||
|
- Section labels (uppercase caps, e.g. "STEP 2 OF 5"): Label (13px/600) with
|
||||||
|
`text-transform: uppercase; letter-spacing: 0.06em` (AdminPage `sectionLabelStyle` pattern)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color
|
||||||
|
|
||||||
|
All values from `tokens.css`. No new hex values.
|
||||||
|
|
||||||
|
| Role | Value | Variable | Usage |
|
||||||
|
|------|-------|----------|-------|
|
||||||
|
| Dominant (60%) | #ffffff | var(--color-surface) | Page background, card background |
|
||||||
|
| Secondary (30%) | #f7f7f8 | var(--color-surface-dim) | Step sidebar/tracker background, generated-secret block background, inactive step indicator |
|
||||||
|
| Accent (10%) | #4a90d9 | var(--color-member-0) | Primary CTA buttons, active step indicator fill, spinner, copy button, links |
|
||||||
|
| Destructive | #dc2626 | var(--color-destructive) | Validation failure border + helper text (same CredentialSheet pattern) |
|
||||||
|
|
||||||
|
Accent reserved for:
|
||||||
|
- Primary action buttons ("Continue", "Complete Setup") — filled background
|
||||||
|
- Active wizard step indicator (filled circle)
|
||||||
|
- Inline spinner (`Loader2`) during async validation
|
||||||
|
- Hyperlinks (e.g. "Get an app password")
|
||||||
|
- Copy-to-clipboard button icon
|
||||||
|
- Focus ring (`var(--color-focus-ring): #4a90d9`)
|
||||||
|
|
||||||
|
Additional semantic colors (not new — already in tokens.css):
|
||||||
|
- `var(--color-border)` #e2e4e9 — card border, input border (default), step connector line
|
||||||
|
- `var(--color-border-subtle)` #eceef2 — section dividers within steps
|
||||||
|
- `var(--color-text-primary)` #111318 — headings, field values
|
||||||
|
- `var(--color-text-secondary)` #6b7280 — descriptions, helper text, "Back" button
|
||||||
|
- `var(--color-text-muted)` #9ca3af — completed step labels, placeholder text, inactive step numbers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Surface Architecture
|
||||||
|
|
||||||
|
The wizard is a **standalone full-page route** (`/setup`) mounted in a separate React root or
|
||||||
|
an App-level gate (see Interaction Contract). It renders none of the AppNav / BottomTabBar /
|
||||||
|
SetupBanner chrome.
|
||||||
|
|
||||||
|
### Surface 1 — Wizard Page Shell
|
||||||
|
|
||||||
|
The wizard page itself.
|
||||||
|
|
||||||
|
- Background: `var(--color-surface)` (#ffffff)
|
||||||
|
- Layout: vertically centered column, `min-height: 100dvh`
|
||||||
|
- Content column: `maxWidth: 540px`, `margin: 0 auto`,
|
||||||
|
`padding: var(--space-12) var(--space-6)` (48px top/bottom, 24px sides)
|
||||||
|
- Page title "FamilySync Setup": Display (24px/600), `color: var(--color-text-primary)`,
|
||||||
|
`marginBottom: var(--space-2)` (8px)
|
||||||
|
- Page subtitle "Let's get your instance ready.": Body (15px/400),
|
||||||
|
`color: var(--color-text-secondary)`, `marginBottom: var(--space-8)` (32px)
|
||||||
|
|
||||||
|
### Surface 2 — Step Indicator
|
||||||
|
|
||||||
|
Linear step tracker shown above the active step card.
|
||||||
|
|
||||||
|
- Horizontal row of N step circles connected by lines
|
||||||
|
- Completed step: filled circle `var(--color-member-0)` with white `Check` icon (16px)
|
||||||
|
- Active step: filled circle `var(--color-member-0)` with white step number (13px/600)
|
||||||
|
- Upcoming step: circle with `var(--color-border)` 2px border, `var(--color-text-muted)` step
|
||||||
|
number
|
||||||
|
- Connector line: 1px `var(--color-border)` between circles; completed segment fills to
|
||||||
|
`var(--color-member-0)`
|
||||||
|
- Step label below each circle: 13px/400, `var(--color-text-muted)` (upcoming/completed),
|
||||||
|
`var(--color-text-primary)` (active)
|
||||||
|
- Circle size: 28px diameter; connector height: 1px; minimum row height: 44px touch target
|
||||||
|
achieved by centering in a 44px tall row
|
||||||
|
|
||||||
|
Step labels (5 steps total):
|
||||||
|
1. Welcome
|
||||||
|
2. Secrets
|
||||||
|
3. Database
|
||||||
|
4. OIDC
|
||||||
|
5. Calendar
|
||||||
|
|
||||||
|
### Surface 3 — Step Card
|
||||||
|
|
||||||
|
The active step's input/content area. One card rendered at a time.
|
||||||
|
|
||||||
|
- Background: `var(--color-surface)` (#ffffff)
|
||||||
|
- Border: `1px solid var(--color-border)` (#e2e4e9)
|
||||||
|
- Border-radius: 8px (2× `var(--space-2)`)
|
||||||
|
- Padding: `var(--space-6)` (24px) all sides
|
||||||
|
- Box-shadow: `0 1px 4px rgba(0,0,0,0.06)` (subtle lift)
|
||||||
|
- Step heading: Heading (18px/600), `color: var(--color-text-primary)`,
|
||||||
|
`marginBottom: var(--space-2)` (8px)
|
||||||
|
- Step description: Body (15px/400), `color: var(--color-text-secondary)`, `lineHeight: 1.5`,
|
||||||
|
`marginBottom: var(--space-6)` (24px)
|
||||||
|
- Field group spacing: `var(--space-4)` (16px) between fields
|
||||||
|
|
||||||
|
### Surface 4 — Generated-Secret Block
|
||||||
|
|
||||||
|
Used in Step 2 (Secrets) for values the operator must copy into env.
|
||||||
|
|
||||||
|
- Background: `var(--color-surface-dim)` (#f7f7f8)
|
||||||
|
- Border: `1px solid var(--color-border-subtle)` (#eceef2)
|
||||||
|
- Border-radius: 4px (`var(--space-1)`)
|
||||||
|
- Padding: `var(--space-3) var(--space-4)` (12px 16px)
|
||||||
|
- Secret value: monospace, 13px/400, `var(--color-text-primary)`, word-break: break-all
|
||||||
|
(VAPID keys are long strings)
|
||||||
|
- Label above block: Label (13px/600), `var(--color-text-primary)`
|
||||||
|
- Copy button: icon-only (`Copy` icon 16px, `var(--color-member-0)`), positioned top-right
|
||||||
|
inside the block, `minWidth: 44px`, `minHeight: 36px` (acceptable — paired with large block)
|
||||||
|
- After copy: icon swaps to `Check` (16px, `var(--color-member-0)`) for 2 seconds, then reverts
|
||||||
|
- Acknowledgement checkbox below each secret block: standard checkbox input, Label (13px/400),
|
||||||
|
"I have copied this value into my `.env` file." — the Continue button is disabled until all
|
||||||
|
checkboxes on the step are checked.
|
||||||
|
|
||||||
|
### Surface 5 — Validation State Row
|
||||||
|
|
||||||
|
Shown after the operator submits a validation step (DB / OIDC / CalDAV).
|
||||||
|
|
||||||
|
- Pending: `Loader2` icon (16px, `var(--color-member-0)`, `animation: spin 1s linear infinite`) +
|
||||||
|
Body (15px/400) status text in `var(--color-text-secondary)` — inline row
|
||||||
|
- Success: `CheckCircle` icon (16px, `var(--color-text-secondary)`) + success text Body (15px/400)
|
||||||
|
in `var(--color-text-secondary)`
|
||||||
|
- Failure: `AlertCircle` icon (16px, `var(--color-destructive)`) + error text Body (15px/400) in
|
||||||
|
`var(--color-destructive)` — same pattern as CredentialSheet `FAILURE_TEXT`
|
||||||
|
- Layout: `display: flex; alignItems: center; gap: var(--space-2, 8px)` (CredentialSheet pattern)
|
||||||
|
|
||||||
|
### Surface 6 — Action Row
|
||||||
|
|
||||||
|
Bottom of each step card.
|
||||||
|
|
||||||
|
- Layout: `display: flex; justifyContent: flex-end; gap: var(--space-3, 12px)` (CredentialSheet
|
||||||
|
pattern)
|
||||||
|
- "Back" button: ghost (no background, no border), Label (13px/600),
|
||||||
|
`color: var(--color-text-secondary)`, `minHeight: 44px`, `padding: 0 var(--space-4)`
|
||||||
|
— hidden on Step 1 (no back from Welcome)
|
||||||
|
- "Continue" / "Complete Setup" button: filled, `background: var(--color-member-0)` (#4a90d9),
|
||||||
|
`color: #ffffff`, Label (13px/600), `minHeight: 44px`, `padding: 0 var(--space-6)`,
|
||||||
|
`borderRadius: var(--space-1)` (4px), `transition: background 0.15s ease`
|
||||||
|
— disabled state: `background: var(--color-border)` (#e2e4e9), `cursor: default`
|
||||||
|
(AdminPage / CredentialSheet pattern)
|
||||||
|
- "Continue" label on steps 1–4; "Complete Setup" label on step 5
|
||||||
|
|
||||||
|
### Surface 7 — Terminal "Setup Complete" Screen
|
||||||
|
|
||||||
|
Replaces the wizard card after step 5 completes successfully.
|
||||||
|
|
||||||
|
- Icon: `ShieldCheck` (48px, `var(--color-member-0)`) centered
|
||||||
|
- Heading: "Setup complete" — Display (24px/600), centered, `marginTop: var(--space-4)`,
|
||||||
|
`color: var(--color-text-primary)`
|
||||||
|
- Body: "Your FamilySync instance is ready. Sign in to continue." — Body (15px/400),
|
||||||
|
`color: var(--color-text-secondary)`, centered, `marginTop: var(--space-2)`
|
||||||
|
- "Sign in" button: filled accent button (same style as Continue), centered,
|
||||||
|
`marginTop: var(--space-6)`, navigates to `/` (triggers OIDC redirect)
|
||||||
|
- No step indicator visible on this screen (step indicator hidden once setup complete)
|
||||||
|
|
||||||
|
### Surface 8 — "Already Locked" Screen
|
||||||
|
|
||||||
|
Shown when the operator navigates to `/setup` after `setup_complete = true` (423 from API).
|
||||||
|
|
||||||
|
- Icon: `ShieldCheck` (48px, `var(--color-text-muted)`) centered
|
||||||
|
- Heading: "Setup already complete" — Heading (18px/600), centered,
|
||||||
|
`color: var(--color-text-primary)`
|
||||||
|
- Body: "This instance has already been configured. Sign in to continue." — Body (15px/400),
|
||||||
|
`color: var(--color-text-secondary)`, centered
|
||||||
|
- "Sign in" link: accent-colored text link (no button chrome), 15px/600,
|
||||||
|
navigates to `/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wizard Steps — Detailed Interaction Contract
|
||||||
|
|
||||||
|
### Step 1: Welcome
|
||||||
|
|
||||||
|
Purpose: orient the operator; no inputs; no validation.
|
||||||
|
|
||||||
|
- Heading: "Welcome to FamilySync Setup"
|
||||||
|
- Description: "This wizard will guide you through configuring your self-hosted instance.
|
||||||
|
You'll need: your OIDC client credentials (Authelia), a Fastmail account with an app password,
|
||||||
|
and a copy of your `docker-compose.yml` to paste generated secrets into. This takes about
|
||||||
|
5 minutes."
|
||||||
|
- No input fields.
|
||||||
|
- Continue button: always enabled.
|
||||||
|
|
||||||
|
### Step 2: Generate Secrets
|
||||||
|
|
||||||
|
Purpose: display generated session secret, encryption key, and VAPID keypair; operator copies
|
||||||
|
each into env.
|
||||||
|
|
||||||
|
- Heading: "Generated Secrets"
|
||||||
|
- Description: "These values are generated once and displayed now. Copy each into your
|
||||||
|
`docker-compose.yml` environment block before continuing. They will never be shown again and
|
||||||
|
are not stored in the database."
|
||||||
|
- Four generated-secret blocks (Surface 4), each with acknowledgement checkbox:
|
||||||
|
1. `SESSION_SECRET` — label "Session secret", 64-char hex string
|
||||||
|
2. `APP_PASSWORD_ENCRYPTION_KEY` — label "Encryption key", 64-char hex string
|
||||||
|
3. `VAPID_PUBLIC_KEY` — label "VAPID public key"
|
||||||
|
4. `VAPID_PRIVATE_KEY` — label "VAPID private key"
|
||||||
|
- Continue button disabled until all 4 checkboxes are checked.
|
||||||
|
- No async validation on this step. Secrets are generated client-side or fetched from
|
||||||
|
`POST /api/setup/generate` (backend choice — UI treats them as string values to display).
|
||||||
|
|
||||||
|
### Step 3: Database
|
||||||
|
|
||||||
|
Purpose: verify the DB connection configured in env is reachable.
|
||||||
|
|
||||||
|
- Heading: "Database Connection"
|
||||||
|
- Description: "Verify that the app can reach the MariaDB database configured in your
|
||||||
|
environment. No changes are made — this is a read-only connectivity check."
|
||||||
|
- No operator input fields (DB creds come from env, not from this UI).
|
||||||
|
- "Test Connection" button (primary filled, full-width on this step — replace normal action row):
|
||||||
|
triggers `POST /api/setup/validate/db`
|
||||||
|
- Validation state row (Surface 5) shown below the description during/after the test:
|
||||||
|
- Pending: "Testing database connection…"
|
||||||
|
- Success: "Database connection verified."
|
||||||
|
- Failure: "Cannot reach the database. Check DB_HOST, DB_PORT, DB_USER, DB_PASSWORD in your
|
||||||
|
`docker-compose.yml` and try again."
|
||||||
|
- Continue button appears after success; disabled during pending; hidden on failure (operator
|
||||||
|
must retry first).
|
||||||
|
- Back is available.
|
||||||
|
|
||||||
|
### Step 4: OIDC & VAPID
|
||||||
|
|
||||||
|
Purpose: verify OIDC discovery resolves and the VAPID keypair in env is structurally valid.
|
||||||
|
|
||||||
|
- Heading: "OIDC & Push"
|
||||||
|
- Description: "Verify that the Authelia OIDC issuer is reachable and that the VAPID keypair
|
||||||
|
you copied in Step 2 is in place."
|
||||||
|
- Two validation rows, triggered sequentially by "Validate" button:
|
||||||
|
- OIDC: label "Authelia issuer discovery", `POST /api/setup/validate/oidc`
|
||||||
|
- Pending: "Checking OIDC discovery…"
|
||||||
|
- Success: "OIDC discovery resolved."
|
||||||
|
- Failure: "OIDC discovery failed. Check OIDC_ISSUER in your environment and that Authelia
|
||||||
|
is reachable."
|
||||||
|
- VAPID: label "VAPID keypair", `POST /api/setup/validate/vapid`
|
||||||
|
- Pending: "Checking VAPID keypair…"
|
||||||
|
- Success: "VAPID keypair is valid."
|
||||||
|
- Failure: "VAPID private key could not be verified. Ensure you copied both keys from Step 2
|
||||||
|
into your environment and restarted the container."
|
||||||
|
- "Validate" button triggers both checks in sequence.
|
||||||
|
- Continue appears (and is enabled) only when both rows show success.
|
||||||
|
- Back is available.
|
||||||
|
|
||||||
|
### Step 5: Calendar Credential
|
||||||
|
|
||||||
|
Purpose: set the first member's Fastmail app password; validate against CalDAV PROPFIND.
|
||||||
|
Reuses the CredentialSheet interaction pattern (same fields, same validation feedback,
|
||||||
|
same copy).
|
||||||
|
|
||||||
|
- Heading: "Fastmail Credential"
|
||||||
|
- Description: "Add the Fastmail app password for the first household member. This credential
|
||||||
|
is validated against Fastmail CalDAV before saving. The password is never stored in
|
||||||
|
plain text."
|
||||||
|
- Fields (same as CredentialSheet):
|
||||||
|
- Fastmail email (type="email", autoComplete="email", placeholder="user@fastmail.com")
|
||||||
|
- App password (type="password", autoComplete="new-password")
|
||||||
|
- Helper text below fields: "Enter the Fastmail app password scoped to Calendars/CalDAV.
|
||||||
|
[Get an app password](https://app.fastmail.com/settings/security/devicetokens) — choose
|
||||||
|
the 'Calendars & Contacts (CalDAV)' scope."
|
||||||
|
- Link: accent-colored, `text-decoration: underline`, opens in new tab
|
||||||
|
`rel="noopener noreferrer"`
|
||||||
|
- Validation state row (Surface 5):
|
||||||
|
- Pending: "Validating against CalDAV…" (Loader2 spinner)
|
||||||
|
- Success: "Credential verified." — Continue becomes enabled
|
||||||
|
- Failure: "Invalid password — CalDAV validation failed. Check the scope is 'Calendars &
|
||||||
|
Contacts (CalDAV)' and try again." (in `var(--color-destructive)`)
|
||||||
|
- Continue button label on this step: "Complete Setup"
|
||||||
|
- On continue: `POST /api/setup/complete` — promotes operator to admin, sets
|
||||||
|
`app_config.setup_complete`, redirects to Surface 7 (Terminal Screen)
|
||||||
|
- Back is available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Routing & App-Level Gate
|
||||||
|
|
||||||
|
The wizard lives at route `/setup`. App.tsx must add a gate at startup:
|
||||||
|
|
||||||
|
1. On app load, `GET /api/setup/status` is fetched (before any other /api route, mounts outside
|
||||||
|
OIDC guard).
|
||||||
|
2. If response is `{ setupComplete: false }` → redirect entire app to `/setup` (full-page
|
||||||
|
takeover, no AppNav/BottomTabBar rendered).
|
||||||
|
3. If response is `{ setupComplete: true }` → normal app boot continues.
|
||||||
|
4. If `/setup` is navigated directly after setup is complete (API returns 423) → render
|
||||||
|
Surface 8 ("Already Locked").
|
||||||
|
|
||||||
|
The `/setup` route does NOT render inside the normal App shell. It replaces it entirely (or
|
||||||
|
is handled before `<BrowserRouter>` routes — implementation choice for planner, but the design
|
||||||
|
contract requires: no AppNav, no BottomTabBar, no SetupBanner, no PermissionDeniedBanner).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Copywriting Contract
|
||||||
|
|
||||||
|
| Element | Copy |
|
||||||
|
|---------|------|
|
||||||
|
| Page title | "FamilySync Setup" |
|
||||||
|
| Page subtitle | "Let's get your instance ready." |
|
||||||
|
| Primary CTA (steps 1–4) | "Continue" |
|
||||||
|
| Primary CTA (step 5) | "Complete Setup" |
|
||||||
|
| Secondary action | "Back" |
|
||||||
|
| Terminal heading | "Setup complete" |
|
||||||
|
| Terminal body | "Your FamilySync instance is ready. Sign in to continue." |
|
||||||
|
| Terminal CTA | "Sign in" |
|
||||||
|
| Locked heading | "Setup already complete" |
|
||||||
|
| Locked body | "This instance has already been configured. Sign in to continue." |
|
||||||
|
| Locked link | "Sign in" |
|
||||||
|
| Step 1 heading | "Welcome to FamilySync Setup" |
|
||||||
|
| Step 1 description | "This wizard will guide you through configuring your self-hosted instance. You'll need: your OIDC client credentials (Authelia), a Fastmail account with an app password, and a copy of your `docker-compose.yml` to paste generated secrets into. This takes about 5 minutes." |
|
||||||
|
| Step 2 heading | "Generated Secrets" |
|
||||||
|
| Step 2 description | "These values are generated once and displayed now. Copy each into your `docker-compose.yml` environment block before continuing. They will never be shown again and are not stored in the database." |
|
||||||
|
| Step 2 acknowledgement | "I have copied this value into my `.env` file." |
|
||||||
|
| Step 3 heading | "Database Connection" |
|
||||||
|
| Step 3 description | "Verify that the app can reach the MariaDB database configured in your environment. No changes are made — this is a read-only connectivity check." |
|
||||||
|
| Step 3 CTA | "Test Connection" |
|
||||||
|
| Step 3 pending | "Testing database connection…" |
|
||||||
|
| Step 3 success | "Database connection verified." |
|
||||||
|
| Step 3 failure | "Cannot reach the database. Check DB_HOST, DB_PORT, DB_USER, DB_PASSWORD in your `docker-compose.yml` and try again." |
|
||||||
|
| Step 4 heading | "OIDC & Push" |
|
||||||
|
| Step 4 description | "Verify that the Authelia OIDC issuer is reachable and that the VAPID keypair you copied in Step 2 is in place." |
|
||||||
|
| Step 4 CTA | "Validate" |
|
||||||
|
| Step 4 OIDC pending | "Checking OIDC discovery…" |
|
||||||
|
| Step 4 OIDC success | "OIDC discovery resolved." |
|
||||||
|
| Step 4 OIDC failure | "OIDC discovery failed. Check OIDC_ISSUER in your environment and that Authelia is reachable." |
|
||||||
|
| Step 4 VAPID pending | "Checking VAPID keypair…" |
|
||||||
|
| Step 4 VAPID success | "VAPID keypair is valid." |
|
||||||
|
| Step 4 VAPID failure | "VAPID private key could not be verified. Ensure you copied both keys from Step 2 into your environment and restarted the container." |
|
||||||
|
| Step 5 heading | "Fastmail Credential" |
|
||||||
|
| Step 5 description | "Add the Fastmail app password for the first household member. This credential is validated against Fastmail CalDAV before saving. The password is never stored in plain text." |
|
||||||
|
| Step 5 helper text | "Enter the Fastmail app password scoped to Calendars/CalDAV." |
|
||||||
|
| Step 5 helper link text | "Get an app password" |
|
||||||
|
| Step 5 helper link suffix | " — choose the 'Calendars & Contacts (CalDAV)' scope." |
|
||||||
|
| Step 5 pending | "Validating against CalDAV…" |
|
||||||
|
| Step 5 success | "Credential verified." |
|
||||||
|
| Step 5 failure | "Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again." |
|
||||||
|
| Empty state (none for wizard — every step has explicit content) | N/A |
|
||||||
|
| Error state (network/unexpected) | "Something went wrong. Please try again." (generic fallback, shown in Surface 5 failure style) |
|
||||||
|
| Destructive actions | None — wizard has no destructive actions. The "Complete Setup" action is irreversible in effect but not destructive; no confirmation dialog required. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accessibility Contract
|
||||||
|
|
||||||
|
- `role="main"` on the wizard content column
|
||||||
|
- Step indicator: `role="list"` with each step as `role="listitem"`;
|
||||||
|
active step has `aria-current="step"`
|
||||||
|
- Step card: `role="group"` with `aria-labelledby` pointing to the step heading `id`
|
||||||
|
- Heading hierarchy: `<h1>` for page title, `<h2>` for step heading
|
||||||
|
- All inputs: explicit `<label htmlFor>` association (same CredentialSheet pattern)
|
||||||
|
- Disabled buttons: `disabled` attribute (not just pointer-events: none)
|
||||||
|
- Copy button: `aria-label="Copy {field name}"`, swaps to `aria-label="Copied"` for 2 seconds
|
||||||
|
- Validation state row: `role="status"` with `aria-live="polite"` so screen readers announce
|
||||||
|
results without focus movement
|
||||||
|
- Escape key: no sheet to close on this page; Escape has no effect in wizard
|
||||||
|
- Focus management: on step advance, focus moves to the new step heading (tabIndex={-1} pattern
|
||||||
|
from CredentialSheet heading)
|
||||||
|
- Minimum touch targets: 44px on all interactive elements (`minHeight: 44px`, `minWidth: 44px`)
|
||||||
|
- Focus ring: `var(--color-focus-ring)` (#4a90d9), 2px outline, 2px offset on all focusable
|
||||||
|
elements (same project convention)
|
||||||
|
- Secret textarea (if used instead of div): `readonly`, `aria-label="{field name} value"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responsive Behavior
|
||||||
|
|
||||||
|
The wizard is desktop-first (operator is typically on a desktop browser when standing up
|
||||||
|
infrastructure), but must be usable on a phone if needed.
|
||||||
|
|
||||||
|
- Desktop (≥768px): card centered at maxWidth 540px; step indicator spans full card width
|
||||||
|
- Phone (<768px): card fills viewport minus 24px horizontal padding;
|
||||||
|
step indicator uses short labels (1, 2, 3, 4, 5) or icon-only to avoid overflow;
|
||||||
|
no bottom tab bar (not rendered at all on wizard page)
|
||||||
|
- No BottomTabBar, no AppNav on this page at any breakpoint
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Display Rules
|
||||||
|
|
||||||
|
These are hard UI rules, not implementation notes:
|
||||||
|
|
||||||
|
- Secret values in Surface 4 blocks: displayed in a readonly `<textarea>` or `<div>` with
|
||||||
|
`userSelect: all` for easy selection — never in a `type="password"` input (they must be
|
||||||
|
visible to copy)
|
||||||
|
- App password in Step 5: `type="password"` — never visible
|
||||||
|
- No `dangerouslySetInnerHTML` anywhere on this page (T-05-24 project convention)
|
||||||
|
- Acknowledgement checkboxes enforce operator intent before Continue is enabled; this is a UX
|
||||||
|
gate only (not a security boundary — the secrets are already displayed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Registry Safety
|
||||||
|
|
||||||
|
| Registry | Blocks Used | Safety Gate |
|
||||||
|
|----------|-------------|-------------|
|
||||||
|
| shadcn official | none | not applicable |
|
||||||
|
| third-party | none | not applicable |
|
||||||
|
|
||||||
|
No third-party registries. All components are hand-rolled following existing project convention.
|
||||||
|
lucide-react icons are already installed (`KeyRound`, `ShieldCheck`, `Loader2`, `CheckCircle`,
|
||||||
|
`AlertCircle`, `Copy`, `Check` are all available or trivially added from the existing
|
||||||
|
lucide-react dependency).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-Population Sources
|
||||||
|
|
||||||
|
| Decision | Source | Value |
|
||||||
|
|----------|--------|-------|
|
||||||
|
| Spacing scale | apps/pwa/src/styles/tokens.css | --space-1 through --space-12 |
|
||||||
|
| Typography scale | apps/pwa/src/styles/tokens.css | 4 sizes (13/15/18/24px), 2 weights (400/600) |
|
||||||
|
| Color palette | apps/pwa/src/styles/tokens.css | All hex values |
|
||||||
|
| Component library | apps/pwa (convention) | Hand-rolled inline styles, no shadcn |
|
||||||
|
| Icon library | apps/pwa imports | lucide-react |
|
||||||
|
| Button style | CredentialSheet.tsx / AdminPage.tsx | Filled accent for primary, ghost for secondary |
|
||||||
|
| Input style | CredentialSheet.tsx | 12px/16px padding, 4px border-radius, destructive border on error |
|
||||||
|
| Section label style | AdminPage.tsx | 13px/600/uppercase/0.06em letter-spacing |
|
||||||
|
| Card padding | AdminPage.tsx | var(--space-12) top/bottom, var(--space-6) horizontal |
|
||||||
|
| Bottom sheet pattern | CredentialSheet.tsx | 12px 12px 0 0 radius, zIndex 301, backdrop 300 |
|
||||||
|
| Credential copy | CredentialSheet.tsx | Same field layout, same validation feedback pattern |
|
||||||
|
| Step 5 fields | CredentialSheet.tsx | Exact field structure, labels, helper text, link |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checker Sign-Off
|
||||||
|
|
||||||
|
- [ ] Dimension 1 Copywriting: PASS
|
||||||
|
- [ ] Dimension 2 Visuals: PASS
|
||||||
|
- [ ] Dimension 3 Color: PASS
|
||||||
|
- [ ] Dimension 4 Typography: PASS
|
||||||
|
- [ ] Dimension 5 Spacing: PASS
|
||||||
|
- [ ] Dimension 6 Registry Safety: PASS
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
---
|
||||||
|
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>
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
---
|
||||||
|
phase: 18-auto-timezone-detection-and-ability-to-change-timezone
|
||||||
|
plan: "01"
|
||||||
|
subsystem: api
|
||||||
|
tags: [timezone, iana, drizzle, vitest, tdd]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 10-admin-role-settings
|
||||||
|
provides: appConfig table (key/value store where household_timezone key lives)
|
||||||
|
provides:
|
||||||
|
- getHouseholdTimezone(db) — single D-05 accessor for stored household timezone with D-06 fallback chain
|
||||||
|
- isValidIanaTimezone(tz) — IANA timezone validator via Intl.DateTimeFormat try/catch
|
||||||
|
|
||||||
|
affects:
|
||||||
|
- 18-02-PLAN (broker rewire — reminderScheduler + outboxWorker import getHouseholdTimezone)
|
||||||
|
- 18-03-PLAN (admin API routes — import isValidIanaTimezone for Zod refine)
|
||||||
|
- 18-04-PLAN (PWA settings UI — consumes admin timezone API built on top of these)
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- Drizzle single-row PK lookup (.select().from().where(eq()).limit(1)) — same pattern as requireAdmin.ts
|
||||||
|
- IANA timezone validation via try/catch on Intl.DateTimeFormat (avoids Intl.supportedValuesOf omitting 'UTC')
|
||||||
|
- TZ env save/restore in beforeEach/afterEach to prevent env state leaks between tests
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/api/src/lib/householdTimezone.ts
|
||||||
|
- apps/api/tests/lib/householdTimezone.test.ts
|
||||||
|
modified: []
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "isValidIanaTimezone uses Intl.DateTimeFormat try/catch — NOT Intl.supportedValuesOf (omits UTC per RESEARCH Pitfall 2)"
|
||||||
|
- "D-06 fallback chain verbatim: row?.value ?? process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone"
|
||||||
|
- "Literal key string 'household_timezone' in the WHERE clause (D-01)"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "householdTimezone accessor pattern: getHouseholdTimezone(db) as the single import site for all tz reads in broker workers"
|
||||||
|
|
||||||
|
requirements-completed: [D-05, D-06]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 2min
|
||||||
|
completed: 2026-06-15
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 18 Plan 01: Household Timezone Accessor + IANA Validator Summary
|
||||||
|
|
||||||
|
**Single D-05 accessor `getHouseholdTimezone(db)` reads `household_timezone` from `app_config` with D-06 fallback chain; `isValidIanaTimezone(tz)` validates via `Intl.DateTimeFormat` try/catch (not `supportedValuesOf`)**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 2 min
|
||||||
|
- **Started:** 2026-06-15T02:04:31Z
|
||||||
|
- **Completed:** 2026-06-15T02:06:50Z
|
||||||
|
- **Tasks:** 2 (TDD RED + GREEN)
|
||||||
|
- **Files modified:** 2
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- Created `apps/api/src/lib/householdTimezone.ts` exporting `getHouseholdTimezone` and `isValidIanaTimezone`
|
||||||
|
- 11 unit tests cover all 6 required behaviors: stored row, no-row + TZ env, no-row + no-TZ, null row, valid zones, invalid zones
|
||||||
|
- D-06 fallback chain `row?.value ?? process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` reproduced verbatim
|
||||||
|
- TypeScript typecheck (`tsc --noEmit`) clean; full 358-test suite green with no regressions
|
||||||
|
- TDD gate compliance: RED commit (`db0077c`) precedes GREEN commit (`eaceff0`)
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
1. **Task 1: RED — failing unit tests** - `db0077c` (test)
|
||||||
|
2. **Task 2: GREEN — implement accessor + validator** - `eaceff0` (feat)
|
||||||
|
|
||||||
|
**Plan metadata:** (see final docs commit below)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `apps/api/src/lib/householdTimezone.ts` — D-05 accessor + IANA validator, exported for broker + admin route consumers
|
||||||
|
- `apps/api/tests/lib/householdTimezone.test.ts` — 11 unit tests for fallback chain and validator
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- `isValidIanaTimezone` uses `Intl.DateTimeFormat(undefined, { timeZone: tz })` try/catch — `Intl.supportedValuesOf('timeZone')` was explicitly avoided because it omits `'UTC'` in some environments (RESEARCH Pitfall 2).
|
||||||
|
- Fallback chain matches the verbatim expression found at `reminderScheduler.ts:247` so the all-day reminder path continues to work unchanged before Plan 02 seeds a stored value.
|
||||||
|
- `db` is accepted as a parameter (not imported from `db/client.js`) to enable clean mock-based unit testing without a live MariaDB connection.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None — plan executed exactly as written.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
- The vitest global setup requires a MariaDB connection (`global-setup.ts`). Tests need to be run with `DB_HOST=127.0.0.1` when running locally (the `.env` sets `DB_HOST=mariadb` for Docker networking). This is a known dev environment pattern documented in `familysync-dev-stack-setup.md` and has no effect on CI (which uses the service container).
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None — this plan creates a pure utility module with no UI stubs or placeholder data.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
None — no new network endpoints, auth paths, file access patterns, or schema changes were introduced. The accessor is a read-only DB lookup within the trusted server process (T-18-01 disposition: accept).
|
||||||
|
|
||||||
|
## TDD Gate Compliance
|
||||||
|
|
||||||
|
- RED gate: `db0077c` — `test(18-01): add failing tests for household timezone accessor + IANA validator`
|
||||||
|
- GREEN gate: `eaceff0` — `feat(18-01): implement household timezone accessor + IANA validator`
|
||||||
|
- REFACTOR gate: N/A (implementation was clean on first pass)
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
- `getHouseholdTimezone` and `isValidIanaTimezone` are the foundation both Plan 18-02 (broker rewire) and Plan 18-03 (admin API + Zod refine) consume.
|
||||||
|
- Plan 18-02 can import `getHouseholdTimezone` from `../lib/householdTimezone.js` to replace the bare `process.env.TZ ?? Intl…` lookups in `reminderScheduler.ts:247` and `outboxWorker.ts:501,607`.
|
||||||
|
- Plan 18-03 can import `isValidIanaTimezone` for the Zod `.refine()` on `PUT /api/admin/config/timezone`.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- `apps/api/src/lib/householdTimezone.ts` — FOUND
|
||||||
|
- `apps/api/tests/lib/householdTimezone.test.ts` — FOUND
|
||||||
|
- Commit `db0077c` — FOUND
|
||||||
|
- Commit `eaceff0` — FOUND
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 18-auto-timezone-detection-and-ability-to-change-timezone*
|
||||||
|
*Completed: 2026-06-15*
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
---
|
||||||
|
phase: 18-auto-timezone-detection-and-ability-to-change-timezone
|
||||||
|
plan: 02
|
||||||
|
type: tdd
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["18-01"]
|
||||||
|
files_modified:
|
||||||
|
- apps/api/src/routes/admin.ts
|
||||||
|
- apps/api/tests/routes/admin.test.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [D-01, D-02, D-03, D-04]
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "GET /api/admin/config/timezone returns the current timezone + isExplicitlySet flag, gated by requireAdmin (D-04)"
|
||||||
|
- "PUT /api/admin/config/timezone validates the IANA string server-side and upserts household_timezone in app_config (D-01, D-04)"
|
||||||
|
- "A non-admin authenticated user gets 403 on both GET and PUT (server is the real boundary — Phase 10 D-03)"
|
||||||
|
- "PUT with an invalid IANA string returns 400 and writes nothing; PUT with 'UTC' returns 200"
|
||||||
|
- "POST /api/admin/config/timezone/seed writes household_timezone ONLY if currently unset (D-02 seed, D-03 no-overwrite)"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/api/src/routes/admin.ts"
|
||||||
|
provides: "GET + PUT + seed timezone endpoints on the existing requireAdmin-gated adminRouter"
|
||||||
|
- path: "apps/api/tests/routes/admin.test.ts"
|
||||||
|
provides: "Integration coverage: 403 non-admin, IANA 400/200, round-trip, seed no-overwrite"
|
||||||
|
key_links:
|
||||||
|
- from: "apps/api/src/routes/admin.ts"
|
||||||
|
to: "apps/api/src/lib/householdTimezone.ts"
|
||||||
|
via: "import isValidIanaTimezone + getHouseholdTimezone"
|
||||||
|
pattern: "isValidIanaTimezone|getHouseholdTimezone"
|
||||||
|
- from: "PUT /api/admin/config/timezone"
|
||||||
|
to: "app_config key='household_timezone'"
|
||||||
|
via: "drizzle insert().onDuplicateKeyUpdate"
|
||||||
|
pattern: "household_timezone.*onDuplicateKeyUpdate"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Extend the existing Phase 10 `adminRouter` with three timezone endpoints — GET (read current +
|
||||||
|
isExplicitlySet), PUT (validate + upsert), and a seed endpoint (write only if unset) — all behind
|
||||||
|
the established `requireAdmin` guard.
|
||||||
|
|
||||||
|
Purpose: D-04 puts the timezone behind the role-gated admin surface; D-01 stores it as the single
|
||||||
|
`household_timezone` app_config row; D-02/D-03 add a first-run seed path that never overwrites an
|
||||||
|
already-set value (so Phase 18 is self-contained without Phase 12, and silent drift is impossible).
|
||||||
|
Output: three new routes on `adminRouter` + integration tests proving the access-control and
|
||||||
|
validation boundaries.
|
||||||
|
</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 integration tests for the three timezone endpoints</name>
|
||||||
|
<files>apps/api/tests/routes/admin.test.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/tests/routes/admin.test.ts (existing: how a non-admin 403 case is set up; how admin requests are authed; beforeEach/afterEach DB cleanup against familysync_test)
|
||||||
|
- apps/api/src/routes/admin.ts (existing GET /calendars + PUT /calendars/:id/shared handlers; requireAdmin at line 41; zValidator import at line 24)
|
||||||
|
- apps/api/src/lib/householdTimezone.ts (the validator + accessor this route consumes)
|
||||||
|
- .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-RESEARCH.md (Pitfall 2 'UTC' must pass; Pitfall 3 requireAdmin coverage; Pitfall 6 seed no-overwrite; Security Domain → no noEchoHook needed)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- GET /api/admin/config/timezone as a non-admin authenticated user → 403.
|
||||||
|
- PUT /api/admin/config/timezone as a non-admin authenticated user → 403.
|
||||||
|
- GET as admin with no stored row → 200 with { isExplicitlySet: false } and a non-empty timezone string (the fallback).
|
||||||
|
- PUT as admin { timezone: 'America/Chicago' } → 200; subsequent GET → { timezone: 'America/Chicago', isExplicitlySet: true }.
|
||||||
|
- PUT as admin { timezone: 'UTC' } → 200 (UTC must be accepted — Pitfall 2).
|
||||||
|
- PUT as admin { timezone: 'Not/AZone' } → 400; app_config has no household_timezone change as a result.
|
||||||
|
- POST /api/admin/config/timezone/seed { timezone: 'Europe/London' } when unset → 200 and stored value becomes 'Europe/London'.
|
||||||
|
- POST seed { timezone: 'Asia/Tokyo' } when already set to 'America/Chicago' → 200 (or 409 if chosen) but stored value REMAINS 'America/Chicago' (no overwrite, D-03).
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Append a `describe('admin timezone config', ...)` block to the existing integration test file,
|
||||||
|
reusing its established admin-auth and DB-cleanup helpers. For the no-overwrite seed test, first
|
||||||
|
PUT (or directly insert) 'America/Chicago', then POST seed 'Asia/Tokyo', then GET and assert the
|
||||||
|
value is still 'America/Chicago'. Ensure each test cleans the household_timezone row in
|
||||||
|
afterEach so cases do not bleed. Run the suite; confirm the new cases FAIL (routes return 404 —
|
||||||
|
not yet implemented). Commit as `test(18-02): add failing integration tests for admin timezone endpoints`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @familysync/api exec vitest run tests/routes/admin.test.ts 2>&1 | grep -qi "fail\|404" && echo RED-OK</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- New describe block covers all eight behavior cases above, including both 403 cases and the seed no-overwrite case.
|
||||||
|
- Run is RED because the endpoints do not exist yet (404), not because of a test bug.
|
||||||
|
- `test(18-02): ...` commit precedes implementation.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>RED gate met for the endpoint contract.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="tdd" tdd="true">
|
||||||
|
<name>Task 2: GREEN — implement GET/PUT/seed timezone endpoints on adminRouter</name>
|
||||||
|
<files>apps/api/src/routes/admin.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/routes/admin.ts (FULL file — append after existing routes so requireAdmin at line 41 covers them; mirror GET /calendars and the zValidator PUT shape)
|
||||||
|
- apps/api/src/lib/householdTimezone.ts (import isValidIanaTimezone + getHouseholdTimezone)
|
||||||
|
- .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md (admin.ts section: exact schema placement, import additions, upsert pattern)
|
||||||
|
- apps/api/src/db/schema.ts §282 (appConfig — add to the schema import on line 28)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Extend `apps/api/src/routes/admin.ts`. Add `appConfig` to the `'../db/schema.js'` import and
|
||||||
|
`import { isValidIanaTimezone, getHouseholdTimezone } from '../lib/householdTimezone.js'`.
|
||||||
|
Define `timezoneSchema = z.object({ timezone: z.string().min(1).max(64).refine(isValidIanaTimezone, { message: 'Invalid IANA timezone identifier' }) })`
|
||||||
|
in the Zod schema block (NO noEchoHook — timezone is non-sensitive, RESEARCH Security Domain).
|
||||||
|
Append three routes AFTER the existing ones (so the line-41 `requireAdmin` covers them):
|
||||||
|
• `adminRouter.get('/config/timezone', ...)` → select value where key='household_timezone' limit 1; return `c.json({ timezone: row?.value ?? process.env.TZ ?? Intl…, isExplicitlySet: row?.value != null })`. (Reuse the exact fallback; you may call getHouseholdTimezone(db) for the value and compute isExplicitlySet from a separate row read or a single read.)
|
||||||
|
• `adminRouter.put('/config/timezone', zValidator('json', timezoneSchema), ...)` → upsert via `db.insert(appConfig).values({ key: 'household_timezone', value: timezone }).onDuplicateKeyUpdate({ set: { value: timezone } })`; return `c.json({ ok: true }, 200)`.
|
||||||
|
• `adminRouter.post('/config/timezone/seed', zValidator('json', timezoneSchema), ...)` → SELECT existing; if `row?.value` is null/undefined, insert the value; else no-op. Always return `c.json({ ok: true, seeded: <bool> }, 200)` (D-03 no-overwrite). Do NOT use a bare onDuplicateKeyUpdate that would overwrite.
|
||||||
|
Run the integration tests; iterate to GREEN. Commit as `feat(18-02): admin timezone GET/PUT/seed endpoints`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @familysync/api exec vitest run tests/routes/admin.test.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- admin.ts registers `/config/timezone` (GET, PUT) and `/config/timezone/seed` (POST) AFTER line 41 so requireAdmin covers them (both 403 tests pass).
|
||||||
|
- PUT body validated by `timezoneSchema` using `isValidIanaTimezone`; 'UTC' → 200, 'Not/AZone' → 400.
|
||||||
|
- Upsert uses `onDuplicateKeyUpdate` on key `'household_timezone'`; seed path conditionally writes only when unset (no-overwrite test passes).
|
||||||
|
- admin.ts does NOT add a noEchoHook to the timezone routes (timezone is non-sensitive).
|
||||||
|
- All eight RED cases now pass (GREEN); `tsc --noEmit` clean.
|
||||||
|
- `feat(18-02): ...` commit follows RED commit.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>GREEN gate met: endpoints enforce requireAdmin + IANA validation + no-overwrite seed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| client → /api/admin/config/timezone | untrusted authenticated request crosses into a privileged config write |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-18-03 | Elevation of Privilege | non-admin sets/reads household timezone | mitigate | `adminRouter.use('*', requireAdmin)` (line 41) is the FIRST statement; new routes appended after inherit it. Integration tests assert 403 for a non-admin authenticated user on both GET and PUT (Pitfall 3). Client `isAdmin` is UX-only (Phase 10 D-03); the DB-backed requireAdmin is the real boundary. |
|
||||||
|
| T-18-04 | Tampering | arbitrary/garbage timezone string in PUT body breaks scheduler | mitigate | Zod `.refine(isValidIanaTimezone)` rejects non-IANA strings with 400 before any DB write; `try/catch Intl.DateTimeFormat` is eval-free. Test asserts 'Not/AZone' → 400 and no stored change. |
|
||||||
|
| T-18-05 | Tampering | unauthenticated/first-run seed clobbers an already-set value (silent drift) | mitigate | seed endpoint reads existing value and writes ONLY when unset (D-03); it is also behind requireAdmin. Test asserts a second seed with a different zone leaves the stored value unchanged. |
|
||||||
|
| T-18-06 | Information Disclosure | log/echo of submitted value | accept | Timezone strings are non-sensitive (not credentials/PII — RESEARCH Security Domain); noEchoHook not required. No console.log of bodies added. |
|
||||||
|
| T-18-07 | Injection | app_config key/value write | mitigate | Drizzle parameterizes the insert/upsert; the key is a hard-coded literal `'household_timezone'`; the value is IANA-validated. No string-concatenated SQL. |
|
||||||
|
| 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/routes/admin.test.ts` green.
|
||||||
|
- `pnpm --filter @familysync/api test -- --run` green (no regression).
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Three admin-gated timezone endpoints exist with server-side IANA validation and a no-overwrite seed.
|
||||||
|
- D-01/D-02/D-03/D-04 enforced and tested.
|
||||||
|
- RED then GREEN commits present.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-02-SUMMARY.md` when done.
|
||||||
|
</output>
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
---
|
||||||
|
phase: 18-auto-timezone-detection-and-ability-to-change-timezone
|
||||||
|
plan: "02"
|
||||||
|
subsystem: api
|
||||||
|
tags: [timezone, iana, drizzle, vitest, tdd, admin, hono]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 18-01
|
||||||
|
provides: isValidIanaTimezone + getHouseholdTimezone (consumed by timezoneSchema + GET handler)
|
||||||
|
- phase: 10-admin-role-settings
|
||||||
|
provides: adminRouter + requireAdmin + appConfig table
|
||||||
|
|
||||||
|
provides:
|
||||||
|
- GET /api/admin/config/timezone — reads stored household timezone with isExplicitlySet flag
|
||||||
|
- PUT /api/admin/config/timezone — validates IANA string + upserts household_timezone in app_config
|
||||||
|
- POST /api/admin/config/timezone/seed — seeds only when unset (D-03 no-overwrite)
|
||||||
|
|
||||||
|
affects:
|
||||||
|
- 18-03-PLAN (broker rewire — reminderScheduler + outboxWorker import getHouseholdTimezone)
|
||||||
|
- 18-04-PLAN (PWA settings UI — consumes GET + PUT endpoints built here)
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- zValidator('json', schema) without noEchoHook for non-sensitive config (timezone is not a credential)
|
||||||
|
- Drizzle onDuplicateKeyUpdate upsert for config PUT (appConfig PK = key)
|
||||||
|
- SELECT-before-INSERT pattern for no-overwrite seed (D-03 — DO NOT use onDuplicateKeyUpdate for seed)
|
||||||
|
- requireAdmin positional coverage: new routes appended after line-41 adminRouter.use('*', requireAdmin)
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- apps/api/src/routes/admin.ts
|
||||||
|
- apps/api/tests/routes/admin.test.ts
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "timezoneSchema uses isValidIanaTimezone (from 18-01) in Zod .refine() — no noEchoHook needed (T-18-06: timezone is non-sensitive)"
|
||||||
|
- "GET /config/timezone reads row directly (not via getHouseholdTimezone) to compute isExplicitlySet from row?.value != null, then uses getHouseholdTimezone for the fallback value"
|
||||||
|
- "seed endpoint uses SELECT-then-INSERT (not onDuplicateKeyUpdate) to ensure D-03 no-overwrite is an explicit code path, not a silent race"
|
||||||
|
|
||||||
|
requirements-completed: [D-01, D-02, D-03, D-04]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 3min
|
||||||
|
completed: 2026-06-15
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 18 Plan 02: Admin Timezone API Endpoints Summary
|
||||||
|
|
||||||
|
**Three role-gated admin endpoints (GET + PUT + seed) added to adminRouter with server-side IANA validation via isValidIanaTimezone and D-03 no-overwrite seed semantics enforced by SELECT-before-INSERT**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 3 min
|
||||||
|
- **Started:** 2026-06-15T02:09:38Z
|
||||||
|
- **Completed:** 2026-06-15T02:12:39Z
|
||||||
|
- **Tasks:** 2 (TDD RED + GREEN)
|
||||||
|
- **Files modified:** 2
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- Added `describe('admin timezone config')` to `admin.test.ts` with 8 test cases covering all boundary conditions
|
||||||
|
- Implemented `GET /api/admin/config/timezone`, `PUT /api/admin/config/timezone`, and `POST /api/admin/config/timezone/seed` on the existing `adminRouter`
|
||||||
|
- All routes inherit the line-41 `requireAdmin` guard (Pitfall 9 / T-18-03) — no per-route auth addition needed
|
||||||
|
- `timezoneSchema` uses `isValidIanaTimezone` from Plan 18-01 in a Zod `.refine()` — no `noEchoHook` (T-18-06)
|
||||||
|
- `PUT` uses `onDuplicateKeyUpdate` for a true upsert; `seed` uses SELECT-then-INSERT to enforce D-03 no-overwrite
|
||||||
|
- 25/25 `admin.test.ts` tests pass; 366/366 full API suite green; `tsc --noEmit` clean
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
1. **Task 1: RED — failing integration tests** - `f109b3c` (test)
|
||||||
|
2. **Task 2: GREEN — implement GET/PUT/seed timezone endpoints** - `3bd6a5d` (feat)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `apps/api/src/routes/admin.ts` — added appConfig + householdTimezone imports, timezoneSchema, and three new route handlers
|
||||||
|
- `apps/api/tests/routes/admin.test.ts` — added appConfig import + `describe('admin timezone config')` with 8 test cases + per-test afterEach cleanup
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- `timezoneSchema` does NOT use `noEchoHook` because timezone strings are non-sensitive (not credentials/PII); standard `zValidator` error responses are safe (T-18-06 accepted disposition).
|
||||||
|
- `GET /config/timezone` does a direct single-row read (not `getHouseholdTimezone`) so the handler can compute `isExplicitlySet` from `row?.value != null` before deciding whether to invoke the fallback chain — using `getHouseholdTimezone` would discard the "was it stored?" signal.
|
||||||
|
- `POST /api/admin/config/timezone/seed` uses a SELECT-then-INSERT (not `onDuplicateKeyUpdate`) so that D-03 no-overwrite is an explicit code branch, not an implicit race. The test asserts the stored value after a second seed attempt remains unchanged.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None — plan executed exactly as written.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
- Running `pnpm --filter @familysync/api exec vitest run` requires `DB_ROOT_PASSWORD` from `.env` sourced into the shell (the global-setup provisions `familysync_test` using the root credential). This is the established pattern from quick 260613-ndv and documented in `familysync-dev-stack-setup.md`.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None — all three endpoints are fully wired to the database. No placeholder data.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
No new threat surface beyond the plan's threat model. All three endpoints are behind `requireAdmin` (T-18-03). IANA validation enforced by `isValidIanaTimezone` (T-18-04). No SQL injection surface — key is a hard-coded literal, value is IANA-validated, Drizzle parameterizes the insert/upsert (T-18-07).
|
||||||
|
|
||||||
|
## TDD Gate Compliance
|
||||||
|
|
||||||
|
- RED gate: `f109b3c` — `test(18-02): add failing integration tests for admin timezone endpoints`
|
||||||
|
- GREEN gate: `3bd6a5d` — `feat(18-02): admin timezone GET/PUT/seed endpoints`
|
||||||
|
- REFACTOR gate: N/A (implementation was clean on first pass)
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
- Plan 18-03 (broker rewire) can import `getHouseholdTimezone(db)` from `../lib/householdTimezone.js` to replace the bare `process.env.TZ ?? Intl…` lookups in `reminderScheduler.ts:247` and `outboxWorker.ts:501,607`.
|
||||||
|
- Plan 18-04 (PWA settings UI) can wire to `GET /api/admin/config/timezone` and `PUT /api/admin/config/timezone`. The seed endpoint is also available for the Phase 12 wizard auto-detect flow.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- `apps/api/src/routes/admin.ts` — FOUND (modified)
|
||||||
|
- `apps/api/tests/routes/admin.test.ts` — FOUND (modified)
|
||||||
|
- Commit `f109b3c` — FOUND
|
||||||
|
- Commit `3bd6a5d` — FOUND
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 18-auto-timezone-detection-and-ability-to-change-timezone*
|
||||||
|
*Completed: 2026-06-15*
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
---
|
||||||
|
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>
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
---
|
||||||
|
phase: 18-auto-timezone-detection-and-ability-to-change-timezone
|
||||||
|
plan: "03"
|
||||||
|
subsystem: api
|
||||||
|
tags: [timezone, broker, reminder-scheduler, outbox-worker, tdd, drizzle]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 18-auto-timezone-detection-and-ability-to-change-timezone
|
||||||
|
plan: "01"
|
||||||
|
provides: getHouseholdTimezone(db) — the accessor imported at all three rewired sites
|
||||||
|
provides:
|
||||||
|
- reminderScheduler.ts all-day branch reads stored household_timezone via getHouseholdTimezone(db)
|
||||||
|
- outboxWorker.ts UPDATE all-day branch reads stored household_timezone via getHouseholdTimezone(db)
|
||||||
|
- outboxWorker.ts CREATE all-day branch reads stored household_timezone via getHouseholdTimezone(db)
|
||||||
|
|
||||||
|
affects:
|
||||||
|
- 18-04-PLAN (PWA settings UI — all three rewired sites now respect the timezone set via Plan 18-02 admin API)
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- getHouseholdTimezone(db) imported and awaited at three all-day broker sites (replacing bare process.env.TZ ?? Intl)
|
||||||
|
- Mock chain extension: makeAppConfigSelectMock for where().limit() chain (different from makeSelectMock which handles innerJoin chains)
|
||||||
|
- mockTwoQueries updated to mock the new third db.select() call, keeping existing tests green via D-06 fallback
|
||||||
|
- wireMockChain() extended in outboxWorker.test.ts to handle app_config table with where().limit() returning []
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
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
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "D-05 wired: three all-day sites replaced bare process.env.TZ ?? Intl with await getHouseholdTimezone(db)"
|
||||||
|
- "D-06 preserved: getHouseholdTimezone falls back to process.env.TZ → Intl when no app_config row exists; existing tests pin process.env.TZ and pass unchanged"
|
||||||
|
- "D-07 enforced: eventDateTime.ts and hydrateEvents.ts not modified (verified via git diff)"
|
||||||
|
- "mockTwoQueries extended to emit a third mockReturnValueOnce for the new app_config SELECT (no-row → D-06 fallback)"
|
||||||
|
- "mockReset() added in mockTwoQueries/mockThreeQueries to clear unconsumed once-queue entries across vi.resetModules() cycles (Vitest caches mock factory instances)"
|
||||||
|
|
||||||
|
requirements-completed: [D-05, D-06, D-07]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 28min
|
||||||
|
completed: 2026-06-15
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 18 Plan 03: Broker Rewire — All-Day TZ Sites Summary
|
||||||
|
|
||||||
|
**Three all-day "9 AM local" reminder call sites in reminderScheduler.ts and outboxWorker.ts now route through `getHouseholdTimezone(db)` instead of the bare `process.env.TZ ?? Intl` expression, making the stored household_timezone the source of truth for all-day reminder fire times (D-05/D-06/D-07)**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 28 min
|
||||||
|
- **Started:** 2026-06-15T02:04:00Z
|
||||||
|
- **Completed:** 2026-06-15T02:32:01Z
|
||||||
|
- **Tasks:** 2 (TDD RED + GREEN)
|
||||||
|
- **Files modified:** 4
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- Rewired `reminderScheduler.ts` line 247: `const serverTz = await getHouseholdTimezone(db);`
|
||||||
|
- Rewired `outboxWorker.ts` UPDATE branch (~line 501): `const tz = await getHouseholdTimezone(db);`
|
||||||
|
- Rewired `outboxWorker.ts` CREATE branch (~line 607): `const tz = await getHouseholdTimezone(db);`
|
||||||
|
- Added 4 new RED tests (2 scheduler + 2 outbox) verifying stored 'America/Chicago' drives the 9 AM alert instant; all correctly FAIL before the rewire
|
||||||
|
- Extended test mock infrastructure: `makeAppConfigSelectMock`, `mockThreeQueries`, updated `mockTwoQueries` to add third call for D-06 fallback path, updated `wireMockChain()` in outboxWorker.test.ts for app_config table
|
||||||
|
- All 76 broker tests pass after GREEN commit; TypeScript typecheck (`tsc --noEmit`) clean
|
||||||
|
- D-07 boundary confirmed: `eventDateTime.ts` and `hydrateEvents.ts` not in changed-files list
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
1. **Task 1: RED — failing stored-TZ all-day tests** - `94daca3` (test)
|
||||||
|
2. **Task 2: GREEN — rewire all three all-day TZ call sites** - `c80845c` (feat)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `apps/api/src/broker/reminderScheduler.ts` — import added + line 247 rewired to `await getHouseholdTimezone(db)`
|
||||||
|
- `apps/api/src/broker/outboxWorker.ts` — import added + lines ~501 and ~607 rewired to `await getHouseholdTimezone(db)`
|
||||||
|
- `apps/api/tests/broker/reminderScheduler.test.ts` — `makeAppConfigSelectMock`, `mockThreeQueries` helpers added; `mockTwoQueries` extended to mock the new third db.select(); Plan 18-03 describe block with 2 stored-TZ tests
|
||||||
|
- `apps/api/tests/broker/outboxWorker.test.ts` — `wireMockChain()` extended for app_config table; `wireMockChainWithTz()` helper for D-05 stored-TZ tests; Plan 18-03 describe block with 2 stored-TZ tests (create + update branch)
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- `mockTwoQueries` was extended (not renamed) to avoid updating 20+ call sites. The third mock call returns an empty app_config row (no stored TZ → D-06 fallback), which is transparent to all existing tests that pin `process.env.TZ`.
|
||||||
|
- `mockReset()` added inside `mockTwoQueries` and `mockThreeQueries` to flush any unconsumed `mockReturnValueOnce` entries. Vitest caches mock factory instances across `vi.resetModules()` cycles, so the pending third entry from one test bleeds into the next test's queue without an explicit reset.
|
||||||
|
- The outbox UPDATE branch test uses `mockWhereCalEvents.mockResolvedValue([{etag: 'W/"abc"'}])` after `wireMockChainWithTz` to drive the freshEtagRows path into the all-day VALARM branch (no rawVevent → falls through to the explicit-reminder/allDay condition).
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] vi.fn() queue contamination across tests with vi.resetModules()**
|
||||||
|
- **Found during:** Task 1 (RED) — second scheduler test crashed with "innerJoin is not a function"
|
||||||
|
- **Issue:** `vi.clearAllMocks()` does not flush `mockReturnValueOnce` queues. Vitest's mock factory for `vi.mock()` returns the SAME `vi.fn()` instance across module resets, so an unconsumed third entry from the first test bled into the first call slot of the second test — causing `makeAppConfigSelectMock` to be returned where `makeSelectMock` was expected.
|
||||||
|
- **Fix:** Added `db.select.mockReset()` at the start of both `mockTwoQueries` and `mockThreeQueries` to explicitly clear the queue before configuring new once-values.
|
||||||
|
- **Files modified:** `apps/api/tests/broker/reminderScheduler.test.ts`
|
||||||
|
|
||||||
|
**2. [Rule 2 - Missing critical functionality] wireMockChain() in outboxWorker.test.ts didn't handle app_config**
|
||||||
|
- **Found during:** Task 2 (GREEN) — the existing CAL-13 all-day test would have broken after the rewire because `app_config` fell through to `{ where: mockWherePending }`, and `mockWherePending(...)` returns a Promise; calling `.limit(1)` on a Promise throws "limit is not a function"
|
||||||
|
- **Fix:** Extended `wireMockChain()` to handle `app_config` table with a `{ where: fn → {limit: fn} }` chain returning empty rows (D-06 fallback), matching the `getHouseholdTimezone` SELECT chain
|
||||||
|
- **Files modified:** `apps/api/tests/broker/outboxWorker.test.ts`
|
||||||
|
|
||||||
|
**3. [Rule 2 - Missing critical functionality] mockTwoQueries needed a third mock for the new db.select() call**
|
||||||
|
- **Found during:** Task 2 (GREEN) — all 20 existing scheduler tests that call `mockTwoQueries` started failing because `runReminderCheck` now issues a third `db.select()` for `getHouseholdTimezone`; the undefined return caused crashes
|
||||||
|
- **Fix:** Updated `mockTwoQueries` to add a third `mockReturnValueOnce(makeAppConfigSelectMock(null))` returning no row, keeping the D-06 fallback path for all existing tests
|
||||||
|
- **Files modified:** `apps/api/tests/broker/reminderScheduler.test.ts`
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None — all three rewired sites now read from the actual DB through `getHouseholdTimezone(db)`. No placeholder values.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
None — no new network endpoints, auth paths, file access patterns, or schema changes. The three rewired sites are read-only DB lookups within the trusted server process (T-18-08 disposition: mitigate → validated values only reach computeAlertInstantUtc via the Plan 01 accessor that enforces the D-06 fallback chain).
|
||||||
|
|
||||||
|
## TDD Gate Compliance
|
||||||
|
|
||||||
|
- RED gate: `94daca3` — `test(18-03): add failing stored-TZ all-day tests for scheduler + outbox`
|
||||||
|
- GREEN gate: `c80845c` — `feat(18-03): route all-day reminder TZ through stored household_timezone`
|
||||||
|
- REFACTOR gate: N/A (implementation was clean on first pass; deviations were auto-fixed inline)
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- `apps/api/src/broker/reminderScheduler.ts` — FOUND
|
||||||
|
- `apps/api/src/broker/outboxWorker.ts` — FOUND
|
||||||
|
- `apps/api/tests/broker/reminderScheduler.test.ts` — FOUND
|
||||||
|
- `apps/api/tests/broker/outboxWorker.test.ts` — FOUND
|
||||||
|
- Commit `94daca3` — FOUND
|
||||||
|
- Commit `c80845c` — FOUND
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 18-auto-timezone-detection-and-ability-to-change-timezone*
|
||||||
|
*Completed: 2026-06-15*
|
||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
---
|
||||||
|
phase: 18-auto-timezone-detection-and-ability-to-change-timezone
|
||||||
|
plan: 04
|
||||||
|
type: execute
|
||||||
|
wave: 3
|
||||||
|
depends_on: ["18-02"]
|
||||||
|
files_modified:
|
||||||
|
- apps/pwa/src/api/client.ts
|
||||||
|
- apps/pwa/src/routes/AdminPage.tsx
|
||||||
|
autonomous: false
|
||||||
|
requirements: [D-02, D-04]
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "An admin sees a Timezone section in /admin Settings showing the current household timezone (D-04)"
|
||||||
|
- "The admin can search/pick an IANA zone and save it; the change persists across reload (D-04)"
|
||||||
|
- "The picker pre-offers the browser-detected zone (Intl.DateTimeFormat().resolvedOptions().timeZone) so first-run seeding is one tap (D-02)"
|
||||||
|
- "The section indicates when the timezone is using the system default (isExplicitlySet=false)"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/pwa/src/api/client.ts"
|
||||||
|
provides: "fetchAdminTimezone() + setAdminTimezone() + AdminTimezoneResponse"
|
||||||
|
exports: ["fetchAdminTimezone", "setAdminTimezone", "AdminTimezoneResponse"]
|
||||||
|
- path: "apps/pwa/src/routes/AdminPage.tsx"
|
||||||
|
provides: "Timezone section (searchable IANA picker + save) after Shared Calendar section"
|
||||||
|
key_links:
|
||||||
|
- from: "apps/pwa/src/routes/AdminPage.tsx"
|
||||||
|
to: "/api/admin/config/timezone"
|
||||||
|
via: "useQuery(fetchAdminTimezone) + useMutation(setAdminTimezone)"
|
||||||
|
pattern: "fetchAdminTimezone|setAdminTimezone"
|
||||||
|
- from: "apps/pwa/src/api/client.ts"
|
||||||
|
to: "PUT /api/admin/config/timezone"
|
||||||
|
via: "fetch with JSON body"
|
||||||
|
pattern: "PUT.*config/timezone"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add the admin-facing Timezone UI: a searchable IANA picker in the existing `/admin` Settings page,
|
||||||
|
backed by two new client functions that call the Plan 02 endpoints. The picker pre-offers the
|
||||||
|
browser-detected zone so an admin (or the Phase 12 wizard, later) can seed the household timezone in
|
||||||
|
one tap.
|
||||||
|
|
||||||
|
Purpose: D-04 surfaces the change-timezone control in the role-gated admin Settings; D-02 wires the
|
||||||
|
browser detection (`Intl.DateTimeFormat().resolvedOptions().timeZone`) as the suggested value. This
|
||||||
|
is UI + glue against the contract Plan 02 already established and tested.
|
||||||
|
Output: `fetchAdminTimezone`/`setAdminTimezone` in client.ts + a Timezone section in AdminPage.tsx,
|
||||||
|
verified end-to-end with playwright-cli.
|
||||||
|
</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-PATTERNS.md
|
||||||
|
@apps/pwa/src/api/client.ts
|
||||||
|
@apps/pwa/src/routes/AdminPage.tsx
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Add fetchAdminTimezone + setAdminTimezone to the PWA API client</name>
|
||||||
|
<files>apps/pwa/src/api/client.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/pwa/src/api/client.ts (lines ~413–469: fetchAdminMembers / saveCredential / fetchAdminCalendars / setSharedCalendar — exact GET + PUT-with-body patterns; handleAuthResponse at lines ~51–58; the `// ── /api/admin/* ──` section header at line 355)
|
||||||
|
- .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md (client.ts section: AdminTimezoneResponse interface + both function bodies)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
In the `// ── /api/admin/* ──` section of client.ts, export `interface AdminTimezoneResponse { timezone: string; isExplicitlySet: boolean }`.
|
||||||
|
Add `fetchAdminTimezone()` — GET `/api/admin/config/timezone` with `credentials: 'include', redirect: 'manual'`,
|
||||||
|
call `handleAuthResponse(res, 'GET /api/admin/config/timezone')`, return `res.json()` as `AdminTimezoneResponse`.
|
||||||
|
Add `setAdminTimezone(timezone: string)` — PUT `/api/admin/config/timezone` with `Content-Type: application/json`,
|
||||||
|
`credentials: 'include', redirect: 'manual'`, body `JSON.stringify({ timezone })`, then
|
||||||
|
`handleAuthResponse(res, 'PUT /api/admin/config/timezone')`. Match the existing wrappers' style exactly.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @familysync/pwa exec tsc --noEmit && grep -q "fetchAdminTimezone" apps/pwa/src/api/client.ts && grep -q "PUT" apps/pwa/src/api/client.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- client.ts exports `fetchAdminTimezone`, `setAdminTimezone`, and `AdminTimezoneResponse`.
|
||||||
|
- Both wrappers call `handleAuthResponse` and use `credentials: 'include', redirect: 'manual'` (same auth handling as sibling admin calls).
|
||||||
|
- `pnpm --filter @familysync/pwa exec tsc --noEmit` passes.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Client functions compile and mirror the established admin fetch wrappers.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Add the Timezone section (searchable IANA picker + save) to AdminPage</name>
|
||||||
|
<files>apps/pwa/src/routes/AdminPage.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/pwa/src/routes/AdminPage.tsx (Shared Calendar section lines ~183–288: section structure, sectionLabelStyle at ~40, calendarsQuery at ~72, sharedCalMutation at ~86, save-button style ~244–285)
|
||||||
|
- apps/pwa/src/api/client.ts (the fetchAdminTimezone / setAdminTimezone / AdminTimezoneResponse added in Task 1)
|
||||||
|
- .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md (AdminPage.tsx section: query/mutation/section template, datalist picker UX, import additions)
|
||||||
|
- CLAUDE.md (Browser-based verification convention — use playwright-cli, NOT @playwright/test, for the manual check)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Import `fetchAdminTimezone, setAdminTimezone, type AdminTimezoneResponse` from `../api/client.js`.
|
||||||
|
Add `timezoneQuery = useQuery({ queryKey: ['admin','timezone'], queryFn: fetchAdminTimezone, retry: false, staleTime: 60*1000 })`
|
||||||
|
and `timezoneMutation = useMutation({ mutationFn: (tz: string) => setAdminTimezone(tz), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['admin','timezone'] }) })`.
|
||||||
|
Add a `<section aria-label="Timezone">` after the Shared Calendar section (give the preceding section
|
||||||
|
`marginBottom: 'var(--space-8, 32px)'`), with `<div style={sectionLabelStyle}>Timezone</div>` and the
|
||||||
|
four states (loading / error / data). Picker: a controlled `<input type="text" list="iana-zones">` +
|
||||||
|
`<datalist id="iana-zones">` populated from `Intl.supportedValuesOf('timeZone')` (guard for absence →
|
||||||
|
empty list). Initialize the input from `timezoneQuery.data?.timezone`. Compute
|
||||||
|
`const detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone` and render a small
|
||||||
|
"Use detected: {detectedTz}" affordance that sets the input to detectedTz (D-02 one-tap seed). When
|
||||||
|
`timezoneQuery.data?.isExplicitlySet === false`, show a subtle "using system default" note. Save button
|
||||||
|
(reuse the existing save-button style; disabled while pending or when the input equals the stored value)
|
||||||
|
calls `timezoneMutation.mutate(inputValue)`; label `timezoneMutation.isPending ? 'Saving…' : 'Save'`.
|
||||||
|
Do NOT touch any other section or the display/timed-event path (D-07).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @familysync/pwa exec tsc --noEmit && pnpm --filter @familysync/pwa test -- --run && grep -q 'aria-label="Timezone"' apps/pwa/src/routes/AdminPage.tsx</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- AdminPage renders a `<section aria-label="Timezone">` with the stored value pre-filled and a datalist-backed searchable input.
|
||||||
|
- A "Use detected: <zone>" affordance sets the input to `Intl.DateTimeFormat().resolvedOptions().timeZone` (D-02).
|
||||||
|
- Save calls setAdminTimezone and invalidates the ['admin','timezone'] query on success; the section shows the "system default" note when isExplicitlySet is false.
|
||||||
|
- `pnpm --filter @familysync/pwa exec tsc --noEmit` and `pnpm --filter @familysync/pwa test -- --run` pass.
|
||||||
|
- No change to eventDateTime.ts / hydrateEvents.ts / any other AdminPage section (D-07).
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Timezone section renders, saves, persists, and offers the detected zone.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<name>Task 3: playwright-cli round-trip of the admin timezone picker</name>
|
||||||
|
<files>apps/pwa/src/routes/AdminPage.tsx</files>
|
||||||
|
<what-built>A Timezone section in /admin Settings: searchable IANA picker, "use detected zone" affordance, save + persist via the Plan 02 endpoints.</what-built>
|
||||||
|
<action>
|
||||||
|
Drive the verification with the global `playwright-cli` binary (NOT @playwright/test) against the
|
||||||
|
host-side dev stack with DEV_AUTH_BYPASS (the dev-bypass user must be admin — see the MEMORY
|
||||||
|
dev-bypass note; unlock admin if the section is not visible). This is an automation-first check the
|
||||||
|
executor runs, then presents the result to the operator for sign-off.
|
||||||
|
</action>
|
||||||
|
<how-to-verify>
|
||||||
|
1. Open the PWA, navigate to /admin Settings.
|
||||||
|
2. Confirm a "Timezone" section shows the current household timezone (or "system default").
|
||||||
|
3. Type a city (e.g. "Chicago"), pick "America/Chicago" from the datalist, click Save.
|
||||||
|
4. Reload /admin and confirm the section now shows "America/Chicago" with no "system default" note.
|
||||||
|
5. Confirm the "Use detected: <zone>" affordance fills the input with the browser zone.
|
||||||
|
Expected: the value persists across reload; no console errors; the save button disables while pending.
|
||||||
|
</how-to-verify>
|
||||||
|
<verify>
|
||||||
|
<human-check>playwright-cli round-trip confirms the timezone saves and persists across reload, and the detected-zone affordance works.</human-check>
|
||||||
|
</verify>
|
||||||
|
<resume-signal>Type "approved" or describe what rendered/persisted incorrectly.</resume-signal>
|
||||||
|
<done>Operator confirms the admin timezone picker saves, persists across reload, and offers the detected zone.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| browser PWA → /api/admin/config/timezone | the UI is a convenience; the server requireAdmin + IANA validation (Plan 02) is the real boundary |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-18-11 | Elevation of Privilege | client renders admin UI based on isAdmin flag | accept | Client `isAdmin` is UX-only (Phase 10 D-03); a non-admin who forges the request still hits server-side requireAdmin → 403 (Plan 02 T-18-03). The UI gate is not a security control and is not relied upon as one. |
|
||||||
|
| T-18-12 | Tampering | user types a non-IANA string into the free-text input | mitigate | The datalist offers only valid zones; a manually-typed invalid string is rejected by the server's Zod `isValidIanaTimezone` refine (Plan 02) → 400 surfaced to the user. Client may additionally pre-check before enabling Save, but the server is authoritative. |
|
||||||
|
| T-18-13 | Information Disclosure | XSS via rendered timezone value | mitigate | The value is rendered as plain-text JSX children (no dangerouslySetInnerHTML), consistent with the existing AdminPage convention. IANA strings are constrained anyway. |
|
||||||
|
| T-18-SC | Tampering | npm/pip/cargo installs | mitigate | No new packages this plan (RESEARCH Package Legitimacy Audit: zero installs; picker uses built-in Intl). |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm --filter @familysync/pwa exec tsc --noEmit` green.
|
||||||
|
- `pnpm --filter @familysync/pwa test -- --run` green.
|
||||||
|
- playwright-cli round-trip (Task 3) confirms save + persist + detected-zone affordance.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Admin Timezone section reads/writes the household timezone and persists across reload (D-04).
|
||||||
|
- Browser-detected zone offered for one-tap seeding (D-02).
|
||||||
|
- No regression to other sections or the display/timed-write path (D-07).
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-04-SUMMARY.md` when done.
|
||||||
|
</output>
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
---
|
||||||
|
phase: 18-auto-timezone-detection-and-ability-to-change-timezone
|
||||||
|
plan: "04"
|
||||||
|
subsystem: pwa
|
||||||
|
tags: [timezone, iana, admin, react, tanstack-query, playwright, ui]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 18-02
|
||||||
|
provides: GET/PUT /api/admin/config/timezone (consumed by fetchAdminTimezone/setAdminTimezone)
|
||||||
|
|
||||||
|
provides:
|
||||||
|
- fetchAdminTimezone() — GET /api/admin/config/timezone in PWA API client
|
||||||
|
- setAdminTimezone(tz) — PUT /api/admin/config/timezone in PWA API client
|
||||||
|
- AdminTimezoneResponse interface (timezone: string, isExplicitlySet: boolean)
|
||||||
|
- Timezone section in AdminPage (/admin) — searchable IANA picker, save, detected-zone affordance
|
||||||
|
|
||||||
|
affects:
|
||||||
|
- apps/pwa/src/api/client.ts
|
||||||
|
- apps/pwa/src/routes/AdminPage.tsx
|
||||||
|
- apps/pwa/e2e/timezone-verify.spec.ts (new verification spec)
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- Intl.supportedValuesOf('timeZone') guarded for runtime availability (datalist population)
|
||||||
|
- ARIA combobox role for <input type="text" list="..."> — Playwright locator uses getByRole('combobox') not getByRole('textbox')
|
||||||
|
- useQuery + useMutation with invalidateQueries on success (same pattern as calendars section)
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/pwa/e2e/timezone-verify.spec.ts
|
||||||
|
modified:
|
||||||
|
- apps/pwa/src/api/client.ts
|
||||||
|
- apps/pwa/src/routes/AdminPage.tsx
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "Removed AdminTimezoneResponse from AdminPage.tsx import list — ESLint no-unused-vars; type is inferred from useQuery return"
|
||||||
|
- "Input with list= attribute has ARIA combobox role in Chromium, not textbox — Playwright e2e uses getByRole('combobox')"
|
||||||
|
- "timezoneInput state is null when the user has not interacted — effectiveTimezoneInput = timezoneInput ?? storedTimezone preserves stored value as pre-fill"
|
||||||
|
- "setTimezoneInput(null) on mutation success resets local override so save button re-disables to match new stored value"
|
||||||
|
|
||||||
|
requirements-completed: [D-02, D-04]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 15min
|
||||||
|
completed: 2026-06-15
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 18 Plan 04: Admin Timezone UI Summary
|
||||||
|
|
||||||
|
**Searchable IANA timezone picker in /admin Settings — fetchAdminTimezone/setAdminTimezone in client.ts + Timezone section in AdminPage.tsx — verified end-to-end via Playwright with the real 18-02 endpoints**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** ~15 min
|
||||||
|
- **Started:** 2026-06-15T02:30:00Z
|
||||||
|
- **Completed:** 2026-06-15T02:45:03Z
|
||||||
|
- **Tasks:** 3 (2 auto + 1 playwright-verified checkpoint)
|
||||||
|
- **Files modified/created:** 3
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- Added `AdminTimezoneResponse` interface + `fetchAdminTimezone()` + `setAdminTimezone()` to `apps/pwa/src/api/client.ts`, following the exact pattern of `fetchAdminCalendars` / `setSharedCalendar` (credentials: include, redirect: manual, handleAuthResponse)
|
||||||
|
- Added Timezone section to `AdminPage.tsx` after the Shared Calendar section (with `marginBottom` on the preceding section for spacing)
|
||||||
|
- Timezone section features: 4-state loading/error/data pattern; `<input type="text" list="iana-zones">` + `<datalist>` from `Intl.supportedValuesOf('timeZone')` (guarded); "Using system default" notice when `isExplicitlySet=false`; "Use detected: {zone}" affordance (D-02); disabled Save when pending or unchanged; `timezoneMutation.mutate(tz)` on save; `invalidateQueries(['admin','timezone'])` on success
|
||||||
|
- Created `apps/pwa/e2e/timezone-verify.spec.ts` with 6 desktop Playwright tests — all 6 pass against the live 18-02 API endpoints, including the save + persist across reload flow
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
1. **Task 1: Add fetchAdminTimezone + setAdminTimezone to PWA API client** - `57424e6` (feat)
|
||||||
|
2. **Task 2: Add Timezone section to AdminPage** - `43d6689` (feat)
|
||||||
|
3. **Task 3: Playwright timezone round-trip e2e spec** - `3013b53` (test)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `apps/pwa/src/api/client.ts` — added `AdminTimezoneResponse` interface + `fetchAdminTimezone()` + `setAdminTimezone()` in the `/api/admin/*` section
|
||||||
|
- `apps/pwa/src/routes/AdminPage.tsx` — added `timezoneQuery`, `timezoneMutation`, `timezoneInput` state, `detectedTz`, computed values, `<section aria-label="Timezone">` with IANA picker + affordances + save button
|
||||||
|
- `apps/pwa/e2e/timezone-verify.spec.ts` — new 6-test Playwright spec verifying the full round-trip
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- `AdminTimezoneResponse` type import removed from `AdminPage.tsx` — `@typescript-eslint/no-unused-vars` flagged it (type is inferred from `useQuery` return value, not used as an explicit annotation). ESLint clean.
|
||||||
|
- `<input type="text" list="iana-zones">` has ARIA `combobox` role (not `textbox`) in Chromium — discovered via Playwright page snapshot. Updated e2e locators to `getByRole('combobox')`.
|
||||||
|
- The `timezoneInput` state variable is `null` when the user hasn't typed anything; `effectiveTimezoneInput = timezoneInput ?? storedTimezone` ensures the input shows the stored value on load without the save button enabling prematurely.
|
||||||
|
- After mutation success, `setTimezoneInput(null)` resets the local override so the save button re-disables (effectiveTimezoneInput collapses back to the now-updated storedTimezone).
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] Removed unused `AdminTimezoneResponse` import**
|
||||||
|
- **Found during:** Task 2 (ESLint check)
|
||||||
|
- **Issue:** `type AdminTimezoneResponse` was imported but not used as an explicit type annotation; ESLint `no-unused-vars` reported it as an error
|
||||||
|
- **Fix:** Removed from the import line; the type is inferred via `useQuery`
|
||||||
|
- **Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
|
||||||
|
- **Commit:** `43d6689`
|
||||||
|
|
||||||
|
**2. [Rule 3 - Blocking] API dev container serving stale compiled code**
|
||||||
|
- **Found during:** Task 3 (playwright verification)
|
||||||
|
- **Issue:** The Docker container running the API had the pre-Plan-18-02 `dist/routes/admin.js` baked in — the timezone endpoints returned 404. The `--watch dist/index.js` watcher only restarts on `index.js` change, not sub-module changes.
|
||||||
|
- **Fix:** Rebuilt the API (`pnpm --filter @familysync/api build`), then `docker cp` the updated `dist/routes/admin.js`, `dist/lib/householdTimezone.js`, and `dist/index.js` into the container to trigger a restart.
|
||||||
|
- **Files modified:** container-only (not tracked in git)
|
||||||
|
- **Commit:** N/A (operational, not code)
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None — the Timezone section is fully wired to the real `GET /api/admin/config/timezone` and `PUT /api/admin/config/timezone` endpoints (Plan 18-02). No placeholder data.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
No new threat surface beyond the plan's threat model. T-18-11 (client isAdmin gate is UX only), T-18-12 (server validates IANA via Plan 02 Zod refine), T-18-13 (plain-text JSX children, no dangerouslySetInnerHTML).
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- `apps/pwa/src/api/client.ts` — FOUND (modified, exports fetchAdminTimezone + setAdminTimezone + AdminTimezoneResponse)
|
||||||
|
- `apps/pwa/src/routes/AdminPage.tsx` — FOUND (modified, contains `aria-label="Timezone"`)
|
||||||
|
- `apps/pwa/e2e/timezone-verify.spec.ts` — FOUND (created, 6 desktop tests all pass)
|
||||||
|
- Commit `57424e6` — FOUND
|
||||||
|
- Commit `43d6689` — FOUND
|
||||||
|
- Commit `3013b53` — FOUND
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 18-auto-timezone-detection-and-ability-to-change-timezone*
|
||||||
|
*Completed: 2026-06-15*
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
# Phase 18: Auto timezone detection and ability to change timezone - Context
|
||||||
|
|
||||||
|
**Gathered:** 2026-06-14
|
||||||
|
**Status:** Ready for planning
|
||||||
|
|
||||||
|
<domain>
|
||||||
|
## Phase Boundary
|
||||||
|
|
||||||
|
Make the household timezone an **explicit, stored, user-changeable setting** —
|
||||||
|
auto-detected from the browser — instead of the implicit `process.env.TZ`
|
||||||
|
fallback that all-day reminder computation relies on today.
|
||||||
|
|
||||||
|
Today the timezone is invisible: the server uses
|
||||||
|
`process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` (which is
|
||||||
|
UTC inside Docker) purely for the all-day "9 AM local" reminder computation,
|
||||||
|
while the browser uses its own local zone for write serialization and display.
|
||||||
|
This phase stores a single household timezone, seeds it from the browser at
|
||||||
|
setup, lets an admin change it, and routes the server-side all-day reminder
|
||||||
|
computation through it.
|
||||||
|
|
||||||
|
**In scope:**
|
||||||
|
- Store a single household timezone (new `app_config` key, e.g. `household_timezone`).
|
||||||
|
- Auto-detect the browser IANA timezone and seed the stored value during the
|
||||||
|
Phase 12 setup wizard / first run.
|
||||||
|
- Admin-facing UI to view and change the household timezone (searchable IANA picker).
|
||||||
|
- Route the server-side **all-day reminder** "9 AM local" computation through the
|
||||||
|
stored timezone (overriding the `process.env.TZ` fallback when set).
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
- Per-member timezones (single household value only — see D-01).
|
||||||
|
- Auto-overwrite of the stored timezone on login/travel/VPN drift (see D-03).
|
||||||
|
- Changing display rendering or timed-event serialization, which stay
|
||||||
|
browser-local and already-correct (see D-07).
|
||||||
|
|
||||||
|
</domain>
|
||||||
|
|
||||||
|
<decisions>
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
### Timezone scope
|
||||||
|
- **D-01:** Single **household-wide** timezone, stored in `app_config` (one row,
|
||||||
|
e.g. key `household_timezone`, IANA string value). Chosen over a per-member
|
||||||
|
`users.timezone` column — the household is two people in the same location, and
|
||||||
|
a single value matches the existing implicit single-TZ model. No new column or
|
||||||
|
per-row scheduler logic.
|
||||||
|
|
||||||
|
### Detection & drift
|
||||||
|
- **D-02:** Auto-detect the browser IANA timezone
|
||||||
|
(`Intl.DateTimeFormat().resolvedOptions().timeZone`) and use it to **seed** the
|
||||||
|
stored value during the Phase 12 setup wizard / first run.
|
||||||
|
- **D-03:** After seeding, the timezone changes **only** via the settings UI. Do
|
||||||
|
NOT auto-overwrite the stored value on later login/detection differences
|
||||||
|
(travel, VPN, new device). Silent drift would unexpectedly shift the "fires at
|
||||||
|
9 AM" guarantee. (Optional drift *notice* is allowed but not required — never an
|
||||||
|
automatic write.)
|
||||||
|
|
||||||
|
### Where to change it
|
||||||
|
- **D-04:** Surface the timezone in the existing **role-gated `/admin` Settings**
|
||||||
|
(Phase 10 `requireAdmin` boundary) and seed it from the **Phase 12 setup
|
||||||
|
wizard**. It is a single household-wide setting, so admin-gating is the correct
|
||||||
|
fit; server-side `requireAdmin` is the real boundary (client `isAdmin` is UX
|
||||||
|
only, per Phase 10 D-03).
|
||||||
|
|
||||||
|
### Behavioral boundary
|
||||||
|
- **D-05:** The stored timezone becomes the **source of truth for the
|
||||||
|
server-side all-day "9 AM local" reminder computation**
|
||||||
|
(`reminderScheduler.ts`, `outboxWorker.ts`), replacing the bare
|
||||||
|
`process.env.TZ ?? Intl…` lookup at those sites.
|
||||||
|
- **D-06:** **Fallback chain when `household_timezone` is unset** (e.g. before the
|
||||||
|
wizard seeds it, or on upgrade of an existing install): fall back to the
|
||||||
|
current behavior — `process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone`.
|
||||||
|
Nothing breaks before a value is stored; this is the backward-compat path.
|
||||||
|
- **D-07:** Display rendering and timed-event write serialization stay
|
||||||
|
**browser-local** and unchanged. This phase must NOT touch the already-correct
|
||||||
|
browser-local write/display path (`eventDateTime.ts`, `hydrateEvents.ts`) —
|
||||||
|
keep the blast radius to the all-day reminder computation only.
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Timezone picker UX: a **searchable IANA dropdown** (recorded default; planner/UI
|
||||||
|
may refine). Validate the value is a real IANA zone before storing.
|
||||||
|
- Exact `app_config` key name and the read/cache strategy for the stored value in
|
||||||
|
the scheduler/outbox (e.g. read-per-run vs cached) — planner's call.
|
||||||
|
- Whether to show a non-blocking "detected zone differs" notice on login (allowed
|
||||||
|
per D-03, not required).
|
||||||
|
|
||||||
|
</decisions>
|
||||||
|
|
||||||
|
<canonical_refs>
|
||||||
|
## Canonical References
|
||||||
|
|
||||||
|
**Downstream agents MUST read these before planning or implementing.**
|
||||||
|
|
||||||
|
### Current timezone touchpoints (the code this phase rewires)
|
||||||
|
- `apps/api/src/broker/reminderScheduler.ts` §~247 — `serverTz` lookup for the
|
||||||
|
all-day branch; primary site to route through the stored timezone (D-05).
|
||||||
|
- `apps/api/src/broker/outboxWorker.ts` §~501, §~607 — `tz` lookups feeding
|
||||||
|
`computeAlertInstantUtc` for all-day alert instants; same rewire (D-05).
|
||||||
|
- `apps/api/src/broker/vevent.ts` §~233–315 — `computeAlertInstantUtc`: the
|
||||||
|
pure 9 AM-local computation that *consumes* the tz; contract unchanged, only
|
||||||
|
the tz it's fed changes.
|
||||||
|
|
||||||
|
### Storage & settings surfaces
|
||||||
|
- `apps/api/src/db/schema.ts` §282 — `appConfig` table (key/value/updatedAt);
|
||||||
|
where the `household_timezone` key lives (D-01).
|
||||||
|
- Phase 10 admin surface — `apps/api/src/` `/api/admin/*` routes +
|
||||||
|
`requireAdmin`, and the PWA `/admin` Settings route; where the change-tz UI
|
||||||
|
attaches (D-04). See `.planning/phases/10-admin-role-settings/10-CONTEXT.md`.
|
||||||
|
- Phase 12 setup wizard — `.planning/phases/12-*/12-CONTEXT.md` and its
|
||||||
|
`12-UI-SPEC.md`; where detection seeding hooks in (D-02). **Note dependency
|
||||||
|
ordering:** Phase 12 may not be executed yet — planner should confirm and, if
|
||||||
|
needed, make wizard seeding additive/optional so Phase 18 isn't blocked.
|
||||||
|
|
||||||
|
### Browser-local path (do NOT modify — boundary guard)
|
||||||
|
- `apps/pwa/src/lib/eventDateTime.ts` — browser-local write serialization (D-07).
|
||||||
|
- `apps/pwa/src/lib/hydrateEvents.ts` — display hydration (D-07).
|
||||||
|
|
||||||
|
</canonical_refs>
|
||||||
|
|
||||||
|
<code_context>
|
||||||
|
## Existing Code Insights
|
||||||
|
|
||||||
|
### Reusable Assets
|
||||||
|
- `appConfig` table (`schema.ts:282`) — already exists from Phase 10; store the
|
||||||
|
household timezone as a single key/value row, no migration needed for a new key
|
||||||
|
(only seed/read logic).
|
||||||
|
- `requireAdmin` gating + `/admin` Settings route (Phase 10) — reuse directly for
|
||||||
|
the change-tz control; no new auth surface.
|
||||||
|
- Setup wizard scaffolding (Phase 12) — reuse for first-run detection + seeding.
|
||||||
|
- `Intl.DateTimeFormat().resolvedOptions().timeZone` — already the detection
|
||||||
|
primitive used server-side; reuse client-side for seeding.
|
||||||
|
|
||||||
|
### Established Patterns
|
||||||
|
- Server is the real authorization boundary; client role flags are UX only
|
||||||
|
(Phase 10 D-03) — apply to any tz-write endpoint.
|
||||||
|
- All-day reminder math is isolated in `computeAlertInstantUtc` (pure, tz-arg) —
|
||||||
|
the phase only needs to change *what tz is passed*, not the math.
|
||||||
|
- Backward-compat via additive `app_config` keys + fallback defaults (Phase 10 /
|
||||||
|
Phase 11 style) — keep the `process.env.TZ ?? Intl` fallback (D-06).
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- All-day reminder fire path: `reminderScheduler.ts` and `outboxWorker.ts` both
|
||||||
|
independently read tz — both must route through the same stored-value accessor
|
||||||
|
(single helper to avoid drift between the two sites).
|
||||||
|
- Admin Settings (read/write tz) ↔ `app_config` ↔ scheduler/outbox read path.
|
||||||
|
|
||||||
|
</code_context>
|
||||||
|
|
||||||
|
<specifics>
|
||||||
|
## Specific Ideas
|
||||||
|
|
||||||
|
- The "9 AM local" all-day reminder guarantee is the load-bearing reason this
|
||||||
|
setting exists — correctness of that fire time is the success measure.
|
||||||
|
- Keep the change tightly scoped to the all-day reminder computation; the
|
||||||
|
browser-local timed/display path was deliberately fixed earlier and must not
|
||||||
|
regress.
|
||||||
|
|
||||||
|
</specifics>
|
||||||
|
|
||||||
|
<deferred>
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
- **Per-member timezones** — only relevant if household members live in different
|
||||||
|
zones; revisit as its own phase if that ever becomes true (would add
|
||||||
|
`users.timezone` + per-row scheduler logic).
|
||||||
|
- **Driving display/timed reminders off the stored tz** — broader behavior change
|
||||||
|
deliberately excluded (D-07) to protect the already-correct browser-local path.
|
||||||
|
|
||||||
|
</deferred>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Phase: 18-auto-timezone-detection-and-ability-to-change-timezone*
|
||||||
|
*Context gathered: 2026-06-14*
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
# Phase 18: Auto timezone detection and ability to change timezone - Discussion Log
|
||||||
|
|
||||||
|
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||||
|
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||||
|
|
||||||
|
**Date:** 2026-06-14
|
||||||
|
**Phase:** 18-auto-timezone-detection-and-ability-to-change-timezone
|
||||||
|
**Areas discussed:** Timezone scope, Detection & drift, Where to change it, Behavioral boundary
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timezone scope
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Single household tz | One value in app_config; matches existing implicit single-TZ model and the 2-person same-household reality | ✓ |
|
||||||
|
| Per-member timezone | users.timezone column; per-member 9 AM reminders; needs migration + per-row scheduler logic | |
|
||||||
|
|
||||||
|
**User's choice:** Single household tz (app_config)
|
||||||
|
**Notes:** Two-person same household — single value is the simplest correct model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detection & drift
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Seed on setup, manual after | Auto-detect during Phase 12 wizard/first run to seed; thereafter changes only via settings UI, no auto-overwrite | ✓ |
|
||||||
|
| Detect every login, prompt on drift | Compare browser tz to stored on each login; prompt to update when different | |
|
||||||
|
| Detect every login, silent update | Always overwrite stored tz with the browser's | |
|
||||||
|
|
||||||
|
**User's choice:** Seed on setup, manual after
|
||||||
|
**Notes:** Avoids travel/VPN silently shifting the "fires at 9 AM" guarantee.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where to change it
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Admin Settings + wizard | Role-gated /admin Settings (Phase 10) + seeded by the Phase 12 setup wizard | ✓ |
|
||||||
|
| Any member, general settings | Non-admin settings area; fits per-member scope better | |
|
||||||
|
|
||||||
|
**User's choice:** Admin Settings + wizard
|
||||||
|
**Notes:** Single household-wide setting behind requireAdmin is the correct fit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Behavioral boundary
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| All-day reminder tz only | Stored tz is source of truth for server-side all-day 9 AM computation, overriding process.env.TZ; display + timed serialization stay browser-local | ✓ |
|
||||||
|
| Also override display / timed logic | Stored tz also drives display rendering and/or timed reminders | |
|
||||||
|
|
||||||
|
**User's choice:** All-day reminder tz only
|
||||||
|
**Notes:** Keeps blast radius small; protects the already-correct browser-local write/display path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Claude's Discretion
|
||||||
|
|
||||||
|
- Timezone picker UX (recorded default: searchable IANA dropdown; validate IANA before storing).
|
||||||
|
- Exact app_config key name + read/cache strategy for the stored value in scheduler/outbox.
|
||||||
|
- Optional non-blocking "detected zone differs" login notice (allowed, not required).
|
||||||
|
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
- Per-member timezones — only relevant if members live in different zones; own phase if ever true.
|
||||||
|
- Driving display/timed reminders off the stored tz — deliberately excluded to protect the browser-local path.
|
||||||
+451
@@ -0,0 +1,451 @@
|
|||||||
|
# Phase 18: Auto Timezone Detection and Ability to Change Timezone - Pattern Map
|
||||||
|
|
||||||
|
**Mapped:** 2026-06-14
|
||||||
|
**Files analyzed:** 6 (1 new lib helper, 2 broker modifications, 1 route extension, 1 client extension, 1 PWA page extension)
|
||||||
|
**Analogs found:** 6 / 6
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Classification
|
||||||
|
|
||||||
|
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||||
|
|-------------------|------|-----------|----------------|---------------|
|
||||||
|
| `apps/api/src/lib/householdTimezone.ts` | utility | request-response (DB read) | `apps/api/src/lib/requireAdmin.ts` | role-match (both: small lib helper, single DB read, typed return) |
|
||||||
|
| `apps/api/src/routes/admin.ts` (extend) | route/controller | request-response | `apps/api/src/routes/admin.ts` (existing GET/PUT routes) | exact |
|
||||||
|
| `apps/api/src/broker/reminderScheduler.ts` (modify line 247) | broker/worker | batch | same file, all-day branch context | exact |
|
||||||
|
| `apps/api/src/broker/outboxWorker.ts` (modify lines 501, 607) | broker/worker | batch | same file, all-day branches | exact |
|
||||||
|
| `apps/pwa/src/api/client.ts` (extend) | client utility | request-response | same file, `fetchAdminCalendars` / `setSharedCalendar` | exact |
|
||||||
|
| `apps/pwa/src/routes/AdminPage.tsx` (extend) | component | request-response | same file, Shared Calendar section | exact |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern Assignments
|
||||||
|
|
||||||
|
### `apps/api/src/lib/householdTimezone.ts` (NEW — utility, DB read)
|
||||||
|
|
||||||
|
**Analog:** `apps/api/src/lib/requireAdmin.ts`
|
||||||
|
|
||||||
|
**Why this analog:** `requireAdmin.ts` is the project's only existing single-purpose lib helper that performs a single Drizzle `SELECT … .limit(1)` lookup from a DB table, matches on a PK-style key, and returns a typed result. The import style, Drizzle usage pattern, and file structure all transfer directly.
|
||||||
|
|
||||||
|
**Imports pattern** (`requireAdmin.ts` lines 17–23):
|
||||||
|
```typescript
|
||||||
|
import '../auth/devBypass.js'; // side-effect import pattern — omit for householdTimezone.ts
|
||||||
|
import type { MiddlewareHandler } from 'hono';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client.js';
|
||||||
|
import { users } from '../db/schema.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
For `householdTimezone.ts`, adapt imports to:
|
||||||
|
```typescript
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
||||||
|
import type * as schema from '../db/schema.js';
|
||||||
|
import { appConfig } from '../db/schema.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Core DB read pattern** (`requireAdmin.ts` lines 37–41):
|
||||||
|
```typescript
|
||||||
|
const [row] = await db
|
||||||
|
.select({ isAdmin: users.isAdmin })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row?.isAdmin) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapt to `appConfig` PK lookup:
|
||||||
|
```typescript
|
||||||
|
const [row] = await db
|
||||||
|
.select({ value: appConfig.value })
|
||||||
|
.from(appConfig)
|
||||||
|
.where(eq(appConfig.key, 'household_timezone'))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
row?.value ??
|
||||||
|
process.env.TZ ??
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**D-06 fallback chain:** The fallback `process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` mirrors the exact pattern currently at `reminderScheduler.ts:247` and `outboxWorker.ts:501,607`. It must be preserved verbatim as the fallback so existing tests (which pin `process.env.TZ`) continue to pass when no DB row is found.
|
||||||
|
|
||||||
|
**IANA validator (same file):** No analog exists in the codebase — use `try/catch Intl.DateTimeFormat` (no external lib, no `Intl.supportedValuesOf` — see Research Pitfall 2 for why):
|
||||||
|
```typescript
|
||||||
|
export function isValidIanaTimezone(tz: string): boolean {
|
||||||
|
try {
|
||||||
|
Intl.DateTimeFormat(undefined, { timeZone: tz });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/api/src/routes/admin.ts` — extend with GET + PUT `/config/timezone`
|
||||||
|
|
||||||
|
**Analog:** `apps/api/src/routes/admin.ts` — existing `GET /calendars` (lines 130–140) and `PUT /calendars/:id/shared` (lines 150–180).
|
||||||
|
|
||||||
|
**Security guard pattern** (`admin.ts` lines 39–41 — DO NOT MOVE):
|
||||||
|
```typescript
|
||||||
|
// Pitfall 9: requireAdmin MUST be the first statement on the router.
|
||||||
|
// All sub-routes are protected — no path can be reached without passing this guard.
|
||||||
|
adminRouter.use('*', requireAdmin);
|
||||||
|
```
|
||||||
|
New routes are appended **after** all existing routes. `adminRouter.use('*', requireAdmin)` already covers them positionally in Hono.
|
||||||
|
|
||||||
|
**Simple GET pattern** (`admin.ts` lines 130–140 — exact model for GET `/config/timezone`):
|
||||||
|
```typescript
|
||||||
|
adminRouter.get('/calendars', async (c) => {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: calendars.id,
|
||||||
|
displayName: calendars.displayName,
|
||||||
|
isShared: calendars.isShared,
|
||||||
|
})
|
||||||
|
.from(calendars);
|
||||||
|
|
||||||
|
return c.json({ calendars: rows });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validated PUT pattern** (`admin.ts` lines 150–180 — model for PUT `/config/timezone`):
|
||||||
|
```typescript
|
||||||
|
adminRouter.put('/calendars/:id/shared', async (c) => {
|
||||||
|
const targetId = parseInt(c.req.param('id'), 10);
|
||||||
|
if (isNaN(targetId)) {
|
||||||
|
return c.json({ error: 'Invalid calendar id' }, 400);
|
||||||
|
}
|
||||||
|
// ... DB write ...
|
||||||
|
return c.json({ ok: true }, 200);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
For the timezone PUT, use `zValidator` (already imported at line 24) instead of manual param parsing:
|
||||||
|
```typescript
|
||||||
|
adminRouter.put(
|
||||||
|
'/config/timezone',
|
||||||
|
zValidator('json', timezoneSchema),
|
||||||
|
async (c) => {
|
||||||
|
const { timezone } = c.req.valid('json');
|
||||||
|
await db
|
||||||
|
.insert(appConfig)
|
||||||
|
.values({ key: 'household_timezone', value: timezone })
|
||||||
|
.onDuplicateKeyUpdate({ set: { value: timezone } });
|
||||||
|
return c.json({ ok: true }, 200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zod schema placement** (`admin.ts` lines 47–52 — place new schema alongside existing schemas):
|
||||||
|
```typescript
|
||||||
|
const credentialSchema = z.object({
|
||||||
|
userId: z.number().int().positive(),
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
New `timezoneSchema` goes in the same "Zod schema" block:
|
||||||
|
```typescript
|
||||||
|
const timezoneSchema = z.object({
|
||||||
|
timezone: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(64)
|
||||||
|
.refine(isValidIanaTimezone, { message: 'Invalid IANA timezone identifier' }),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Import additions needed** (`admin.ts` line 28 — add `appConfig` to schema imports; add `householdTimezone.ts` exports):
|
||||||
|
```typescript
|
||||||
|
import { users, memberCredentials, calendars, appConfig } from '../db/schema.js';
|
||||||
|
import { isValidIanaTimezone, getHouseholdTimezone } from '../lib/householdTimezone.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
**noEchoHook:** NOT needed for timezone routes. Timezone strings are non-sensitive (RESEARCH.md Security Domain). Standard `zValidator` without a custom hook is correct.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/api/src/broker/reminderScheduler.ts` — modify line 247
|
||||||
|
|
||||||
|
**Analog:** Same file, same function (`runReminderCheck`).
|
||||||
|
|
||||||
|
**Current pattern at line 247** (verified by research):
|
||||||
|
```typescript
|
||||||
|
const serverTz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replacement pattern:**
|
||||||
|
```typescript
|
||||||
|
const serverTz = await getHouseholdTimezone(db);
|
||||||
|
```
|
||||||
|
|
||||||
|
The surrounding call at line 256 (`computeAlertInstantUtc(dtstartDate, leadDays, serverTz)`) is unchanged — `serverTz` remains a `string`, the contract is identical.
|
||||||
|
|
||||||
|
**Import to add** (top of `reminderScheduler.ts`):
|
||||||
|
```typescript
|
||||||
|
import { getHouseholdTimezone } from '../lib/householdTimezone.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
**DB parameter:** `reminderScheduler.ts` already has `db` in scope in the `runReminderCheck` function (Drizzle queries are present elsewhere in the file). Pass it directly to `getHouseholdTimezone(db)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/api/src/broker/outboxWorker.ts` — modify lines 501 and 607
|
||||||
|
|
||||||
|
**Analog:** Same file, same function (`runOutboxDrain`), two all-day branches.
|
||||||
|
|
||||||
|
**Current pattern at both sites** (verified by research):
|
||||||
|
```typescript
|
||||||
|
// Line 501 (update branch):
|
||||||
|
const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
const leadDays = fields.reminderLeadMinutes / 1440;
|
||||||
|
allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz);
|
||||||
|
|
||||||
|
// Line 607 (create branch):
|
||||||
|
const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
const leadDays = fields.reminderLeadMinutes / 1440;
|
||||||
|
allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replacement at both sites:**
|
||||||
|
```typescript
|
||||||
|
const tz = await getHouseholdTimezone(db);
|
||||||
|
```
|
||||||
|
|
||||||
|
Both sites are in the same `runOutboxDrain` function. If both branches can be reached in a single call, consider computing `tz` once at the top of the all-day processing block and reusing it. The surrounding calls to `computeAlertInstantUtc` are unchanged.
|
||||||
|
|
||||||
|
**Import to add** (top of `outboxWorker.ts`):
|
||||||
|
```typescript
|
||||||
|
import { getHouseholdTimezone } from '../lib/householdTimezone.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/pwa/src/api/client.ts` — extend with admin timezone functions
|
||||||
|
|
||||||
|
**Analog:** Same file, `fetchAdminCalendars` (lines 445–454) and `setSharedCalendar` (lines 462–469).
|
||||||
|
|
||||||
|
**GET fetch pattern** (`client.ts` lines 445–454 — exact model):
|
||||||
|
```typescript
|
||||||
|
export async function fetchAdminCalendars(): Promise<AdminCalendarsResponse> {
|
||||||
|
const res = await fetch('/api/admin/calendars', {
|
||||||
|
credentials: 'include',
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
handleAuthResponse(res, 'GET /api/admin/calendars');
|
||||||
|
|
||||||
|
return res.json() as Promise<AdminCalendarsResponse>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapt to timezone:
|
||||||
|
```typescript
|
||||||
|
export interface AdminTimezoneResponse {
|
||||||
|
timezone: string;
|
||||||
|
isExplicitlySet: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminTimezone(): Promise<AdminTimezoneResponse> {
|
||||||
|
const res = await fetch('/api/admin/config/timezone', {
|
||||||
|
credentials: 'include',
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
handleAuthResponse(res, 'GET /api/admin/config/timezone');
|
||||||
|
return res.json() as Promise<AdminTimezoneResponse>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**PUT fetch pattern** (`client.ts` lines 462–469 — model for setAdminTimezone; note `setSharedCalendar` has no body, so also borrow the body pattern from `saveCredential` at lines 429–439):
|
||||||
|
```typescript
|
||||||
|
export async function setAdminTimezone(timezone: string): Promise<void> {
|
||||||
|
const res = await fetch('/api/admin/config/timezone', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
redirect: 'manual',
|
||||||
|
body: JSON.stringify({ timezone }),
|
||||||
|
});
|
||||||
|
handleAuthResponse(res, 'PUT /api/admin/config/timezone');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Placement:** Append in the `// ── /api/admin/* ──` section (after line 469), before the `saveMyCredential` function.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/pwa/src/routes/AdminPage.tsx` — extend with Timezone section
|
||||||
|
|
||||||
|
**Analog:** Same file, Shared Calendar section (lines 182–288).
|
||||||
|
|
||||||
|
**Query pattern** (`AdminPage.tsx` lines 72–77 — exact model):
|
||||||
|
```typescript
|
||||||
|
const calendarsQuery = useQuery({
|
||||||
|
queryKey: ['admin', 'calendars'],
|
||||||
|
queryFn: fetchAdminCalendars,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapt:
|
||||||
|
```typescript
|
||||||
|
const timezoneQuery = useQuery({
|
||||||
|
queryKey: ['admin', 'timezone'],
|
||||||
|
queryFn: fetchAdminTimezone,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mutation + invalidation pattern** (`AdminPage.tsx` lines 86–94 — exact model):
|
||||||
|
```typescript
|
||||||
|
const sharedCalMutation = useMutation({
|
||||||
|
mutationFn: (calId: number) => setSharedCalendar(calId),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'calendars'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
setSelectedCalendarId(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapt:
|
||||||
|
```typescript
|
||||||
|
const timezoneMutation = useMutation({
|
||||||
|
mutationFn: (tz: string) => setAdminTimezone(tz),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'timezone'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Section structure** (`AdminPage.tsx` lines 182–288 — use as template):
|
||||||
|
```tsx
|
||||||
|
<section aria-label="Shared Calendar">
|
||||||
|
<div style={sectionLabelStyle}>Shared Calendar</div>
|
||||||
|
{/* loading, error, empty, data states */}
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
New section follows the same four-state pattern (loading, error, empty/unset, data). Reuse `sectionLabelStyle` (defined at line 40–47) without modification. Add `marginBottom: 'var(--space-8, 32px)'` to the preceding section to separate from the new one.
|
||||||
|
|
||||||
|
**Save button pattern** (`AdminPage.tsx` lines 244–285):
|
||||||
|
```tsx
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saveDisabled}
|
||||||
|
onClick={() => { /* mutate */ }}
|
||||||
|
style={{
|
||||||
|
background: saveDisabled ? 'var(--color-border, #E2E4E9)' : 'var(--color-member-0, #4A90D9)',
|
||||||
|
color: '#ffffff',
|
||||||
|
border: 'none',
|
||||||
|
cursor: saveDisabled ? 'default' : 'pointer',
|
||||||
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
|
fontWeight: 600,
|
||||||
|
minHeight: '44px',
|
||||||
|
minWidth: '44px',
|
||||||
|
padding: '0 var(--space-6, 24px)',
|
||||||
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**IANA picker UX — no library:** Use `<input type="text" list="iana-zones">` + `<datalist>` populated from `Intl.supportedValuesOf('timeZone')`. This is consistent with the project's hand-rolled inline-styles convention (no component library). Initial value of the input: `timezoneQuery.data?.timezone ?? ''` (shows stored value or fallback). The `isExplicitlySet` flag from the API response can display a subtle "using system default" note when `false`.
|
||||||
|
|
||||||
|
**Import additions for AdminPage.tsx:**
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
fetchAdminTimezone,
|
||||||
|
setAdminTimezone,
|
||||||
|
type AdminTimezoneResponse,
|
||||||
|
} from '../api/client.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Patterns
|
||||||
|
|
||||||
|
### Admin Route Guard
|
||||||
|
**Source:** `apps/api/src/routes/admin.ts` line 41
|
||||||
|
**Apply to:** All new routes in `admin.ts` (inherited automatically — no per-route addition needed)
|
||||||
|
```typescript
|
||||||
|
adminRouter.use('*', requireAdmin);
|
||||||
|
// This single middleware statement covers ALL routes registered on adminRouter,
|
||||||
|
// including appended routes. Do not add requireAdmin inline to individual handlers.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drizzle App Config Upsert (MariaDB)
|
||||||
|
**Source:** Established pattern from `admin.ts` + Drizzle mysql2 dialect (verified in RESEARCH.md)
|
||||||
|
**Apply to:** PUT `/config/timezone` handler and the seeding helper
|
||||||
|
```typescript
|
||||||
|
await db
|
||||||
|
.insert(appConfig)
|
||||||
|
.values({ key: 'household_timezone', value: timezone })
|
||||||
|
.onDuplicateKeyUpdate({ set: { value: timezone } });
|
||||||
|
```
|
||||||
|
Note: `app_config.key` is the PK (`varchar(128).primaryKey()`), so this is a true upsert. `drizzle-orm@0.45.2` + `mysql2@3.22.4` support this syntax natively.
|
||||||
|
|
||||||
|
### Drizzle Single-Row PK Lookup
|
||||||
|
**Source:** `apps/api/src/lib/requireAdmin.ts` lines 37–41
|
||||||
|
**Apply to:** `getHouseholdTimezone` helper and GET `/config/timezone` handler
|
||||||
|
```typescript
|
||||||
|
const [row] = await db
|
||||||
|
.select({ value: appConfig.value })
|
||||||
|
.from(appConfig)
|
||||||
|
.where(eq(appConfig.key, 'household_timezone'))
|
||||||
|
.limit(1);
|
||||||
|
// row is undefined when no row exists — use optional chaining: row?.value
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client Auth Response Handling
|
||||||
|
**Source:** `apps/pwa/src/api/client.ts` lines 51–58 (`handleAuthResponse`)
|
||||||
|
**Apply to:** All new fetch wrappers in `client.ts`
|
||||||
|
```typescript
|
||||||
|
function handleAuthResponse(res: Response, label: string): void {
|
||||||
|
if (res.type === 'opaqueredirect' || res.status === 401) {
|
||||||
|
throw new SessionExpiredError();
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`${label} failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Usage: call immediately after fetch, before res.json()
|
||||||
|
handleAuthResponse(res, 'GET /api/admin/config/timezone');
|
||||||
|
```
|
||||||
|
|
||||||
|
### TanStack Query + Mutation + Invalidation
|
||||||
|
**Source:** `apps/pwa/src/routes/AdminPage.tsx` lines 64–94
|
||||||
|
**Apply to:** New Timezone section in `AdminPage.tsx`
|
||||||
|
```typescript
|
||||||
|
// useQueryClient() already called at top of AdminPage — no additional call needed
|
||||||
|
const timezoneQuery = useQuery({ queryKey: ['admin', 'timezone'], queryFn: fetchAdminTimezone, retry: false, staleTime: 60 * 1000 });
|
||||||
|
const timezoneMutation = useMutation({
|
||||||
|
mutationFn: (tz: string) => setAdminTimezone(tz),
|
||||||
|
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['admin', 'timezone'] }),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No Analog Found
|
||||||
|
|
||||||
|
| File | Role | Data Flow | Reason |
|
||||||
|
|------|------|-----------|--------|
|
||||||
|
| `apps/api/tests/lib/householdTimezone.test.ts` | test | — | No existing `tests/lib/` helper test exists; follow `tests/broker/reminderScheduler.test.ts` vitest structure for the unit test pattern |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Analog search scope:** `apps/api/src/lib/`, `apps/api/src/routes/`, `apps/api/src/broker/`, `apps/pwa/src/api/`, `apps/pwa/src/routes/`
|
||||||
|
**Files read:** 6 source files
|
||||||
|
**Pattern extraction date:** 2026-06-14
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
# Phase 18 — Plan Index & Coverage Audit
|
||||||
|
|
||||||
|
**Created:** 2026-06-15
|
||||||
|
**Plans:** 4 (3 waves)
|
||||||
|
**Mode:** standard · TDD mode ON · MVP off
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave Structure
|
||||||
|
|
||||||
|
| Wave | Plans | Autonomous | Files (disjoint per wave) |
|
||||||
|
|------|-------|------------|----------------------------|
|
||||||
|
| 1 | 18-01 (accessor + IANA validator, TDD) | yes | `lib/householdTimezone.ts` (+test) |
|
||||||
|
| 2 | 18-02 (admin endpoints, TDD), 18-03 (broker wiring, TDD) | yes, yes | 02: `routes/admin.ts` (+test); 03: `broker/reminderScheduler.ts` + `broker/outboxWorker.ts` (+tests) — no overlap with 02 |
|
||||||
|
| 3 | 18-04 (PWA picker UI) | no (human-verify checkpoint) | `pwa/api/client.ts`, `pwa/routes/AdminPage.tsx` |
|
||||||
|
|
||||||
|
Dependency rationale: 02 and 03 both depend only on the Wave-1 accessor and touch disjoint files →
|
||||||
|
parallel in Wave 2. 04 depends on the 02 endpoint contract → Wave 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifacts This Phase Produces (MANDATORY)
|
||||||
|
|
||||||
|
| Symbol / Artifact | Kind | File | Plan |
|
||||||
|
|-------------------|------|------|------|
|
||||||
|
| `getHouseholdTimezone(db)` | function (async, → Promise<string>) | `apps/api/src/lib/householdTimezone.ts` | 01 |
|
||||||
|
| `isValidIanaTimezone(tz)` | function (→ boolean) | `apps/api/src/lib/householdTimezone.ts` | 01 |
|
||||||
|
| `'household_timezone'` | app_config key string (new additive row, no migration) | `apps/api/src/db/schema.ts` appConfig (existing table) | 01/02 |
|
||||||
|
| `GET /api/admin/config/timezone` | route | `apps/api/src/routes/admin.ts` | 02 |
|
||||||
|
| `PUT /api/admin/config/timezone` | route | `apps/api/src/routes/admin.ts` | 02 |
|
||||||
|
| `POST /api/admin/config/timezone/seed` | route (no-overwrite seed, D-02/D-03) | `apps/api/src/routes/admin.ts` | 02 |
|
||||||
|
| `timezoneSchema` | Zod schema | `apps/api/src/routes/admin.ts` | 02 |
|
||||||
|
| `fetchAdminTimezone()` | client fn | `apps/pwa/src/api/client.ts` | 04 |
|
||||||
|
| `setAdminTimezone(timezone)` | client fn | `apps/pwa/src/api/client.ts` | 04 |
|
||||||
|
| `AdminTimezoneResponse` | interface | `apps/pwa/src/api/client.ts` | 04 |
|
||||||
|
| Timezone section (`aria-label="Timezone"`) | UI component/section | `apps/pwa/src/routes/AdminPage.tsx` | 04 |
|
||||||
|
| `apps/api/tests/lib/householdTimezone.test.ts` | new test file | — | 01 |
|
||||||
|
|
||||||
|
Test files extended (not new): `apps/api/tests/routes/admin.test.ts` (02),
|
||||||
|
`apps/api/tests/broker/reminderScheduler.test.ts` + `apps/api/tests/broker/outboxWorker.test.ts` (03).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multi-Source Coverage Audit
|
||||||
|
|
||||||
|
### GOAL (ROADMAP Phase 18 goal)
|
||||||
|
"Auto-detect the household timezone and allow changing it" → COVERED: detection seed (D-02, Plans 02 seed
|
||||||
|
endpoint + 04 detected-zone affordance); change (D-04, Plans 02 PUT + 04 picker); behavioral wiring
|
||||||
|
(D-05, Plan 03).
|
||||||
|
|
||||||
|
### REQ (phase_req_ids from REQUIREMENTS.md)
|
||||||
|
No REQ-IDs are mapped to Phase 18 in ROADMAP.md (confirmed: "Requirements: TBD"). Per the planning
|
||||||
|
context, the D-01..D-07 decision set is the coverage contract — see CONTEXT below. Not a gap.
|
||||||
|
|
||||||
|
### RESEARCH (18-RESEARCH.md features/constraints)
|
||||||
|
- Single accessor `getHouseholdTimezone` + IANA validator → Plan 01.
|
||||||
|
- GET/PUT endpoints on adminRouter, no new router → Plan 02.
|
||||||
|
- Seed endpoint, additive/non-blocking (Phase 12 not yet executed) → Plan 02.
|
||||||
|
- Rewire reminderScheduler:247 + outboxWorker:501,607 → Plan 03.
|
||||||
|
- PWA picker, no new package, Intl-based → Plan 04.
|
||||||
|
- No new npm installs (Package Legitimacy Audit: zero) → honored across all plans; T-18-SC = no-op.
|
||||||
|
|
||||||
|
### CONTEXT (D-01..D-07 — the trackable decision contract)
|
||||||
|
|
||||||
|
| Decision | Covered by | Where |
|
||||||
|
|----------|-----------|-------|
|
||||||
|
| D-01 single household app_config key `household_timezone` | Plan 01 (key literal), Plan 02 (upsert) | accessor + endpoints |
|
||||||
|
| D-02 browser-detect + seed at first run | Plan 02 (seed endpoint), Plan 04 (detected-zone affordance) | endpoints + UI |
|
||||||
|
| D-03 no auto-overwrite after seed | Plan 02 (seed writes only if unset) | seed endpoint |
|
||||||
|
| D-04 change via role-gated /admin Settings | Plan 02 (requireAdmin endpoints), Plan 04 (Timezone section) | endpoints + UI |
|
||||||
|
| D-05 stored TZ = source of truth, single accessor at both scheduler sites | Plan 01 (accessor), Plan 03 (both sites route through it) | accessor + broker |
|
||||||
|
| D-06 fallback chain when unset | Plan 01 (verbatim fallback), Plan 03 (existing tests stay green) | accessor + broker |
|
||||||
|
| D-07 do not touch display/timed-write path | Plan 03 + Plan 04 (boundary as acceptance criterion) | broker + UI |
|
||||||
|
|
||||||
|
**No unplanned items.** Deferred Ideas (per-member timezones; driving display/timed off stored TZ) are
|
||||||
|
correctly absent from all plans.
|
||||||
+732
@@ -0,0 +1,732 @@
|
|||||||
|
# Phase 18: Auto Timezone Detection and Ability to Change Timezone - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-06-15
|
||||||
|
**Domain:** Server-side timezone configuration, Admin settings, IANA validation
|
||||||
|
**Confidence:** HIGH (all findings grounded in actual codebase inspection)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
### Locked Decisions
|
||||||
|
|
||||||
|
- **D-01:** Single **household-wide** timezone, stored in `app_config` (key `household_timezone`, IANA string value). No per-member `users.timezone` column.
|
||||||
|
- **D-02:** Auto-detect the browser IANA timezone (`Intl.DateTimeFormat().resolvedOptions().timeZone`) and use it to **seed** the stored value during the Phase 12 setup wizard / first run.
|
||||||
|
- **D-03:** After seeding, timezone changes **only** via the settings UI. No auto-overwrite on later login/detection differences. (Optional drift notice allowed, not required.)
|
||||||
|
- **D-04:** Surface the timezone in the existing **role-gated `/admin` Settings** (Phase 10 `requireAdmin` boundary) and seed it from the **Phase 12 setup wizard**.
|
||||||
|
- **D-05:** The stored timezone becomes the **source of truth for the server-side all-day "9 AM local" reminder computation** (`reminderScheduler.ts`, `outboxWorker.ts`), replacing the bare `process.env.TZ ?? Intl…` lookup at those sites.
|
||||||
|
- **D-06:** **Fallback chain when `household_timezone` is unset**: fall back to `process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone`.
|
||||||
|
- **D-07:** Display rendering and timed-event write serialization stay **browser-local** and unchanged. Must NOT touch `eventDateTime.ts` or `hydrateEvents.ts`.
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
|
||||||
|
- Timezone picker UX: a searchable IANA dropdown. Validate the value is a real IANA zone before storing.
|
||||||
|
- Exact `app_config` key name and the read/cache strategy for the stored value in the scheduler/outbox (e.g. read-per-run vs cached).
|
||||||
|
- Whether to show a non-blocking "detected zone differs" notice on login (allowed per D-03, not required).
|
||||||
|
|
||||||
|
### Deferred Ideas (OUT OF SCOPE)
|
||||||
|
|
||||||
|
- Per-member timezones (would add `users.timezone` + per-row scheduler logic).
|
||||||
|
- Driving display/timed reminders off the stored tz (deliberately excluded, D-07).
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 18 is a tightly scoped wiring change: one new `app_config` key (`household_timezone`) becomes the source of truth for the server-side all-day reminder computation, replacing two bare `process.env.TZ ?? Intl…` lookups in `reminderScheduler.ts` (line 247) and `outboxWorker.ts` (lines 501 and 607). A single shared accessor helper reads this key from the DB with the D-06 fallback, ensuring both scheduler sites stay synchronized. No new npm packages are required. No schema migration is needed — `app_config` already exists and accepts arbitrary keys as additive rows.
|
||||||
|
|
||||||
|
The admin surface (Phase 10 `adminRouter`, `requireAdmin`, `AdminPage.tsx`) already provides the exact pattern to extend: add GET + PUT endpoints to `/api/admin/config/timezone` following the same route file, client API function, TanStack Query + mutation pattern already present in `AdminPage.tsx`. The IANA validation uses a Zod `.refine()` with a `try/catch` on `Intl.DateTimeFormat` — no external library needed.
|
||||||
|
|
||||||
|
Phase 12 (Initial Setup Wizard) has NOT been executed yet. Its status is `draft` (only a UI spec exists, no route code). The `household_timezone` seeding must therefore be planned as a Phase 18 deliverable that is additive/optional — a standalone seed endpoint (`POST /api/admin/config/timezone/seed`) or an inline seed in an early Phase 18 task — so Phase 18 is not blocked. When Phase 12 eventually executes, it calls the same write endpoint.
|
||||||
|
|
||||||
|
**Primary recommendation:** Extract a `getHouseholdTimezone(db): Promise<string>` helper in `apps/api/src/lib/householdTimezone.ts`, add GET/PUT endpoints on `adminRouter`, extend `AdminPage.tsx` with a new Timezone section, and wire both scheduler sites through the helper. No new dependencies. Read-per-run (not cached) for correctness.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectural Responsibility Map
|
||||||
|
|
||||||
|
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||||
|
|------------|-------------|----------------|-----------|
|
||||||
|
| Store household timezone | Database / Storage | API | `app_config` table; PK-keyed key/value row |
|
||||||
|
| Read timezone for scheduling | API / Backend | — | `reminderScheduler.ts` + `outboxWorker.ts` run server-side; DB read at each tick |
|
||||||
|
| Validate IANA timezone string | API / Backend | Browser / Client | Server validates before write (real boundary); browser validates before submit (UX) |
|
||||||
|
| Admin read/write timezone | API / Backend | Frontend Server | `requireAdmin` is server enforced; client `isAdmin` is UX only (Phase 10 D-03) |
|
||||||
|
| Browser timezone detection | Browser / Client | — | `Intl.DateTimeFormat().resolvedOptions().timeZone` is client-side only |
|
||||||
|
| Display rendering, timed-event serialization | Browser / Client | — | Unchanged by D-07; remains browser-local |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core (all already installed — no new packages)
|
||||||
|
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| drizzle-orm | 0.45.2 | DB read/write for `app_config` | Already in stack; `eq()` + `.select()` / `.insert().onDuplicateKeyUpdate()` for upsert |
|
||||||
|
| zod | 3.25.x | IANA string validation | Already in stack; `.refine()` with `Intl.DateTimeFormat` try/catch |
|
||||||
|
| @hono/zod-validator | 0.8.0 | Route body validation | Already wired in `adminRouter` |
|
||||||
|
| hono | 4.12.23 | Route handlers | Already in stack; extend `adminRouter` |
|
||||||
|
| @tanstack/react-query | 5.101.0 | PWA data fetch + mutation | Already in `AdminPage.tsx` |
|
||||||
|
| Native `Intl` API | Node 22 built-in | IANA validation + browser detection | No package needed |
|
||||||
|
|
||||||
|
**No new npm installs required for this phase.**
|
||||||
|
|
||||||
|
### Supporting
|
||||||
|
|
||||||
|
| Library | Version | Purpose | When to Use |
|
||||||
|
|---------|---------|---------|-------------|
|
||||||
|
| `mysql2` (via drizzle) | 3.22.4 | Underlying driver | Used implicitly by drizzle; no direct use needed |
|
||||||
|
|
||||||
|
### Alternatives Considered
|
||||||
|
|
||||||
|
| Instead of | Could Use | Tradeoff |
|
||||||
|
|------------|-----------|----------|
|
||||||
|
| `try/catch Intl.DateTimeFormat` | `Intl.supportedValuesOf('timeZone')` membership check | `supportedValuesOf` excludes 'UTC', 'GMT', 'Etc/UTC' (verified in Node 22) — those ARE valid. The try/catch approach accepts all valid zones including UTC variants [VERIFIED: Node 22 runtime test] |
|
||||||
|
| Read-per-run DB read | In-memory TTL cache | Cache adds invalidation complexity; read-per-run means changes propagate within 60s (one scheduler tick) with no restart; PK lookup is negligible cost |
|
||||||
|
| Extend existing `adminRouter` | New router/file | The existing pattern (`adminRouter.get/put`, `requireAdmin` first, `zValidator`) is correct and established — extending is the right choice |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Package Legitimacy Audit
|
||||||
|
|
||||||
|
This phase installs **no new packages**. All capabilities use packages already in the monorepo.
|
||||||
|
|
||||||
|
**Packages removed due to [SLOP] verdict:** none
|
||||||
|
**Packages flagged as suspicious [SUS]:** none (no new installs)
|
||||||
|
|
||||||
|
> Note: `hono` was flagged `SUS` by the registry scanner due to a recent publish date, but it is a locked stack choice from CLAUDE.md and already installed. This flag does not apply to existing dependencies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### System Architecture Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser (PWA) API Server MariaDB
|
||||||
|
─────────────────────────────────────────────────────────────────────────────
|
||||||
|
[AdminPage /admin] [adminRouter] [app_config]
|
||||||
|
useQuery('admin','timezone') → GET /api/admin/config/timezone → SELECT key='household_timezone'
|
||||||
|
useMutation(PUT) → PUT /api/admin/config/timezone → INSERT ... ON DUPLICATE KEY UPDATE
|
||||||
|
<TimezonePickerSection> requireAdmin (DB check)
|
||||||
|
Intl.DateTimeFormat() zod IANA validate
|
||||||
|
.resolvedOptions().timeZone → store value
|
||||||
|
(browser detection for seed)
|
||||||
|
|
||||||
|
[reminderScheduler.ts] [getHouseholdTimezone(db)] [app_config]
|
||||||
|
runReminderCheck() every 60s → SELECT key='household_timezone' → value | null
|
||||||
|
fallback: process.env.TZ ?? Intl…
|
||||||
|
pass tz → computeAlertInstantUtc()
|
||||||
|
|
||||||
|
[outboxWorker.ts] [getHouseholdTimezone(db)] [app_config]
|
||||||
|
processOutboxRow() → SELECT key='household_timezone' → value | null
|
||||||
|
(allDay branch: lines 501, 607) fallback chain (same key, same fallback)
|
||||||
|
pass tz → computeAlertInstantUtc()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/api/src/
|
||||||
|
├── lib/
|
||||||
|
│ └── householdTimezone.ts # NEW: getHouseholdTimezone(db) helper + IANA validator
|
||||||
|
├── routes/
|
||||||
|
│ └── admin.ts # EXTEND: add GET + PUT /config/timezone endpoints
|
||||||
|
├── broker/
|
||||||
|
│ ├── reminderScheduler.ts # MODIFY: line 247 — replace bare process.env.TZ lookup
|
||||||
|
│ └── outboxWorker.ts # MODIFY: lines 501, 607 — replace bare process.env.TZ lookups
|
||||||
|
apps/pwa/src/
|
||||||
|
├── api/
|
||||||
|
│ └── client.ts # EXTEND: add fetchAdminTimezone() + setAdminTimezone()
|
||||||
|
└── routes/
|
||||||
|
└── AdminPage.tsx # EXTEND: add Timezone section after Shared Calendar section
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: Stored TZ Accessor Helper (getHouseholdTimezone)
|
||||||
|
|
||||||
|
**What:** A single exported async function that reads `household_timezone` from `app_config` and applies the D-06 fallback chain.
|
||||||
|
|
||||||
|
**When to use:** Called at the start of each all-day processing block in `reminderScheduler.ts` and `outboxWorker.ts`. Not called for timed events (those don't use local time).
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```typescript
|
||||||
|
// apps/api/src/lib/householdTimezone.ts
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
||||||
|
import type * as schema from '../db/schema.js';
|
||||||
|
import { appConfig } from '../db/schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the household timezone from app_config.
|
||||||
|
* D-06 fallback: process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
* (same fallback the bare lookups used before Phase 18).
|
||||||
|
*
|
||||||
|
* Read-per-call so timezone changes propagate within one scheduler tick
|
||||||
|
* without requiring a worker restart.
|
||||||
|
*/
|
||||||
|
export async function getHouseholdTimezone(
|
||||||
|
db: MySql2Database<typeof schema>,
|
||||||
|
): Promise<string> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ value: appConfig.value })
|
||||||
|
.from(appConfig)
|
||||||
|
.where(eq(appConfig.key, 'household_timezone'))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
row?.value ??
|
||||||
|
process.env.TZ ??
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a string is an IANA timezone accepted by the JS engine.
|
||||||
|
* try/catch on Intl.DateTimeFormat covers 'UTC', 'GMT', 'Etc/UTC', and all
|
||||||
|
* 418 named IANA zones. Intl.supportedValuesOf('timeZone') is NOT used because
|
||||||
|
* it excludes 'UTC' and 'Etc/*' variants in Node 22 and Chrome.
|
||||||
|
*/
|
||||||
|
export function isValidIanaTimezone(tz: string): boolean {
|
||||||
|
try {
|
||||||
|
Intl.DateTimeFormat(undefined, { timeZone: tz });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage in reminderScheduler.ts (line 247 replacement):**
|
||||||
|
```typescript
|
||||||
|
// BEFORE (line 247):
|
||||||
|
const serverTz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
const serverTz = await getHouseholdTimezone(db);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage in outboxWorker.ts (lines 501 and 607 replacement):**
|
||||||
|
```typescript
|
||||||
|
// BEFORE (line 501):
|
||||||
|
const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
const tz = await getHouseholdTimezone(db);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Admin Endpoint (GET + PUT /api/admin/config/timezone)
|
||||||
|
|
||||||
|
**What:** Two new routes on `adminRouter`, following the exact pattern of existing admin routes. GET reads the current value (with fallback), PUT validates + upserts.
|
||||||
|
|
||||||
|
**When to use:** Admin settings UI reads/writes.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```typescript
|
||||||
|
// In apps/api/src/routes/admin.ts (extend existing file)
|
||||||
|
|
||||||
|
const timezoneSchema = z.object({
|
||||||
|
timezone: z.string().refine(isValidIanaTimezone, { message: 'Invalid IANA timezone' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/admin/config/timezone
|
||||||
|
adminRouter.get('/config/timezone', async (c) => {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ value: appConfig.value })
|
||||||
|
.from(appConfig)
|
||||||
|
.where(eq(appConfig.key, 'household_timezone'))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const timezone =
|
||||||
|
row?.value ??
|
||||||
|
process.env.TZ ??
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
|
return c.json({ timezone, isExplicitlySet: row?.value != null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/admin/config/timezone
|
||||||
|
adminRouter.put(
|
||||||
|
'/config/timezone',
|
||||||
|
zValidator('json', timezoneSchema),
|
||||||
|
async (c) => {
|
||||||
|
const { timezone } = c.req.valid('json');
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(appConfig)
|
||||||
|
.values({ key: 'household_timezone', value: timezone })
|
||||||
|
.onDuplicateKeyUpdate({ set: { value: timezone } });
|
||||||
|
|
||||||
|
return c.json({ ok: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: PWA TanStack Query + Mutation (AdminPage extension)
|
||||||
|
|
||||||
|
**What:** A new Timezone section in `AdminPage.tsx`, mirroring the existing Shared Calendar section pattern exactly.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```typescript
|
||||||
|
// In apps/pwa/src/api/client.ts (extend existing file)
|
||||||
|
export interface AdminTimezoneResponse {
|
||||||
|
timezone: string;
|
||||||
|
isExplicitlySet: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminTimezone(): Promise<AdminTimezoneResponse> {
|
||||||
|
const res = await fetch('/api/admin/config/timezone', {
|
||||||
|
credentials: 'include',
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
handleAuthResponse(res, 'GET /api/admin/config/timezone');
|
||||||
|
return res.json() as Promise<AdminTimezoneResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAdminTimezone(timezone: string): Promise<void> {
|
||||||
|
const res = await fetch('/api/admin/config/timezone', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
redirect: 'manual',
|
||||||
|
body: JSON.stringify({ timezone }),
|
||||||
|
});
|
||||||
|
handleAuthResponse(res, 'PUT /api/admin/config/timezone');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In AdminPage.tsx (new section, same query/mutation pattern as calendarsQuery)
|
||||||
|
const timezoneQuery = useQuery({
|
||||||
|
queryKey: ['admin', 'timezone'],
|
||||||
|
queryFn: fetchAdminTimezone,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const timezoneMutation = useMutation({
|
||||||
|
mutationFn: (tz: string) => setAdminTimezone(tz),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'timezone'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**IANA Picker UX — no new library:** A `<select>` with a `<datalist>` or a controlled `<input>` + filtered `<select>` using `Intl.supportedValuesOf('timeZone')` on the browser side. The project uses no component library (hand-rolled inline styles, project convention). A simple searchable `<select>` is sufficient:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Browser-side IANA list for the picker (client only)
|
||||||
|
const IANA_ZONES = typeof Intl.supportedValuesOf === 'function'
|
||||||
|
? Intl.supportedValuesOf('timeZone')
|
||||||
|
: [];
|
||||||
|
// Note: browser Intl.supportedValuesOf works in Chrome 93+, Safari 14.1+, Firefox 91+.
|
||||||
|
// The 'UTC' omission from the list is benign on the browser side — server validates with
|
||||||
|
// try/catch, so a manually-typed 'UTC' still passes. The picker's filtered <select>
|
||||||
|
// shows continent/ocean zones only; the text input allows override.
|
||||||
|
```
|
||||||
|
|
||||||
|
A `<input type="text" list="iana-zones">` + `<datalist id="iana-zones">` with all zone options is the lowest-friction approach for a non-technical user — they can type their city name and browser autocompletes from the datalist.
|
||||||
|
|
||||||
|
### Pattern 4: Phase 12 Wizard Seeding (additive, non-blocking)
|
||||||
|
|
||||||
|
**What:** A standalone seed write so Phase 18 can be executed without waiting for Phase 12.
|
||||||
|
|
||||||
|
**When to use:** Phase 18 executor includes a Wave 0 task: add a `POST /api/admin/config/timezone/seed` endpoint (or inline in the wizard's `complete` endpoint in Phase 12). The seeding endpoint writes `household_timezone` only if it is not already set (no-overwrite-if-set guard per D-03).
|
||||||
|
|
||||||
|
**Recommendation:** Make Phase 18 include a seeding helper in the API. Phase 12 (when executed) calls the same PUT endpoint or uses the helper directly in the `/setup/complete` handler.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Seeding write (wizard or any first-run path)
|
||||||
|
// Only write if not already set (no silent overwrite per D-03)
|
||||||
|
async function seedTimezoneIfUnset(db, browserTz: string) {
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ value: appConfig.value })
|
||||||
|
.from(appConfig)
|
||||||
|
.where(eq(appConfig.key, 'household_timezone'))
|
||||||
|
.limit(1);
|
||||||
|
if (!existing?.value) {
|
||||||
|
await db
|
||||||
|
.insert(appConfig)
|
||||||
|
.values({ key: 'household_timezone', value: browserTz })
|
||||||
|
.onDuplicateKeyUpdate({ set: { value: browserTz } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- **Modifying `eventDateTime.ts` or `hydrateEvents.ts`:** D-07 is a hard boundary. The browser-local write/display path was deliberately fixed in earlier phases. Any touch to these files is out of scope and risks regression.
|
||||||
|
- **Using `Intl.supportedValuesOf('timeZone')` for server-side validation:** It excludes 'UTC' and 'Etc/*' variants in Node 22 and Chrome. Use `try/catch Intl.DateTimeFormat` instead.
|
||||||
|
- **Caching the timezone value in memory:** Read-per-call in the scheduler and outbox is correct. In-memory caching requires invalidation signaling and doesn't meaningfully improve a 60s interval.
|
||||||
|
- **Installing a timezone-list package:** The project convention is hand-rolled, no new dependencies. `Intl.supportedValuesOf('timeZone')` is available in all target browsers and Node 22.
|
||||||
|
- **Auto-overwriting `household_timezone` on login:** D-03 forbids this. Only the settings UI write and the first-run seed may write this key.
|
||||||
|
- **Putting the seeding call on the `/api/me` route (or OIDC callback):** This would trigger on every login, violating D-03 (no auto-overwrite after seeding).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| IANA timezone validation | Custom regex or static list | `try/catch Intl.DateTimeFormat()` | Engine-validated, handles all variants including UTC/Etc, zero deps |
|
||||||
|
| IANA zone list for picker | npm `moment-timezone`, `tzdata` | `Intl.supportedValuesOf('timeZone')` | Built-in to Node 22 and modern browsers, 418 zones, no package needed |
|
||||||
|
| Key/value DB upsert | Manual SELECT + conditional INSERT | Drizzle `.insert().onDuplicateKeyUpdate()` | MariaDB-compatible upsert pattern; Drizzle mysql dialect handles it correctly |
|
||||||
|
|
||||||
|
**Key insight:** All complexity for this phase lives in the wiring, not the algorithms. `computeAlertInstantUtc` is already correct; the phase only changes what timezone string is passed to it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Confirmed Code Touchpoints (verified by file inspection)
|
||||||
|
|
||||||
|
### `reminderScheduler.ts` — line 247 (confirmed, CONTEXT hint was accurate)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 247 (VERIFIED by grep):
|
||||||
|
const serverTz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
// Line 256: passed to computeAlertInstantUtc(dtstartDate, leadDays, serverTz)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Contract:** `serverTz` is a `string` passed as the third argument to `computeAlertInstantUtc`. The function signature is `computeAlertInstantUtc(eventDateStr: string, leadDays: number, tz: string): Date` (`vevent.ts` line 240). The contract is tz-string-in, unchanged. Phase 18 only changes the value of `serverTz`.
|
||||||
|
|
||||||
|
### `outboxWorker.ts` — lines 501 and 607 (confirmed, both sites)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 501 (update branch, allDay+explicit reminder):
|
||||||
|
const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
const leadDays = fields.reminderLeadMinutes / 1440;
|
||||||
|
allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz);
|
||||||
|
|
||||||
|
// Line 607 (create branch, allDay+reminder):
|
||||||
|
const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
const leadDays = fields.reminderLeadMinutes / 1440;
|
||||||
|
allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz);
|
||||||
|
```
|
||||||
|
|
||||||
|
Both sites are in the `runOutboxDrain` function. Both are in all-day event branches. The CONTEXT hint (§~501, §~607) is accurate.
|
||||||
|
|
||||||
|
### `vevent.ts` — `computeAlertInstantUtc` signature (lines 240–343, confirmed)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 240 (VERIFIED):
|
||||||
|
export function computeAlertInstantUtc(eventDateStr: string, leadDays: number, tz: string): Date
|
||||||
|
```
|
||||||
|
|
||||||
|
The function is a pure computation: event date string, lead days, tz string → UTC Date. The tz argument is used via `Intl.DateTimeFormat` internally. **No change to this function.** Phase 18 only changes what is passed as `tz`.
|
||||||
|
|
||||||
|
### `schema.ts` — `appConfig` table (line 282, confirmed)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 282 (VERIFIED):
|
||||||
|
export const appConfig = mysqlTable('app_config', {
|
||||||
|
key: varchar('key', { length: 128 }).primaryKey(),
|
||||||
|
value: text('value'), // nullable
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The `household_timezone` key is a new additive row — no migration needed. The existing `0001_famous_mad_thinker.sql` migration already created this table.
|
||||||
|
|
||||||
|
### `admin.ts` (Phase 10 route) — confirmed extend pattern
|
||||||
|
|
||||||
|
Current routes: `GET /members`, `POST /credentials`, `GET /calendars`, `PUT /calendars/:id/shared`. All gated by `adminRouter.use('*', requireAdmin)` as first statement. New endpoints extend the same file and inherit the guard.
|
||||||
|
|
||||||
|
### `AdminPage.tsx` — confirmed extend pattern
|
||||||
|
|
||||||
|
- Uses `useQuery(['admin', 'calendars'], ...)` + `useMutation` + `useQueryClient` + `invalidateQueries` pattern.
|
||||||
|
- New Timezone section follows the same structure as the Shared Calendar section.
|
||||||
|
- `sectionLabelStyle` is defined at top of file and reused — new section reuses it.
|
||||||
|
|
||||||
|
### Phase 12 status — NOT YET EXECUTED
|
||||||
|
|
||||||
|
Confirmed by `STATE.md` (current position is Phase 13, Phase 12 only has `12-UI-SPEC.md` under `.planning/phases/12-initial-setup-wizard/`). No setup route code exists in `apps/api/src/` or `apps/pwa/src/`. Phase 18 must be self-contained: the timezone seeding must work without Phase 12.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: Both Scheduler Sites Must Use the Same Accessor
|
||||||
|
|
||||||
|
**What goes wrong:** Separately duplicating the DB read in both `reminderScheduler.ts` and `outboxWorker.ts`. If you add the read inline in both files separately, future changes must be made in two places, and they can drift.
|
||||||
|
|
||||||
|
**Why it happens:** The CONTEXT.md says "both must route through the same stored-value accessor" — this is easy to forget if planning treats the two files as independent tasks.
|
||||||
|
|
||||||
|
**How to avoid:** Define `getHouseholdTimezone(db)` in `apps/api/src/lib/householdTimezone.ts` in Wave 0 / Plan 1. Both scheduler files import from there.
|
||||||
|
|
||||||
|
**Warning signs:** If a plan task says "add `getHouseholdTimezone`" to both `reminderScheduler.ts` and `outboxWorker.ts` without a shared lib — wrong approach.
|
||||||
|
|
||||||
|
### Pitfall 2: Intl.supportedValuesOf Excludes 'UTC'
|
||||||
|
|
||||||
|
**What goes wrong:** Server-side Zod validation uses `Intl.supportedValuesOf('timeZone').includes(tz)` — then a user who types 'UTC' gets a 400 error even though it is a valid timezone.
|
||||||
|
|
||||||
|
**Why it happens:** The MDN docs say `supportedValuesOf('timeZone')` works, but the spec excludes 'UTC', 'GMT', 'Etc/UTC', and all `Etc/*` identifiers from the return value in Node 22 and Chrome (verified with runtime test: `Intl.supportedValuesOf('timeZone').includes('UTC')` → `false`).
|
||||||
|
|
||||||
|
**How to avoid:** Use `try/catch Intl.DateTimeFormat(undefined, { timeZone: val })` in the Zod `.refine()`. This accepts all valid zones including UTC variants.
|
||||||
|
|
||||||
|
**Warning signs:** Unit test for 'UTC' input fails with 400 from the validation endpoint.
|
||||||
|
|
||||||
|
### Pitfall 3: requireAdmin Must Remain First Middleware Statement
|
||||||
|
|
||||||
|
**What goes wrong:** Adding new routes before `adminRouter.use('*', requireAdmin)` or in a position where the middleware doesn't cover them.
|
||||||
|
|
||||||
|
**Why it happens:** The middleware is positional in Hono — routes registered before `use('*', ...)` are not covered.
|
||||||
|
|
||||||
|
**How to avoid:** New endpoints are appended after the existing routes in `admin.ts`. The `adminRouter.use('*', requireAdmin)` is already the first statement (line 41) and covers all routes registered on `adminRouter` regardless of append order in Hono.
|
||||||
|
|
||||||
|
**Warning signs:** Test for non-admin user on new endpoint returns 200 instead of 403.
|
||||||
|
|
||||||
|
### Pitfall 4: Drizzle `onDuplicateKeyUpdate` Syntax for MariaDB
|
||||||
|
|
||||||
|
**What goes wrong:** Using wrong Drizzle syntax for upsert, or trying `drizzle-kit push` (forbidden — D-Task5-DDL).
|
||||||
|
|
||||||
|
**Why it happens:** Drizzle's mysql dialect supports `.insert().onDuplicateKeyUpdate({ set: { ... } })`. The `app_config` key is the PK, so inserting with an existing key is an upsert. No migration needed for a new key; only a data write.
|
||||||
|
|
||||||
|
**How to avoid:** Use the exact Drizzle pattern: `db.insert(appConfig).values({...}).onDuplicateKeyUpdate({ set: { value: ... } })`. Verified compatible with `drizzle-orm@0.45.2` + `mysql2@3.22.4` (MariaDB wire-compatible).
|
||||||
|
|
||||||
|
**Warning signs:** Drizzle throws `Duplicate entry` error instead of updating.
|
||||||
|
|
||||||
|
### Pitfall 5: Existing All-Day Scheduler Tests Pin `process.env.TZ`
|
||||||
|
|
||||||
|
**What goes wrong:** After Phase 18 wires `getHouseholdTimezone(db)`, the existing all-day tests in `reminderScheduler.test.ts` that pin `process.env.TZ = 'America/New_York'` rely on the old bare `process.env.TZ` read. When the code switches to a DB read, the mock DB must return the expected timezone, otherwise the test reads `null` → falls back to `process.env.TZ` → still works.
|
||||||
|
|
||||||
|
**Why it's actually safe:** The D-06 fallback chain preserves `process.env.TZ` when `household_timezone` is unset. As long as the test's mock DB returns no `household_timezone` row (which it won't, since tests mock the DB to return only event rows), the fallback to `process.env.TZ` kicks in — tests continue to pass without modification. This is the backward-compat guarantee.
|
||||||
|
|
||||||
|
**How to verify:** Existing all-day tests pass without any modification (fallback fires). New Phase 18 tests for the DB-stored case mock the DB to also return the `app_config` row.
|
||||||
|
|
||||||
|
**Warning signs:** Existing all-day tests fail after Phase 18 wiring — indicates fallback isn't implemented correctly.
|
||||||
|
|
||||||
|
### Pitfall 6: Phase 12 Seeding Must Not Overwrite After First Set
|
||||||
|
|
||||||
|
**What goes wrong:** Phase 12 (or any seeding path) unconditionally writes `household_timezone` on every setup/login, violating D-03.
|
||||||
|
|
||||||
|
**How to avoid:** The seed write must check if the key is already set before writing. Use a SELECT + conditional INSERT pattern, or use `.onDuplicateKeyUpdate` only with a no-op set guard: `INSERT ... ON DUPLICATE KEY UPDATE value = IF(value IS NULL, VALUES(value), value)`. Simpler: SELECT first, only INSERT if `row?.value` is null.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
### IANA validation — production-safe, UTC-inclusive
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: Node 22 runtime verification (2026-06-15)
|
||||||
|
// try/catch accepts UTC, GMT, Etc/UTC, Etc/GMT, and all 418 continent/ocean zones
|
||||||
|
export function isValidIanaTimezone(tz: string): boolean {
|
||||||
|
try {
|
||||||
|
Intl.DateTimeFormat(undefined, { timeZone: tz });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zod schema for PUT /api/admin/config/timezone body
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: zod.dev official docs (.refine() pattern) + project CLAUDE.md (zod 3.24.x)
|
||||||
|
const timezoneSchema = z.object({
|
||||||
|
timezone: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(64)
|
||||||
|
.refine(isValidIanaTimezone, { message: 'Invalid IANA timezone identifier' }),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drizzle upsert for app_config (MariaDB compatible)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: drizzle-orm mysql2 dialect, verified pattern in existing codebase
|
||||||
|
import { appConfig } from '../db/schema.js';
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(appConfig)
|
||||||
|
.values({ key: 'household_timezone', value: timezone })
|
||||||
|
.onDuplicateKeyUpdate({ set: { value: timezone } });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Browser timezone seeding (PWA side, first-run only)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Called once during Phase 12 wizard complete step (or Phase 18 standalone seed)
|
||||||
|
// D-03: only seed if not yet set (server enforces IF NOT EXISTS logic)
|
||||||
|
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
await setAdminTimezone(browserTz); // Server applies no-overwrite guard
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|--------------|--------|
|
||||||
|
| `process.env.TZ ?? Intl…` bare lookup in scheduler | Stored `app_config.household_timezone` + fallback | Phase 18 | Timezone correct across Docker restarts where TZ env is unset |
|
||||||
|
| No user-facing timezone setting | Admin UI picker + stored value | Phase 18 | Admin can fix "9 AM local" if it fires at wrong time |
|
||||||
|
|
||||||
|
**Deprecated/outdated:**
|
||||||
|
- Direct `process.env.TZ` reads in scheduler/outbox for all-day logic: these sites are replaced by `getHouseholdTimezone(db)` in Phase 18.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 12 Dependency Analysis
|
||||||
|
|
||||||
|
Phase 12 (Initial Setup Wizard) has status `draft` — only `12-UI-SPEC.md` exists. No routes, no PWA wizard route code. The Phase 12 UI spec describes a 5-step wizard where Step 5 ("Calendar Credential") calls `POST /api/setup/complete`. The timezone seeding would naturally go here but is not yet implemented.
|
||||||
|
|
||||||
|
**Phase 18 must be self-contained.** Recommended approach: Phase 18 adds a seed-timezone endpoint or admin-writable PUT endpoint that Phase 12 can later call. The AdminPage.tsx timezone section (Phase 18 deliverable) also shows the browser-detected zone as the pre-filled value in the picker, so an admin can confirm or change it on first login — covering the seeding requirement without needing Phase 12.
|
||||||
|
|
||||||
|
**Ordering:** Phase 18 executes before Phase 12. Phase 12 can call `PUT /api/admin/config/timezone` (or the seeding helper) during the wizard's `POST /api/setup/complete` to pre-populate from the browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Availability
|
||||||
|
|
||||||
|
This phase is code/config-only (new routes + lib helper + PWA section). No external service dependencies beyond the existing MariaDB and API server.
|
||||||
|
|
||||||
|
| Dependency | Required By | Available | Version | Fallback |
|
||||||
|
|------------|------------|-----------|---------|----------|
|
||||||
|
| MariaDB (app_config table) | DB read/write | Already in stack | MariaDB 11 (Unraid) | — |
|
||||||
|
| Node 22 `Intl` API | IANA validation | Built-in | Node 22.22.3 (confirmed) | — |
|
||||||
|
| Browser `Intl.DateTimeFormat` | Browser detection | Chrome 93+, Safari 14.1+, Firefox 91+ | Built-in | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
> `workflow.nyquist_validation: true` in `.planning/config.json` — section required.
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework (API) | Vitest 4.1.8 |
|
||||||
|
| Framework (PWA) | Vitest 4.1.8 + jsdom |
|
||||||
|
| Config file (API) | `apps/api/vitest.config.ts` |
|
||||||
|
| Config file (PWA) | `apps/pwa/vitest.config.ts` |
|
||||||
|
| Quick run command (API) | `pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts` |
|
||||||
|
| Full suite command | `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test` |
|
||||||
|
|
||||||
|
**TDD mode is ON.** All new files need RED tests before implementation.
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
|
||||||
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|--------|----------|-----------|-------------------|-------------|
|
||||||
|
| D-05/D-06 | `getHouseholdTimezone` returns stored value when set | Unit | `vitest run tests/lib/householdTimezone.test.ts` | No — Wave 0 |
|
||||||
|
| D-05/D-06 | `getHouseholdTimezone` falls back to `process.env.TZ` when unset | Unit | same | No — Wave 0 |
|
||||||
|
| D-05/D-06 | `getHouseholdTimezone` falls back to `Intl` when both unset | Unit | same | No — Wave 0 |
|
||||||
|
| D-05 | `reminderScheduler` all-day branch uses stored TZ | Unit (mock DB) | `vitest run tests/broker/reminderScheduler.test.ts` | Exists (extend) |
|
||||||
|
| D-05 | `outboxWorker` all-day branch uses stored TZ | Unit (mock DB) | `vitest run tests/broker/outboxWorker.test.ts` | Exists (extend) |
|
||||||
|
| IANA validation | Invalid zone → 400 from PUT endpoint | Integration | `vitest run tests/routes/admin.test.ts` | Exists (extend) |
|
||||||
|
| IANA validation | 'UTC' accepted → 200 from PUT endpoint | Integration | same | Exists (extend) |
|
||||||
|
| requireAdmin | GET /api/admin/config/timezone → 403 non-admin | Integration | same | Exists (extend) |
|
||||||
|
| requireAdmin | PUT /api/admin/config/timezone → 403 non-admin | Integration | same | Exists (extend) |
|
||||||
|
| D-06 backward compat | Existing all-day scheduler tests still pass (fallback) | Unit (existing) | `vitest run tests/broker/reminderScheduler.test.ts` | Exists (no change) |
|
||||||
|
| End-to-end | Admin sets TZ → next 9AM reminder fires in new zone | Manual | `playwright-cli` (Chromium) | No — verify step |
|
||||||
|
|
||||||
|
### Existing Test Infrastructure Notes
|
||||||
|
|
||||||
|
- `tests/broker/reminderScheduler.test.ts` lines 656–729: existing all-day tests pin `process.env.TZ = 'America/New_York'` in `beforeEach`. After Phase 18 wiring, these tests mock the DB to return only event rows (not app_config rows), so `getHouseholdTimezone` finds no stored value and falls back to `process.env.TZ` — tests continue to pass **without modification** (D-06 backward compat).
|
||||||
|
- New Phase 18 all-day tests that verify stored TZ behavior: mock the DB to return `{ key: 'household_timezone', value: 'America/Chicago' }` and assert firing at 9 AM Chicago time.
|
||||||
|
- `tests/routes/admin.test.ts`: integration tests hit real MariaDB (`familysync_test` DB). New timezone tests follow same `beforeEach`/`afterEach` DB cleanup pattern.
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
|
||||||
|
- **Per task commit:** `pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts`
|
||||||
|
- **Per wave merge:** `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test`
|
||||||
|
- **Phase gate:** Full suite green (all 244+ existing tests pass) + manual admin change-TZ round-trip via `playwright-cli`
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
|
||||||
|
- [ ] `apps/api/tests/lib/householdTimezone.test.ts` — covers D-05/D-06 accessor + fallback chain + IANA validator
|
||||||
|
- [ ] Extend `apps/api/tests/routes/admin.test.ts` with GET/PUT timezone endpoint tests (403 non-admin, IANA validation, round-trip)
|
||||||
|
- [ ] Extend `apps/api/tests/broker/reminderScheduler.test.ts` with stored-TZ all-day test
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Domain
|
||||||
|
|
||||||
|
> `security_enforcement: true` (default) in `.planning/config.json`.
|
||||||
|
|
||||||
|
### Applicable ASVS Categories
|
||||||
|
|
||||||
|
| ASVS Category | Applies | Standard Control |
|
||||||
|
|---------------|---------|-----------------|
|
||||||
|
| V2 Authentication | No | (OIDC already handled by `@hono/oidc-auth`) |
|
||||||
|
| V3 Session Management | No | (session cookie already handled) |
|
||||||
|
| V4 Access Control | Yes | `requireAdmin` middleware (DB-enforced, not client flag) |
|
||||||
|
| V5 Input Validation | Yes | Zod `.refine(isValidIanaTimezone)` on PUT body |
|
||||||
|
| V6 Cryptography | No | No new crypto; timezone is a non-sensitive plain string |
|
||||||
|
|
||||||
|
### Known Threat Patterns for This Stack
|
||||||
|
|
||||||
|
| Pattern | STRIDE | Standard Mitigation |
|
||||||
|
|---------|--------|---------------------|
|
||||||
|
| Non-admin sets timezone via direct API call | Elevation of Privilege | `requireAdmin` middleware is FIRST statement on `adminRouter`; server 403 before any handler |
|
||||||
|
| Invalid/malicious timezone string in PUT body | Tampering | Zod `.refine(isValidIanaTimezone)` rejects before DB write; `try/catch Intl.DateTimeFormat` is safe (no eval) |
|
||||||
|
| Timezone injection causing log pollution | Information Disclosure | IANA strings are limited to standard zone identifiers; Intl validation rejects anything else |
|
||||||
|
|
||||||
|
**Note:** Timezone strings are non-sensitive (not credentials, not PII). No special sanitization beyond IANA validation is required. The `noEchoHook` pattern from credentials is NOT needed here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assumptions Log
|
||||||
|
|
||||||
|
| # | Claim | Section | Risk if Wrong |
|
||||||
|
|---|-------|---------|---------------|
|
||||||
|
| A1 | Read-per-run DB lookup is negligible overhead for a 60s interval scheduler | Architecture Patterns | If DB is slow (unlikely for PK lookup), could add a few ms to each tick. Impact: none on correctness. |
|
||||||
|
| A2 | `Intl.DateTimeFormat` try/catch accepts all valid IANA zones in target browsers (iOS Safari 16.4+) | Standard Stack | iOS < 16.4 outside scope (CLAUDE.md min); tested in Node 22 [VERIFIED: runtime test] |
|
||||||
|
| A3 | Phase 12 setup wizard will call `PUT /api/admin/config/timezone` when executed | Phase 12 section | If Phase 12 uses a different mechanism, Phase 18's seeding logic may conflict. Low risk: Phase 12 spec shows a `POST /api/setup/complete` pattern that can call the helper. |
|
||||||
|
|
||||||
|
**If this table is empty:** All claims in this research were verified or cited.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Should Phase 18 include a seed endpoint accessible during the Phase 12 wizard?**
|
||||||
|
- What we know: Phase 12 `POST /api/setup/complete` will need to write `household_timezone`. The PUT endpoint on `adminRouter` is admin-gated (require admin), which may not be available at wizard-complete time (first admin not yet set).
|
||||||
|
- What's unclear: Is `requireAdmin` already satisfied at wizard-complete time (the first user becomes admin during the wizard)?
|
||||||
|
- Recommendation: Phase 10 `auth/user.ts` line 114 shows "first user → admin" logic; if the wizard calls `setup/complete` after promoting the user to admin, the PUT endpoint is accessible. Planner should confirm the admin-promotion timing relative to the timezone seed call. If promotion happens in the same `setup/complete` handler, the PUT endpoint is available.
|
||||||
|
|
||||||
|
2. **Should the AdminPage timezone picker pre-fill with the detected browser timezone as a hint?**
|
||||||
|
- What we know: D-02 says "auto-detect from browser at setup." The AdminPage is post-login.
|
||||||
|
- What's unclear: Whether showing a "detected: America/New_York — click to use" affordance is in scope.
|
||||||
|
- Recommendation: This is in Claude's Discretion (CONTEXT.md). The planner should treat it as optional UX polish — the base requirement is a searchable picker with save, not a detection affordance. The picker's initial value shows the current stored timezone (or the fallback zone). A pre-fill is nice but not required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence — verified by direct codebase inspection)
|
||||||
|
|
||||||
|
- `apps/api/src/broker/reminderScheduler.ts` line 247 — `serverTz` lookup confirmed; CONTEXT hint accurate
|
||||||
|
- `apps/api/src/broker/outboxWorker.ts` lines 501, 607 — two `tz` lookups confirmed; CONTEXT hints accurate
|
||||||
|
- `apps/api/src/broker/vevent.ts` line 240 — `computeAlertInstantUtc` signature confirmed; pure tz-in function
|
||||||
|
- `apps/api/src/db/schema.ts` line 282 — `appConfig` table confirmed; key/value/updatedAt structure
|
||||||
|
- `apps/api/src/routes/admin.ts` — full adminRouter pattern confirmed; `requireAdmin` first
|
||||||
|
- `apps/pwa/src/routes/AdminPage.tsx` — TanStack Query pattern confirmed; mutation + invalidation pattern
|
||||||
|
- `apps/pwa/src/api/client.ts` — API client function pattern confirmed
|
||||||
|
- `.planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` — Phase 12 draft status confirmed; no code exists
|
||||||
|
- Node 22 runtime tests — `Intl.supportedValuesOf`, `isValidIanaTimezone` try/catch, UTC edge case
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
|
||||||
|
- [Zod docs — .refine() pattern](https://zod.dev/api?id=apply) — CITED for `.refine()` + custom message syntax [CITED: zod.dev]
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence)
|
||||||
|
|
||||||
|
- MDN + caniuse `Intl.supportedValuesOf` browser support: Chrome 93+, Safari 14.1+, Firefox 91+ [ASSUMED from training + search result summary]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Stored TZ accessor pattern: HIGH — read directly from reminderScheduler.ts, outboxWorker.ts, schema.ts
|
||||||
|
- Admin endpoint pattern: HIGH — read directly from admin.ts, AdminPage.tsx, client.ts
|
||||||
|
- IANA validation approach: HIGH — verified by Node 22 runtime execution
|
||||||
|
- Phase 12 status: HIGH — confirmed by file listing (only 12-UI-SPEC.md, no code)
|
||||||
|
- Picker UX (no new package): HIGH — confirmed no combobox library in PWA package.json
|
||||||
|
|
||||||
|
**Research date:** 2026-06-15
|
||||||
|
**Valid until:** 2026-07-15 (stable; all from local codebase inspection)
|
||||||
+92
@@ -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_
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
---
|
||||||
|
phase: 18-auto-timezone-detection-and-ability-to-change-timezone
|
||||||
|
reviewed: 2026-06-15T04:30:00Z
|
||||||
|
depth: standard
|
||||||
|
files_reviewed: 8
|
||||||
|
files_reviewed_list:
|
||||||
|
- apps/api/src/lib/householdTimezone.ts
|
||||||
|
- apps/api/src/routes/admin.ts
|
||||||
|
- apps/api/src/broker/reminderScheduler.ts
|
||||||
|
- 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: 0
|
||||||
|
info: 0
|
||||||
|
total: 0
|
||||||
|
status: clean
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 18: Code Review Report
|
||||||
|
|
||||||
|
**Reviewed:** 2026-06-15T04:30:00Z
|
||||||
|
**Depth:** standard
|
||||||
|
**Files Reviewed:** 8
|
||||||
|
**Status:** clean
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Narrative Findings (AI reviewer)
|
||||||
|
|
||||||
|
No Critical, Warning, or actionable Info findings remain. Verification notes below.
|
||||||
|
|
||||||
|
### Verification of applied fixes
|
||||||
|
|
||||||
|
- **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`).
|
||||||
|
|
||||||
|
- **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`).
|
||||||
|
|
||||||
|
- **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`).
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
### Other checks (no regressions)
|
||||||
|
|
||||||
|
- **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).
|
||||||
|
|
||||||
|
All reviewed files meet quality standards. No actionable issues remain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Reviewed: 2026-06-15T04:30:00Z_
|
||||||
|
_Reviewer: Claude (gsd-code-reviewer)_
|
||||||
|
_Depth: standard_
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
phase: 18-auto-timezone-detection-and-ability-to-change-timezone
|
||||||
|
audited: 2026-06-15
|
||||||
|
status: secured
|
||||||
|
asvs_level: 1
|
||||||
|
block_on: high
|
||||||
|
register_authored_at_plan_time: true
|
||||||
|
threats_total: 13
|
||||||
|
threats_closed: 13
|
||||||
|
threats_open: 0
|
||||||
|
threats_accepted: 4
|
||||||
|
supply_chain_checks: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 18 — Auto Timezone Detection & Change Timezone: Security Audit
|
||||||
|
|
||||||
|
**Audited:** 2026-06-15
|
||||||
|
**ASVS Level:** 1
|
||||||
|
**block_on:** high
|
||||||
|
**Compared against:** origin/main..HEAD
|
||||||
|
**Status:** SECURED — 13/13 threats closed (8 mitigate verified, 5 accept documented), 4× T-18-SC supply-chain verified
|
||||||
|
|
||||||
|
This audit verifies each declared threat mitigation EXISTS in the implemented code. It does not scan for new vulnerabilities. Implementation files were not modified.
|
||||||
|
|
||||||
|
## Threat Verification
|
||||||
|
|
||||||
|
| Threat ID | Category | Disposition | Status | Evidence |
|
||||||
|
|-----------|----------|-------------|--------|----------|
|
||||||
|
| T-18-01 | Tampering | mitigate | CLOSED | `householdTimezone.ts:25-35` reads only; never returns an unvalidated forwarded write. `resolveHouseholdTimezone` (`:49-61`) guarantees non-empty fallback (trim guard). All writes go through Plan 02 IANA-validated path. |
|
||||||
|
| T-18-02 | DoS | accept | CLOSED | Accepted risk logged below. Single PK lookup per scheduler tick (`reminderScheduler.ts:250`). |
|
||||||
|
| T-18-03 | Elevation of Privilege | mitigate | CLOSED | `admin.ts:42` `adminRouter.use('*', requireAdmin)` is the FIRST router statement, before all routes. GET (`:200`), PUT (`:224`), POST seed (`:251`) all appended after it → inherit the guard. 403 tests exist (`admin.test.ts:603-625, 711-720`). |
|
||||||
|
| T-18-04 | Tampering | mitigate | CLOSED | `admin.ts:56-62` `timezoneSchema` uses `.refine(isValidIanaTimezone)`; PUT (`:224`) and seed (`:251`) both bind it via `zValidator('json', timezoneSchema)`. `isValidIanaTimezone` (`householdTimezone.ts:70-77`) is eval-free try/catch on `Intl.DateTimeFormat`. Invalid → 400 before any DB write. |
|
||||||
|
| T-18-05 | Tampering | mitigate | CLOSED | Seed (`admin.ts:262-266`) uses `INSERT IGNORE`; an existing row is silently ignored (value preserved, D-03 no-overwrite). `seeded` derived from `affectedRows === 1`. Cannot overwrite an existing value. |
|
||||||
|
| T-18-06 | Information Disclosure | accept | CLOSED | Accepted risk logged below. Grep confirms no `console.log` of request bodies in `admin.ts` (only a T-10-10 comment reference). Timezone is non-sensitive. |
|
||||||
|
| T-18-07 | Injection | mitigate | CLOSED | PUT upsert (`admin.ts:227-230`) uses Drizzle `.insert().onDuplicateKeyUpdate` — parameterized, key is hard-coded literal `'household_timezone'`. Seed (`:262-263`) uses a `sql` template where `INSERT IGNORE` is a literal keyword, column identifiers via `sql.identifier`, and `${timezone}` is a bound parameter (not string-concatenated) and IANA-validated upstream. |
|
||||||
|
| T-18-08 | Tampering | mitigate | CLOSED | Broker sites consume `getHouseholdTimezone(db)` only (`reminderScheduler.ts:250`, `outboxWorker.ts:391`). `resolveHouseholdTimezone` (`householdTimezone.ts:49-61`) trims stored value and `process.env.TZ`; empty/blank falls through to a valid Intl zone — fallback can never return `''`/invalid. |
|
||||||
|
| T-18-09 | Tampering (regression) | mitigate | CLOSED | `git diff --name-only origin/main..HEAD` excludes `eventDateTime.ts` and `hydrateEvents.ts` (D-07 boundary intact). No timed-write/display path touched. |
|
||||||
|
| T-18-10 | DoS | accept | CLOSED | Accepted risk logged below. Per-drain-cycle memoization (`outboxWorker.ts:385-395`, created `:774`) shares one app_config read across both all-day branches (`:527`, `:634`). |
|
||||||
|
| T-18-11 | Elevation of Privilege | accept | CLOSED | Accepted risk logged below. Client gate is UX-only; server `requireAdmin` (`admin.ts:42`) is the real control. Client (`client.ts:498`) documents server enforcement. |
|
||||||
|
| T-18-12 | Tampering | mitigate | CLOSED | Client `setAdminTimezone` (`client.ts:501-511`) sends raw input to server; server `timezoneSchema.refine` (`admin.ts:56-62`) is authoritative (400 on invalid). Free-text input (`AdminPage.tsx:387-406`) is not the security boundary. |
|
||||||
|
| T-18-13 | Information Disclosure | mitigate | CLOSED | Timezone rendered as plain-text JSX (`AdminPage.tsx:431` `Use detected: {detectedTz}`) and as controlled input `value={effectiveTimezoneInput}` (`:390`). Grep confirms NO `dangerouslySetInnerHTML` in AdminPage.tsx. |
|
||||||
|
| T-18-SC (×4, plans 01-04) | Supply chain | mitigate | CLOSED | `git diff origin/main..HEAD` against all `package.json` / `pnpm-lock.yaml` returns EMPTY — zero new dependencies. PWA picker uses built-in `Intl`. |
|
||||||
|
|
||||||
|
## Accepted Risks Log
|
||||||
|
|
||||||
|
- **T-18-02 (DoS — DB read per scheduler tick):** Single primary-key lookup on `app_config` per 60s scheduler interval. Negligible load; read-per-run chosen so timezone changes propagate within one tick without a worker restart. Accepted.
|
||||||
|
- **T-18-06 (Information Disclosure — log/echo of submitted timezone):** Timezone identifiers are non-sensitive (not credentials or PII). No `noEchoHook` required; verified no body logging in handlers. Accepted.
|
||||||
|
- **T-18-10 (DoS — extra DB read at 3 call sites):** Mitigated in practice by per-drain-cycle memoization; PK lookups on a 60s interval are negligible. Accepted.
|
||||||
|
- **T-18-11 (Elevation of Privilege — client renders admin UI from isAdmin flag):** The client `isAdmin` gate is a UX convenience only. A forged request still hits server-side `requireAdmin` → 403 (T-18-03). The UI gate is not relied upon as a security control. Accepted.
|
||||||
|
|
||||||
|
## Unregistered Flags
|
||||||
|
|
||||||
|
None. All four plan SUMMARY `## Threat Flags` sections declare "No new threat surface beyond the plan's threat model." No new endpoints, auth paths, file-access patterns, or schema changes appeared during implementation that lack a mapped threat ID.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Post-review fixes (18-REVIEW-FIX.md: WR-01, WR-02, IN-01/02/03) were verified in code, not accepted on documentation alone:
|
||||||
|
- WR-01 empty/blank TZ guard present at `householdTimezone.ts:50-58` (relevant to T-18-08).
|
||||||
|
- WR-02 `INSERT IGNORE` + affectedRows-derived `seeded` present at `admin.ts:262-266` (relevant to T-18-05).
|
||||||
|
- D-07 boundary independently confirmed via `git diff --name-only`.
|
||||||
|
- Zero-dependency claim independently confirmed via empty manifest/lockfile diff.
|
||||||
|
|
||||||
|
## Security Audit 2026-06-15
|
||||||
|
|
||||||
|
| Metric | Count |
|
||||||
|
|--------|-------|
|
||||||
|
| Threats found | 13 |
|
||||||
|
| Closed | 13 |
|
||||||
|
| Open | 0 |
|
||||||
|
| Accepted risks | 4 |
|
||||||
|
| Supply-chain checks | 4 |
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
phase: 18
|
||||||
|
slug: auto-timezone-detection-and-ability-to-change-timezone
|
||||||
|
status: draft
|
||||||
|
nyquist_compliant: false
|
||||||
|
wave_0_complete: false
|
||||||
|
created: 2026-06-14
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 18 — Validation Strategy
|
||||||
|
|
||||||
|
> Per-phase validation contract for feedback sampling during execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Framework** | vitest |
|
||||||
|
| **Config file** | apps/api/vitest.config.ts (backend), apps/pwa/vitest.config.ts (frontend) |
|
||||||
|
| **Quick run command** | `pnpm --filter @familysync/api test -- --run` |
|
||||||
|
| **Full suite command** | `pnpm -r test -- --run` |
|
||||||
|
| **Estimated runtime** | ~30 seconds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Rate
|
||||||
|
|
||||||
|
- **After every task commit:** Run `pnpm --filter @familysync/api test -- --run`
|
||||||
|
- **After every plan wave:** Run `pnpm -r test -- --run`
|
||||||
|
- **Before `/gsd-verify-work`:** Full suite must be green
|
||||||
|
- **Max feedback latency:** 60 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Task Verification Map
|
||||||
|
|
||||||
|
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||||
|
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||||
|
| {N}-01-01 | 01 | 1 | D-05/D-06 | — | tz accessor returns stored value, falls back to process.env.TZ ?? Intl when unset | unit | `pnpm --filter @familysync/api test -- --run` | ❌ W0 | ⬜ pending |
|
||||||
|
|
||||||
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
|
> Planner fills this table per task. Key automated coverage targets from RESEARCH.md "Validation Architecture":
|
||||||
|
> - stored-tz accessor + D-06 fallback chain (unit)
|
||||||
|
> - IANA validation (try/catch on `Intl.DateTimeFormat`, accepts UTC) (unit)
|
||||||
|
> - `computeAlertInstantUtc` fed the stored tz vs fallback produces correct 9 AM-local instant (unit)
|
||||||
|
> - admin change-tz read/write round-trip (integration)
|
||||||
|
> - all-day reminder fires at 9 AM in the stored zone under a non-UTC value (integration/E2E)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 0 Requirements
|
||||||
|
|
||||||
|
- [ ] Backend unit-test stubs for the stored-tz accessor + fallback chain
|
||||||
|
- [ ] Backend unit-test stubs for IANA validation
|
||||||
|
- [ ] vitest already present — no framework install needed
|
||||||
|
|
||||||
|
*Existing all-day scheduler tests remain green unmodified (mock DB returns no app_config rows → D-06 fallback path).*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual-Only Verifications
|
||||||
|
|
||||||
|
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||||
|
|----------|-------------|------------|-------------------|
|
||||||
|
| Searchable IANA picker UX in /admin Settings | D-04 | Visual/interaction quality | Drive with `playwright-cli`: open /admin Settings, search a zone, save, reload, confirm persisted |
|
||||||
|
|
||||||
|
*iOS-Safari standalone behavior is out of scope for this phase.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Sign-Off
|
||||||
|
|
||||||
|
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||||
|
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||||
|
- [ ] Wave 0 covers all MISSING references
|
||||||
|
- [ ] No watch-mode flags
|
||||||
|
- [ ] Feedback latency < 60s
|
||||||
|
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
+164
@@ -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 | `<section aria-label="Timezone">` 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: `<zone>`" affordance appears when the input does not match the browser zone.
|
||||||
|
5. Click "Use detected: `<zone>`" — 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)_
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"browser": {
|
|
||||||
"browserName": "chromium",
|
|
||||||
"launchOptions": {
|
|
||||||
"channel": "chromium"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
} from './vevent.js';
|
} from './vevent.js';
|
||||||
import type { FastmailClient } from './client.js';
|
import type { FastmailClient } from './client.js';
|
||||||
import { dispatchEventChange } from '../lib/eventChangeDispatcher.js';
|
import { dispatchEventChange } from '../lib/eventChangeDispatcher.js';
|
||||||
|
import { getHouseholdTimezone } from '../lib/householdTimezone.js';
|
||||||
import { onOutboxDrain } from '../lib/outboxTrigger.js';
|
import { onOutboxDrain } from '../lib/outboxTrigger.js';
|
||||||
|
|
||||||
// ── Constants (D-07) ────────────────────────────────────────────────────────
|
// ── Constants (D-07) ────────────────────────────────────────────────────────
|
||||||
@@ -372,7 +373,31 @@ interface DispatchResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
/**
|
||||||
|
* Lazily resolves the household timezone at most once, caching the promise.
|
||||||
|
* Threaded through a drain cycle (mirroring clientCache, IN-01) so an all-day
|
||||||
|
* create row and an all-day update row in the same cycle share a single
|
||||||
|
* app_config read instead of issuing two identical SELECTs (IN-03). The read is
|
||||||
|
* still lazy: cycles with no all-day work never touch the DB.
|
||||||
|
*/
|
||||||
|
type TimezoneResolver = () => Promise<string>;
|
||||||
|
|
||||||
|
function makeTimezoneResolver(): TimezoneResolver {
|
||||||
|
let cached: Promise<string> | undefined;
|
||||||
|
return () => {
|
||||||
|
if (cached === undefined) {
|
||||||
|
// D-05: route through the single stored-TZ accessor (no inline fallback duplicated here).
|
||||||
|
// D-06: getHouseholdTimezone falls back to process.env.TZ → Intl when no row is stored.
|
||||||
|
cached = getHouseholdTimezone(db);
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchRow(
|
||||||
|
row: OutboxRow,
|
||||||
|
resolveTimezone: TimezoneResolver,
|
||||||
|
): Promise<DispatchResult> {
|
||||||
// CR-03: fail closed on credential errors — let loadClientForUser throw.
|
// CR-03: fail closed on credential errors — let loadClientForUser throw.
|
||||||
// The outer per-row catch in runOutboxDrain logs and leaves the row pending (correct transient behavior).
|
// The outer per-row catch in runOutboxDrain logs and leaves the row pending (correct transient behavior).
|
||||||
// Do NOT add an empty-credential fallback — that would silently PUT with no authentication.
|
// Do NOT add an empty-credential fallback — that would silently PUT with no authentication.
|
||||||
@@ -498,7 +523,8 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
|||||||
fields.allDay &&
|
fields.allDay &&
|
||||||
fields.start
|
fields.start
|
||||||
) {
|
) {
|
||||||
const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
// IN-03: shared per-cycle resolver — one app_config read across all-day rows.
|
||||||
|
const tz = await resolveTimezone();
|
||||||
const leadDays = fields.reminderLeadMinutes / 1440;
|
const leadDays = fields.reminderLeadMinutes / 1440;
|
||||||
allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz);
|
allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz);
|
||||||
}
|
}
|
||||||
@@ -604,7 +630,8 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
|||||||
// carries an explicit picker value or no reminder at all; no preserve path needed).
|
// carries an explicit picker value or no reminder at all; no preserve path needed).
|
||||||
let allDayAlertInstantUtcCreate: Date | undefined;
|
let allDayAlertInstantUtcCreate: Date | undefined;
|
||||||
if (fields.reminderLeadMinutes != null && fields.allDay && fields.start) {
|
if (fields.reminderLeadMinutes != null && fields.allDay && fields.start) {
|
||||||
const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
// IN-03: shared per-cycle resolver — one app_config read across all-day rows.
|
||||||
|
const tz = await resolveTimezone();
|
||||||
const leadDays = fields.reminderLeadMinutes / 1440;
|
const leadDays = fields.reminderLeadMinutes / 1440;
|
||||||
allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz);
|
allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz);
|
||||||
}
|
}
|
||||||
@@ -742,6 +769,10 @@ export async function runOutboxDrain(): Promise<void> {
|
|||||||
// credential at most once per cycle. Discarded when the drain returns — never persisted.
|
// credential at most once per cycle. Discarded when the drain returns — never persisted.
|
||||||
const clientCache = new Map<number, FastmailClient>();
|
const clientCache = new Map<number, FastmailClient>();
|
||||||
|
|
||||||
|
// IN-03: per-drain-cycle timezone resolver so multiple all-day rows in the same cycle
|
||||||
|
// share one app_config read. Lazy: cycles with no all-day work never hit the DB.
|
||||||
|
const resolveTimezone = makeTimezoneResolver();
|
||||||
|
|
||||||
for (const row of sorted) {
|
for (const row of sorted) {
|
||||||
// D-04 fast path: if the create for this group already failed in this batch, skip the delete
|
// D-04 fast path: if the create for this group already failed in this batch, skip the delete
|
||||||
if (row.operation === 'delete' && row.groupId && failedCreateGroups.has(row.groupId)) {
|
if (row.operation === 'delete' && row.groupId && failedCreateGroups.has(row.groupId)) {
|
||||||
@@ -788,7 +819,7 @@ export async function runOutboxDrain(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await dispatchRow(row);
|
const result = await dispatchRow(row, resolveTimezone);
|
||||||
|
|
||||||
if (result.conflict) {
|
if (result.conflict) {
|
||||||
// WR-06: distinguish an edit-as-move create-412 from a same-calendar conflict.
|
// WR-06: distinguish an edit-as-move create-412 from a same-calendar conflict.
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { calendarEvents, pushSubscriptions } from '../db/schema.js';
|
|||||||
import { dispatchPush } from '../lib/pushDispatcher.js';
|
import { dispatchPush } from '../lib/pushDispatcher.js';
|
||||||
import { computeAlertInstantUtc } from './vevent.js';
|
import { computeAlertInstantUtc } from './vevent.js';
|
||||||
import type { NotificationPayload } from '../lib/pushDispatcher.js';
|
import type { NotificationPayload } from '../lib/pushDispatcher.js';
|
||||||
|
import { getHouseholdTimezone } from '../lib/householdTimezone.js';
|
||||||
|
|
||||||
// Maximum lead time preset (2880 min = 2 days for timed; 7 days for all-day is handled separately)
|
// Maximum lead time preset (2880 min = 2 days for timed; 7 days for all-day is handled separately)
|
||||||
// Pre-filter window upper bound for timed events: fetch events starting up to MAX_LEAD_MINUTES out.
|
// Pre-filter window upper bound for timed events: fetch events starting up to MAX_LEAD_MINUTES out.
|
||||||
@@ -244,7 +245,9 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process ALL-DAY events
|
// Process ALL-DAY events
|
||||||
const serverTz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
// D-05: read the stored household timezone through the single accessor (no inline fallback).
|
||||||
|
// D-06: getHouseholdTimezone falls back to process.env.TZ → Intl when no row is stored.
|
||||||
|
const serverTz = await getHouseholdTimezone(db);
|
||||||
for (const row of allDayRows) {
|
for (const row of allDayRows) {
|
||||||
// Drizzle's date() column type is Date|null in TS but mysql2 returns ISO string at runtime
|
// Drizzle's date() column type is Date|null in TS but mysql2 returns ISO string at runtime
|
||||||
const dtstartDate = row.dtstartDate as unknown as string; // 'YYYY-MM-DD'
|
const dtstartDate = row.dtstartDate as unknown as string; // 'YYYY-MM-DD'
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* householdTimezone — shared accessor for the stored household timezone (D-05).
|
||||||
|
*
|
||||||
|
* D-05: single accessor so reminderScheduler.ts and outboxWorker.ts cannot drift.
|
||||||
|
* D-06: fallback chain when household_timezone is unset:
|
||||||
|
* stored value → process.env.TZ → Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
*
|
||||||
|
* Do NOT use Intl.supportedValuesOf for IANA validation — it omits 'UTC' (RESEARCH Pitfall 2).
|
||||||
|
* Use try/catch on Intl.DateTimeFormat construction instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
||||||
|
import type * as schema from '../db/schema.js';
|
||||||
|
import { appConfig } from '../db/schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the stored household timezone from app_config, or falls back to:
|
||||||
|
* 1. process.env.TZ (only if set and non-empty — empty/whitespace is ignored, WR-01)
|
||||||
|
* 2. Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
*
|
||||||
|
* This is the D-05 single source of truth for the server-side all-day "9 AM local"
|
||||||
|
* reminder computation in reminderScheduler.ts and outboxWorker.ts.
|
||||||
|
*/
|
||||||
|
export async function getHouseholdTimezone(db: MySql2Database<typeof schema>): Promise<string> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ value: appConfig.value })
|
||||||
|
.from(appConfig)
|
||||||
|
.where(eq(appConfig.key, 'household_timezone'))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return resolveHouseholdTimezone(row?.value ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the household timezone from an already-fetched stored value, applying
|
||||||
|
* the D-06 fallback chain. Centralizing the policy here (D-05) means callers that
|
||||||
|
* have already read the row — e.g. the GET /config/timezone handler — can reuse it
|
||||||
|
* without a second DB round-trip (IN-01), and there is exactly one place where the
|
||||||
|
* fallback rules live (IN-02).
|
||||||
|
*
|
||||||
|
* WR-01: `??` only short-circuits on null/undefined, so a set-but-empty
|
||||||
|
* `process.env.TZ` (`TZ=` or `TZ=' '`) would otherwise leak through and yield an
|
||||||
|
* invalid IANA zone that throws inside `Intl.DateTimeFormat({ timeZone })` downstream.
|
||||||
|
* Empty/whitespace-only candidate values are treated as absent so they fall through.
|
||||||
|
*/
|
||||||
|
export function resolveHouseholdTimezone(storedValue: string | null): string {
|
||||||
|
const stored = storedValue?.trim();
|
||||||
|
if (stored) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envTz = process.env.TZ?.trim();
|
||||||
|
if (envTz) {
|
||||||
|
return envTz;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if `tz` is a valid IANA timezone identifier recognized by the
|
||||||
|
* runtime's Intl implementation, false otherwise.
|
||||||
|
*
|
||||||
|
* Uses try/catch on Intl.DateTimeFormat construction — NOT Intl.supportedValuesOf,
|
||||||
|
* which omits 'UTC' in some environments (RESEARCH Pitfall 2).
|
||||||
|
*/
|
||||||
|
export function isValidIanaTimezone(tz: string): boolean {
|
||||||
|
try {
|
||||||
|
Intl.DateTimeFormat(undefined, { timeZone: tz });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,10 +23,11 @@ import { Hono } from 'hono';
|
|||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, sql } from 'drizzle-orm';
|
||||||
import { db } from '../db/client.js';
|
import { db } from '../db/client.js';
|
||||||
import { users, memberCredentials, calendars } from '../db/schema.js';
|
import { users, memberCredentials, calendars, appConfig } from '../db/schema.js';
|
||||||
import { requireAdmin } from '../lib/requireAdmin.js';
|
import { requireAdmin } from '../lib/requireAdmin.js';
|
||||||
|
import { isValidIanaTimezone, resolveHouseholdTimezone } from '../lib/householdTimezone.js';
|
||||||
import {
|
import {
|
||||||
validateEncryptAndStoreCredential,
|
validateEncryptAndStoreCredential,
|
||||||
CredentialValidationError,
|
CredentialValidationError,
|
||||||
@@ -51,6 +52,15 @@ const credentialSchema = z.object({
|
|||||||
appPassword: z.string().min(1).max(500),
|
appPassword: z.string().min(1).max(500),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Timezone schema — no noEchoHook needed (timezone strings are non-sensitive, T-18-06)
|
||||||
|
const timezoneSchema = z.object({
|
||||||
|
timezone: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(64)
|
||||||
|
.refine(isValidIanaTimezone, { message: 'Invalid IANA timezone identifier' }),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* noEchoHook: NEVER return result.error from zValidator for credential routes.
|
* noEchoHook: NEVER return result.error from zValidator for credential routes.
|
||||||
* Zod's error object contains issues[].received which echoes the submitted value
|
* Zod's error object contains issues[].received which echoes the submitted value
|
||||||
@@ -178,3 +188,82 @@ adminRouter.put('/calendars/:id/shared', async (c) => {
|
|||||||
|
|
||||||
return c.json({ ok: true }, 200);
|
return c.json({ ok: true }, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/admin/config/timezone
|
||||||
|
//
|
||||||
|
// Returns the stored household timezone and whether it has been explicitly set
|
||||||
|
// (D-01, D-04, D-06). isExplicitlySet: false when no row is in app_config
|
||||||
|
// (the response still includes the D-06 fallback as the timezone value).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
adminRouter.get('/config/timezone', async (c) => {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ value: appConfig.value })
|
||||||
|
.from(appConfig)
|
||||||
|
.where(eq(appConfig.key, 'household_timezone'))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const isExplicitlySet = row?.value != null;
|
||||||
|
// IN-01/IN-02: reuse the row we just SELECTed and let the centralized accessor apply
|
||||||
|
// the D-06 fallback — no second app_config round-trip, single source for the policy.
|
||||||
|
const timezone = resolveHouseholdTimezone(row?.value ?? null);
|
||||||
|
|
||||||
|
return c.json({ timezone, isExplicitlySet });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PUT /api/admin/config/timezone
|
||||||
|
//
|
||||||
|
// Validates the IANA timezone string (server-side, T-18-04) and upserts the
|
||||||
|
// household_timezone key in app_config (D-01, D-04).
|
||||||
|
// Uses onDuplicateKeyUpdate — appConfig.key is the PK so this is a true upsert.
|
||||||
|
// No noEchoHook: timezone strings are non-sensitive (T-18-06).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
adminRouter.put('/config/timezone', zValidator('json', timezoneSchema), async (c) => {
|
||||||
|
const { timezone } = c.req.valid('json');
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(appConfig)
|
||||||
|
.values({ key: 'household_timezone', value: timezone })
|
||||||
|
.onDuplicateKeyUpdate({ set: { value: timezone } });
|
||||||
|
|
||||||
|
return c.json({ ok: true }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/admin/config/timezone/seed
|
||||||
|
//
|
||||||
|
// Seeds household_timezone ONLY when currently unset (D-02 first-run / D-03
|
||||||
|
// no-overwrite). Used by the Phase 12 setup wizard and the Phase 18 auto-detect
|
||||||
|
// flow to store the browser-detected IANA zone without clobbering an admin's
|
||||||
|
// explicit choice.
|
||||||
|
//
|
||||||
|
// Always returns 200 with { ok: true, seeded: <bool> }.
|
||||||
|
// Uses a single INSERT IGNORE: when the household_timezone row already exists the
|
||||||
|
// insert is silently ignored (existing value untouched — D-03 no-overwrite) and
|
||||||
|
// cannot 500 on the PK constraint under a concurrent seed/PUT. `seeded` is derived
|
||||||
|
// from the result's affectedRows so it reflects what the DB actually did, accurately
|
||||||
|
// even under a genuine concurrent race (WR-02).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
adminRouter.post('/config/timezone/seed', zValidator('json', timezoneSchema), async (c) => {
|
||||||
|
const { timezone } = c.req.valid('json');
|
||||||
|
|
||||||
|
// WR-02: always run the INSERT and let the DB be the source of truth, instead of a
|
||||||
|
// pre-flight SELECT whose result could be stale under a concurrent race (two racers
|
||||||
|
// both observing an empty table and both returning seeded:true). INSERT IGNORE on
|
||||||
|
// MariaDB reports affectedRows === 1 for a real insert and 0 when the row already
|
||||||
|
// exists (ignored, value preserved — D-03), so deriving `seeded` from affectedRows
|
||||||
|
// is accurate: only the racer whose INSERT actually wrote the row gets seeded:true.
|
||||||
|
// `timezone` is interpolated via drizzle's parameterized sql template (bound param,
|
||||||
|
// not string concatenation) and is already validated as an IANA zone by timezoneSchema.
|
||||||
|
const result = (await db.execute(
|
||||||
|
sql`INSERT IGNORE INTO ${appConfig} (${sql.identifier('key')}, ${sql.identifier('value')}) VALUES ('household_timezone', ${timezone})`,
|
||||||
|
)) as unknown as [{ affectedRows: number }, unknown];
|
||||||
|
|
||||||
|
const seeded = result[0].affectedRows === 1;
|
||||||
|
|
||||||
|
return c.json({ ok: true, seeded }, 200);
|
||||||
|
});
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ function wireMockChain() {
|
|||||||
// mockFromFn differentiates by table argument using Symbol.for('drizzle:Name'):
|
// mockFromFn differentiates by table argument using Symbol.for('drizzle:Name'):
|
||||||
// - memberCredentials → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default)
|
// - memberCredentials → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default)
|
||||||
// - calendarEvents → returns mockWhereCalEvents (etag re-read for WR-02)
|
// - calendarEvents → returns mockWhereCalEvents (etag re-read for WR-02)
|
||||||
|
// - app_config → returns empty row array (D-06 fallback: no stored TZ → Intl fallback)
|
||||||
|
// getHouseholdTimezone(db) chain: .from(appConfig).where(...).limit(1) → []
|
||||||
// - calendarOutbox (and anything else) → returns mockWherePending (pending-rows + sibling-status)
|
// - calendarOutbox (and anything else) → returns mockWherePending (pending-rows + sibling-status)
|
||||||
// JSON.stringify throws on circular Drizzle table structures; use Symbol identity instead.
|
// JSON.stringify throws on circular Drizzle table structures; use Symbol identity instead.
|
||||||
mockFromFn.mockImplementation((table: unknown) => {
|
mockFromFn.mockImplementation((table: unknown) => {
|
||||||
@@ -160,6 +162,13 @@ function wireMockChain() {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (tableName === 'app_config') {
|
||||||
|
// D-06: default = no stored timezone row → getHouseholdTimezone falls back to
|
||||||
|
// process.env.TZ → Intl. The where().limit(1) chain must be mockable.
|
||||||
|
// Plan 18-03 tests override this via wireMockChainWithTz to inject a stored zone.
|
||||||
|
const limitFn = vi.fn().mockResolvedValue([]); // no stored row → D-06 fallback
|
||||||
|
return { where: vi.fn().mockReturnValue({ limit: limitFn }) };
|
||||||
|
}
|
||||||
return { where: mockWherePending };
|
return { where: mockWherePending };
|
||||||
});
|
});
|
||||||
mockWhereCalEvents.mockImplementation(() => Promise.resolve([]));
|
mockWhereCalEvents.mockImplementation(() => Promise.resolve([]));
|
||||||
@@ -1094,3 +1103,116 @@ describe('runOutboxDrain — WR-02: reminderLeadMinutes max(10080) in outboxPayl
|
|||||||
expect(setArg?.status).not.toBe('failed');
|
expect(setArg?.status).not.toBe('failed');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Plan 18-03: stored household_timezone drives all-day alert instant ───────
|
||||||
|
//
|
||||||
|
// D-05: when getHouseholdTimezone(db) returns 'America/Chicago', the CREATE and
|
||||||
|
// UPDATE all-day branches must compute the VALARM TRIGGER using Chicago
|
||||||
|
// local time, not process.env.TZ.
|
||||||
|
// D-06: existing tests that do not mock app_config continue to fall back to the
|
||||||
|
// current process.env.TZ / Intl behavior (backward-compat path).
|
||||||
|
//
|
||||||
|
// These tests FAIL before the rewire (GREEN) because the code still reads
|
||||||
|
// process.env.TZ at both sites.
|
||||||
|
|
||||||
|
describe('runOutboxDrain — Plan 18-03: stored household_timezone drives all-day alert (D-05)', () => {
|
||||||
|
// Helper: wire the full db mock chain including app_config for getHouseholdTimezone.
|
||||||
|
// The app_config SELECT chain: select({value}).from(appConfig).where(...).limit(1)
|
||||||
|
// → from(app_config) → { where: fn → limitFn } → limitFn() → [{value}]
|
||||||
|
function wireMockChainWithTz(storedTz: string | null) {
|
||||||
|
wireMockChain(); // base wiring for outbox/credentials/events
|
||||||
|
|
||||||
|
// Extend mockFromFn to also handle app_config table with where().limit() chain
|
||||||
|
const baseMockFromFn = mockFromFn.getMockImplementation();
|
||||||
|
mockFromFn.mockImplementation((table: unknown) => {
|
||||||
|
const tableName = (table as Record<symbol, string>)[Symbol.for('drizzle:Name')] ?? '';
|
||||||
|
if (tableName === 'app_config') {
|
||||||
|
const tzRows = storedTz ? [{ value: storedTz }] : [];
|
||||||
|
const limitResolve = vi.fn().mockResolvedValue(tzRows);
|
||||||
|
// .where() must return an object with .limit(), not the fn itself
|
||||||
|
return { where: vi.fn().mockReturnValue({ limit: limitResolve }) };
|
||||||
|
}
|
||||||
|
return baseMockFromFn ? baseMockFromFn(table) : { where: mockWherePending };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
mockPendingRows = [];
|
||||||
|
wireMockChain();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event date: 2026-06-20 (summer). Lead = 1440 min = 1 day → alert date = 2026-06-19.
|
||||||
|
// America/Chicago CDT (UTC-5): 9 AM CDT on 2026-06-19 = 2026-06-19T14:00:00Z.
|
||||||
|
// process.env.TZ is unset (UTC in test environment): 9 AM UTC on 2026-06-19 = 2026-06-19T09:00:00Z.
|
||||||
|
// When stored zone drives computation: TRIGGER must contain the Chicago time (14:00 UTC).
|
||||||
|
|
||||||
|
it('D-05 create branch: stored America/Chicago produces 14:00 UTC trigger for 2026-06-20 all-day', async () => {
|
||||||
|
wireMockChainWithTz('America/Chicago');
|
||||||
|
|
||||||
|
const { createCalendarEvent } = await import('../../src/broker/write.js');
|
||||||
|
let capturedIcsString: unknown = null;
|
||||||
|
vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => {
|
||||||
|
capturedIcsString = icsString;
|
||||||
|
return makeResponse(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// all-day CREATE payload with 1440-min lead (1 day before)
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title: 'Stored-TZ Birthday',
|
||||||
|
allDay: true,
|
||||||
|
start: '2026-06-20',
|
||||||
|
end: '2026-06-20',
|
||||||
|
reminderLeadMinutes: 1440,
|
||||||
|
});
|
||||||
|
mockPendingRows = [makeRow({ payload })];
|
||||||
|
|
||||||
|
await runOutboxDrain();
|
||||||
|
|
||||||
|
expect(typeof capturedIcsString).toBe('string');
|
||||||
|
expect(capturedIcsString as string).toContain('BEGIN:VALARM');
|
||||||
|
// With stored 'America/Chicago' (CDT, UTC-5): alert date = 2026-06-19, 9 AM CDT = 14:00 UTC.
|
||||||
|
// The absolute TRIGGER value must be 20260619T140000Z (Chicago time).
|
||||||
|
expect(capturedIcsString as string).toContain('20260619T140000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('D-05 update branch: stored America/Chicago produces 14:00 UTC trigger for 2026-06-20 all-day', async () => {
|
||||||
|
wireMockChainWithTz('America/Chicago');
|
||||||
|
|
||||||
|
const { updateCalendarEvent } = await import('../../src/broker/write.js');
|
||||||
|
let capturedIcsString: unknown = null;
|
||||||
|
vi.mocked(updateCalendarEvent).mockImplementation(
|
||||||
|
async (_client, _calObjectUrl, icsString, _etag) => {
|
||||||
|
capturedIcsString = icsString;
|
||||||
|
return makeResponse(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// all-day UPDATE payload with 1440-min lead — must also read stored TZ
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title: 'Stored-TZ Update',
|
||||||
|
allDay: true,
|
||||||
|
start: '2026-06-20',
|
||||||
|
end: '2026-06-20',
|
||||||
|
reminderLeadMinutes: 1440,
|
||||||
|
});
|
||||||
|
mockPendingRows = [
|
||||||
|
makeRow({
|
||||||
|
operation: 'update',
|
||||||
|
calendarObjectUrl: 'https://example.com/event.ics',
|
||||||
|
etag: 'W/"abc"',
|
||||||
|
payload,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Provide etag so WR-02 re-read path resolves (no rawVevent → falls through to all-day branch)
|
||||||
|
mockWhereCalEvents.mockResolvedValue([{ etag: 'W/"abc"' }]);
|
||||||
|
|
||||||
|
await runOutboxDrain();
|
||||||
|
|
||||||
|
expect(typeof capturedIcsString).toBe('string');
|
||||||
|
expect(capturedIcsString as string).toContain('BEGIN:VALARM');
|
||||||
|
// Same expectation: alert date = 2026-06-19, 9 AM CDT = 14:00 UTC.
|
||||||
|
expect(capturedIcsString as string).toContain('20260619T140000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -59,21 +59,32 @@ function makeSelectMock(rows: unknown[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup the db.select mock so the FIRST call (timed query) returns `timedRows`
|
* Setup the db.select mock so the FIRST call (timed query) returns `timedRows`,
|
||||||
* and the SECOND call (all-day query) returns `allDayRows` (default empty).
|
* the SECOND call (all-day query) returns `allDayRows` (default empty), and the
|
||||||
|
* THIRD call (getHouseholdTimezone app_config lookup, Plan 18-03) returns no row
|
||||||
|
* so the D-06 fallback fires (process.env.TZ → Intl).
|
||||||
*
|
*
|
||||||
* runReminderCheck() issues two sequential db.select() calls:
|
* runReminderCheck() after the Plan 18-03 rewire issues three sequential db.select() calls:
|
||||||
* 1st: timed events query
|
* 1st: timed events query
|
||||||
* 2nd: all-day events query
|
* 2nd: all-day events query
|
||||||
|
* 3rd: getHouseholdTimezone → SELECT value FROM app_config WHERE key='household_timezone'
|
||||||
|
*
|
||||||
|
* Existing tests that pin process.env.TZ rely on the D-06 fallback (empty app_config row),
|
||||||
|
* so the third call returning no row keeps them green unchanged.
|
||||||
|
*
|
||||||
|
* To test a stored timezone (D-05), use mockThreeQueries instead.
|
||||||
*/
|
*/
|
||||||
function mockTwoQueries(
|
function mockTwoQueries(
|
||||||
db: { select: ReturnType<typeof vi.fn> },
|
db: { select: ReturnType<typeof vi.fn> },
|
||||||
timedRows: unknown[],
|
timedRows: unknown[],
|
||||||
allDayRows: unknown[] = [],
|
allDayRows: unknown[] = [],
|
||||||
) {
|
) {
|
||||||
|
// Reset pending once-values to prevent cross-test queue contamination.
|
||||||
|
db.select.mockReset();
|
||||||
db.select
|
db.select
|
||||||
.mockReturnValueOnce(makeSelectMock(timedRows))
|
.mockReturnValueOnce(makeSelectMock(timedRows))
|
||||||
.mockReturnValueOnce(makeSelectMock(allDayRows));
|
.mockReturnValueOnce(makeSelectMock(allDayRows))
|
||||||
|
.mockReturnValueOnce(makeAppConfigSelectMock(null)); // null → D-06 fallback (no stored TZ)
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeEventRow(overrides: {
|
function makeEventRow(overrides: {
|
||||||
@@ -911,3 +922,126 @@ describe('humanizeLeadMinutes — CR-02 all-day-aware push body', () => {
|
|||||||
expect(humanizeLeadMinutes(30)).toBe('Starts in 30 min');
|
expect(humanizeLeadMinutes(30)).toBe('Starts in 30 min');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Plan 18-03: stored household_timezone drives all-day 9 AM-local fire ─────
|
||||||
|
//
|
||||||
|
// D-05: when getHouseholdTimezone(db) returns a stored IANA zone, the scheduler
|
||||||
|
// must compute the 9 AM alert using THAT zone (not process.env.TZ).
|
||||||
|
// D-06: the existing process.env.TZ-pinned tests above still pass unchanged
|
||||||
|
// (the fallback fires when no app_config row is returned).
|
||||||
|
//
|
||||||
|
// Before the rewire (GREEN), serverTz is still read from process.env.TZ, so the
|
||||||
|
// stored 'America/Chicago' has no effect → these tests FAIL (RED gate).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock a db with three sequential select calls:
|
||||||
|
* 1st: timed events query → timedRows
|
||||||
|
* 2nd: all-day events query → allDayRows
|
||||||
|
* 3rd: getHouseholdTimezone app_config query → [{value: tz}] (or [] for no-row)
|
||||||
|
*
|
||||||
|
* The 3rd select chain is: select({value}).from(appConfig).where(...).limit(1)
|
||||||
|
* which resolves via: from → where → limit → promise.
|
||||||
|
*/
|
||||||
|
function makeAppConfigSelectMock(tzRow: { value: string } | null) {
|
||||||
|
const rows = tzRow ? [tzRow] : [];
|
||||||
|
const limitResolve = vi.fn().mockResolvedValue(rows);
|
||||||
|
// .where() must return an object with a .limit() method (not the fn itself)
|
||||||
|
const whereFn = vi.fn().mockReturnValue({ limit: limitResolve });
|
||||||
|
return {
|
||||||
|
from: vi.fn().mockReturnValue({ where: whereFn }),
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockThreeQueries(
|
||||||
|
db: { select: ReturnType<typeof vi.fn> },
|
||||||
|
timedRows: unknown[],
|
||||||
|
allDayRows: unknown[],
|
||||||
|
tzRow: { value: string } | null,
|
||||||
|
) {
|
||||||
|
// Reset any unconsumed mockReturnValueOnce entries from a prior test before adding new ones.
|
||||||
|
// vi.clearAllMocks() does NOT flush the once-queue; vi.resetModules() may return the same
|
||||||
|
// vi.fn() instance across tests (Vitest caches mock factories across module resets).
|
||||||
|
db.select.mockReset();
|
||||||
|
db.select
|
||||||
|
.mockReturnValueOnce(makeSelectMock(timedRows))
|
||||||
|
.mockReturnValueOnce(makeSelectMock(allDayRows))
|
||||||
|
.mockReturnValueOnce(makeAppConfigSelectMock(tzRow));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('reminderScheduler — Plan 18-03: stored household_timezone drives all-day alert (D-05)', () => {
|
||||||
|
// These tests FAIL before the rewire because serverTz is still read from process.env.TZ.
|
||||||
|
// After the rewire, getHouseholdTimezone(db) returns the stored zone → GREEN.
|
||||||
|
//
|
||||||
|
// Event date: 2026-06-20 (summer).
|
||||||
|
// America/New_York: EDT (UTC-4) → 9 AM = 13:00 UTC.
|
||||||
|
// America/Chicago: CDT (UTC-5) → 9 AM = 14:00 UTC.
|
||||||
|
// process.env.TZ is set to 'America/New_York' for these tests; stored zone is 'America/Chicago'.
|
||||||
|
// When the stored zone drives the computation: alert fires at 14:00 UTC.
|
||||||
|
let prevTz: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prevTz = process.env.TZ;
|
||||||
|
process.env.TZ = 'America/New_York';
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
if (prevTz === undefined) delete process.env.TZ;
|
||||||
|
else process.env.TZ = prevTz;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('D-05: stored America/Chicago fires at 9 AM Chicago (14:00 UTC), not 13:00 UTC (New York)', async () => {
|
||||||
|
// 2026-06-20 all-day, 0-lead, stored zone 'America/Chicago' (CDT, UTC-5)
|
||||||
|
// Expected alert: 9 AM CDT = 2026-06-20T14:00:00Z
|
||||||
|
const alertUtcChicago = new Date('2026-06-20T14:00:00Z');
|
||||||
|
vi.setSystemTime(alertUtcChicago); // now = Chicago alert time
|
||||||
|
|
||||||
|
const { db } = await import('../../src/db/client.js');
|
||||||
|
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||||
|
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
|
||||||
|
|
||||||
|
const allDayRow = makeEventRow({
|
||||||
|
uid: 'stored-tz-chicago-uid',
|
||||||
|
title: 'All-Day Stored TZ Event',
|
||||||
|
dtstartDate: '2026-06-20',
|
||||||
|
dtstartUtc: null,
|
||||||
|
allDay: true,
|
||||||
|
reminderLeadMinutes: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Third select returns the stored 'America/Chicago' row from app_config
|
||||||
|
mockThreeQueries(vi.mocked(db), [], [allDayRow], { value: 'America/Chicago' });
|
||||||
|
await runReminderCheck(alertUtcChicago);
|
||||||
|
|
||||||
|
// Must dispatch at 14:00 UTC (9 AM CDT) — the stored zone drives the computation
|
||||||
|
expect(vi.mocked(dispatchPush)).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('D-05: stored America/Chicago does NOT fire at 13:00 UTC (that is 9 AM New York)', async () => {
|
||||||
|
// now = 13:00 UTC. If stored zone is 'America/Chicago', alert = 14:00 UTC → NOT yet.
|
||||||
|
const nyAlertTime = new Date('2026-06-20T13:00:00Z');
|
||||||
|
vi.setSystemTime(nyAlertTime);
|
||||||
|
|
||||||
|
const { db } = await import('../../src/db/client.js');
|
||||||
|
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||||
|
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
|
||||||
|
|
||||||
|
const allDayRow = makeEventRow({
|
||||||
|
uid: 'stored-tz-no-early-fire-uid',
|
||||||
|
title: 'All-Day No Early Fire',
|
||||||
|
dtstartDate: '2026-06-20',
|
||||||
|
dtstartUtc: null,
|
||||||
|
allDay: true,
|
||||||
|
reminderLeadMinutes: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockThreeQueries(vi.mocked(db), [], [allDayRow], { value: 'America/Chicago' });
|
||||||
|
await runReminderCheck(nyAlertTime);
|
||||||
|
|
||||||
|
// Stored zone is Chicago → alert is 14:00 UTC, not 13:00 UTC → must NOT fire
|
||||||
|
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* householdTimezone unit tests (Plan 18-01)
|
||||||
|
*
|
||||||
|
* Behavior-pinned contracts:
|
||||||
|
* 1. getHouseholdTimezone: DB row present → returns stored value
|
||||||
|
* 2. getHouseholdTimezone: no DB row, process.env.TZ set → returns TZ value
|
||||||
|
* 3. getHouseholdTimezone: no DB row, no TZ env var → returns Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
* 4. getHouseholdTimezone: DB row present but value is null → falls through to env.TZ branch
|
||||||
|
* 5. isValidIanaTimezone: returns true for valid IANA zones ('UTC', 'Etc/UTC', 'America/Chicago', 'Europe/London')
|
||||||
|
* 6. isValidIanaTimezone: returns false for garbage ('Not/AZone', '', 'Mars/Phobos')
|
||||||
|
*
|
||||||
|
* Uses a mocked Drizzle db chain (same pattern as requireAdmin.test.ts).
|
||||||
|
* Saves and restores process.env.TZ around each test to avoid leaking env state.
|
||||||
|
*
|
||||||
|
* Run: pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the db singleton so tests do not need a live MariaDB connection.
|
||||||
|
vi.mock('../../src/db/client.js', () => ({
|
||||||
|
db: {
|
||||||
|
select: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { db } from '../../src/db/client.js';
|
||||||
|
import { getHouseholdTimezone, isValidIanaTimezone } from '../../src/lib/householdTimezone.js';
|
||||||
|
|
||||||
|
const mockDb = db as { select: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
// ── DB query chain builder ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a Drizzle select() → from() → where() → limit() chain
|
||||||
|
* that resolves to the given array.
|
||||||
|
*/
|
||||||
|
function makeSelectChain(resolvedValue: unknown[]) {
|
||||||
|
const chain = {
|
||||||
|
from: vi.fn(),
|
||||||
|
where: vi.fn(),
|
||||||
|
limit: vi.fn().mockResolvedValue(resolvedValue),
|
||||||
|
};
|
||||||
|
chain.from.mockReturnValue(chain);
|
||||||
|
chain.where.mockReturnValue(chain);
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test suite ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getHouseholdTimezone', () => {
|
||||||
|
let originalTZ: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Save the current TZ env value before each test
|
||||||
|
originalTZ = process.env.TZ;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore TZ to its original value (or delete if it was unset)
|
||||||
|
if (originalTZ === undefined) {
|
||||||
|
delete process.env.TZ;
|
||||||
|
} else {
|
||||||
|
process.env.TZ = originalTZ;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the stored value when the DB has a row for household_timezone', async () => {
|
||||||
|
mockDb.select.mockReturnValue(makeSelectChain([{ value: 'America/Chicago' }]));
|
||||||
|
|
||||||
|
const result = await getHouseholdTimezone(mockDb as never);
|
||||||
|
expect(result).toBe('America/Chicago');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to process.env.TZ when the DB returns no row', async () => {
|
||||||
|
mockDb.select.mockReturnValue(makeSelectChain([]));
|
||||||
|
process.env.TZ = 'America/New_York';
|
||||||
|
|
||||||
|
const result = await getHouseholdTimezone(mockDb as never);
|
||||||
|
expect(result).toBe('America/New_York');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to Intl.DateTimeFormat().resolvedOptions().timeZone when no DB row and TZ is unset', async () => {
|
||||||
|
mockDb.select.mockReturnValue(makeSelectChain([]));
|
||||||
|
delete process.env.TZ;
|
||||||
|
|
||||||
|
const expected = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
const result = await getHouseholdTimezone(mockDb as never);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through to process.env.TZ when the DB row value is null', async () => {
|
||||||
|
mockDb.select.mockReturnValue(makeSelectChain([{ value: null }]));
|
||||||
|
process.env.TZ = 'Europe/London';
|
||||||
|
|
||||||
|
const result = await getHouseholdTimezone(mockDb as never);
|
||||||
|
expect(result).toBe('Europe/London');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an empty process.env.TZ as unset and falls through to the Intl zone (WR-01)', async () => {
|
||||||
|
mockDb.select.mockReturnValue(makeSelectChain([]));
|
||||||
|
process.env.TZ = '';
|
||||||
|
|
||||||
|
const expected = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
const result = await getHouseholdTimezone(mockDb as never);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a whitespace-only process.env.TZ as unset and falls through to the Intl zone (WR-01)', async () => {
|
||||||
|
mockDb.select.mockReturnValue(makeSelectChain([]));
|
||||||
|
process.env.TZ = ' ';
|
||||||
|
|
||||||
|
const expected = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
const result = await getHouseholdTimezone(mockDb as never);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidIanaTimezone', () => {
|
||||||
|
it("returns true for 'UTC'", () => {
|
||||||
|
expect(isValidIanaTimezone('UTC')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for 'Etc/UTC'", () => {
|
||||||
|
expect(isValidIanaTimezone('Etc/UTC')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for 'America/Chicago'", () => {
|
||||||
|
expect(isValidIanaTimezone('America/Chicago')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for 'Europe/London'", () => {
|
||||||
|
expect(isValidIanaTimezone('Europe/London')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for 'Not/AZone'", () => {
|
||||||
|
expect(isValidIanaTimezone('Not/AZone')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for empty string ''", () => {
|
||||||
|
expect(isValidIanaTimezone('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for 'Mars/Phobos'", () => {
|
||||||
|
expect(isValidIanaTimezone('Mars/Phobos')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,7 +30,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db } from '../../src/db/client.js';
|
import { db } from '../../src/db/client.js';
|
||||||
import { users, memberCredentials, calendars } from '../../src/db/schema.js';
|
import { users, memberCredentials, calendars, appConfig } from '../../src/db/schema.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// CalDAV mock — intercept createFastmailClient so tests do not hit live Fastmail.
|
// CalDAV mock — intercept createFastmailClient so tests do not hit live Fastmail.
|
||||||
@@ -585,3 +585,263 @@ describe('POST /api/me/credential', () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// admin timezone config (Plan 18-02: D-01, D-02, D-03, D-04)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('admin timezone config', () => {
|
||||||
|
// Clean up household_timezone between tests to avoid bleed
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.delete(appConfig).where(eq(appConfig.key, 'household_timezone'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/admin/config/timezone — access control
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('GET returns 403 for a non-admin authenticated user (T-18-03)', async () => {
|
||||||
|
const nonAdminId = await seedUser('tz-non-admin-get', false);
|
||||||
|
currentDevUserId = nonAdminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
const res = await app.fetch(jsonRequest('GET', '/api/admin/config/timezone'));
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// PUT /api/admin/config/timezone — access control
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('PUT returns 403 for a non-admin authenticated user (T-18-03)', async () => {
|
||||||
|
const nonAdminId = await seedUser('tz-non-admin-put', false);
|
||||||
|
currentDevUserId = nonAdminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
const res = await app.fetch(
|
||||||
|
jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'America/Chicago' }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/admin/config/timezone — admin reads when no row is stored
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('GET as admin with no stored row returns 200 with isExplicitlySet: false and a non-empty timezone', async () => {
|
||||||
|
const adminId = await seedUser('tz-admin-get-default', true);
|
||||||
|
currentDevUserId = adminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
const res = await app.fetch(jsonRequest('GET', '/api/admin/config/timezone'));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { timezone: string; isExplicitlySet: boolean };
|
||||||
|
expect(typeof body.timezone).toBe('string');
|
||||||
|
expect(body.timezone.length).toBeGreaterThan(0);
|
||||||
|
expect(body.isExplicitlySet).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// PUT then GET round-trip
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('PUT America/Chicago then GET returns that value with isExplicitlySet: true', async () => {
|
||||||
|
const adminId = await seedUser('tz-admin-roundtrip', true);
|
||||||
|
currentDevUserId = adminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
const putRes = await app.fetch(
|
||||||
|
jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'America/Chicago' }),
|
||||||
|
);
|
||||||
|
expect(putRes.status).toBe(200);
|
||||||
|
|
||||||
|
const getRes = await app.fetch(jsonRequest('GET', '/api/admin/config/timezone'));
|
||||||
|
expect(getRes.status).toBe(200);
|
||||||
|
const body = (await getRes.json()) as { timezone: string; isExplicitlySet: boolean };
|
||||||
|
expect(body.timezone).toBe('America/Chicago');
|
||||||
|
expect(body.isExplicitlySet).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// PUT with UTC must succeed (Pitfall 2)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('PUT UTC returns 200 (Pitfall 2 — UTC must be accepted)', async () => {
|
||||||
|
const adminId = await seedUser('tz-admin-utc', true);
|
||||||
|
currentDevUserId = adminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
const res = await app.fetch(
|
||||||
|
jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'UTC' }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// PUT with invalid IANA string returns 400 and writes nothing (T-18-04)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('PUT Not/AZone returns 400 and does not store a value (T-18-04)', async () => {
|
||||||
|
const adminId = await seedUser('tz-admin-invalid', true);
|
||||||
|
currentDevUserId = adminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
const res = await app.fetch(
|
||||||
|
jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'Not/AZone' }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
|
||||||
|
// Verify no row was written to app_config
|
||||||
|
const [row] = await db
|
||||||
|
.select({ value: appConfig.value })
|
||||||
|
.from(appConfig)
|
||||||
|
.where(eq(appConfig.key, 'household_timezone'))
|
||||||
|
.limit(1);
|
||||||
|
expect(row).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/admin/config/timezone/seed — seeds when unset (D-02)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/admin/config/timezone/seed — access control (WR-02)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('POST seed returns 403 for a non-admin authenticated user (WR-02 access control)', async () => {
|
||||||
|
const nonAdminId = await seedUser('tz-non-admin-seed', false);
|
||||||
|
currentDevUserId = nonAdminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
const res = await app.fetch(
|
||||||
|
jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'America/Chicago' }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/admin/config/timezone/seed — seeds when unset (D-02)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('POST seed when unset stores the value and returns { ok: true, seeded: true } (D-02, WR-02)', async () => {
|
||||||
|
const adminId = await seedUser('tz-admin-seed-unset', true);
|
||||||
|
currentDevUserId = adminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
const res = await app.fetch(
|
||||||
|
jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'Europe/London' }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { ok: boolean; seeded: boolean };
|
||||||
|
// WR-02: seeded flag must be true when this request performed the seed
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(body.seeded).toBe(true);
|
||||||
|
|
||||||
|
// Verify the value was stored
|
||||||
|
const [row] = await db
|
||||||
|
.select({ value: appConfig.value })
|
||||||
|
.from(appConfig)
|
||||||
|
.where(eq(appConfig.key, 'household_timezone'))
|
||||||
|
.limit(1);
|
||||||
|
expect(row?.value).toBe('Europe/London');
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/admin/config/timezone/seed — no-overwrite when already set (D-03)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('POST seed when already set does NOT overwrite the existing value (D-03)', async () => {
|
||||||
|
const adminId = await seedUser('tz-admin-seed-overwrite', true);
|
||||||
|
currentDevUserId = adminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
// First set a value via PUT
|
||||||
|
const putRes = await app.fetch(
|
||||||
|
jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'America/Chicago' }),
|
||||||
|
);
|
||||||
|
expect(putRes.status).toBe(200);
|
||||||
|
|
||||||
|
// Now attempt to seed a different value
|
||||||
|
const seedRes = await app.fetch(
|
||||||
|
jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'Asia/Tokyo' }),
|
||||||
|
);
|
||||||
|
expect(seedRes.status).toBe(200);
|
||||||
|
|
||||||
|
// The stored value must still be America/Chicago (no overwrite, D-03)
|
||||||
|
const [row] = await db
|
||||||
|
.select({ value: appConfig.value })
|
||||||
|
.from(appConfig)
|
||||||
|
.where(eq(appConfig.key, 'household_timezone'))
|
||||||
|
.limit(1);
|
||||||
|
expect(row?.value).toBe('America/Chicago');
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/admin/config/timezone/seed — idempotent under concurrent race (WR-02)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('POST seed when value already exists returns 200 with seeded:false and does NOT throw (WR-02 idempotent race)', async () => {
|
||||||
|
const adminId = await seedUser('tz-admin-seed-race', true);
|
||||||
|
currentDevUserId = adminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
// First seed establishes the value (simulates the "winner" of the race)
|
||||||
|
const firstRes = await app.fetch(
|
||||||
|
jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'America/New_York' }),
|
||||||
|
);
|
||||||
|
expect(firstRes.status).toBe(200);
|
||||||
|
const firstBody = (await firstRes.json()) as { ok: boolean; seeded: boolean };
|
||||||
|
expect(firstBody.seeded).toBe(true);
|
||||||
|
|
||||||
|
// Second seed with a different value — simulates the "loser" of the race that
|
||||||
|
// arrives after the row already exists. Pre-fix this would throw a 500 due to
|
||||||
|
// the PK constraint. Post-fix it must return 200 with seeded:false and preserve
|
||||||
|
// the original value (D-03 no-overwrite).
|
||||||
|
const secondRes = await app.fetch(
|
||||||
|
jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'Europe/Paris' }),
|
||||||
|
);
|
||||||
|
expect(secondRes.status).toBe(200);
|
||||||
|
const secondBody = (await secondRes.json()) as { ok: boolean; seeded: boolean };
|
||||||
|
expect(secondBody.ok).toBe(true);
|
||||||
|
expect(secondBody.seeded).toBe(false);
|
||||||
|
|
||||||
|
// The stored value must still be America/New_York (winner's value preserved, D-03)
|
||||||
|
const [row] = await db
|
||||||
|
.select({ value: appConfig.value })
|
||||||
|
.from(appConfig)
|
||||||
|
.where(eq(appConfig.key, 'household_timezone'))
|
||||||
|
.limit(1);
|
||||||
|
expect(row?.value).toBe('America/New_York');
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST seed — `seeded` flag is derived from what the DB actually did (WR-02)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('POST seed reports seeded:false when a row was pre-inserted directly, not via the endpoint (WR-02 accurate flag)', async () => {
|
||||||
|
const adminId = await seedUser('tz-admin-seed-derived', true);
|
||||||
|
currentDevUserId = adminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
// Insert the row directly (bypassing the seed endpoint) so no request-scoped
|
||||||
|
// pre-flight SELECT could have observed "unset". A correct implementation must
|
||||||
|
// derive seeded from the INSERT result (affectedRows), so this returns false.
|
||||||
|
await db.insert(appConfig).values({ key: 'household_timezone', value: 'America/Denver' });
|
||||||
|
|
||||||
|
const res = await app.fetch(
|
||||||
|
jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'Asia/Tokyo' }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { ok: boolean; seeded: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(body.seeded).toBe(false);
|
||||||
|
|
||||||
|
// D-03 preserved: the directly-inserted value is untouched.
|
||||||
|
const [row] = await db
|
||||||
|
.select({ value: appConfig.value })
|
||||||
|
.from(appConfig)
|
||||||
|
.where(eq(appConfig.key, 'household_timezone'))
|
||||||
|
.limit(1);
|
||||||
|
expect(row?.value).toBe('America/Denver');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -109,6 +109,13 @@ export default async function globalSetup(): Promise<void> {
|
|||||||
await conn.execute('TRUNCATE TABLE calendar_events');
|
await conn.execute('TRUNCATE TABLE calendar_events');
|
||||||
await conn.execute('SET FOREIGN_KEY_CHECKS=1');
|
await conn.execute('SET FOREIGN_KEY_CHECKS=1');
|
||||||
|
|
||||||
|
// Phase 18: clear any stored household timezone so the timezone spec always
|
||||||
|
// starts from the first-run (isExplicitlySet:false) state. app_config is NOT
|
||||||
|
// truncated above (it can hold other non-test config), so delete only this key.
|
||||||
|
// Without this, a prior run's saved value leaks across runs and makes the
|
||||||
|
// first-run / persist-across-reload timezone assertions non-deterministic.
|
||||||
|
await conn.execute("DELETE FROM app_config WHERE `key` = 'household_timezone'");
|
||||||
|
|
||||||
// Seed the dev-bypass admin user row for id=1 (D-01 dev note, Phase 10).
|
// Seed the dev-bypass admin user row for id=1 (D-01 dev note, Phase 10).
|
||||||
// DEV_USER (id=1) is injected by devBypass.ts WITHOUT a DB upsert, so the users table
|
// DEV_USER (id=1) is injected by devBypass.ts WITHOUT a DB upsert, so the users table
|
||||||
// has no row for id=1 by default. requireAdmin (Plan 02) does a DB lookup and would 403
|
// has no row for id=1 by default. requireAdmin (Plan 02) does a DB lookup and would 403
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* timezone-verify.spec.ts — 18-04 round-trip verification
|
||||||
|
*
|
||||||
|
* Verifies the admin Timezone section with the real 18-02 API endpoints.
|
||||||
|
* Runs on desktop profile only (admin UI is desktop-focused).
|
||||||
|
*
|
||||||
|
* NOTE: the IANA picker is a searchable combobox — a text input (role=combobox)
|
||||||
|
* that opens a role=listbox of role=option items on focus. Selecting a zone means
|
||||||
|
* focusing the input, typing to filter, then clicking the option (not selectOption).
|
||||||
|
* Option accessible names are the full IANA id (e.g. "America/Chicago").
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Admin Timezone section — 18-04 round-trip', () => {
|
||||||
|
test.beforeEach(async ({ page }, testInfo) => {
|
||||||
|
// Desktop-only: the admin timezone UI is desktop-focused, and these tests mutate
|
||||||
|
// the single household_timezone row. global-setup resets that row once per run, so
|
||||||
|
// running on multiple profiles lets one project's save leak into another project's
|
||||||
|
// first-run assertions. Restricting to one project keeps the shared state coherent.
|
||||||
|
test.skip(testInfo.project.name !== 'desktop', 'Admin timezone UI is desktop-only');
|
||||||
|
await page.goto('/admin');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Admin Settings' })).toBeVisible();
|
||||||
|
// Wait for the Timezone section to load (requires the 18-02 GET endpoint)
|
||||||
|
const tzSection = page.getByRole('region', { name: 'Timezone' });
|
||||||
|
await expect(tzSection).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Timezone section is visible on /admin', async ({ page }) => {
|
||||||
|
await expect(page.getByRole('region', { name: 'Timezone' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Timezone picker (combobox) is visible and pre-filled', async ({ page }) => {
|
||||||
|
// The searchable text input exposes role=combobox
|
||||||
|
const input = page.getByRole('combobox', { name: 'Household timezone' });
|
||||||
|
await expect(input).toBeVisible();
|
||||||
|
const val = await input.inputValue();
|
||||||
|
expect(val.length, 'Picker should have a non-empty timezone').toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Save is enabled on first run when timezone is not yet explicit (WR-01)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// On first run the GET returns isExplicitlySet:false with the detected zone
|
||||||
|
// pre-filled. Saving that value to make the choice explicit is a meaningful
|
||||||
|
// action, so Save must be ENABLED even though the input matches the displayed
|
||||||
|
// default. (The disabled-when-unchanged behaviour for an already-explicit
|
||||||
|
// value is covered by the "Save persists" test below, which re-disables Save
|
||||||
|
// after a successful save.)
|
||||||
|
const tzSection = page.getByRole('region', { name: 'Timezone' });
|
||||||
|
// Confirm we are in the first-run (system-default) state for this assertion.
|
||||||
|
await expect(tzSection.getByText('Using system default')).toBeVisible();
|
||||||
|
const saveBtn = tzSection.getByRole('button', { name: /Save|Saving/ });
|
||||||
|
await expect(saveBtn).toBeVisible();
|
||||||
|
await expect(saveBtn).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Changing the selection enables Save', async ({ page }) => {
|
||||||
|
const tzSection = page.getByRole('region', { name: 'Timezone' });
|
||||||
|
const input = page.getByRole('combobox', { name: 'Household timezone' });
|
||||||
|
await input.click();
|
||||||
|
await input.fill('America/Chicago');
|
||||||
|
await page.getByRole('option', { name: 'America/Chicago' }).click();
|
||||||
|
await expect(input).toHaveValue('America/Chicago');
|
||||||
|
const saveBtn = tzSection.getByRole('button', { name: /Save/ });
|
||||||
|
await expect(saveBtn).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Typing filters the list (type-to-search)', async ({ page }) => {
|
||||||
|
const input = page.getByRole('combobox', { name: 'Household timezone' });
|
||||||
|
const listbox = page.getByRole('listbox', { name: 'Timezones' });
|
||||||
|
|
||||||
|
// Focus opens the full list with no typing required.
|
||||||
|
await input.click();
|
||||||
|
await expect(listbox).toBeVisible();
|
||||||
|
await expect(listbox.getByRole('option').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Human-friendly partial query (case-insensitive, underscores ignored) filters.
|
||||||
|
await input.fill('york');
|
||||||
|
await expect(page.getByRole('option', { name: 'America/New_York' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option', { name: 'Europe/Paris' })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Save persists timezone across reload', async ({ page }) => {
|
||||||
|
const input = page.getByRole('combobox', { name: 'Household timezone' });
|
||||||
|
const tzSection = page.getByRole('region', { name: 'Timezone' });
|
||||||
|
|
||||||
|
// Set to a known value via the searchable combobox
|
||||||
|
await input.click();
|
||||||
|
await input.fill('America/Chicago');
|
||||||
|
await page.getByRole('option', { name: 'America/Chicago' }).click();
|
||||||
|
await expect(input).toHaveValue('America/Chicago');
|
||||||
|
const saveBtn = tzSection.getByRole('button', { name: /^Save$/ });
|
||||||
|
await expect(saveBtn).toBeEnabled();
|
||||||
|
await saveBtn.click();
|
||||||
|
// Wait for save to complete (button re-disables when stored === input)
|
||||||
|
await expect(saveBtn).toBeDisabled({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Reload and verify persistence
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Admin Settings' })).toBeVisible();
|
||||||
|
await expect(tzSection).toBeVisible({ timeout: 15_000 });
|
||||||
|
const inputAfterReload = page.getByRole('combobox', { name: 'Household timezone' });
|
||||||
|
await expect(inputAfterReload).toHaveValue('America/Chicago');
|
||||||
|
|
||||||
|
// system-default note should be gone after explicit set
|
||||||
|
await expect(tzSection.getByText('Using system default')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"Use detected" affordance sets input to browser zone', async ({ page }) => {
|
||||||
|
const tzSection = page.getByRole('region', { name: 'Timezone' });
|
||||||
|
const detectedBtn = tzSection.getByRole('button', { name: /Use detected:/ });
|
||||||
|
|
||||||
|
// The button is only visible when detected TZ != stored TZ
|
||||||
|
const count = await detectedBtn.count();
|
||||||
|
if (count === 0) {
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the detected zone from the button label
|
||||||
|
const btnText = await detectedBtn.textContent();
|
||||||
|
const match = btnText?.match(/Use detected:\s*(.+)/);
|
||||||
|
const detectedZone = match?.[1]?.trim();
|
||||||
|
expect(detectedZone, 'Detected zone should be non-empty').toBeTruthy();
|
||||||
|
|
||||||
|
await detectedBtn.click();
|
||||||
|
const input = page.getByRole('combobox', { name: 'Household timezone' });
|
||||||
|
await expect(input).toHaveValue(detectedZone!);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -468,6 +468,48 @@ export async function setSharedCalendar(calendarId: number): Promise<void> {
|
|||||||
handleAuthResponse(res, `PUT /api/admin/calendars/${calendarId}/shared`);
|
handleAuthResponse(res, `PUT /api/admin/calendars/${calendarId}/shared`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response shape for GET /api/admin/config/timezone.
|
||||||
|
* isExplicitlySet is false when the household timezone has not been saved yet
|
||||||
|
* (the server returns the process.env.TZ / Intl fallback in that case — D-06).
|
||||||
|
*/
|
||||||
|
export interface AdminTimezoneResponse {
|
||||||
|
timezone: string;
|
||||||
|
isExplicitlySet: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the current household timezone setting.
|
||||||
|
* Admin-only: the server enforces requireAdmin (403 for non-admins).
|
||||||
|
*/
|
||||||
|
export async function fetchAdminTimezone(): Promise<AdminTimezoneResponse> {
|
||||||
|
const res = await fetch('/api/admin/config/timezone', {
|
||||||
|
credentials: 'include',
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
handleAuthResponse(res, 'GET /api/admin/config/timezone');
|
||||||
|
|
||||||
|
return res.json() as Promise<AdminTimezoneResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the household timezone.
|
||||||
|
* Admin-only: the server enforces requireAdmin and IANA validation (Plan 02).
|
||||||
|
* @param timezone A valid IANA timezone identifier (e.g. 'America/Chicago').
|
||||||
|
*/
|
||||||
|
export async function setAdminTimezone(timezone: string): Promise<void> {
|
||||||
|
const res = await fetch('/api/admin/config/timezone', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
redirect: 'manual',
|
||||||
|
body: JSON.stringify({ timezone }),
|
||||||
|
});
|
||||||
|
|
||||||
|
handleAuthResponse(res, 'PUT /api/admin/config/timezone');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Self-service: save (add) the current member's own credential.
|
* Self-service: save (add) the current member's own credential.
|
||||||
* Member-scoped: NO userId in payload — server resolves from session (Pitfall 6 / T-10-12).
|
* Member-scoped: NO userId in payload — server resolves from session (Pitfall 6 / T-10-12).
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* AdminPage timezone Save-button disabled logic — unit tests (WR-01 fix verification)
|
||||||
|
*
|
||||||
|
* Tests the `timezoneSaveDisabled` derivation inline:
|
||||||
|
*
|
||||||
|
* const timezoneSaveDisabled =
|
||||||
|
* timezoneMutation.isPending ||
|
||||||
|
* effectiveTimezoneInput === '' ||
|
||||||
|
* (isExplicit && effectiveTimezoneInput === storedTimezone);
|
||||||
|
*
|
||||||
|
* The pre-fix bug: Save was always disabled when input === stored value,
|
||||||
|
* regardless of isExplicitlySet. On first run (isExplicitlySet: false) this meant
|
||||||
|
* the admin could never confirm/save the displayed system-default zone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function extracted from AdminPage that computes whether Save should be disabled.
|
||||||
|
* This mirrors the logic that must be present post-fix in AdminPage.tsx.
|
||||||
|
*/
|
||||||
|
function computeTimezoneSaveDisabled({
|
||||||
|
isPending,
|
||||||
|
effectiveTimezoneInput,
|
||||||
|
storedTimezone,
|
||||||
|
isExplicit,
|
||||||
|
}: {
|
||||||
|
isPending: boolean;
|
||||||
|
effectiveTimezoneInput: string;
|
||||||
|
storedTimezone: string;
|
||||||
|
isExplicit: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
isPending ||
|
||||||
|
effectiveTimezoneInput === '' ||
|
||||||
|
(isExplicit && effectiveTimezoneInput === storedTimezone)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pre-fix behavior: the bug that WR-01 identified
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('timezoneSaveDisabled — WR-01 first-run (isExplicitlySet: false)', () => {
|
||||||
|
it('Save is ENABLED when not explicitly set, even if input matches stored value (WR-01)', () => {
|
||||||
|
// First run: GET returns { timezone: 'UTC', isExplicitlySet: false }
|
||||||
|
// Input pre-fills to 'UTC'. This must enable Save so admin can confirm.
|
||||||
|
const disabled = computeTimezoneSaveDisabled({
|
||||||
|
isPending: false,
|
||||||
|
effectiveTimezoneInput: 'UTC',
|
||||||
|
storedTimezone: 'UTC',
|
||||||
|
isExplicit: false,
|
||||||
|
});
|
||||||
|
expect(disabled).toBe(false); // WR-01: was `true` before fix (bug)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Save is ENABLED when not explicitly set with a real zone matching stored', () => {
|
||||||
|
const disabled = computeTimezoneSaveDisabled({
|
||||||
|
isPending: false,
|
||||||
|
effectiveTimezoneInput: 'America/Chicago',
|
||||||
|
storedTimezone: 'America/Chicago',
|
||||||
|
isExplicit: false,
|
||||||
|
});
|
||||||
|
expect(disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Save is DISABLED when pending (regardless of isExplicit)', () => {
|
||||||
|
const disabled = computeTimezoneSaveDisabled({
|
||||||
|
isPending: true,
|
||||||
|
effectiveTimezoneInput: 'UTC',
|
||||||
|
storedTimezone: 'UTC',
|
||||||
|
isExplicit: false,
|
||||||
|
});
|
||||||
|
expect(disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Save is DISABLED when input is empty', () => {
|
||||||
|
const disabled = computeTimezoneSaveDisabled({
|
||||||
|
isPending: false,
|
||||||
|
effectiveTimezoneInput: '',
|
||||||
|
storedTimezone: 'UTC',
|
||||||
|
isExplicit: false,
|
||||||
|
});
|
||||||
|
expect(disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Already-explicit cases: no-op re-saves should stay disabled
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('timezoneSaveDisabled — already explicitly set (isExplicitlySet: true)', () => {
|
||||||
|
it('Save is DISABLED when input matches stored and timezone is explicit (no-op guard)', () => {
|
||||||
|
// Admin previously saved 'America/Chicago'. Input still shows 'America/Chicago'.
|
||||||
|
// No change → Save stays disabled (two-tap UX).
|
||||||
|
const disabled = computeTimezoneSaveDisabled({
|
||||||
|
isPending: false,
|
||||||
|
effectiveTimezoneInput: 'America/Chicago',
|
||||||
|
storedTimezone: 'America/Chicago',
|
||||||
|
isExplicit: true,
|
||||||
|
});
|
||||||
|
expect(disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Save is ENABLED when input differs from stored (user changed value)', () => {
|
||||||
|
const disabled = computeTimezoneSaveDisabled({
|
||||||
|
isPending: false,
|
||||||
|
effectiveTimezoneInput: 'Europe/Paris',
|
||||||
|
storedTimezone: 'America/Chicago',
|
||||||
|
isExplicit: true,
|
||||||
|
});
|
||||||
|
expect(disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Save is DISABLED when pending even if value differs', () => {
|
||||||
|
const disabled = computeTimezoneSaveDisabled({
|
||||||
|
isPending: true,
|
||||||
|
effectiveTimezoneInput: 'Europe/Paris',
|
||||||
|
storedTimezone: 'America/Chicago',
|
||||||
|
isExplicit: true,
|
||||||
|
});
|
||||||
|
expect(disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,6 +30,8 @@ import {
|
|||||||
fetchAdminMembers,
|
fetchAdminMembers,
|
||||||
fetchAdminCalendars,
|
fetchAdminCalendars,
|
||||||
setSharedCalendar,
|
setSharedCalendar,
|
||||||
|
fetchAdminTimezone,
|
||||||
|
setAdminTimezone,
|
||||||
type AdminMember,
|
type AdminMember,
|
||||||
type AdminCalendar,
|
type AdminCalendar,
|
||||||
} from '../api/client.js';
|
} from '../api/client.js';
|
||||||
@@ -60,6 +62,16 @@ export function AdminPage() {
|
|||||||
// Shared calendar picker state
|
// Shared calendar picker state
|
||||||
const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null);
|
const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Timezone picker state
|
||||||
|
const [timezoneInput, setTimezoneInput] = useState<string | null>(null);
|
||||||
|
// Searchable combobox state: tzSearch is the live filter text while the list is
|
||||||
|
// open (null = closed, input shows the selected zone). tzActiveIndex tracks the
|
||||||
|
// keyboard-highlighted option.
|
||||||
|
const [tzOpen, setTzOpen] = useState(false);
|
||||||
|
const [tzSearch, setTzSearch] = useState<string | null>(null);
|
||||||
|
const [tzActiveIndex, setTzActiveIndex] = useState(0);
|
||||||
|
const tzBlurTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Members query
|
// Members query
|
||||||
const membersQuery = useQuery({
|
const membersQuery = useQuery({
|
||||||
queryKey: ['admin', 'members'],
|
queryKey: ['admin', 'members'],
|
||||||
@@ -82,6 +94,71 @@ export function AdminPage() {
|
|||||||
// Effective selected = user pick OR fallback to current saved
|
// Effective selected = user pick OR fallback to current saved
|
||||||
const effectiveSelected = selectedCalendarId ?? currentSharedId;
|
const effectiveSelected = selectedCalendarId ?? currentSharedId;
|
||||||
|
|
||||||
|
// Timezone query
|
||||||
|
const timezoneQuery = useQuery({
|
||||||
|
queryKey: ['admin', 'timezone'],
|
||||||
|
queryFn: fetchAdminTimezone,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timezone save mutation
|
||||||
|
const timezoneMutation = useMutation({
|
||||||
|
mutationFn: (tz: string) => setAdminTimezone(tz),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'timezone'] });
|
||||||
|
setTimezoneInput(null); // reset local override after save
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detected browser timezone (D-02)
|
||||||
|
const detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
|
// Effective timezone input value: local override → stored value → ''
|
||||||
|
const storedTimezone = timezoneQuery.data?.timezone ?? '';
|
||||||
|
const effectiveTimezoneInput = timezoneInput ?? storedTimezone;
|
||||||
|
|
||||||
|
// WR-01: derive isExplicit so we only apply the no-op guard when the timezone
|
||||||
|
// has ALREADY been explicitly saved. On first run (isExplicitlySet: false) the
|
||||||
|
// admin must be able to confirm/save the displayed system-default — even if the
|
||||||
|
// input value already matches the fallback string. Keep pending and empty-input
|
||||||
|
// guards unconditional.
|
||||||
|
const isExplicit = timezoneQuery.data?.isExplicitlySet ?? false;
|
||||||
|
|
||||||
|
// Save is disabled when:
|
||||||
|
// - mutation is in-flight (pending), OR
|
||||||
|
// - input is empty, OR
|
||||||
|
// - the timezone IS already explicitly set AND the input is unchanged (no-op)
|
||||||
|
const timezoneSaveDisabled =
|
||||||
|
timezoneMutation.isPending ||
|
||||||
|
effectiveTimezoneInput === '' ||
|
||||||
|
(isExplicit && effectiveTimezoneInput === storedTimezone);
|
||||||
|
|
||||||
|
// IANA zones list (Intl.supportedValuesOf may not be present in all runtimes)
|
||||||
|
const ianaZones: string[] =
|
||||||
|
typeof (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf ===
|
||||||
|
'function'
|
||||||
|
? (Intl as { supportedValuesOf: (key: string) => string[] }).supportedValuesOf('timeZone')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Searchable combobox: filter zones by the live search text (case-insensitive,
|
||||||
|
// ignoring underscores so "new york" matches "America/New_York"). When the search
|
||||||
|
// is empty the full list shows — so tapping the field reveals every zone with no
|
||||||
|
// typing required.
|
||||||
|
const tzNorm = (s: string) => s.toLowerCase().replace(/_/g, ' ');
|
||||||
|
const tzQuery = tzOpen ? tzNorm(tzSearch ?? '') : '';
|
||||||
|
const filteredZones = tzQuery
|
||||||
|
? ianaZones.filter((tz) => tzNorm(tz).includes(tzQuery))
|
||||||
|
: ianaZones;
|
||||||
|
|
||||||
|
// Commit a zone selection from the list, then close.
|
||||||
|
function selectTimezone(tz: string) {
|
||||||
|
if (tzBlurTimer.current) clearTimeout(tzBlurTimer.current);
|
||||||
|
setTimezoneInput(tz);
|
||||||
|
setTzSearch(null);
|
||||||
|
setTzOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Save shared calendar mutation
|
// Save shared calendar mutation
|
||||||
const sharedCalMutation = useMutation({
|
const sharedCalMutation = useMutation({
|
||||||
mutationFn: (calId: number) => setSharedCalendar(calId),
|
mutationFn: (calId: number) => setSharedCalendar(calId),
|
||||||
@@ -180,7 +257,7 @@ export function AdminPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── SHARED CALENDAR section ──────────────────────────────────────── */}
|
{/* ── SHARED CALENDAR section ──────────────────────────────────────── */}
|
||||||
<section aria-label="Shared Calendar">
|
<section aria-label="Shared Calendar" style={{ marginBottom: 'var(--space-8, 32px)' }}>
|
||||||
<div style={sectionLabelStyle}>Shared Calendar</div>
|
<div style={sectionLabelStyle}>Shared Calendar</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
@@ -286,6 +363,263 @@ export function AdminPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ── TIMEZONE section ─────────────────────────────────────────────── */}
|
||||||
|
<section aria-label="Timezone">
|
||||||
|
<div style={sectionLabelStyle}>Timezone</div>
|
||||||
|
|
||||||
|
{timezoneQuery.isLoading && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
fontSize: 'var(--text-body-size, 15px)',
|
||||||
|
padding: 'var(--space-2, 8px) 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Loading timezone…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{timezoneQuery.isError && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-destructive)',
|
||||||
|
fontSize: 'var(--text-body-size, 15px)',
|
||||||
|
padding: 'var(--space-2, 8px) 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Could not load timezone setting.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{timezoneQuery.data && (
|
||||||
|
<>
|
||||||
|
{/* System-default notice (D-06) */}
|
||||||
|
{!timezoneQuery.data.isExplicitlySet && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
marginBottom: 'var(--space-3, 12px)',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Using system default — save a timezone to make it explicit.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* IANA picker — searchable combobox. Focusing shows the full list
|
||||||
|
(no typing/erasing needed); typing filters it case-insensitively
|
||||||
|
(underscores ignored, so "new york" matches America/New_York). */}
|
||||||
|
<div style={{ position: 'relative', marginBottom: 'var(--space-3, 12px)' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
role="combobox"
|
||||||
|
aria-label="Household timezone"
|
||||||
|
aria-expanded={tzOpen}
|
||||||
|
aria-controls="tz-listbox"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-activedescendant={
|
||||||
|
tzOpen && filteredZones.length ? `tz-opt-${tzActiveIndex}` : undefined
|
||||||
|
}
|
||||||
|
autoComplete="off"
|
||||||
|
value={tzOpen ? (tzSearch ?? '') : effectiveTimezoneInput}
|
||||||
|
placeholder={
|
||||||
|
tzOpen ? effectiveTimezoneInput || 'Search timezones…' : 'Search timezones…'
|
||||||
|
}
|
||||||
|
onFocus={() => {
|
||||||
|
if (tzBlurTimer.current) clearTimeout(tzBlurTimer.current);
|
||||||
|
setTzOpen(true);
|
||||||
|
setTzSearch('');
|
||||||
|
setTzActiveIndex(0);
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTzSearch(e.target.value);
|
||||||
|
setTzOpen(true);
|
||||||
|
setTzActiveIndex(0);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!tzOpen) {
|
||||||
|
setTzOpen(true);
|
||||||
|
setTzSearch('');
|
||||||
|
}
|
||||||
|
setTzActiveIndex((i) => Math.min(i + 1, filteredZones.length - 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setTzActiveIndex((i) => Math.max(i - 1, 0));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
if (tzOpen && filteredZones[tzActiveIndex]) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectTimezone(filteredZones[tzActiveIndex]);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setTzOpen(false);
|
||||||
|
setTzSearch(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
// Delay so an option's onClick fires before the list unmounts.
|
||||||
|
tzBlurTimer.current = setTimeout(() => {
|
||||||
|
setTzOpen(false);
|
||||||
|
setTzSearch(null);
|
||||||
|
}, 120);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
padding: 'var(--space-2, 8px) var(--space-3, 12px)',
|
||||||
|
fontSize: 'var(--text-body-size, 15px)',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
background: 'var(--color-surface, #ffffff)',
|
||||||
|
minHeight: '44px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{tzOpen && (
|
||||||
|
<ul
|
||||||
|
id="tz-listbox"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Timezones"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
zIndex: 10,
|
||||||
|
top: 'calc(100% + 4px)',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
margin: 0,
|
||||||
|
padding: 'var(--space-1, 4px)',
|
||||||
|
listStyle: 'none',
|
||||||
|
maxHeight: '260px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
background: 'var(--color-surface, #ffffff)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
|
boxShadow: '0 6px 20px rgba(0,0,0,0.12)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredZones.length === 0 && (
|
||||||
|
<li
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-2, 8px) var(--space-3, 12px)',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
fontSize: 'var(--text-body-size, 15px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No matching timezones
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{filteredZones.map((tz, i) => {
|
||||||
|
const active = i === tzActiveIndex;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={tz}
|
||||||
|
id={`tz-opt-${i}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={tz === effectiveTimezoneInput}
|
||||||
|
ref={
|
||||||
|
active
|
||||||
|
? (el) => {
|
||||||
|
el?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onMouseEnter={() => setTzActiveIndex(i)}
|
||||||
|
onClick={() => selectTimezone(tz)}
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-2, 8px) var(--space-3, 12px)',
|
||||||
|
fontSize: 'var(--text-body-size, 15px)',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: active ? 'var(--color-member-0, #4A90D9)' : 'transparent',
|
||||||
|
...(active ? { color: '#ffffff' } : null),
|
||||||
|
minHeight: '44px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tz}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Use detected zone affordance (D-02) */}
|
||||||
|
{detectedTz && detectedTz !== effectiveTimezoneInput && (
|
||||||
|
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTimezoneInput(detectedTz)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
|
color: 'var(--color-member-0, #4A90D9)',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use detected: {detectedTz}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save button */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={timezoneSaveDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (effectiveTimezoneInput) {
|
||||||
|
timezoneMutation.mutate(effectiveTimezoneInput);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: timezoneSaveDisabled
|
||||||
|
? 'var(--color-border, #E2E4E9)'
|
||||||
|
: 'var(--color-member-0, #4A90D9)',
|
||||||
|
color: '#ffffff',
|
||||||
|
border: 'none',
|
||||||
|
cursor: timezoneSaveDisabled ? 'default' : 'pointer',
|
||||||
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
|
fontWeight: 600,
|
||||||
|
minHeight: '44px',
|
||||||
|
minWidth: '44px',
|
||||||
|
padding: '0 var(--space-6, 24px)',
|
||||||
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{timezoneMutation.isPending ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{timezoneMutation.isError && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-destructive)',
|
||||||
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
|
marginTop: 'var(--space-2, 8px)',
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Could not save timezone. Please check the value and try again.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Credential sheet — admin-rotate or admin-add */}
|
{/* Credential sheet — admin-rotate or admin-add */}
|
||||||
|
|||||||
Reference in New Issue
Block a user