wulf-pulse/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-PLAN.md
lorentz f9ab954518 docs(phase-07.1): plan urgent user timezone fix (TZ-01..TZ-04)
Insert Phase 7.1 between Phase 7 and Phase 8 to address dashboards/filters
rendering wrong dates because day/week boundary math runs in server UTC
instead of the viewing user's timezone. Persistence stays UTC; only the
read/display path changes.

5 plans in 3 waves:
- 07.1-01 (Wave 1): migration 083 + Better Auth additionalField timezone
- 07.1-02 (Wave 1): /api/me/timezone GET+PUT with IANA validation
- 07.1-03 (Wave 2): server-side AT TIME ZONE migration across 6 routes,
  including auth-gate fix on /api/mobile/finance and trends route
- 07.1-04 (Wave 2): useUserTimezone() hook + 2 mobile pages + codebase audit
- 07.1-05 (Wave 3): codebase-wide useUserTimezone() adoption per audit

Add Phase 9 stub (User Profile & Preferences) to roadmap for the picker UI
that reuses 7.1's hook + endpoint.

REQUIREMENTS.md TZ-02 carves out engagement_snapshots UTC bucketing as a
documented exception (≤24h drift acceptable for admin overview).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 07:02:07 -04:00

29 KiB

phase plan type wave depends_on files_modified autonomous requirements requirements_addressed must_haves
07.1-user-timezone-fix-inserted-urgent 04 execute 2
07.1-01
lib/hooks/use-user-timezone.ts
app/mobile/finance/page.tsx
app/mobile/tickets/[id]/page.tsx
true
TZ-04
TZ-02
TZ-04
TZ-02
truths artifacts key_links
A single client hook `useUserTimezone()` returns the calling user's IANA tz from the Better Auth session
The hook returns a safe fallback (`process.env.NEXT_PUBLIC_DEFAULT_TIMEZONE || 'UTC'`) when the session is loading or the field is missing
The hook validates the session value against Intl.supportedValuesOf('timeZone') — corrupt values fall back, never crash
The mobile pages that previously called `toLocaleDateString` / `toLocaleString` with the implicit browser zone now use the user's chosen tz via the hook
Every `toLocaleDateString` / `toLocaleString` callsite in `app/mobile/finance/page.tsx` and `app/mobile/tickets/[id]/page.tsx` passes a `timeZone:` option (verified with positive-assertion greps)
A discovery audit (Task 3) classifies every `Intl.DateTimeFormat` / `toLocale*String(` callsite across `app/`, `components/`, and `lib/hooks/` as 'browser-local zone leak' / 'explicit zone passed' / 'server-side' — guides whether SC#4 (single source of truth) is satisfied by Plan 04 alone or requires Plan 05
path provides exports
lib/hooks/use-user-timezone.ts Client hook reading user.timezone from useSession()
useUserTimezone
formatInUserTimezone
path provides contains
app/mobile/finance/page.tsx Mobile finance page formats dates in the user's tz, not the browser's useUserTimezone
path provides contains
app/mobile/tickets/[id]/page.tsx Mobile ticket detail formats timestamps in the user's tz useUserTimezone
from to via pattern
lib/hooks/use-user-timezone.ts useSession() from @/lib/auth-client additionalField propagated by Better Auth Plan 01 config useSession()
from to via pattern
Mobile pages useUserTimezone hook import { useUserTimezone } from '@/lib/hooks/use-user-timezone' useUserTimezone
Ship the shared client hook `useUserTimezone()` and migrate the two mobile pages whose existing `toLocaleDateString` / `toLocaleString` calls render in the browser's local zone. Going forward, any future client-side date formatting must go through this hook — no scattered `Intl.DateTimeFormat` instantiations.

