docs(07.1-04): complete useUserTimezone hook + mobile migration plan

- Hook signature documented (useUserTimezone + formatInUserTimezone)
- Migrated callsite tables (before/after) for finance + tickets pages
- Audit results: 81 leak callsites across 39 files; Plan 05 dispatch = NEEDED
- All 3 task commits and acceptance criteria pass self-check
This commit is contained in:
lorentz 2026-05-07 07:58:42 -04:00
parent dfd0a9f2b2
commit 3f3142bbb7

View file

@ -0,0 +1,230 @@
---
phase: 07.1-user-timezone-fix-inserted-urgent
plan: 04
subsystem: client-hooks
tags: [timezone, iana, intl, better-auth, react, hook, mobile, audit]
# Dependency graph
requires:
- phase: 07.1-user-timezone-fix-inserted-urgent (Plan 01)
provides: "session.user.timezone via Better Auth additionalField"
provides:
- "lib/hooks/use-user-timezone.ts — canonical client tz hook + format helper"
- "Mobile finance page renders dates in user.timezone (not browser local zone)"
- "Mobile ticket detail page renders timestamps in user.timezone"
- "Codebase-wide leak inventory at .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md (81 leak callsites across 39 files)"
affects:
- 07.1-05 (Wave 2 sibling — closes the 81-callsite codebase-wide leak surface using this audit verbatim as input)
# Tech tracking
tech-stack:
added: []
patterns:
- "Client-only hook returning validated user IANA tz (Intl.supportedValuesOf whitelist)"
- "Pure formatInUserTimezone helper — safe to call in loops; not a hook"
- "NEXT_PUBLIC_DEFAULT_TIMEZONE env-driven fallback (mirrors server DEFAULT_TIMEZONE)"
- "fmtDate(ts, tz) signature — module-scope formatter accepts tz arg, no useMemo"
key-files:
created:
- lib/hooks/use-user-timezone.ts
- .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md
modified:
- app/mobile/finance/page.tsx
- app/mobile/tickets/[id]/page.tsx
key-decisions:
- "Hook validates against Intl.supportedValuesOf('timeZone') on every call — V8 caches internally; no module-scope memo"
- "formatInUserTimezone is a pure function (not a hook) so it works inside .map() / loops"
- "fmtDate kept at module scope; accepts tz arg (lighter touch than moving inside component)"
- "TimelineCard threaded with new tz prop instead of duplicating useUserTimezone() per card"
- "Audit excluded number-format toLocaleString() calls (47 of them) — those are not date callsites"
- "Plan 05 IS NEEDED: 81 leak callsites across 39 files — Plan 04 alone does not satisfy SC#4 (single source of truth)"
patterns-established:
- "Pattern: any 'use client' component formatting absolute dates imports useUserTimezone at the top, calls it inside the component, threads tz into every toLocale* option object"
- "Pattern: helper formatters (fmtDate, etc.) accept tz as a positional arg rather than reading the hook themselves (preserves rules-of-hooks for non-component callers)"
requirements-completed: [TZ-04, TZ-02]
# Metrics
duration: ~5 min
completed: 2026-05-07
---
# Phase 07.1 Plan 04: Client-side `useUserTimezone` Hook + Mobile Migration Summary
**`useUserTimezone()` is the canonical client-side accessor for the user's IANA timezone, sourced from `useSession()` and validated via `Intl.supportedValuesOf`. The two reported-bug-surface mobile pages (finance, ticket detail) now render dates in the user's chosen tz, not the browser's local zone. A 39-file codebase-wide leak audit dispatches Plan 05.**
## Performance
- **Duration:** ~5 min
- **Started:** 2026-05-07T11:51:29Z
- **Completed:** 2026-05-07T11:56:51Z
- **Tasks:** 3
- **Files created:** 2 (hook + audit)
- **Files modified:** 2 (finance, ticket detail)
## Accomplishments
- **Task 1 — `lib/hooks/use-user-timezone.ts` created.** Two exports:
- `useUserTimezone(): string` — reads `useSession().data?.user.timezone`, validates against `Intl.supportedValuesOf('timeZone')`, falls back to `process.env.NEXT_PUBLIC_DEFAULT_TIMEZONE || 'UTC'`. Always returns a usable string; never undefined; never throws on bad input.
- `formatInUserTimezone(input, tz, options?, locale='en-US'): string` — pure helper wrapping `toLocaleString` with the given tz threaded into options. Safe inside loops because it's not a hook.
- **Task 2 — Mobile pages migrated.**
- `app/mobile/finance/page.tsx`: imported the hook, called it once in `MobileFinance`, converted module-scope `fmtDate(ts)` to `fmtDate(ts, tz)`, threaded `tz` into 3 formatter callsites (`fmtDate` helper at line 4344, `setLastSync` at 77, `monthLabel` at 375). Updated 2 `fmtDate(...)` callers (lines 320, 359) to pass `tz`.
- `app/mobile/tickets/[id]/page.tsx`: imported the hook, called it once in `TicketTimeline`, converted module-scope `fmtDate(ts)` to `fmtDate(ts, tz)`, threaded `tz` into 5 callsites (4 inside `TimelineCard` for the four timeline-item kinds at lines 108/124/142/183, plus the "Created" header at line 289). Added `tz` prop to `TimelineCard` component to avoid duplicate hook calls.
- **Task 3 — Codebase-wide audit committed at `07.1-04-AUDIT.md`.** Classified 96+ raw discovery hits into:
- **81 leak callsites** across **39 files** (Plan 05 inputs)
- **5 explicit-zone callsites** (Plan 04 already migrated)
- **47 number-format callsites** (`Number.toLocaleString` — ignored, not dates)
- **7 server-side callsites** (LLM prompt builders in `app/api/veeam/*` — out of scope)
- **1 deliberate-UTC callsite** (`EngagementHoursSparkline` — leave as-is)
- **Plan 05 dispatch decision:** **CREATE Plan 05.** Leak count > 0; Plan 04 alone does not satisfy SC#4 codebase-wide.
## Task Commits
Each task was committed atomically (parallel-executor mode, `--no-verify`):
1. **Task 1: Create lib/hooks/use-user-timezone.ts**`2ac2db7` (feat)
2. **Task 2: Migrate mobile finance + ticket detail pages**`14f4da3` (feat)
3. **Task 3: Codebase-wide tz audit + Plan 05 dispatch**`dfd0a9f` (docs)
_Plan metadata commit will be added by the orchestrator after the wave completes._
## Files Created/Modified
- `lib/hooks/use-user-timezone.ts` (created, 61 lines) — `"use client"`; exports `useUserTimezone` and `formatInUserTimezone`; imports only `useSession` from `@/lib/auth-client`.
- `app/mobile/finance/page.tsx` (modified) — added `useUserTimezone` import, `const tz = useUserTimezone();` inside `MobileFinance`, threaded `tz` into 3 formatter callsites (positive grep: `timeZone:` count = 3, `toLocale*` count = 3).
- `app/mobile/tickets/[id]/page.tsx` (modified) — added `useUserTimezone` import, `const tz = useUserTimezone();` inside `TicketTimeline`, threaded `tz` into 5 `fmtDate(...)` callsites + `TimelineCard` prop. The single `toLocaleString(...)` callsite at line 50 has `timeZone: tz`.
- `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md` (created) — 39-file leak inventory with Plan 05 dispatch.
## Hook Signature
```ts
// lib/hooks/use-user-timezone.ts
export function useUserTimezone(): string;
export function formatInUserTimezone(
input: string | number | Date,
tz: string,
options?: Intl.DateTimeFormatOptions,
locale?: string, // default 'en-US'
): string;
```
Behavior:
- `useUserTimezone()` returns `session.user.timezone` if it's a valid IANA zone (≤64 chars, present in `Intl.supportedValuesOf('timeZone')`); otherwise returns `process.env.NEXT_PUBLIC_DEFAULT_TIMEZONE || 'UTC'`. Never throws.
- `formatInUserTimezone(input, tz, options, locale)` is a thin wrapper — `new Date(input).toLocaleString(locale, { ...options, timeZone: tz })`. Pure; safe inside loops.
## Migrated Callsites (before vs after)
### app/mobile/finance/page.tsx
| Line | Before | After |
|------|--------|-------|
| 4344 | `function fmtDate(ts: string) { return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); }` | `function fmtDate(ts: string, tz: string) { return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: tz }); }` |
| 77 | `setLastSync(ts ? new Date(ts).toLocaleString('en-US', { …, minute: '2-digit' }) : null)` | `setLastSync(ts ? new Date(ts).toLocaleString('en-US', { …, minute: '2-digit', timeZone: tz }) : null)` |
| 375 | `const monthLabel = new Date(m.month).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })` | `const monthLabel = new Date(m.month).toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: tz })` |
| 320 | `Due {fmtDate(inv.due_date)}` | `Due {fmtDate(inv.due_date, tz)}` |
| 359 | `secondary={fmtDate(p.txn_date)}` | `secondary={fmtDate(p.txn_date, tz)}` |
### app/mobile/tickets/[id]/page.tsx
| Line | Before | After |
|------|--------|-------|
| 4950 | `function fmtDate(ts: string) { return new Date(ts).toLocaleString('en-US', { …, minute: '2-digit' }); }` | `function fmtDate(ts: string, tz: string) { return new Date(ts).toLocaleString('en-US', { …, minute: '2-digit', timeZone: tz }); }` |
| 94 | `function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defaultOpen?: boolean })` | `function TimelineCard({ item, tz, defaultOpen = false }: { item: TimelineItem; tz: string; defaultOpen?: boolean })` |
| 108 | `{fmtDate(item.ts)}` (created kind) | `{fmtDate(item.ts, tz)}` |
| 124 | `{fmtDate(item.ts)}` (resolved kind) | `{fmtDate(item.ts, tz)}` |
| 142 | `{fmtDate(item.ts)}` (time kind) | `{fmtDate(item.ts, tz)}` |
| 183 | `{fmtDate(item.ts)}` (note kind) | `{fmtDate(item.ts, tz)}` |
| 289 | `Created {fmtDate(ticket.create_date)}` | `Created {fmtDate(ticket.create_date, tz)}` |
| 374 | `<TimelineCard key={i} item={item} defaultOpen={…} />` | `<TimelineCard key={i} item={item} tz={tz} defaultOpen={…} />` |
## Audit Results
- **Total raw discovery hits (across `app/`, `components/`, `lib/hooks/`, after the planned exclusions):** ~140 lines
- **Leak count: 81** across 39 files — Plan 05 inputs
- **Explicit-zone count: 5** — Plan 04 deliverables (3 finance + 1 tickets) + 1 hook helper
- **Number-format count: 47**`Number.toLocaleString()` thousands separators; not dates
- **Server-side count: 7** — LLM prompt builders in `app/api/veeam/{rpo-analyze,ticket-analysis/run}/route.ts`; locale-only by design
- **Deliberate-UTC count: 1**`components/mobile/EngagementHoursSparkline.tsx:39` (sparkline shape, not a clock)
Full inventory at `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md`.
## Plan 05 Dispatch Decision
**CREATE Plan 05** as a Wave 2 sibling (`depends_on: 07.1-04`). Inputs:
- `files_modified` = 39 unique file paths from the leak table.
- Acceptance criteria per file: `count(timeZone:) ≥ count(toLocaleDateString( + toLocaleString( + toLocaleTimeString()`.
- Reuse the same hook (`@/lib/hooks/use-user-timezone`) created in Plan 04 Task 1.
- Skip the 4 out-of-scope groups documented in the audit.
Without Plan 05, SC#4 (single source of truth) is satisfied only on the two reported-bug-surface pages — desktop dashboards / engagement / analyzer / admin pages still leak the browser's local zone.
## Decisions Made
- **Hook is a thin wrapper, not a context.** `useSession()` already de-duplicates; adding a Provider/Context layer adds boilerplate without observable benefit. The hook compiles to exactly the `useSession + validate + fallback` triple every caller would write by hand.
- **`formatInUserTimezone` is exported but not yet used by Plan 04.** It's exported because Plan 05 will likely consume it for one-line replacements of the form `new Date(x).toLocaleString(...)` — the wrapper saves a pattern-match per callsite.
- **`fmtDate` stays at module scope.** Moving it inside the component would close over `tz` for free, but module-scope + explicit `tz` arg parallels the existing helper layout (`fmtHours`, `fmt$`) and is what callers in `TimelineCard` need anyway (sub-component access).
- **`TimelineCard` gets a `tz` prop, not its own hook call.** Sub-components called inside a parent that already has the value should receive it as a prop — duplicating `useUserTimezone()` works but adds extra session reads per render.
- **Audit excludes `*.backup` files.** `app/admin/data-browser/time-entries/page.tsx.backup` has a leak at line 166 but is unreachable. Documented in the audit as a separate one-line note: Plan 05 should `git rm` the file rather than migrate it.
## Deviations from Plan
None — plan executed exactly as written.
The Task 2 plan called out "if either file declares its formatter at module scope, move INSIDE the component OR keep module scope AND accept tz as a param. The latter is the lighter-touch fix." Both `fmtDate` definitions were already at module scope, so I took the lighter-touch fix in both files (signature change + thread `tz` through callsites). The resulting `tz`-as-prop on `TimelineCard` is the same pattern.
## Issues Encountered
- **Worktree branch base mismatch (pre-execution).** The worktree's HEAD was at `db375fb0` (a master commit) instead of the expected base `3a3564fc` containing prior-wave commits (Plans 01 + 02). `db375fb0` was an ancestor of `3a3564fc`, so a `git merge --ff-only 3a3564fc` fast-forwarded cleanly with no conflicts — pulled in 6 commits including the timezone column migration, the Better Auth additionalField, and the API endpoint. No code changes resulted from this; it only affected which commits were visible in the worktree.
## Authentication Gates
None — no external service auth required.
## Threat Flags
None — Plan 04 introduces no new trust boundaries. The hook reads `useSession()` (already a trusted source per Plan 01) and validates the value before returning. The mobile-page edits are pure formatting changes; no new IO, no new authn/authz surface.
## User Setup Required
None — the hook is online once deployed. The optional `NEXT_PUBLIC_DEFAULT_TIMEZONE` env var (mirrors server-side `DEFAULT_TIMEZONE`) controls the fallback when a user has no stored tz; default behavior (`'UTC'` fallback) is fine for any deploy. Recommend setting it in `.env.local` to match `DEFAULT_TIMEZONE`.
## Behavioral Test Plan (deferred to runtime)
The plan calls for a two-browser-same-user test once the dev server is running:
1. Set user A's timezone to `'America/Los_Angeles'` via `curl -X PUT /api/me/timezone -d '{"timezone":"America/Los_Angeles"}'`.
2. Open `/mobile/finance` in two browsers — one with system tz `UTC`, one with `America/New_York`.
3. Confirm both browsers render IDENTICAL date strings (because both pull `America/Los_Angeles` from the session, regardless of device tz).
4. PUT a different tz, refresh, confirm strings re-render in the new tz.
This is not automatable in the executor (no running app server) and is recorded for the verifier / human UAT.
## Next Phase Readiness
- **Plan 05 unblocked:** the audit IS the input. Plan 05's `files_modified` is the leak-table file set; Plan 05's per-file acceptance criteria is the positive-assertion grep pattern Plan 04 used (`count(timeZone:) ≥ count(toLocale* callsites)`).
- **Threat register status:** all six T-07.1-04-* threats addressed by the as-built hook (validation + env fallback + session-only read; `formatInUserTimezone` cannot throw on a validated tz).
## Self-Check: PASSED
Verified at `/opt/stacks/pulse/.claude/worktrees/agent-ace09ac50dab44e5c`:
- `lib/hooks/use-user-timezone.ts` — FOUND, 61 lines, all 6 static assertions pass (`"use client"`, `useUserTimezone`, `formatInUserTimezone`, `useSession`, `Intl.supportedValuesOf`, `NEXT_PUBLIC_DEFAULT_TIMEZONE`)
- `app/mobile/finance/page.tsx` — modified, `useUserTimezone` imported and called, `timeZone:` count = 3 ≥ `toLocale*` count = 3
- `app/mobile/tickets/[id]/page.tsx` — modified, `useUserTimezone` imported and called, `timeZone:` count = 1 ≥ `toLocaleString(` count = 1
- `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md` — FOUND with all 6 required sections (Leak / Explicit-zone / Number-format / Server-side / Deliberate-UTC / Plan 05 dispatch / Summary)
- Commit `2ac2db7` (Task 1) — FOUND in `git log`
- Commit `14f4da3` (Task 2) — FOUND in `git log`
- Commit `dfd0a9f` (Task 3) — FOUND in `git log`
- `npx tsc --noEmit --pretty` for all three modified files — PASS (no errors)
- All Task 1 acceptance criteria — PASS
- All Task 2 acceptance criteria — PASS
- All Task 3 acceptance criteria — PASS
---
*Phase: 07.1-user-timezone-fix-inserted-urgent*
*Completed: 2026-05-07*