docs(18): research phase domain
This commit is contained in:
+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)
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"browser": {
|
||||
"browserName": "chromium",
|
||||
"launchOptions": {
|
||||
"channel": "chromium"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user