Purpose: Resolve TZ-04 (the hook itself) and the client-side portion of TZ-02 on the directly-reported bug surface (the two mobile pages with absolute date formatters). This plan migrates only the two pages whose dates were the reported bug; a Task 3 audit produces the full codebase-wide leak inventory that the follow-up Plan 05 (Wave 2 sibling, depends_on 07.1-04) will close to satisfy SC#4 (single source of truth) at the codebase scale.

Output: New lib/hooks/use-user-timezone.ts, edits to two existing mobile pages, and a Task 3 audit log committed to the phase directory.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/STATE.md @.planning/ROADMAP.md @.planning/REQUIREMENTS.md @CLAUDE.md @lib/auth.ts @lib/auth-client.ts @components/auth/auth-provider.tsx @app/mobile/finance/page.tsx @app/mobile/tickets/[id]/page.tsx After Plan 01 ships, `useSession().data?.user.timezone: string` is available on every client. Before that, it's not — this plan therefore depends on Plan 01 (but NOT on Plan 02 or 03, which are independent).

useSession is exported from @/lib/auth-client:

import { useSession } from "@/lib/auth-client";
const { data, isPending, error } = useSession();
// data?.user.timezone : string | undefined

The codebase has scattered callsites today (verified by grep at planning time):

  • app/mobile/finance/page.tsx:43,75,373toLocaleDateString / toLocaleString
  • app/mobile/tickets/[id]/page.tsx:49toLocaleString

Other mobile files (analyzer, dashboard, engagement) either don't format absolute dates client-side or already drive bucket boundaries from the server (now user-tz aware via Plan 03). The desktop callsites (/components/admin/*, /app/dashboard/page.tsx's header new Date().toLocaleDateString, etc.) are known to be numerous (>10 leak callsites — verified by codebase grep at revision time). Migrating all of them in this plan would balloon Plan 04 past its budget; Task 3 produces a classified inventory and Plan 05 (sibling in Wave 2) closes them.

Browser environment variable:

  • process.env.NEXT_PUBLIC_DEFAULT_TIMEZONE is the client-readable equivalent of DEFAULT_TIMEZONE. If unset, fall back to 'UTC'. Setting it is an operator concern (Phase 9 / .env.local), out of scope here.
Task 1: Create lib/hooks/use-user-timezone.ts lib/hooks/use-user-timezone.ts - lib/auth-client.ts (the `useSession` export — line 34) - components/auth/auth-provider.tsx (canonical example of consuming useSession in this codebase: `const { data: session, isPending, error } = useSession();`) - app/mobile/finance/page.tsx (existing scattered formatting call shape — what API the hook needs to support so a one-line replacement works) - CLAUDE.md (Frontend section: 'use client' pages, no SWR/react-query, useState/useEffect pattern) Create `lib/hooks/use-user-timezone.ts` with EXACTLY this content:
    "use client";

    import { useSession } from "@/lib/auth-client";

    // Public-readable default (Next.js exposes NEXT_PUBLIC_* to the browser).
    // Operators can set this in .env.local to match the server-side
    // DEFAULT_TIMEZONE. If unset, both server and client default to 'UTC'.
    function getClientDefaultTimezone(): string {
      return process.env.NEXT_PUBLIC_DEFAULT_TIMEZONE || "UTC";
    }

    function isValidIanaTimezone(tz: unknown): tz is string {
      if (typeof tz !== "string" || tz.length === 0 || tz.length > 64) return false;
      try {
        return Intl.supportedValuesOf("timeZone").includes(tz);
      } catch {
        return false;
      }
    }

    /**
     * useUserTimezone — TZ-04.
     *
     * Returns the calling user's IANA timezone string, sourced from the
     * Better Auth additionalField on `useSession()`. Falls back to
     * `process.env.NEXT_PUBLIC_DEFAULT_TIMEZONE || 'UTC'` while the session
     * is loading, when the field is missing, or when the stored value is
     * not a recognized IANA zone.
     *
     * This is the ONLY supported way to read the user's tz on the client.
     * Do NOT call `Intl.DateTimeFormat()` with a hardcoded zone or rely on
     * the browser's local zone — the user may have travelled or set a
     * preference that differs from the device.
     */
    export function useUserTimezone(): string {
      const { data } = useSession();
      // Better Auth additionalField is typed `string` post-Plan-01; defence
      // in depth: validate before returning.
      const raw = (data?.user as { timezone?: unknown } | undefined)?.timezone;
      if (isValidIanaTimezone(raw)) return raw;
      return getClientDefaultTimezone();
    }

    /**
     * formatInUserTimezone — convenience wrapper for the common case of
     * "format an ISO string in the user's tz". Equivalent to
     * `new Date(iso).toLocaleString(locale, { ...options, timeZone: tz })`.
     *
     * Pass `tz` from `useUserTimezone()` and the same options object you'd
     * pass to toLocaleString / toLocaleDateString — this helper keeps
     * existing format strings working without rewrites.
     */
    export function formatInUserTimezone(
      input: string | number | Date,
      tz: string,
      options?: Intl.DateTimeFormatOptions,
      locale: string = "en-US",
    ): string {
      const date = input instanceof Date ? input : new Date(input);
      return date.toLocaleString(locale, { ...options, timeZone: tz });
    }

