- 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
16 KiB
| phase | plan | subsystem | tags | requires | provides | affects | tech-stack | key-files | key-decisions | patterns-established | requirements-completed | duration | completed | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07.1-user-timezone-fix-inserted-urgent | 04 | client-hooks |
|
|
|
|
|
|
|
|
|
~5 min | 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.tscreated. Two exports:useUserTimezone(): string— readsuseSession().data?.user.timezone, validates againstIntl.supportedValuesOf('timeZone'), falls back toprocess.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 wrappingtoLocaleStringwith 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 inMobileFinance, converted module-scopefmtDate(ts)tofmtDate(ts, tz), threadedtzinto 3 formatter callsites (fmtDatehelper at line 43–44,setLastSyncat 77,monthLabelat 375). Updated 2fmtDate(...)callers (lines 320, 359) to passtz.app/mobile/tickets/[id]/page.tsx: imported the hook, called it once inTicketTimeline, converted module-scopefmtDate(ts)tofmtDate(ts, tz), threadedtzinto 5 callsites (4 insideTimelineCardfor the four timeline-item kinds at lines 108/124/142/183, plus the "Created" header at line 289). Addedtzprop toTimelineCardcomponent 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):
- Task 1: Create lib/hooks/use-user-timezone.ts —
2ac2db7(feat) - Task 2: Migrate mobile finance + ticket detail pages —
14f4da3(feat) - 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"; exportsuseUserTimezoneandformatInUserTimezone; imports onlyuseSessionfrom@/lib/auth-client.app/mobile/finance/page.tsx(modified) — addeduseUserTimezoneimport,const tz = useUserTimezone();insideMobileFinance, threadedtzinto 3 formatter callsites (positive grep:timeZone:count = 3,toLocale*count = 3).app/mobile/tickets/[id]/page.tsx(modified) — addeduseUserTimezoneimport,const tz = useUserTimezone();insideTicketTimeline, threadedtzinto 5fmtDate(...)callsites +TimelineCardprop. The singletoLocaleString(...)callsite at line 50 hastimeZone: 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
// 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()returnssession.user.timezoneif it's a valid IANA zone (≤64 chars, present inIntl.supportedValuesOf('timeZone')); otherwise returnsprocess.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 theuseSession + validate + fallbacktriple every caller would write by hand. formatInUserTimezoneis exported but not yet used by Plan 04. It's exported because Plan 05 will likely consume it for one-line replacements of the formnew Date(x).toLocaleString(...)— the wrapper saves a pattern-match per callsite.fmtDatestays at module scope. Moving it inside the component would close overtzfor free, but module-scope + explicittzarg parallels the existing helper layout (fmtHours,fmt$) and is what callers inTimelineCardneed anyway (sub-component access).TimelineCardgets atzprop, not its own hook call. Sub-components called inside a parent that already has the value should receive it as a prop — duplicatinguseUserTimezone()works but adds extra session reads per render.- Audit excludes
*.backupfiles.app/admin/data-browser/time-entries/page.tsx.backuphas a leak at line 166 but is unreachable. Documented in the audit as a separate one-line note: Plan 05 shouldgit rmthe 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 base3a3564fccontaining prior-wave commits (Plans 01 + 02).db375fb0was an ancestor of3a3564fc, so agit merge --ff-only 3a3564fcfast-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:
- Set user A's timezone to
'America/Los_Angeles'viacurl -X PUT /api/me/timezone -d '{"timezone":"America/Los_Angeles"}'. - Open
/mobile/financein two browsers — one with system tzUTC, one withAmerica/New_York. - Confirm both browsers render IDENTICAL date strings (because both pull
America/Los_Angelesfrom the session, regardless of device tz). - 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_modifiedis 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;
formatInUserTimezonecannot 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,useUserTimezoneimported and called,timeZone:count = 3 ≥toLocale*count = 3app/mobile/tickets/[id]/page.tsx— modified,useUserTimezoneimported 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 ingit log - Commit
14f4da3(Task 2) — FOUND ingit log - Commit
dfd0a9f(Task 3) — FOUND ingit log npx tsc --noEmit --prettyfor 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