docs(18-01): complete household timezone accessor + IANA validator plan
This commit is contained in:
@@ -636,12 +636,12 @@ Plans:
|
|||||||
**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.
|
**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 (decision contract D-01..D-07 from 18-CONTEXT.md)
|
**Requirements**: TBD (decision contract D-01..D-07 from 18-CONTEXT.md)
|
||||||
**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.
|
**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:** 4 plans (3 waves)
|
**Plans:** 1/4 plans executed
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
**Wave 1**
|
**Wave 1**
|
||||||
|
|
||||||
- [ ] 18-01-PLAN.md — TDD: getHouseholdTimezone(db) accessor + isValidIanaTimezone (D-05/D-06)
|
- [x] 18-01-PLAN.md — TDD: getHouseholdTimezone(db) accessor + isValidIanaTimezone (D-05/D-06)
|
||||||
|
|
||||||
**Wave 2** *(blocked on Wave 1 completion)*
|
**Wave 2** *(blocked on Wave 1 completion)*
|
||||||
|
|
||||||
|
|||||||
+11
-10
@@ -4,13 +4,13 @@ milestone: v1.1
|
|||||||
milestone_name: Operability & Polish
|
milestone_name: Operability & Polish
|
||||||
status: executing
|
status: executing
|
||||||
stopped_at: Phase 18 context gathered
|
stopped_at: Phase 18 context gathered
|
||||||
last_updated: "2026-06-15T01:53:38.101Z"
|
last_updated: "2026-06-15T02:08:26.245Z"
|
||||||
last_activity: 2026-06-15 -- Phase 18 planning complete
|
last_activity: 2026-06-15
|
||||||
progress:
|
progress:
|
||||||
total_phases: 23
|
total_phases: 23
|
||||||
completed_phases: 9
|
completed_phases: 9
|
||||||
total_plans: 32
|
total_plans: 36
|
||||||
completed_plans: 32
|
completed_plans: 33
|
||||||
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 (auto-timezone-detection-and-ability-to-change-timezone) — EXECUTING
|
||||||
Plan: Not started
|
Plan: 2 of 4
|
||||||
Status: Ready to execute
|
Status: Ready to execute
|
||||||
Last activity: 2026-06-15 -- Phase 18 planning complete
|
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,7 @@ _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 |
|
||||||
|
|
||||||
## Accumulated Context
|
## Accumulated Context
|
||||||
|
|
||||||
@@ -252,9 +253,9 @@ Recent decisions affecting current work:
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-06-15T01:25:46.029Z
|
Last session: 2026-06-15T02:08:26.229Z
|
||||||
Stopped at: Phase 18 context gathered
|
Stopped at: Phase 18 context gathered
|
||||||
Resume file: .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-CONTEXT.md
|
Resume file: None
|
||||||
|
|
||||||
## Operator Next Steps
|
## Operator Next Steps
|
||||||
|
|
||||||
|
|||||||
+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*
|
||||||
+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
|
||||||
Reference in New Issue
Block a user