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.
|
||||
**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.
|
||||
**Plans:** 4 plans (3 waves)
|
||||
**Plans:** 1/4 plans executed
|
||||
|
||||
Plans:
|
||||
**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)*
|
||||
|
||||
|
||||
+11
-10
@@ -4,13 +4,13 @@ milestone: v1.1
|
||||
milestone_name: Operability & Polish
|
||||
status: executing
|
||||
stopped_at: Phase 18 context gathered
|
||||
last_updated: "2026-06-15T01:53:38.101Z"
|
||||
last_activity: 2026-06-15 -- Phase 18 planning complete
|
||||
last_updated: "2026-06-15T02:08:26.245Z"
|
||||
last_activity: 2026-06-15
|
||||
progress:
|
||||
total_phases: 23
|
||||
completed_phases: 9
|
||||
total_plans: 32
|
||||
completed_plans: 32
|
||||
total_plans: 36
|
||||
completed_plans: 33
|
||||
percent: 39
|
||||
---
|
||||
|
||||
@@ -21,14 +21,14 @@ progress:
|
||||
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
|
||||
**Current focus:** Phase 11 — per-event-reminders
|
||||
**Current focus:** Phase 18 — auto-timezone-detection-and-ability-to-change-timezone
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 13
|
||||
Plan: Not started
|
||||
Phase: 18 (auto-timezone-detection-and-ability-to-change-timezone) — EXECUTING
|
||||
Plan: 2 of 4
|
||||
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)
|
||||
|
||||
@@ -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 P04 | 1315 | 3 tasks | 8 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
|
||||
|
||||
@@ -252,9 +253,9 @@ Recent decisions affecting current work:
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-06-15T01:25:46.029Z
|
||||
Last session: 2026-06-15T02:08:26.229Z
|
||||
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
|
||||
|
||||
|
||||
+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