Notes:
- The "use client" pragma is required because the hook calls
  `useSession()`. Without it, attempting to use the hook from a server
  component would error at build time.
- We do NOT memoize the validation — `Intl.supportedValuesOf` is fast and
  `useSession()` already de-duplicates renders internally. Premature memo
  adds a `useMemo` dependency that's the same object identity anyway.
- `formatInUserTimezone` is a pure function (not a hook), so it can be
  called inside loops/maps without violating rules-of-hooks.
- Default locale `'en-US'` matches the existing callsites in mobile
  pages. Callers can override.
ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "lib/hooks/use-user-timezone\.ts"); [ -z "$ERR" ] && grep -q '"use client"' lib/hooks/use-user-timezone.ts && grep -q "export function useUserTimezone" lib/hooks/use-user-timezone.ts && grep -q "export function formatInUserTimezone" lib/hooks/use-user-timezone.ts && grep -q "useSession" lib/hooks/use-user-timezone.ts && grep -q "Intl.supportedValuesOf" lib/hooks/use-user-timezone.ts - File exists at `lib/hooks/use-user-timezone.ts` - First non-blank line is `"use client";` - Exports a function `useUserTimezone(): string` - Exports a function `formatInUserTimezone(input, tz, options?, locale?): string` - Imports `useSession` from `@/lib/auth-client` - Contains the literal `Intl.supportedValuesOf("timeZone")` - Contains the literal `process.env.NEXT_PUBLIC_DEFAULT_TIMEZONE || "UTC"` - `npx tsc --noEmit --pretty` reports no errors in this file - Behavioral (manual): in a `'use client'` component that calls `useUserTimezone()`, the returned string is the user's stored tz; toggling the user's tz to `'America/Los_Angeles'` via curl PUT (Plan 02) and refreshing the page returns `'America/Los_Angeles'`. `useUserTimezone()` is the canonical client-side accessor for the user's IANA tz. Any 'use client' component can import it and consume the result safely (always a usable string, never undefined). Task 2: Migrate app/mobile/finance/page.tsx and app/mobile/tickets/[id]/page.tsx to useUserTimezone app/mobile/finance/page.tsx, app/mobile/tickets/[id]/page.tsx - app/mobile/finance/page.tsx (the existing helpers — `formatDate` at ~line 43, the `setLastSync` line at ~75, and the `monthLabel` computation at ~373; all three currently rely on the browser's local tz) - app/mobile/tickets/[id]/page.tsx (the timestamp formatter at ~line 49 — same pattern) - lib/hooks/use-user-timezone.ts (the hook + helper from Task 1) - CLAUDE.md (Frontend: 'use client' is already in these files; no server-component conversion needed) Two files. Both already declare `"use client"`. Edits are minimal — call the hook at the top of the component, thread `tz` into each existing `toLocaleDateString` / `toLocaleString` call.
--- A: app/mobile/finance/page.tsx ---

