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:
parent
dfd0a9f2b2
commit
3f3142bbb7
1 changed files with 230 additions and 0 deletions
|
|
@ -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 43–44, `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 |
|
||||
|------|--------|-------|
|
||||
| 43–44 | `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 |
|
||||
|------|--------|-------|
|
||||
| 49–50 | `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*
|
||||
Loading…
Add table
Add a link
Reference in a new issue