wulf-pulse/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-SUMMARY.md
lorentz 3f3142bbb7 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
2026-05-07 07:58:42 -04:00

16 KiB
Raw Blame History

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
timezone
iana
intl
better-auth
react
hook
mobile
audit
phase provides
07.1-user-timezone-fix-inserted-urgent (Plan 01) session.user.timezone via Better Auth additionalField
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)
07.1-05 (Wave 2 sibling — closes the 81-callsite codebase-wide leak surface using this audit verbatim as input)
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
created modified
lib/hooks/use-user-timezone.ts
.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md
app/mobile/finance/page.tsx
app/mobile/tickets/[id]/page.tsx
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)
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)
TZ-04
TZ-02
~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.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.ts2ac2db7 (feat)
  2. Task 2: Migrate mobile finance + ticket detail pages14f4da3 (feat)
  3. Task 3: Codebase-wide tz audit + Plan 05 dispatchdfd0a9f (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

// 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: 47Number.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: 1components/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