1. Add import (next to the other `@/lib` imports near the top):
   `import { useUserTimezone } from '@/lib/hooks/use-user-timezone';`
2. Inside the default-exported component function, BEFORE any
   useState/useEffect calls, add:
   `const tz = useUserTimezone();`
3. The `formatDate` helper at line ~43 currently:
       function formatDate(ts: string | undefined): string {
         if (!ts) return '—';
         return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
       }
   This helper is currently a module-scope function with no access to
   `tz`. Convert it to accept `tz` as an argument:
       function formatDate(ts: string | undefined, tz: string): string {
         if (!ts) return '—';
         return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: tz });
       }
   And update every call site of `formatDate(...)` inside this file to
   pass `tz` as the second argument (search the file for `formatDate(` —
   fix each occurrence).
4. The `setLastSync` line at ~line 75:
       setLastSync(ts ? new Date(ts).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : null);
   Change to:
       setLastSync(ts ? new Date(ts).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz }) : null);
5. The `monthLabel` computation at ~line 373:
       const monthLabel = new Date(m.month).toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
   Change to:
       const monthLabel = new Date(m.month).toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: tz });
6. Do NOT change anything else in this file — no behavioral changes
   beyond the timezone of the rendered strings.

--- B: app/mobile/tickets/[id]/page.tsx ---

1. Add import:
   `import { useUserTimezone } from '@/lib/hooks/use-user-timezone';`
2. Inside the default-exported component function, add:
   `const tz = useUserTimezone();`
3. The formatter at ~line 49:
       function formatTs(ts: string | undefined): string {
         if (!ts) return '—';
         return new Date(ts).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
       }
   (or whatever the exact name/shape — adapt to the actual file). Convert
   to accept `tz`:
       function formatTs(ts: string | undefined, tz: string): string {
         if (!ts) return '—';
         return new Date(ts).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz });
       }
   Update every callsite in the file to pass `tz`.

Important: if EITHER file declares its formatter at module scope (outside
the component), it must be moved INSIDE the component OR keep its module
scope AND accept tz as a param. The latter is the lighter-touch fix. Do
not introduce a `useMemo` for the formatter — overhead exceeds benefit at
these call frequencies.

Verify nothing else in either file calls `toLocaleDateString` /
`toLocaleString` without a `timeZone:` option after this change. Use
POSITIVE assertions (count `timeZone:` occurrences) rather than the
fragile `! grep | grep -v` chain — see verify section.

Threshold note for the verify positive-assertion: at planning time
`app/mobile/finance/page.tsx` has 3 date-formatter callsites (formatDate
helper, setLastSync, monthLabel) and `app/mobile/tickets/[id]/page.tsx`
has 1. After this task, the threshold for `timeZone:` count must be ≥ the
count of `toLocaleDateString(` + `toLocaleString(` callsites in each file.
The thresholds in the verify command (3 and 1) reflect those pre-existing
counts.
ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/(finance|tickets/\[id\])/page\.tsx"); [ -z "$ERR" ] && grep -q "useUserTimezone" app/mobile/finance/page.tsx && grep -q "useUserTimezone" 'app/mobile/tickets/[id]/page.tsx' && [ "$(grep -cE 'toLocaleDateString\(|toLocaleString\(' app/mobile/finance/page.tsx)" -ge 3 ] && [ "$(grep -c 'timeZone:' app/mobile/finance/page.tsx)" -ge 3 ] && [ "$(grep -cE 'toLocaleDateString\(|toLocaleString\(' 'app/mobile/tickets/[id]/page.tsx')" -ge 1 ] && [ "$(grep -c 'timeZone:' 'app/mobile/tickets/[id]/page.tsx')" -ge 1 ] - `app/mobile/finance/page.tsx` imports `useUserTimezone` from `@/lib/hooks/use-user-timezone` - `app/mobile/finance/page.tsx` calls `useUserTimezone()` exactly once inside the default-exported component - `app/mobile/finance/page.tsx`: count of `timeZone:` ≥ count of `toLocaleDateString(` + `toLocaleString(` (positive assertion: every formatter callsite has been threaded with `timeZone:`) - `app/mobile/tickets/[id]/page.tsx` imports `useUserTimezone` and calls it inside the component - `app/mobile/tickets/[id]/page.tsx`: count of `timeZone:` ≥ count of `toLocaleString(` callsites - `npx tsc --noEmit --pretty` reports no NEW errors in either file - Both files still compile as `'use client'` (the directive at top is preserved) - Behavioral (manual once running): - With user.timezone = 'America/New_York' and the device set to UTC, opening `/mobile/finance` renders `monthLabel` strings that match Eastern Time (e.g. an invoice dated 2026-01-01T03:00Z renders as "Dec 2025" — last day of December ET — not "Jan 2026" UTC). The two mobile pages with absolute date formatting now render in the user's chosen tz, regardless of the browser's local zone. The hook is the only source of truth for these two pages. Task 3: Codebase-wide audit + classification of remaining toLocale* / Intl.DateTimeFormat callsites .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md - lib/hooks/use-user-timezone.ts (the migration target — Task 1 just created it) - app/mobile/finance/page.tsx, app/mobile/tickets/[id]/page.tsx (the two files Task 2 already migrated — exclude them from the audit) - CLAUDE.md (Frontend section: 'use client' pages — components/ and app/ are the consumer surface) Produce a one-pass audit of every `Intl.DateTimeFormat` / `toLocaleString(` / `toLocaleDateString(` / `toLocaleTimeString(` callsite across `app/`, `components/`, and `lib/hooks/`, EXCLUDING the two files Task 2 just migrated. Classify each callsite, then write the result to `07.1-04-AUDIT.md` so Plan 05 can consume it.
Steps:

1. Run the full discovery grep:
       grep -rEn "Intl\.DateTimeFormat|\.toLocaleDateString\(|\.toLocaleTimeString\(|\.toLocaleString\(" app/ components/ lib/hooks/ \
         | grep -v "node_modules" \
         | grep -v "app/mobile/finance/page.tsx" \
         | grep -v "app/mobile/tickets/\[id\]/page.tsx" \
         | grep -v "components/ui/calendar.tsx" \
         > /tmp/tz-audit-raw.txt

   (`components/ui/calendar.tsx` is a shadcn/ui primitive; its
   `toLocaleString("default", { month: "short" })` call is a calendar-cell
   label, not a user-visible date — exclude.)

2. For each line in `/tmp/tz-audit-raw.txt`, classify into ONE of:

   - **leak**: `toLocaleString(` / `toLocaleDateString(` / `toLocaleTimeString(`
     on a Date instance with NO `timeZone:` option in the same call. These
     render in the device's local zone — the bug TZ-02 is patching.
     Examples: `new Date(ts).toLocaleString()`,
     `d.toLocaleDateString('en-US', { month: 'short' })`.

   - **explicit_zone**: a `timeZone:` option IS passed in the same call
     (e.g., `{ timeZone: 'UTC' }` for deliberate UTC display, or
     `{ timeZone: tz }` already migrated). Leave as-is.

   - **number_format**: `.toLocaleString()` called on a `number` /
     `bigint` (formatted thousand separators, NOT a date). Recognizable
     because the callee is not a `Date` instance — e.g., `count.toLocaleString()`,
     `value.toLocaleString()`, `summary.organizations?.toLocaleString()`.
     These are not date callsites; ignore.

   - **server_side**: file path matches `app/api/**/route.ts` or otherwise
     runs in Node (not a React component). Out of scope for the client
     hook. Server-side formatting belongs to Plan 03's `getUserTimezone`
     server helper if it ever needs to format dates server-side; today the
     only such callsites are the analyzer prompt builders
     (`app/api/veeam/ticket-analysis/run/route.ts`,
     `app/api/veeam/rpo-analyze/route.ts`) which are deliberately
     locale-only (LLM input). Do NOT migrate.

   - **deliberate_utc**: file already passes `{ timeZone: 'UTC' }` for a
     specific reason (e.g., `components/mobile/EngagementHoursSparkline.tsx:39`
     pins UTC because the data points are stored as UTC dates and the
     sparkline is a 7/30-day shape, not a clock). Leave as-is.

3. Write the inventory to
   `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md`
   with EXACTLY this structure:

       # Phase 7.1 — Codebase-wide tz audit (Plan 04 Task 3)

       Discovery date: <ISO date>
       Excluded: `app/mobile/finance/page.tsx`,
                 `app/mobile/tickets/[id]/page.tsx`,
                 `components/ui/calendar.tsx`,
                 `node_modules/**`

       ## Leak callsites (must migrate via Plan 05)

       | File | Line | Snippet | Notes |
       |------|------|---------|-------|
       | app/foo/page.tsx | 42 | `new Date(ts).toLocaleDateString()` | client component, default-zone leak |
       | ... | ... | ... | ... |

       ## Explicit-zone callsites (no migration)

       | File | Line | Snippet |

       ## Number-format callsites (not date — ignore)

       Count: <N>

       ## Server-side callsites (out of scope)

       | File | Line | Reason |
       | app/api/veeam/ticket-analysis/run/route.ts | 78,90,97,98 | LLM prompt builder — locale-only by design |
       | ... | ... | ... |

       ## Deliberate UTC callsites

       | File | Line | Reason |
       | components/mobile/EngagementHoursSparkline.tsx | 39 | UTC pin for sparkline shape (not a clock) |

       ## Summary

       - Leak count: N
       - Explicit-zone count: N
       - Server-side count: N
       - Deliberate-UTC count: N

       ## Plan 05 dispatch

       - If Leak count == 0: Plan 05 is unnecessary. Mark in SUMMARY.
       - If Leak count > 0: Plan 05 (sibling, depends_on `07.1-04`) closes
         every Leak file in this audit. Plan 05's `files_modified` is the
         unique set of leak file paths above.

4. Do NOT modify any of the leak files in this task — only inventory them.
   Plan 05 owns the migration. The audit file IS the deliverable.

Notes:
- This task is intentionally scoped to discovery + classification, not
  migration. It's the bridge between Plan 04 (two reported-bug-surface
  pages) and Plan 05 (codebase-wide adoption).
- The audit file becomes the SOURCE OF TRUTH for Plan 05's
  `files_modified` and Plan 05's per-file acceptance criteria.
- From the planning-time grep, the leak count is >10 (a `grep -rEn` across
  `app/`, `components/`, `lib/hooks/` returned ~96 candidate lines; many
  are number formatters, but Mimecast / engagement / analyzer / dashboard
  pages alone yield >10 confirmed Date-instance leaks). Plan 05 is
  therefore expected to be created. Confirm by counting the rows in the
  "Leak callsites" table.
test -f .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md && grep -q '## Leak callsites' .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md && grep -q '## Explicit-zone callsites' .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md && grep -q '## Server-side callsites' .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md && grep -q '## Plan 05 dispatch' .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md && grep -q '## Summary' .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md - File exists at `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md` - Contains the five required sections (Leak / Explicit-zone / Number-format / Server-side / Deliberate-UTC) plus Summary and Plan 05 dispatch - Every callsite from the discovery grep appears in exactly one section (no double-counting) - The audit committed to git in the same commit as Tasks 1+2 - The Plan 05 dispatch decision (create / skip) is unambiguous A complete codebase-wide leak inventory is committed at `07.1-04-AUDIT.md`. If Leak count > 0, Plan 05 will be drafted as a Wave 2 sibling (depends_on `07.1-04`) consuming this audit verbatim. If Leak count == 0, the audit becomes a one-time deliverable proving SC#4 is already satisfied by Plan 04 alone.

<threat_model>

Trust Boundaries

Boundary Description
Server → client (session payload) tz string travels via Better Auth session cookie; client trusts it for formatting only
Browser → display tz misuse only affects what the user themselves sees on their own screen — no cross-user impact

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-07.1-04-01 Tampering Tampered session payload with malformed tz crashing toLocaleString mitigate useUserTimezone() validates against Intl.supportedValuesOf('timeZone') before returning; falls back to env default. toLocaleString with the validated value cannot throw.
T-07.1-04-02 Information Disclosure tz exposed in client memory accept Same disposition as T-07.1-01-02: tz is non-sensitive metadata.
T-07.1-04-03 Denial of Service Calling Intl.supportedValuesOf on every hook call accept Hook is called per-render; V8 caches internally; ~600-entry array. Negligible.
T-07.1-04-04 Tampering NEXT_PUBLIC_DEFAULT_TIMEZONE override at build time accept Public env var is intentionally operator-controlled; same trust level as the server-side DEFAULT_TIMEZONE. Out of scope.
T-07.1-04-05 Spoofing Client showing one user's tz while session has another's mitigate Hook reads exclusively from useSession(); Better Auth invalidates sessions on sign-out. No cross-session leakage.
T-07.1-04-06 Information Disclosure Audit file leaks file paths / snippets accept The audit file lives in .planning/ (already part of the planning artefact tree), references only file paths and short code snippets that exist in the public repo, no secrets.
</threat_model>
End-to-end checks for this plan:
  1. Static: grep -q '"use client"' lib/hooks/use-user-timezone.ts
  2. Static: grep -q "useSession" lib/hooks/use-user-timezone.ts
  3. Static: grep -q "useUserTimezone" app/mobile/finance/page.tsx
  4. Static: grep -q "useUserTimezone" 'app/mobile/tickets/[id]/page.tsx'
  5. Static (positive): [ "$(grep -c 'timeZone:' app/mobile/finance/page.tsx)" -ge 3 ] and [ "$(grep -c 'timeZone:' 'app/mobile/tickets/[id]/page.tsx')" -ge 1 ]
  6. Static: [ "$(grep -cE 'toLocaleDateString\(|toLocaleString\(' app/mobile/finance/page.tsx)" -ge 3 ] (the threshold matches the pre-existing date-formatter call count; if a future commit adds another formatter without timeZone:, the assertion above (5) will catch it because counts must be equal)
  7. Static: audit file exists and contains all six required sections
  8. Type: ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "lib/hooks/use-user-timezone\.ts|app/mobile/(finance|tickets/\[id\])/page\.tsx"); [ -z "$ERR" ]
  9. Runtime (with the dev server running, two browsers — one on UTC, one on Eastern, same user with timezone='America/New_York'):
    • Opening /mobile/finance in both browsers shows IDENTICAL date strings (because both pull the same user-tz from session, regardless of device tz).
    • Toggling the user's tz via curl -X PUT /api/me/timezone then refreshing re-renders the page with the new tz applied to all date strings.

<success_criteria>

  • lib/hooks/use-user-timezone.ts is the single canonical source of truth for client tz
  • app/mobile/finance/page.tsx and app/mobile/tickets/[id]/page.tsx both consume it; every date formatter passes timeZone:
  • TypeScript compiles for all three migrated files
  • A complete codebase-wide leak audit is committed; Plan 05 dispatch decision is recorded
  • Plan 05 closes the codebase-wide adoption gap to satisfy SC#4 (single source of truth) </success_criteria>
After completion, create `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-SUMMARY.md` documenting: the hook signature, the migrated callsites (file + line numbers before vs after), the audit results (leak count, explicit-zone count, etc.), the Plan 05 dispatch decision (create / skip), and the behavioral test result for two-browser-same-user tz consistency.