diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md
index 33dcd6c..461adca 100644
--- a/.planning/REQUIREMENTS.md
+++ b/.planning/REQUIREMENTS.md
@@ -82,6 +82,13 @@ Requirements for this milestone. Each maps to a spec section and a roadmap phase
- [ ] **ENG-08**: Profile reuses existing engagement profile data endpoints; no new data
- [ ] **ENG-09**: Engagement is reachable from the More drawer, NOT the bottom bar
+### TZ — User Timezone (Phase 7.1, urgent insertion)
+
+- [ ] **TZ-01**: Better Auth users table extended with a `timezone` field (IANA string). Default for users with no value = `process.env.DEFAULT_TIMEZONE || 'UTC'`. Existing rows backfill to the default; UTC remains the storage timezone for all date columns
+- [ ] **TZ-02**: Date math that powers dashboards, ticket filters, finance views, and engagement period selectors computes day/week boundaries against the viewing user's `timezone` — NOT UTC, NOT the browser's local zone (browsers may differ from the user's chosen tz, e.g. travel) (Exception: `engagement_snapshots`-derived metrics — active users D7/D30/D90, MS Graph hours — bucket by UTC at sync time and remain UTC-bucketed in this phase; ≤24h drift is accepted because engagement is an admin-overview surface, not an operational date display. Per-user snapshot bucketing is deferred to a future phase if needed.)
+- [ ] **TZ-03**: `GET /api/me/timezone` (auth required) returns `{ timezone, source: 'user' | 'default' }`. `PUT /api/me/timezone` accepts `{ timezone }`, validates against `Intl.supportedValuesOf('timeZone')`, persists to the user row, returns the new value
+- [ ] **TZ-04**: Shared client hook `useUserTimezone()` reads tz from `useSession()` (Better Auth additionalField). All date-formatting and range-bucketing in mobile + desktop pages goes through this hook — no scattered `Intl.DateTimeFormat` instantiations with hardcoded zones
+
## v2 Requirements
Acknowledged but deferred. Not in this milestone's roadmap.
@@ -121,6 +128,7 @@ Explicitly excluded for v1. Documented to prevent scope creep.
| Multi-series chart on mobile Engagement overview | Replaced by single sparkline (spec §6.5) |
| Modal-based user detail on mobile | Replaced by real page so back gesture works (spec §6.5) |
| `/mobile-v2` parallel directory | Rebuild `/mobile` in place — keep canonical URLs (spec §2) |
+| Per-user-tz `engagement_snapshots` bucketing (TZ-02 carve-out) | Engagement metrics derived from `engagement_snapshots` (active users D7/D30/D90, MS Graph hours) are bucketed by UTC at sync time. Per-user re-bucketing would require either per-request re-bucket (expensive) or per-user snapshot rebuild (doubles storage). ≤24h drift accepted on this admin-overview surface. May revisit in a future phase. |
## Traceability
@@ -193,4 +201,6 @@ Updated during roadmap creation.
---
*Requirements defined: 2026-05-03*
-*Last updated: 2026-05-03 — traceability filled in at roadmap creation*
+*Last updated: 2026-05-07 — TZ-02 amended with engagement_snapshots carve-out (Phase 7.1 revision)*
+
+
\ No newline at end of file
diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 0964bef..ca2e98d 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -28,7 +28,9 @@ Decimal phases appear between their surrounding integers in numeric order.
- [x] **Phase 5: Finance Restyle** — Adopt new Card + typography scale, swap wide tables for stacked lists (completed 2026-05-03)
- [ ] **Phase 6: Analyzer Feed (NEW)** — `/mobile/analyzer` read-only stream + `/api/mobile/analyzer/feed`
- [ ] **Phase 7: Engagement Overview (NEW)** — `/mobile/engagement` phone-first overview reachable from the More drawer
+- [ ] **Phase 7.1: User Timezone Fix (INSERTED — urgent)** — Per-user IANA timezone column + viewer-tz date math so dashboards and filters render the right "today"
- [ ] **Phase 8: Engagement User Profile (NEW)** — `/mobile/engagement/[userId]` real-page profile that replaces the desktop modal pattern
+- [ ] **Phase 9: User Profile & Preferences (NEW)** — `/mobile/profile` settings page (timezone chooser, theme, mobile push, Teams + ntfy channels)
## Phase Details
@@ -136,6 +138,24 @@ Decimal phases appear between their surrounding integers in numeric order.
- [x] 07-03-PLAN.md — app/mobile/engagement/page.tsx orchestration (period/sort state, IntersectionObserver, empty/error/not-configured states) (ENG-01, ENG-02, ENG-03, ENG-04, ENG-05, ENG-09)
**UI hint**: yes
+### Phase 7.1: User Timezone Fix (INSERTED — urgent)
+**Goal**: A user opening Pulse sees dashboards, filters, and "today/this week" date math computed in their own IANA timezone — not server UTC — so reports stop showing yesterday's data as today (and vice versa). Persistence layer remains UTC; only the read/display path changes.
+**Depends on**: Nothing structural (Better Auth users table extension + read-path changes)
+**Requirements**: TZ-01, TZ-02, TZ-03, TZ-04
+**Success Criteria** (what must be TRUE):
+ 1. Each user has an IANA timezone (e.g. `America/New_York`) persisted server-side; default = `process.env.DEFAULT_TIMEZONE || 'UTC'` for users with no value yet
+ 2. Mobile and desktop dashboards, ticket filters, finance views, and engagement period selectors compute day/week boundaries against the viewer's timezone — not UTC and not the browser's local zone (browser zone may differ from the user's chosen zone, e.g. travel)
+ 3. Authenticated `GET /api/me/timezone` returns the user's tz; `PUT /api/me/timezone` accepts an IANA string and rejects anything not in `Intl.supportedValuesOf('timeZone')`
+ 4. A shared client hook (`useUserTimezone()`) reads the value from `useSession()` so all components use a single source of truth — no per-page `Intl` calls scattered around
+ 5. Existing UTC-stored data stays untouched (no destructive migration); only formatting and range-bucketing change
+**Plans**: 5 plans
+- [ ] 07.1-01-PLAN.md — Add timezone column to user table + Better Auth additionalField (TZ-01)
+- [ ] 07.1-02-PLAN.md — /api/me/timezone GET + PUT with IANA validation (TZ-03)
+- [ ] 07.1-03-PLAN.md — Server-side read paths use user.timezone for day/week/month boundaries; auth-gates /api/mobile/finance; migrates /api/dashboard/trends (TZ-02)
+- [ ] 07.1-04-PLAN.md — useUserTimezone() client hook + reported-bug-surface mobile page migration + codebase-wide audit (TZ-04, TZ-02 client portion)
+- [ ] 07.1-05-PLAN.md — Codebase-wide useUserTimezone() adoption per the Plan 04 audit (TZ-04 SC#4 single-source-of-truth at codebase scale)
+**UI hint**: no (this is a data/plumbing phase; the picker UI is part of Phase 9)
+
### Phase 8: Engagement User Profile (NEW)
**Goal**: From the Engagement overview, a manager taps an employee row and arrives at a real, shareable profile page — single-column phone-first — and the device back gesture returns them to the overview.
**Depends on**: Phase 7
@@ -147,6 +167,17 @@ Decimal phases appear between their surrounding integers in numeric order.
**Plans**: TBD
**UI hint**: yes
+### Phase 9: User Profile & Preferences (NEW)
+**Goal**: A logged-in user reaches a profile/settings page from the More drawer and can configure timezone (chooser UI for TZ-01), theme (light/dark/system, persisted server-side for cross-device consistency), mobile push notifications (per-event toggles), and personal notification channels (Teams webhook URL, ntfy topic). Changes persist per-user and the existing notify pipeline routes through these per-user channels for events the user is subscribed to.
+**Depends on**: Phase 7.1 (timezone schema), Phase 2 (More drawer)
+**Requirements**: TBD — flesh out via `/gsd-discuss-phase 9` before planning
+**Success Criteria** (what must be TRUE):
+ 1. Tapping Profile/Account in the More drawer routes to `/mobile/profile` (real page, not modal)
+ 2. Page exposes four sections — Timezone, Theme, Notifications, Channels — each persisting per-user; cross-device consistent; gated by `requireAuth()`
+ 3. Notification routing in `lib/services/pipeline-steps/notify.ts` resolves per-user channel preferences when the event has a user owner, falling back to global channels otherwise
+**Plans**: TBD
+**UI hint**: yes
+
## Progress
**Execution Order:**
@@ -161,8 +192,12 @@ Phases execute in numeric order. Phase 2 unblocks Phases 3–7 (any order, paral
| 5. Finance Restyle | 2/2 | Complete | 2026-05-03 |
| 6. Analyzer Feed | 0/3 | Not started | - |
| 7. Engagement Overview | 0/3 | Not started | - |
+| 7.1. User Timezone Fix | 0/5 | Not started | - |
| 8. Engagement User Profile | 0/TBD | Not started | - |
+| 9. User Profile & Preferences | 0/TBD | Not started | - |
---
*Roadmap created: 2026-05-03*
*Source spec: `docs/superpowers/specs/2026-05-03-mobile-shell-design.md`*
+
+
\ No newline at end of file
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 1d9e168..a03bce7 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -4,14 +4,14 @@ milestone: v1.0
milestone_name: milestone
status: executing
stopped_at: Phase 7 UI-SPEC approved
-last_updated: "2026-05-04T03:04:12.504Z"
-last_activity: 2026-05-04
+last_updated: "2026-05-07T11:01:40.811Z"
+last_activity: 2026-05-07 -- Phase 7.1 planning complete
progress:
- total_phases: 8
+ total_phases: 10
completed_phases: 7
- total_plans: 17
+ total_plans: 22
completed_plans: 17
- percent: 100
+ percent: 77
---
# Project State
@@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-05-03)
Phase: 8
Plan: Not started
-Status: Executing Phase 07
-Last activity: 2026-05-04
+Status: Ready to execute
+Last activity: 2026-05-07 -- Phase 7.1 planning complete
Progress: [░░░░░░░░░░] 0%
diff --git a/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-01-PLAN.md b/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-01-PLAN.md
new file mode 100644
index 0000000..b949200
--- /dev/null
+++ b/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-01-PLAN.md
@@ -0,0 +1,296 @@
+---
+phase: 07.1-user-timezone-fix-inserted-urgent
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - migrations/083_add_user_timezone.sql
+ - lib/auth.ts
+autonomous: true
+requirements: [TZ-01]
+requirements_addressed: [TZ-01]
+
+must_haves:
+ truths:
+ - "Every row in the Better Auth `user` table has a non-NULL `timezone` value"
+ - "New users created after this change default to `process.env.DEFAULT_TIMEZONE || 'UTC'`"
+ - "Existing rows are backfilled to the same default (UTC unless DEFAULT_TIMEZONE is set)"
+ - "session.user.timezone is populated on subsequent logins (Better Auth additionalField)"
+ - "All existing UTC-stored timestamp columns are unchanged (no destructive migration)"
+ artifacts:
+ - path: "migrations/083_add_user_timezone.sql"
+ provides: "Adds timezone TEXT column to \"user\" table with default + backfill"
+ contains: "ADD COLUMN IF NOT EXISTS timezone"
+ - path: "lib/auth.ts"
+ provides: "Better Auth additionalField config exposing timezone on session.user"
+ contains: "timezone:"
+ key_links:
+ - from: "Better Auth session"
+ to: "user.timezone column"
+ via: "additionalFields config in lib/auth.ts"
+ pattern: "additionalFields[\\s\\S]*timezone"
+ - from: "Default value at insert time"
+ to: "process.env.DEFAULT_TIMEZONE"
+ via: "SQL DEFAULT clause + Better Auth defaultValue"
+ pattern: "COALESCE\\(.*DEFAULT_TIMEZONE.*'UTC'\\)|defaultValue.*timezone"
+---
+
+
+Add a per-user IANA timezone field to the Better Auth `user` table and surface it on
+every session via Better Auth's `additionalFields`. This is the ground floor for
+Phase 7.1 — without it, no read path or hook in subsequent plans has anything to
+read. Storage stays UTC; only the `user.timezone` column is added.
+
+Purpose: Resolve TZ-01. Provide the data plumbing that Plan 02 (the
+`/api/me/timezone` endpoint) and Plans 03 + 04 (read-path fixes and the client
+hook) depend on.
+Output: Migration `083_add_user_timezone.sql`, updated `lib/auth.ts` exposing
+`timezone` on `session.user`.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/STATE.md
+@.planning/ROADMAP.md
+@.planning/REQUIREMENTS.md
+@CLAUDE.md
+@lib/auth.ts
+@migrations/012_create_auth_tables.sql
+@migrations/081_integration_settings.sql
+@lib/bootstrap.ts
+
+
+
+```typescript
+// lib/auth.ts current shape:
+user: {
+ additionalFields: {
+ role: {
+ type: "string",
+ defaultValue: "user",
+ },
+ requires_setup: {
+ type: "boolean",
+ defaultValue: false,
+ },
+ },
+},
+```
+
+
+```sql
+CREATE TABLE IF NOT EXISTS "user" (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ email TEXT NOT NULL UNIQUE,
+ email_verified BOOLEAN NOT NULL DEFAULT FALSE,
+ image TEXT,
+ role TEXT DEFAULT 'user',
+ banned BOOLEAN DEFAULT FALSE,
+ banned_reason TEXT,
+ ban_expires TIMESTAMP,
+ requires_setup BOOLEAN DEFAULT FALSE,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+```
+
+
+
+
+
+
+
+
+
+ Task 1: Create migration 083_add_user_timezone.sql
+ migrations/083_add_user_timezone.sql
+
+ - migrations/012_create_auth_tables.sql (the "user" table definition; column type/casing — note TEXT and TIMESTAMP, not VARCHAR/TIMESTAMPTZ)
+ - migrations/081_integration_settings.sql (recent migration idiom: IF NOT EXISTS, ON CONFLICT DO NOTHING, header comment block)
+ - migrations/082_company_scope.sql (confirm 082 is the latest — the new file MUST be 083)
+ - CLAUDE.md (Database section: snake_case columns, IF NOT EXISTS guards, no destructive ops, numbered migrations apply alphabetically on Postgres init only)
+
+
+ Create the file `migrations/083_add_user_timezone.sql` with EXACTLY this content (DEFAULT_TIMEZONE is read at INSERT time from a Postgres GUC fallback, not the SQL itself, since psql can't read process.env — so the SQL default is `'UTC'` and the application layer in `lib/auth.ts` overrides via `defaultValue` referencing `process.env.DEFAULT_TIMEZONE`):
+
+ ```sql
+ -- =============================================================================
+ -- Per-user IANA timezone (Phase 7.1 — TZ-01)
+ -- =============================================================================
+ -- Adds a `timezone` column to the Better Auth "user" table so day/week
+ -- boundary math (dashboards, ticket filters, finance, engagement) can be
+ -- computed against the viewer's zone instead of server UTC.
+ --
+ -- Storage zone for every existing TIMESTAMP / TIMESTAMPTZ column is unchanged.
+ -- Only display/range-bucketing logic in subsequent plans reads this column.
+ --
+ -- The SQL default here is the literal 'UTC'. The application-level default
+ -- (process.env.DEFAULT_TIMEZONE || 'UTC') is enforced by Better Auth's
+ -- additionalField `defaultValue` in lib/auth.ts so new sessions see the env-
+ -- driven value even if a row was created without it.
+ -- =============================================================================
+
+ ALTER TABLE "user"
+ ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT 'UTC';
+
+ -- Backfill any rows that may have been created with NULL (defensive — the
+ -- DEFAULT clause above covers new inserts, but on managed Postgres a column
+ -- added with DEFAULT may briefly show NULL in flight on some replicas).
+ UPDATE "user" SET timezone = 'UTC' WHERE timezone IS NULL;
+
+ COMMENT ON COLUMN "user".timezone IS
+ 'IANA timezone string (e.g. America/New_York). Storage timezone for all date columns remains UTC; this column only affects display and range-bucketing.';
+ ```
+
+ Notes:
+ - Use `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` so re-running the file on an
+ existing DB (via `scripts/apply-migrations`) is a no-op. Postgres init only
+ applies migrations on first boot — for the running container an operator
+ will run the apply-migrations script.
+ - Do NOT add a CHECK constraint validating against `Intl.supportedValuesOf` —
+ Postgres can't evaluate JS APIs. Validation lives in Plan 02's PUT route.
+ - `TIMESTAMP` (without time zone) is the existing column type for `created_at`
+ / `updated_at` in this table — match the file style. The `timezone` column
+ itself is `TEXT`, not a Postgres `TIMESTAMP WITH TIME ZONE`.
+
+
+ test -f migrations/083_add_user_timezone.sql && grep -q 'ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT' migrations/083_add_user_timezone.sql && grep -q "ON COLUMN \"user\".timezone IS" migrations/083_add_user_timezone.sql
+
+
+ - File exists at exact path `migrations/083_add_user_timezone.sql`
+ - Filename matches regex `^083_add_user_timezone\.sql$`
+ - File contains the literal string `ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT 'UTC'`
+ - File contains a `COMMENT ON COLUMN "user".timezone` statement
+ - File contains a backfill `UPDATE "user" SET timezone = 'UTC' WHERE timezone IS NULL` line
+ - File has NO `DROP`, `DELETE FROM`, or `TRUNCATE` (non-destructive)
+ - File has NO Zod, no JS — pure SQL
+
+
+ Migration file is committed and applying it (via Postgres init on a fresh
+ volume OR via scripts/apply-migrations on the running DB) results in the
+ `user` table having a `timezone TEXT NOT NULL DEFAULT 'UTC'` column with
+ every existing row populated.
+
+
+
+
+ Task 2: Extend Better Auth additionalFields with timezone
+ lib/auth.ts
+
+ - lib/auth.ts (current additionalFields block at lines 84-96 — this is the source of truth for the existing pattern; copy the shape exactly)
+ - lib/auth-utils.ts (UserWithRole type at lines 7-14 — note `[key: string]: unknown` already accepts new fields; no type change needed there)
+ - lib/auth-client.ts (the magicLink / twoFactor / admin client plugins — no change needed; additionalFields propagate via Better Auth's session inference)
+ - CLAUDE.md (Auth section: Better Auth 1.4, additionalFields pattern, no Zod here)
+
+
+ Edit `lib/auth.ts`. In the `user.additionalFields` object (currently lines
+ 86-95), add a third field `timezone` after `requires_setup`. The exact final
+ shape of the `user` block must be:
+
+ ```typescript
+ // User configuration
+ user: {
+ additionalFields: {
+ role: {
+ type: "string",
+ defaultValue: "user",
+ },
+ requires_setup: {
+ type: "boolean",
+ defaultValue: false,
+ },
+ timezone: {
+ type: "string",
+ defaultValue: process.env.DEFAULT_TIMEZONE || "UTC",
+ },
+ },
+ },
+ ```
+
+ Notes:
+ - `defaultValue` is evaluated when Better Auth provisions a new user row that
+ didn't supply the field. Reading `process.env.DEFAULT_TIMEZONE` here means
+ a fresh user gets the operator-configured default, while existing rows
+ (already backfilled to 'UTC' by Task 1) keep their stored value.
+ - Do NOT add a custom validator here — Better Auth's `additionalFields`
+ doesn't run runtime IANA validation, and we don't want to. Validation for
+ writes is owned by Plan 02's PUT /api/me/timezone route.
+ - Do NOT touch any other config in this file (session, plugins, social
+ providers, account linking). Only add the one field inside additionalFields.
+ - The exported `User` type at the bottom of the file (`typeof
+ auth.$Infer.Session.user`) automatically picks up the new field — no
+ explicit type addition needed.
+
+
+ ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "lib/auth\.(ts|tsx)"); [ -z "$ERR" ] && grep -A1 "timezone:" lib/auth.ts | grep -q 'process.env.DEFAULT_TIMEZONE || "UTC"'
+
+
+ - `lib/auth.ts` contains the literal substring `timezone: {` inside the `additionalFields` object
+ - The same field block contains `defaultValue: process.env.DEFAULT_TIMEZONE || "UTC"`
+ - `npx tsc --noEmit --pretty` reports no NEW errors in `lib/auth.ts` (pre-existing errors elsewhere are out of scope; this file in particular must be clean)
+ - Existing `role` and `requires_setup` additionalFields are preserved unchanged
+ - No new imports added (the change is one new property on an existing object)
+
+
+ `lib/auth.ts` exports an `auth` instance whose `User` type now includes
+ `timezone: string`. After re-deploying, `session.user.timezone` is available
+ on every authenticated request — populated either from the stored row value
+ or from the env-driven default for newly-provisioned users.
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Operator → DB schema | Migration runs at deploy time; only operator-controlled SQL crosses this boundary |
+| Better Auth → session payload | additionalField propagates to client-readable session — must be a benign string, not credentials |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-07.1-01-01 | Tampering | migration 083 (SQL injection via env var) | accept | `process.env.DEFAULT_TIMEZONE` is read by Better Auth at runtime in TypeScript, not interpolated into SQL. The SQL DEFAULT is the literal `'UTC'`. No user input touches the migration. |
+| T-07.1-01-02 | Information Disclosure | session.user.timezone exposed to client | accept | IANA timezone is non-sensitive metadata (visible in `Intl.DateTimeFormat().resolvedOptions().timeZone` on any device). Already the level of exposure assumed by every web app that renders dates. |
+| T-07.1-01-03 | Denial of Service | NOT NULL DEFAULT 'UTC' on existing rows | mitigate | Postgres handles ADD COLUMN with constant DEFAULT in O(1) since version 11 (no table rewrite). For very large `user` tables this is still safe; Pulse's user count is bounded by employee headcount (small). |
+| T-07.1-01-04 | Elevation of Privilege | Writing through additionalField | mitigate | additionalFields default config does NOT make the field client-writable. Writes are gated by Plan 02's authenticated /api/me/timezone PUT route only. Confirm by checking that Better Auth's default `input` flag for additionalField is false (it is, per Better Auth 1.4 docs). |
+| T-07.1-01-05 | Spoofing | Default value injection via env var rewrite | accept | An attacker who can rewrite `process.env.DEFAULT_TIMEZONE` already controls the deploy. Out of scope. |
+
+
+
+End-to-end checks for this plan:
+
+1. SQL: After applying the migration on a fresh DB,
+ `SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name='user' AND column_name='timezone'`
+ returns exactly one row: `timezone | text | NO | 'UTC'::text`.
+2. SQL: `SELECT COUNT(*) FROM "user" WHERE timezone IS NULL` returns 0.
+3. Type: `ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "lib/auth\.(ts|tsx)"); [ -z "$ERR" ]` (TS errors in lib/auth.ts fail the check; pre-existing errors elsewhere in the codebase are out of scope for Phase 7.1).
+4. Runtime: After redeploying with this change, sign in once and inspect
+ `session.user` in DevTools — `timezone` is a string property of the user
+ object.
+
+
+
+- `migrations/083_add_user_timezone.sql` exists, idempotent, non-destructive
+- `lib/auth.ts` `additionalFields` includes `timezone` with the env-driven default
+- TypeScript compilation of `lib/auth.ts` succeeds
+- Storage timezone of every existing TIMESTAMP column in the database remains UTC (no migration touches them)
+
+
+
+
+
\ No newline at end of file
diff --git a/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-02-PLAN.md b/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-02-PLAN.md
new file mode 100644
index 0000000..1462e81
--- /dev/null
+++ b/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-02-PLAN.md
@@ -0,0 +1,342 @@
+---
+phase: 07.1-user-timezone-fix-inserted-urgent
+plan: 02
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - app/api/me/timezone/route.ts
+ - middleware.ts
+autonomous: true
+requirements: [TZ-03]
+requirements_addressed: [TZ-03]
+
+must_haves:
+ truths:
+ - "Authenticated GET /api/me/timezone returns { timezone: string, source: 'user' | 'default' }"
+ - "Authenticated PUT /api/me/timezone with a valid IANA tz persists to the calling user's row and returns the new value"
+ - "PUT with an invalid tz string returns 400 (rejected via Intl.supportedValuesOf('timeZone'))"
+ - "Unauthenticated GET or PUT returns 401 (via requireAuth())"
+ - "PUT only updates the calling user's row — no userId parameter is accepted"
+ - "/api/me/* is NOT in middleware.ts publicRoutes"
+ artifacts:
+ - path: "app/api/me/timezone/route.ts"
+ provides: "GET + PUT /api/me/timezone handlers"
+ exports: ["GET", "PUT"]
+ - path: "middleware.ts"
+ provides: "Confirms /api/me/* is excluded from publicRoutes (no change needed unless an audit reveals it leaked in)"
+ contains: "publicRoutes"
+ key_links:
+ - from: "app/api/me/timezone/route.ts"
+ to: "lib/auth-utils.ts requireAuth()"
+ via: "import { requireAuth } from '@/lib/auth-utils'"
+ pattern: "import.*requireAuth.*from.*auth-utils"
+ - from: "PUT handler"
+ to: "UPDATE user SET timezone WHERE id = session.user.id"
+ via: "session-scoped UPDATE"
+ pattern: "UPDATE \"user\" SET timezone"
+ - from: "PUT validation"
+ to: "Intl.supportedValuesOf timeZone whitelist"
+ via: "runtime IANA whitelist"
+ pattern: "Intl.supportedValuesOf"
+---
+
+
+Ship the authenticated GET + PUT endpoint for a user's timezone. This is the
+read/write surface that the (Phase 9) timezone picker UI will eventually call;
+in 7.1 it's API-only — admins / curl can set tz before the UI lands. Validation
+uses Intl.supportedValuesOf('timeZone') so callers can't store an arbitrary
+string that would crash toLocaleString downstream.
+
+Purpose: Resolve TZ-03. Provide the only writeable surface for user.timezone —
+no other code path mutates this column.
+Output: New app/api/me/timezone/route.ts exporting GET and PUT.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/STATE.md
+@.planning/ROADMAP.md
+@.planning/REQUIREMENTS.md
+@CLAUDE.md
+@lib/auth-utils.ts
+@lib/services/postgres-client.ts
+@middleware.ts
+@app/api/mobile/engagement/summary/route.ts
+
+
+Existing helpers in this codebase that the new route must use verbatim:
+
+- `requireAuth()` from `@/lib/auth-utils` returns `{ session, error }`. When unauthenticated, `error` is a `NextResponse` with status 401 — return it as-is.
+- `postgresClient` from `@/lib/services/postgres-client` exposes `query(sql, params)` returning a `pg` `QueryResult`. There is no ORM.
+- After Plan 01, `session.user.timezone` is typed `string` and `session.user.id` is `string` (TEXT primary key in the `"user"` table).
+- API response convention: `NextResponse.json({ error: 'short', message: 'detail' }, { status: N })` for failures; bare `NextResponse.json(payload)` for success. No Zod.
+
+middleware.ts publicRoutes list (lines 6-43 at planning time): NONE of the entries is a prefix of `/api/me/...`, so the route is correctly auth-gated by the existing middleware + requireAuth() combo.
+
+
+
+
+
+
+ Task 1: Confirm middleware.ts does not whitelist /api/me
+ middleware.ts
+
+ - middleware.ts (the publicRoutes array, lines 6-43 — verify no entry has a prefix that would match /api/me; specifically look at every string and confirm none is a prefix of /api/me/timezone)
+
+
+ Read middleware.ts and confirm by inspection that none of the publicRoutes
+ entries is a prefix of /api/me. The current list (verified at planning
+ time) contains entries like /api/auth, /api/webhooks, /api/kiosk,
+ /api/mobile, /api/sync, etc. — none of which match /api/me/.
+
+ Action: NO file changes are required. Run a verification grep to PROVE no
+ entry is a prefix of /api/me:
+
+ grep -nE '"/api/me' middleware.ts
+
+ The grep MUST return zero matches. If it does match, STOP — that's a
+ surprise that needs investigation before Task 2 (some past commit may have
+ whitelisted /api/me which would defeat the auth gate).
+
+ If grep returns zero matches: do not edit middleware.ts. The next task can
+ proceed knowing the route handler's own requireAuth() is the authoritative
+ auth gate.
+
+
+ ! grep -nE '"/api/me' middleware.ts
+
+
+ - Command `grep -nE '"/api/me' middleware.ts` exits non-zero (no matches)
+ - middleware.ts is unchanged (`git diff --quiet middleware.ts`)
+
+
+ Confirmed by automated grep that /api/me/* is NOT exempt from auth in
+ middleware.ts. Plan 02 Task 2 may proceed knowing the route handler's own
+ requireAuth() is the authoritative gate.
+
+
+
+
+ Task 2: Create app/api/me/timezone/route.ts (GET + PUT)
+ app/api/me/timezone/route.ts
+
+ - lib/auth-utils.ts (`requireAuth()` at lines 31-45 — copy the call shape exactly: `const { session, error } = await requireAuth(); if (error) return error;`)
+ - lib/services/postgres-client.ts (the `query()` method signature; this codebase uses `postgresClient.query(sql, params)` and gets back a `QueryResult`)
+ - app/api/mobile/engagement/summary/route.ts (canonical Pulse API route shape: imports, requireAuth, parametrized query, NextResponse.json with `error`/`message` envelope on failure, no Zod)
+ - CLAUDE.md ("API routes" section: no Zod, manual try/catch, status code conventions — 401 from auth helper, 400 for bad input, 500 for runtime, 503 for missing config)
+
+
+ Create the new file `app/api/me/timezone/route.ts` with EXACTLY the
+ following content. No Zod. Manual validation. Matches the
+ engagement/summary route shape.
+
+ import { NextRequest, NextResponse } from 'next/server';
+ import { requireAuth } from '@/lib/auth-utils';
+ import { postgresClient } from '@/lib/services/postgres-client';
+
+ // GET /api/me/timezone -> { timezone: string, source: 'user' | 'default' }
+ // PUT /api/me/timezone -> body { timezone: string } -> { timezone: string }
+ //
+ // TZ-03. Authentication: requireAuth(). The PUT handler updates ONLY the
+ // calling user's row — there is no `userId` query param or body field. The
+ // write target is always `session.user.id`.
+ //
+ // Validation: the input timezone must appear in
+ // `Intl.supportedValuesOf('timeZone')`. Anything else is rejected with 400
+ // before touching the database.
+
+ function getDefaultTimezone(): string {
+ return process.env.DEFAULT_TIMEZONE || 'UTC';
+ }
+
+ function isValidIanaTimezone(tz: unknown): tz is string {
+ if (typeof tz !== 'string' || tz.length === 0 || tz.length > 64) return false;
+ try {
+ const zones = Intl.supportedValuesOf('timeZone');
+ return zones.includes(tz);
+ } catch {
+ return false;
+ }
+ }
+
+ export async function GET(): Promise {
+ const { session, error } = await requireAuth();
+ if (error) return error;
+
+ try {
+ const result = await postgresClient.query<{ timezone: string | null }>(
+ 'SELECT timezone FROM "user" WHERE id = $1',
+ [session!.user.id],
+ );
+ const stored = result.rows[0]?.timezone;
+ const fallback = getDefaultTimezone();
+ const timezone = stored && stored.length > 0 ? stored : fallback;
+ const source: 'user' | 'default' =
+ stored && stored.length > 0 && stored !== fallback ? 'user' : 'default';
+ return NextResponse.json({ timezone, source });
+ } catch (e) {
+ console.error('GET /api/me/timezone failed:', e);
+ return NextResponse.json(
+ { error: 'Failed to read timezone', message: e instanceof Error ? e.message : 'unknown' },
+ { status: 500 },
+ );
+ }
+ }
+
+ export async function PUT(request: NextRequest): Promise {
+ const { session, error } = await requireAuth();
+ if (error) return error;
+
+ let body: unknown;
+ try {
+ body = await request.json();
+ } catch {
+ return NextResponse.json(
+ { error: 'Invalid JSON', message: 'Request body must be JSON' },
+ { status: 400 },
+ );
+ }
+
+ const candidate =
+ body && typeof body === 'object' && 'timezone' in body
+ ? (body as { timezone: unknown }).timezone
+ : undefined;
+
+ if (!isValidIanaTimezone(candidate)) {
+ return NextResponse.json(
+ {
+ error: 'Invalid timezone',
+ message: "timezone must be an IANA zone present in Intl.supportedValuesOf('timeZone')",
+ },
+ { status: 400 },
+ );
+ }
+
+ try {
+ // Authoritative write target: session.user.id. NO userId from body.
+ const result = await postgresClient.query<{ timezone: string }>(
+ 'UPDATE "user" SET timezone = $1, updated_at = NOW() WHERE id = $2 RETURNING timezone',
+ [candidate, session!.user.id],
+ );
+ if (result.rowCount === 0) {
+ return NextResponse.json(
+ { error: 'User not found', message: 'No user row matched the session' },
+ { status: 404 },
+ );
+ }
+ return NextResponse.json({ timezone: result.rows[0].timezone });
+ } catch (e) {
+ console.error('PUT /api/me/timezone failed:', e);
+ return NextResponse.json(
+ { error: 'Failed to update timezone', message: e instanceof Error ? e.message : 'unknown' },
+ { status: 500 },
+ );
+ }
+ }
+
+ Notes:
+ - The route is `/api/me/timezone` (matches the orchestrator's spec and the
+ Task 1 audit).
+ - `Intl.supportedValuesOf('timeZone')` is called per request. It returns a
+ static ~600-entry array; V8 caches internally. No module-scope memo
+ needed (would also miss tzdata updates between Node restarts).
+ - The `source` field on GET helps the future Phase 9 picker show "(default)".
+ A row equal to the env default is reported as 'default' even if it was a
+ no-op write — intentional and acceptable.
+ - 64-char length cap is belt-and-suspenders before the IANA whitelist.
+ - Do NOT add Zod (Pulse convention, CLAUDE.md API routes section).
+ - UPDATE writes `updated_at = NOW()` to match audit-column conventions used
+ throughout the codebase.
+
+
+ ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/me/timezone/route\.ts"); [ -z "$ERR" ] && grep -q "export async function GET" app/api/me/timezone/route.ts && grep -q "export async function PUT" app/api/me/timezone/route.ts && grep -q "Intl.supportedValuesOf('timeZone')" app/api/me/timezone/route.ts && grep -q 'UPDATE "user" SET timezone' app/api/me/timezone/route.ts
+
+
+ - File exists at exact path `app/api/me/timezone/route.ts`
+ - File exports `GET` (no params) and `PUT` (NextRequest param)
+ - File imports `requireAuth` from `@/lib/auth-utils` and uses it as the FIRST line of each handler
+ - File contains the literal `Intl.supportedValuesOf('timeZone')`
+ - File contains the literal `UPDATE "user" SET timezone = $1, updated_at = NOW() WHERE id = $2`
+ - File contains NO `userId` parameter parsing — only `session.user.id` is used as the WHERE id target
+ - File does NOT import `zod` or `z` from `zod`
+ - `npx tsc --noEmit --pretty` reports no NEW errors in this file
+ - Behavioral (manual once running):
+ - `curl -X GET http://localhost:3100/api/me/timezone` (no cookie) returns HTTP 401
+ - `curl -X PUT -H 'Content-Type: application/json' -d '{"timezone":"Etc/Garbage"}' http://localhost:3100/api/me/timezone` (with valid cookie) returns HTTP 400
+ - `curl -X PUT -H 'Content-Type: application/json' -d '{"timezone":"America/New_York"}' http://localhost:3100/api/me/timezone` (with valid cookie) returns HTTP 200 with `{"timezone":"America/New_York"}`
+ - `curl -X GET http://localhost:3100/api/me/timezone` (with valid cookie, after the PUT above) returns HTTP 200 with `{"timezone":"America/New_York","source":"user"}`
+
+
+ GET /api/me/timezone returns the calling user's stored tz (or env default)
+ with a `source` discriminator. PUT validates the input against
+ `Intl.supportedValuesOf('timeZone')`, persists only to `session.user.id`'s
+ row, and returns the stored value. Unauthenticated calls return 401.
+ Invalid tz strings return 400. The route is the SOLE write surface for
+ `user.timezone`.
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Browser → API route | Untrusted JSON body crosses here on PUT |
+| Session cookie → handler | Better Auth cookie carries the authoritative user identity |
+| Handler → Postgres | Parametrized writes; the handler's `id` parameter MUST come from the verified session, never from the request body |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-07.1-02-01 | Tampering | PUT body — arbitrary timezone string | mitigate | `isValidIanaTimezone()` rejects anything not in `Intl.supportedValuesOf('timeZone')` and anything longer than 64 chars; returns 400 before any DB call. |
+| T-07.1-02-02 | Spoofing | Cross-user write (PUT updating someone else's row) | mitigate | UPDATE WHERE clause uses `session!.user.id` exclusively. The handler does NOT read or accept any `userId` field from query string, body, or headers. Test: a request body of `{"timezone":"Etc/UTC","userId":"someone-else"}` writes to the caller's own row only. |
+| T-07.1-02-03 | Information Disclosure | Unauthenticated read of user's tz | mitigate | `requireAuth()` is the FIRST statement of GET. Returns 401 without touching the DB. |
+| T-07.1-02-04 | Denial of Service | Repeated PUTs spamming the user table | accept | Rate limiting is out of scope for this phase; Pulse has no global rate limiter today. The UPDATE is O(1) on a tiny table. If abuse becomes a concern, add a per-session limiter in a follow-up. |
+| T-07.1-02-05 | Repudiation | Audit of who set what tz | accept | `updated_at = NOW()` records when the change happened. We do NOT log the old→new value pair; user-controlled timezone is low-sensitivity. |
+| T-07.1-02-06 | Elevation of Privilege | An admin endpoint masquerading as /api/me | accept | Route lives at the user-self path; no admin-targeted user-id parameter is accepted, so there is no role-confusion surface here. |
+| T-07.1-02-07 | Tampering | SQL injection via timezone string | mitigate | Parameterized query (`$1`, `$2`); the value is also pre-validated against the IANA whitelist (no injection-shaped strings will pass `Intl.supportedValuesOf` membership). |
+| T-07.1-02-08 | Tampering | JSON parse errors crashing the handler | mitigate | `try { await request.json() } catch` returns a 400 on invalid JSON instead of letting the framework return a 500. |
+| T-07.1-02-09 | Information Disclosure | Middleware leaking /api/me as public | mitigate | Task 1 audits middleware.ts and asserts no publicRoutes entry prefixes /api/me. |
+
+
+
+End-to-end checks for this plan:
+
+1. Static: `grep -nE '"/api/me' middleware.ts` returns nothing.
+2. Static: `grep -E "Intl.supportedValuesOf\\('timeZone'\\)" app/api/me/timezone/route.ts` returns one line.
+3. Static: `grep -E 'WHERE id = \$2' app/api/me/timezone/route.ts` returns the PUT handler's UPDATE.
+4. Static: `grep -E 'userId|user_id' app/api/me/timezone/route.ts` returns nothing (no cross-user write surface).
+5. Type: `ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/me/timezone/route\.ts"); [ -z "$ERR" ]` (TS errors in this file fail the check; pre-existing errors elsewhere in the codebase are out of scope for Phase 7.1).
+6. Runtime (with the dev server running and a logged-in cookie in `curl`):
+ - GET unauthenticated → 401 JSON `{"error":"Unauthorized"}`
+ - PUT with `{"timezone":"Etc/Garbage"}` → 400 JSON `{"error":"Invalid timezone",...}`
+ - PUT with `{"timezone":"America/New_York"}` → 200 JSON `{"timezone":"America/New_York"}`
+ - GET after the successful PUT → 200 JSON `{"timezone":"America/New_York","source":"user"}`
+
+
+
+- New `app/api/me/timezone/route.ts` exports working GET and PUT handlers
+- Validation rejects non-IANA strings with HTTP 400
+- Auth gates reject unauthenticated requests with HTTP 401
+- Write target is exclusively `session.user.id` — no user-supplied id parameter
+- middleware.ts is unchanged and confirmed not to leak /api/me/* to publicRoutes
+- TypeScript compiles for the new file
+
+
+
+
+
\ No newline at end of file
diff --git a/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-03-PLAN.md b/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-03-PLAN.md
new file mode 100644
index 0000000..1f3adef
--- /dev/null
+++ b/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-03-PLAN.md
@@ -0,0 +1,669 @@
+---
+phase: 07.1-user-timezone-fix-inserted-urgent
+plan: 03
+type: execute
+wave: 2
+depends_on: [07.1-01]
+files_modified:
+ - lib/services/user-timezone.ts
+ - app/api/mobile/dashboard/route.ts
+ - app/api/dashboard/overview/route.ts
+ - app/api/dashboard/trends/route.ts
+ - app/api/mobile/finance/route.ts
+ - app/api/mobile/engagement/summary/route.ts
+ - app/api/mobile/engagement/trend/route.ts
+autonomous: true
+requirements: [TZ-02]
+requirements_addressed: [TZ-02]
+
+must_haves:
+ truths:
+ - "GET /api/dashboard/overview computes 'today/yesterday/last 7 days' against the calling user's tz, not server UTC"
+ - "GET /api/mobile/dashboard computes 'opened today / resolved today / SLA breaches' against the calling user's tz"
+ - "GET /api/dashboard/trends returns daily ticket counts and time-entry hours with day buckets aligned to the calling user's timezone, not UTC"
+ - "GET /api/mobile/finance computes 'paid_mtd' / 'paid_ytd' / aging buckets against the calling user's tz (after the route is auth-gated by this plan)"
+ - "GET /api/mobile/finance now requires authentication (requireAuth() returns 401 to anonymous callers); previously-authenticated browser sessions reach the route unchanged via session cookie"
+ - "GET /api/mobile/engagement/summary computes the rolling D7/D30/D90 time-entries window against the calling user's tz"
+ - "GET /api/mobile/engagement/trend produces day buckets aligned to the calling user's tz (point N corresponds to the user-tz day, not UTC day)"
+ - "Engagement summary's snapshot-derived counters (D7/D30/D90 active users, total MS Graph hours) bucket by UTC at sync time; only the rolling time-entries window uses user-tz (per TZ-02 carve-out — see REQUIREMENTS.md)"
+ - "Dashboard ticket counters (`opened_today`, `resolved_today` etc. in `/api/dashboard/overview` and `/api/mobile/dashboard`) are bucketed against the viewing user's timezone. The mobile and desktop ticket *list* pages do not currently expose today/7d/30d range filters; if/when those filters are added, they MUST consume `useUserTimezone()` from Phase 7.1"
+ - "All affected routes still require auth (existing requireAuth() preserved; finance gains it for the first time)"
+ - "Storage timezone of every TIMESTAMP column on disk is unchanged (no schema migration in this plan)"
+ artifacts:
+ - path: "lib/services/user-timezone.ts"
+ provides: "Server-side helper getUserTimezone(session) returning a validated IANA string with safe fallback"
+ exports: ["getUserTimezone", "DEFAULT_TIMEZONE_FALLBACK"]
+ - path: "app/api/mobile/dashboard/route.ts"
+ provides: "Dashboard KPIs scoped to user-tz day boundaries"
+ contains: "getUserTimezone"
+ - path: "app/api/dashboard/overview/route.ts"
+ provides: "Desktop dashboard overview scoped to user-tz day boundaries"
+ contains: "getUserTimezone"
+ - path: "app/api/dashboard/trends/route.ts"
+ provides: "Desktop dashboard trends with daily buckets aligned to the calling user's tz"
+ contains: "getUserTimezone"
+ - path: "app/api/mobile/finance/route.ts"
+ provides: "Mobile finance summary scoped to user-tz month boundaries; now auth-gated via requireAuth()"
+ contains: "getUserTimezone"
+ - path: "app/api/mobile/engagement/summary/route.ts"
+ provides: "Mobile engagement summary with user-tz window math (rolling time_entries only; snapshot bucketing remains UTC by design)"
+ contains: "getUserTimezone"
+ - path: "app/api/mobile/engagement/trend/route.ts"
+ provides: "Mobile engagement trend bucketed in user-tz days"
+ contains: "getUserTimezone"
+ key_links:
+ - from: "Each affected route"
+ to: "session.user.timezone via lib/services/user-timezone.ts"
+ via: "import { getUserTimezone } and call it after requireAuth()"
+ pattern: "getUserTimezone"
+ - from: "SQL queries"
+ to: "Postgres timezone-aware day boundaries"
+ via: "(value AT TIME ZONE 'UTC') AT TIME ZONE $tz idiom"
+ pattern: "AT TIME ZONE"
+---
+
+
+Switch every server-side day/week/month boundary computation in the affected
+read paths from server UTC to the calling user's IANA timezone. Storage stays
+UTC; only the WHERE clauses and DATE_TRUNC arguments change. This plan also
+adds `requireAuth()` to `/api/mobile/finance` (a 5-line hardening that aligns
+it with every other `/api/mobile/*` route) so it can use the proper
+`getUserTimezone(session)` resolution path instead of the env-default fallback.
+
+Purpose: Resolve TZ-02 server-side. The six routes touched here are the ones
+that surfaced the bug (dashboards and filters showing wrong dates) per the
+phase scope context. The Plan 04 client work follows up on TZ-02 client-side +
+TZ-04.
+
+Output: A single shared helper `lib/services/user-timezone.ts` and edits to
+six existing route handlers (the four originally listed plus
+`/api/dashboard/trends`, which the first revision pass missed). No new
+endpoints. No schema changes.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/STATE.md
+@.planning/ROADMAP.md
+@.planning/REQUIREMENTS.md
+@CLAUDE.md
+@lib/auth-utils.ts
+@lib/auth.ts
+@app/api/mobile/dashboard/route.ts
+@app/api/dashboard/overview/route.ts
+@app/api/dashboard/trends/route.ts
+@app/api/mobile/finance/route.ts
+@app/api/mobile/engagement/summary/route.ts
+@app/api/mobile/engagement/trend/route.ts
+
+
+After Plan 01 ships, `session.user.timezone: string` is on the Better Auth
+`User` type. Before that, it's not. This plan therefore depends on Plan 01.
+
+The Postgres idiom for "day boundary in user tz" is:
+
+ -- "Today" in user's local zone, comparing a UTC-stored timestamp:
+ WHERE create_date AT TIME ZONE $tz_param >= DATE_TRUNC('day', NOW() AT TIME ZONE $tz_param)
+ AND create_date AT TIME ZONE $tz_param < DATE_TRUNC('day', NOW() AT TIME ZONE $tz_param) + INTERVAL '1 day'
+
+Or, more compactly:
+
+ WHERE (create_date AT TIME ZONE $tz_param)::date = (NOW() AT TIME ZONE $tz_param)::date
+
+Notes on Postgres `AT TIME ZONE` semantics:
+- For a `TIMESTAMP WITH TIME ZONE` (timestamptz) input: `value AT TIME ZONE 'America/New_York'` returns a `TIMESTAMP WITHOUT TIME ZONE` adjusted to that zone (correct for our purpose).
+- For a `TIMESTAMP WITHOUT TIME ZONE` input: `value AT TIME ZONE 'America/New_York'` ASSUMES the input is in `America/New_York` and returns a `timestamptz`. This is the wrong direction for us.
+- The Pulse `tickets`, `qbo_invoices`, `engagement_snapshots`, and `time_entries` tables use `TIMESTAMP WITHOUT TIME ZONE` for their date columns (per the existing 069/070/079 migrations and confirmed by the routes using `::date` casts directly). UTC-stored. So the correct idiom is `(value AT TIME ZONE 'UTC') AT TIME ZONE $tz`.
+
+Using a TWO-STEP convert is the safe canonical form regardless of column type:
+
+ -- "today" in user tz:
+ (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
+
+ -- "row in today (user tz)":
+ ((create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
+
+This works for both `timestamp` and `timestamptz` columns:
+- For `timestamptz`, `AT TIME ZONE 'UTC'` returns a `timestamp` already in UTC.
+- For `timestamp` (assumed UTC, which is Pulse's convention per CLAUDE.md), `AT TIME ZONE 'UTC'` interprets the value as UTC and returns a `timestamptz` representing that instant; the second `AT TIME ZONE $1` then shifts it to the user zone.
+
+Use this two-step idiom in every replacement.
+
+Postgres validates the IANA string at query time and throws `invalid_parameter_value` for unknown zones. Since `session.user.timezone` is constrained at write time by Plan 02's PUT validation (and at read time by `getUserTimezone()`'s safe fallback below), we never expect that error in practice — but it's also not catastrophic if it ever fires; the catch block returns 500 like any other DB error.
+
+
+
+
+
+
+ Task 1: Create lib/services/user-timezone.ts helper
+ lib/services/user-timezone.ts
+
+ - lib/auth-utils.ts (the `requireAuth` return shape and `UserWithRole` type — note `[key: string]: unknown` so `timezone` is accessible without a type cast)
+ - lib/auth.ts (the additionalFields block, after Plan 01 — confirms `timezone` is on `session.user` and is a string)
+ - CLAUDE.md (Layout / Conventions — `lib/services/` is the right home for shared server helpers)
+
+
+ Create `lib/services/user-timezone.ts` with EXACTLY this content:
+
+ // Server-side helper for resolving the calling user's IANA timezone.
+ //
+ // After Phase 7.1 Plan 01, `session.user.timezone` is a string populated
+ // either from the stored `"user".timezone` column or from Better Auth's
+ // additionalField `defaultValue` (`process.env.DEFAULT_TIMEZONE || 'UTC'`).
+ //
+ // This helper:
+ // - reads the value off a Better Auth session
+ // - validates it against `Intl.supportedValuesOf('timeZone')` (defence in
+ // depth — Plan 02 already validates writes, but a corrupt row from
+ // before this phase, or a manual SQL edit, must not crash dashboards)
+ // - falls back to `process.env.DEFAULT_TIMEZONE || 'UTC'` if invalid
+ //
+ // Use this in every API route that does day/week/month boundary math.
+
+ export const DEFAULT_TIMEZONE_FALLBACK = (): string =>
+ process.env.DEFAULT_TIMEZONE || 'UTC';
+
+ type SessionLike = {
+ user?: { timezone?: unknown } | null;
+ } | null | undefined;
+
+ 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;
+ }
+ }
+
+ /**
+ * Returns a validated IANA timezone string for the given session.
+ * Never throws; always returns a usable string (worst case: 'UTC').
+ */
+ export function getUserTimezone(session: SessionLike): string {
+ const raw = session?.user?.timezone;
+ if (isValidIanaTimezone(raw)) return raw;
+ return DEFAULT_TIMEZONE_FALLBACK();
+ }
+
+ Notes:
+ - Do NOT import from `@/lib/auth-utils` here (would create a circular module
+ graph for routes that already import requireAuth). Accept a duck-typed
+ session.
+ - The helper is intentionally pure and synchronous — no DB calls, no env
+ lookups beyond the fallback. Routes already have the session in hand from
+ requireAuth(), so we just pass it in.
+ - DEFAULT_TIMEZONE_FALLBACK is a function (not a const) so test code can
+ override `process.env.DEFAULT_TIMEZONE` between calls without resetting
+ module state.
+
+
+ ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "lib/services/user-timezone\.ts"); [ -z "$ERR" ] && grep -q "export function getUserTimezone" lib/services/user-timezone.ts && grep -q "Intl.supportedValuesOf('timeZone')" lib/services/user-timezone.ts && grep -q "DEFAULT_TIMEZONE_FALLBACK" lib/services/user-timezone.ts
+
+
+ - File exists at `lib/services/user-timezone.ts`
+ - Exports a function named `getUserTimezone`
+ - Exports a function named `DEFAULT_TIMEZONE_FALLBACK`
+ - Contains the literal `Intl.supportedValuesOf('timeZone')`
+ - Contains the literal `process.env.DEFAULT_TIMEZONE || 'UTC'`
+ - Does NOT import from `@/lib/auth-utils` (no circular)
+ - Does NOT import `pg` or `postgresClient` (no DB)
+ - `npx tsc --noEmit --pretty` reports no errors in this file
+
+
+ `getUserTimezone(session)` is available to every server route. Given a
+ session with `user.timezone === 'America/New_York'`, returns
+ `'America/New_York'`. Given a session with garbage or missing tz, returns
+ the env default (or `'UTC'`).
+
+
+
+
+ Task 2: Migrate /api/mobile/dashboard and /api/dashboard/overview to user-tz day math
+ app/api/mobile/dashboard/route.ts, app/api/dashboard/overview/route.ts
+
+ - app/api/mobile/dashboard/route.ts (lines 48-133 — the SQL block; note the existing UTC anchors at lines 70-76: `create_date::date = CURRENT_DATE`, `completed_date::date = CURRENT_DATE`, and the SLA-breach `due_date_time < NOW()` line)
+ - app/api/dashboard/overview/route.ts (lines 38-78 — same idiom: `create_date::date = CURRENT_DATE`, `CURRENT_DATE - INTERVAL '1 day'`, `CURRENT_DATE - INTERVAL '7 days'`)
+ - lib/services/user-timezone.ts (created in Task 1 — the import target)
+ - The interfaces block above (the `(value AT TIME ZONE 'UTC') AT TIME ZONE $tz`::date idiom — apply verbatim)
+
+
+ Two route files. Edit them in this order:
+
+ --- A: app/api/mobile/dashboard/route.ts ---
+
+ 1. Add import at the top of the imports block:
+ `import { getUserTimezone } from '@/lib/services/user-timezone';`
+ 2. After the `requireAuth()` line in `GET()`, add:
+ `const tz = getUserTimezone(session);`
+ (note: `requireAuth()` currently destructures only `error` — change it
+ to `const { session, error } = await requireAuth(); if (error) return error;`)
+ 3. The KPI snapshot query at lines 67-80 has TWO occurrences of
+ `create_date::date = CURRENT_DATE` and `completed_date::date = CURRENT_DATE`,
+ plus an `AND due_date_time < NOW()` clause. Replace as follows
+ (parametrize tz as `$1`):
+
+ Before:
+ SELECT
+ COUNT(*) FILTER (WHERE completed_date IS NULL)::text AS open_total,
+ COUNT(*) FILTER (WHERE create_date::date = CURRENT_DATE)::text AS opened_today,
+ COUNT(*) FILTER (WHERE completed_date::date = CURRENT_DATE)::text AS resolved_today,
+ COUNT(*) FILTER (
+ WHERE completed_date IS NULL
+ AND due_date_time IS NOT NULL
+ AND due_date_time < NOW()
+ )::text AS sla_breaches
+ FROM tickets
+ WHERE (is_deleted = false OR is_deleted IS NULL)
+ AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false)
+
+ After (pass `[tz]` as the params arg to `postgresClient.query<...>`):
+ SELECT
+ COUNT(*) FILTER (WHERE completed_date IS NULL)::text AS open_total,
+ COUNT(*) FILTER (WHERE ((create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date)::text AS opened_today,
+ COUNT(*) FILTER (WHERE ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date)::text AS resolved_today,
+ COUNT(*) FILTER (
+ WHERE completed_date IS NULL
+ AND due_date_time IS NOT NULL
+ AND due_date_time < NOW()
+ )::text AS sla_breaches
+ FROM tickets
+ WHERE (is_deleted = false OR is_deleted IS NULL)
+ AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false)
+
+ The `due_date_time < NOW()` clause stays as-is (compares two
+ UTC-relative instants — the SLA-breach concept is "is this ticket past
+ its due time RIGHT NOW", which is timezone-independent).
+
+ 4. The other queries in the Promise.all (failed backups 24h, stalled
+ workflows 5min, analyzer 1h, RMM 1h, backup success 24h) all use
+ `INTERVAL '24 hours'` / `INTERVAL '5 minutes'` / `INTERVAL '1 hour'`
+ with `NOW() - INTERVAL ...`. These compare UTC instants to UTC
+ instants — they are NOT day-boundary calculations. DO NOT MODIFY THEM.
+ Add a code comment immediately above the failed-backups query
+ confirming this:
+ // INTERVAL '24 hours' here is rolling — not a calendar-day boundary —
+ // so timezone does not apply. Do not migrate to user-tz.
+
+ --- B: app/api/dashboard/overview/route.ts ---
+
+ 1. Add the import:
+ `import { getUserTimezone } from '@/lib/services/user-timezone';`
+ 2. Inside `GET()`, after the `requireAuth()` call, change the destructure
+ to `const { session, error } = await requireAuth(); if (error) return error;`
+ and add `const tz = getUserTimezone(session);`.
+ 3. The `today` query at lines 38-57: same idiom as A.3 above. Replace
+ `WHERE create_date::date = CURRENT_DATE` and
+ `WHERE completed_date::date = CURRENT_DATE` with the user-tz forms,
+ parametrize as `$1`, pass `[tz]`. The `due_date_time < NOW()` clause
+ stays as-is.
+ 4. The `yesterdayOpened` query at lines 59-65 currently:
+ WHERE create_date::date = CURRENT_DATE - INTERVAL '1 day'
+ Replace with (parametrized `[tz]`):
+ WHERE ((create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - INTERVAL '1 day'
+ 5. The `last7AvgResolvedRes` query at lines 67-78:
+ WHERE completed_date >= CURRENT_DATE - INTERVAL '7 days'
+ AND completed_date < CURRENT_DATE
+ GROUP BY completed_date::date
+ Replace with:
+ WHERE ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - INTERVAL '7 days'
+ AND ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date < (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
+ GROUP BY ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date
+ Pass `[tz]` to the query.
+ 6. The remaining queries in this route (linkConflicts, itglueUnlinked,
+ s1Unmapped, schedules, observations, audits, syncHealth, companies, ci,
+ xref) do NOT use day-boundary math. DO NOT MODIFY THEM.
+
+ Both files: keep the existing `try/catch` shape, the existing
+ `NextResponse.json` envelope, the existing `Promise.all` ordering, and the
+ existing return shapes. Only the SQL strings and the new `tz` parameter
+ change. No new exports.
+
+
+ ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/(mobile/)?dashboard/(route|overview/route)\.ts"); [ -z "$ERR" ] && [ "$(grep -c 'AT TIME ZONE' app/api/mobile/dashboard/route.ts)" -ge 2 ] && [ "$(grep -c 'AT TIME ZONE' app/api/dashboard/overview/route.ts)" -ge 4 ] && grep -q "getUserTimezone" app/api/mobile/dashboard/route.ts && grep -q "getUserTimezone" app/api/dashboard/overview/route.ts && ! grep -E '::date = CURRENT_DATE|create_date::date = CURRENT_DATE - INTERVAL' app/api/dashboard/overview/route.ts && ! grep -E '::date = CURRENT_DATE' app/api/mobile/dashboard/route.ts
+
+
+ - Both files import `getUserTimezone` from `@/lib/services/user-timezone`
+ - Both files destructure `session` from `requireAuth()` and pass it to `getUserTimezone`
+ - `app/api/mobile/dashboard/route.ts` no longer contains the literal `::date = CURRENT_DATE`
+ - `app/api/dashboard/overview/route.ts` no longer contains `::date = CURRENT_DATE` (today, yesterday, or 7-day-avg variants)
+ - `app/api/mobile/dashboard/route.ts` contains at least 2 occurrences of `AT TIME ZONE`
+ - `app/api/dashboard/overview/route.ts` contains at least 4 occurrences of `AT TIME ZONE`
+ - The `due_date_time < NOW()` clauses are PRESERVED (rolling-now SLA check is tz-independent)
+ - The `INTERVAL '24 hours'` / `INTERVAL '5 minutes'` / `INTERVAL '1 hour'` queries are PRESERVED unchanged
+ - `npx tsc --noEmit --pretty` reports no NEW errors in either file
+ - Behavioral (manual once running):
+ - With user.timezone = 'America/New_York' and a ticket created at 2026-05-07T03:30:00Z (which is 2026-05-06 23:30 ET), the `opened_today` count for that user includes that ticket on 2026-05-06 ET — NOT on 2026-05-07 ET
+
+
+ `/api/mobile/dashboard` and `/api/dashboard/overview` compute "today",
+ "yesterday", and "last 7 days" against the calling user's tz. SLA
+ breach-and rolling-window metrics are unchanged. No new endpoints were
+ added; no schema migrations ran.
+
+
+
+
+ Task 3: Migrate /api/mobile/finance (with auth-gate hardening) and the engagement endpoints to user-tz boundaries
+ app/api/mobile/finance/route.ts, app/api/mobile/engagement/summary/route.ts, app/api/mobile/engagement/trend/route.ts
+
+ - app/api/mobile/finance/route.ts (full file — note it currently has NO requireAuth() call; this plan adds it)
+ - app/mobile/finance/page.tsx (the consumer — confirm it uses a session-cookie-bearing fetch with no extra Authorization header; that's the default for browser fetches to same-origin Next.js routes, and Better Auth's session cookie travels automatically — no changes needed on the page)
+ - lib/auth-utils.ts (`requireAuth()` shape — copy from `/api/mobile/engagement/summary/route.ts`)
+ - app/api/mobile/engagement/summary/route.ts (the `interval` map at lines 53-58 and the `te.entry_date >= NOW() - INTERVAL '${interval}'` line at 122 — that's the calendar-window seam; also the `period_end = $2` join on snapshots, which is a stored DATE so tz doesn't apply there)
+ - app/api/mobile/engagement/trend/route.ts (the `generate_series` block at lines 46-72 — `CURRENT_DATE` is the seam)
+ - lib/services/user-timezone.ts (the helper from Task 1)
+
+
+ Three files.
+
+ --- A: app/api/mobile/finance/route.ts ---
+
+ The route currently has NO auth. That has been an outstanding pre-existing
+ gap; this plan fixes it as a 5-line change because (a) every other
+ `/api/mobile/*` route already uses `requireAuth()`, (b) the consumer at
+ `app/mobile/finance/page.tsx` fetches via the browser with the Better
+ Auth session cookie automatically attached, so adding the gate does not
+ break the existing UI, and (c) once the gate is in place we can resolve
+ the calling user's tz the proper way (`getUserTimezone(session)`) instead
+ of the env-default fallback.
+
+ Steps:
+ 1. Add imports:
+ `import { requireAuth } from '@/lib/auth-utils';`
+ `import { getUserTimezone } from '@/lib/services/user-timezone';`
+ 2. As the FIRST line of the existing `GET()` handler (before
+ `Promise.all`), add:
+ const { session, error } = await requireAuth();
+ if (error) return error;
+ const tz = getUserTimezone(session);
+ Add a code comment immediately above the requireAuth call:
+ // Auth gate (Phase 7.1): aligns this route with every other
+ // /api/mobile/* handler and lets us resolve the caller's tz from
+ // the session. Browser callers carry the Better Auth session
+ // cookie automatically, so the existing /mobile/finance page works
+ // unchanged.
+ 3. The `summary` query (lines 6-17): replace
+ DATE_TRUNC('month', NOW()) → DATE_TRUNC('month', NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)
+ DATE_TRUNC('year', NOW()) → DATE_TRUNC('year', NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)
+ AND change the comparison operands so both sides are in the same tz —
+ `txn_date` is `TIMESTAMP WITHOUT TIME ZONE` (UTC-stored), so:
+ txn_date >= DATE_TRUNC('month', NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)
+ compares a UTC timestamp to a `timestamp` (without tz) in user-zone —
+ semantically wrong. Correct form (compare like with like):
+ (txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= DATE_TRUNC('month', NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)
+ Apply this transformation to BOTH `paid_mtd` and `paid_ytd` filters.
+ Pass `[tz]` as the params arg to `postgresClient.query`.
+ 4. The `aging` query (lines 18-29) uses `due_date >= CURRENT_DATE - 30`
+ etc. `due_date` is a `DATE` (date-only, not a timestamp). For DATE
+ columns, `CURRENT_DATE` is server-local (UTC in our deploy) and
+ comparing user-tz "today" to a stored DATE column is the right move:
+ CURRENT_DATE → (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
+ Apply this transformation to all SIX comparisons in the aging query
+ (`days_1_30`, `cnt_1_30`, `days_31_60`, `cnt_31_60`, `days_60_plus`,
+ `cnt_60_plus`). Pass `[tz]` as params.
+ 5. The `overdueInvoices` query (lines 37-43) has
+ CURRENT_DATE - due_date::date as days_overdue
+ Replace `CURRENT_DATE` with `(NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date`
+ and pass `[tz]` as params.
+ 6. The `topCustomers`, `recentPayments`, `monthlyRevenue` queries do not
+ use any day-boundary date math relative to "today/this month/this year"
+ (monthlyRevenue uses `>= NOW() - INTERVAL '12 months'` which is a
+ rolling window, NOT a calendar boundary — leave it). DO NOT MODIFY
+ THESE THREE.
+
+ --- B: app/api/mobile/engagement/summary/route.ts ---
+
+ 1. Add import: `import { getUserTimezone } from '@/lib/services/user-timezone';`
+ 2. After the existing `const { error: authError } = await requireAuth()`,
+ change to:
+ const { session, error: authError } = await requireAuth();
+ if (authError) return authError;
+ const tz = getUserTimezone(session);
+ 3. The Autotask-hours query (lines 117-127) currently uses:
+ WHERE te.entry_date >= NOW() - INTERVAL '${interval}'
+ `entry_date` is `TIMESTAMP WITHOUT TIME ZONE` (UTC-stored). The
+ `interval` is one of '7 days', '30 days', '90 days'. Change the WHERE
+ to anchor on user-tz "today":
+ WHERE (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1) - INTERVAL '${interval}'
+ Pass `[tz]` as the params arg (currently no params; add the array).
+ Keep the SQL injection comment that's already in the file — the
+ `${interval}` interpolation safety still applies.
+ 4. The other queries (`activeResult`, `graphHoursResult`) join on
+ `period_end = $2` where `period_end` is a stored DATE (precomputed by
+ the engagement-sync service against UTC). Period-bucket DATEs are
+ NOT migrated by this phase — see "TZ-02 carve-out" note below. Add a
+ code comment immediately above the `latestResult` query (around line
+ 37) — DO NOT MODIFY THESE QUERIES:
+ // NOTE (TZ-02 carve-out, see REQUIREMENTS.md): engagement_snapshots
+ // are bucketed by UTC at sync time by lib/services/engagement-sync-service.ts.
+ // Per-user-tz snapshot bucketing is deferred to a future phase
+ // (would require either per-request re-bucketing — expensive — or
+ // per-user snapshot rebuild — doubles storage). The ≤24h drift on
+ // active-users D7/D30/D90 + total MS Graph hours is acceptable for
+ // an admin-overview surface. Only the rolling time_entries window
+ // below is migrated to user-tz.
+
+ --- C: app/api/mobile/engagement/trend/route.ts ---
+
+ 1. Add import: `import { getUserTimezone } from '@/lib/services/user-timezone';`
+ 2. After the `requireAuth()` call, destructure session and resolve tz:
+ const { session, error: authError } = await requireAuth();
+ if (authError) return authError;
+ const tz = getUserTimezone(session);
+ 3. The `generate_series` SQL (lines 45-72) uses `CURRENT_DATE` four times.
+ Replace EACH with `(NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date`,
+ parametrized as `$1`. Specifically:
+ (CURRENT_DATE - INTERVAL '${days - 1} days')::date
+ → ((NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date
+ CURRENT_DATE, -- 2nd arg of generate_series
+ → (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date,
+ te.entry_date >= CURRENT_DATE - INTERVAL '${days - 1} days'
+ → (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date >= ((NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date
+ AND te.entry_date <= CURRENT_DATE
+ → AND (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date <= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
+ GROUP BY te.entry_date::date
+ → GROUP BY (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
+ And in the daily_hours.day reference downstream, use the same expression.
+ 4. Pass `[tz]` as the params arg to `postgresClient.query(sql, [tz])`.
+ 5. The existing comment at line 38 ("T-07-03 mitigation: period whitelist
+ bounds the date range to max 90 days") still applies — keep it. Add an
+ additional comment immediately below it:
+ // TZ-02 (Phase 7.1): day buckets are aligned to the calling user's
+ // IANA timezone via $1 (validated by getUserTimezone). Storage tz
+ // for `time_entries.entry_date` remains UTC.
+
+
+ ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/mobile/(finance|engagement/(summary|trend))/route\.ts"); [ -z "$ERR" ] && grep -q "requireAuth" app/api/mobile/finance/route.ts && grep -q "getUserTimezone" app/api/mobile/finance/route.ts && grep -q "getUserTimezone" app/api/mobile/engagement/summary/route.ts && grep -q "getUserTimezone" app/api/mobile/engagement/trend/route.ts && [ "$(grep -c 'AT TIME ZONE' app/api/mobile/finance/route.ts)" -ge 5 ] && [ "$(grep -c 'AT TIME ZONE' app/api/mobile/engagement/trend/route.ts)" -ge 3 ] && ! grep -E "DATE_TRUNC\('(month|year)', NOW\(\)\)" app/api/mobile/finance/route.ts && ! grep -wE "CURRENT_DATE" app/api/mobile/engagement/trend/route.ts
+
+
+ - `app/api/mobile/finance/route.ts` imports `requireAuth` AND `getUserTimezone`; calls both at the top of `GET()`
+ - `app/api/mobile/finance/route.ts` no longer contains `DATE_TRUNC('month', NOW())` or `DATE_TRUNC('year', NOW())` (note the original had a double-space)
+ - `app/api/mobile/finance/route.ts` contains at least 5 occurrences of `AT TIME ZONE` (paid_mtd, paid_ytd, six aging filters, days_overdue — actually MORE than 5; tolerance: ≥ 5)
+ - `app/api/mobile/engagement/summary/route.ts` imports `getUserTimezone`, destructures `session` from `requireAuth()`, passes session to `getUserTimezone`
+ - `app/api/mobile/engagement/summary/route.ts` `time_entries` query parametrizes `tz` and uses `AT TIME ZONE` on both sides of the `>=` comparison
+ - `app/api/mobile/engagement/summary/route.ts` contains the `TZ-02 carve-out` comment block referencing REQUIREMENTS.md
+ - `app/api/mobile/engagement/trend/route.ts` imports `getUserTimezone`, destructures session, passes to helper
+ - `app/api/mobile/engagement/trend/route.ts` no longer contains the bare token `CURRENT_DATE` (every occurrence becomes the AT TIME ZONE expression). Verify: `! grep -wE "CURRENT_DATE" app/api/mobile/engagement/trend/route.ts`
+ - `app/api/mobile/engagement/trend/route.ts` passes `[tz]` to `postgresClient.query`
+ - `npx tsc --noEmit --pretty` reports no NEW errors in any of the three files
+ - Behavioral (manual once running):
+ - `curl -s -o /dev/null -w '%{http_code}' http://localhost:3100/api/mobile/finance` returns `401` (no auth header)
+ - With user.timezone = 'America/New_York' and a time_entries row at 2026-05-07T03:30:00Z (= 2026-05-06 23:30 ET), `/api/mobile/engagement/trend?period=D7` puts that row in the 2026-05-06 bucket — NOT 2026-05-07
+
+
+ `/api/mobile/finance` is now auth-gated and computes month / aging / days-overdue
+ boundaries against the calling user's tz. `/api/mobile/engagement/summary`
+ (the rolling time_entries window only — snapshots remain UTC by the
+ explicit TZ-02 carve-out) and `/api/mobile/engagement/trend` both compute
+ their day boundaries in user-tz.
+
+
+
+
+ Task 4: Migrate /api/dashboard/trends to user-tz day buckets
+ app/api/dashboard/trends/route.ts
+
+ - app/api/dashboard/trends/route.ts (lines 21-98 — the four queries; note the two `generate_series(CURRENT_DATE - INTERVAL '${TREND_DAYS - 1} days', CURRENT_DATE, INTERVAL '1 day')::date` blocks at lines 27-32 and 44-49, the `t.create_date::date = days.d` join at line 38, the `t.completed_date::date = days.d` join at line 55, and the `te.entry_date::date = CURRENT_DATE` filter at line 92)
+ - lib/services/user-timezone.ts (helper from Task 1)
+ - lib/auth-utils.ts (`requireAuth()` already used by this route at line 22 — verify with `grep -q requireAuth app/api/dashboard/trends/route.ts`)
+
+
+ `/api/dashboard/trends` was missed in the original plan but powers the
+ desktop dashboard's chart row + queue posture. Migrate its day-bucket math
+ to the same `(value AT TIME ZONE 'UTC') AT TIME ZONE $1` two-step idiom
+ used in Tasks 2 and 3.
+
+ Steps:
+ 1. Confirm the route already calls `requireAuth()` (it does, line 22). If
+ it doesn't, add it: import `requireAuth` from `@/lib/auth-utils`, call
+ it as the first line of `GET()`, return `error` on failure.
+ 2. Add import:
+ `import { getUserTimezone } from '@/lib/services/user-timezone';`
+ 3. Change the destructure on line 22 from `const { error } = await requireAuth();`
+ to:
+ const { session, error } = await requireAuth();
+ if (error) return error;
+ const tz = getUserTimezone(session);
+ 4. The `volumeRes` query (lines 26-42) — apply the user-tz substitutions
+ and parametrize `tz` as `$1`:
+ generate_series(
+ CURRENT_DATE - INTERVAL '${TREND_DAYS - 1} days',
+ CURRENT_DATE,
+ INTERVAL '1 day'
+ )::date AS d
+ →
+ generate_series(
+ (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - INTERVAL '${TREND_DAYS - 1} days',
+ (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date,
+ INTERVAL '1 day'
+ )::date AS d
+ And:
+ ON t.create_date::date = days.d
+ →
+ ON ((t.create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = days.d
+ Pass `[tz]` as the params arg.
+ 5. The `resolutionRes` query (lines 43-60) — same idiom, applied to the
+ `generate_series` block AND the `t.completed_date::date = days.d` join:
+ ON t.completed_date::date = days.d
+ →
+ ON ((t.completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = days.d
+ Pass `[tz]` as the params arg.
+ 6. The `heatmapRes` query (lines 61-77) — does NOT use day-boundary math
+ (filters on `t.completed_date IS NULL` only). DO NOT MODIFY.
+ 7. The `engineersRes` query (lines 78-97) — `te.entry_date::date = CURRENT_DATE`
+ filter on line 92. Replace with the user-tz form and parametrize `tz`
+ as `$1`:
+ WHERE te.entry_date::date = CURRENT_DATE
+ →
+ WHERE ((te.entry_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
+ Pass `[tz]` as the params arg. Note the existing `LIMIT ${TOP_ENGINEERS}`
+ is a server-side constant interpolation — leave it.
+
+ Notes:
+ - All four `postgresClient.query<...>(...)` calls take ONE bind value (`$1` =
+ tz). Use `[tz]` consistently. The existing query signatures don't have
+ a params arg today; add one.
+ - Keep the existing types on each `query<>` generic. No shape changes to
+ the response.
+ - The `INTERVAL '${TREND_DAYS - 1} days'` interpolation is server-side
+ constant — safe to leave as a JS template literal.
+
+
+ ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/dashboard/trends/route\.ts"); [ -z "$ERR" ] && grep -q requireAuth app/api/dashboard/trends/route.ts && grep -q "getUserTimezone" app/api/dashboard/trends/route.ts && [ "$(grep -c 'AT TIME ZONE' app/api/dashboard/trends/route.ts)" -ge 6 ] && ! grep -wE "CURRENT_DATE" app/api/dashboard/trends/route.ts && ! grep -E '\.create_date::date = days\.d|\.completed_date::date = days\.d|\.entry_date::date = CURRENT_DATE' app/api/dashboard/trends/route.ts
+
+
+ - `app/api/dashboard/trends/route.ts` imports `getUserTimezone` from `@/lib/services/user-timezone`
+ - The route destructures `session` from `requireAuth()` and resolves `tz` via `getUserTimezone(session)`
+ - The route still calls `requireAuth()` first (auth gate preserved)
+ - `app/api/dashboard/trends/route.ts` no longer contains the bare token `CURRENT_DATE`
+ - `app/api/dashboard/trends/route.ts` no longer contains any of the literal patterns `t.create_date::date = days.d`, `t.completed_date::date = days.d`, or `te.entry_date::date = CURRENT_DATE`
+ - `app/api/dashboard/trends/route.ts` contains at least 6 occurrences of `AT TIME ZONE` (two per migrated query × three migrated queries)
+ - The `heatmapRes` query (queue/priority counts) is preserved unchanged
+ - All migrated queries pass `[tz]` as the params arg
+ - `npx tsc --noEmit --pretty` reports no NEW errors in this file
+ - Behavioral (manual once running):
+ - With user.timezone = 'America/New_York' and a ticket created at 2026-05-07T03:30:00Z, the `volumeByDay` count for 2026-05-06 (ET) includes that ticket — the 2026-05-07 (ET) bucket does not.
+ - The trend covers exactly TREND_DAYS (30) consecutive ET days ending today (ET).
+
+
+ `/api/dashboard/trends` returns daily ticket counts and time-entry hours
+ with day buckets aligned to the calling user's timezone, not UTC. The
+ queue/priority heatmap is unchanged.
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Browser → API route | No new untrusted input; tz is read off the verified session |
+| Handler → Postgres | tz string is parameterized via `$N` — no string interpolation into SQL |
+| Stored row → handler | A corrupt `user.timezone` value (manual SQL edit, pre-Plan-02 row) is sanitized by `getUserTimezone()`'s IANA whitelist |
+| (NEW) Anonymous → /api/mobile/finance | This phase newly adds `requireAuth()` to a previously-public route |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-07.1-03-01 | Tampering | SQL injection via tz parameter | mitigate | tz is passed as a parameterized `$N` value to `postgresClient.query`, never interpolated. |
+| T-07.1-03-02 | Tampering | Garbage tz from stored row crashing query | mitigate | `getUserTimezone()` validates against `Intl.supportedValuesOf('timeZone')` and falls back to `DEFAULT_TIMEZONE_FALLBACK()` before the SQL ever sees the value. Even if it slipped through, Postgres would throw and the existing try/catch returns a 500 (no crash). |
+| T-07.1-03-03 | Information Disclosure | Cross-user data leak via tz parameter | accept | tz only affects WHERE clause day boundaries — never widens the result set, never selects rows belonging to other users. The `mine` filter on tickets and per-user joins are unchanged. |
+| T-07.1-03-04 | Spoofing | tz read from wrong session | mitigate | Each handler reads tz from its own `requireAuth()` result; `getUserTimezone()` is pure and accepts only the passed-in session. No global state. |
+| T-07.1-03-05 | Denial of Service | `Intl.supportedValuesOf` per request | accept | V8 caches internally; the array is ~600 entries. Plan 02 already accepted this risk for the PUT endpoint. |
+| T-07.1-03-06 | Repudiation | Engagement snapshot bucketing left UTC | accept | Documented explicitly in REQUIREMENTS.md TZ-02 carve-out and re-asserted in code comment. The drift is bounded at ≤24h on an admin-overview surface; per-user snapshot bucketing is deferred (would require either per-request re-bucket or per-user snapshot rebuild). |
+| T-07.1-03-fin-auth | Spoofing / Information Disclosure | Previously-public `/api/mobile/finance` now requires auth | mitigate | Adding `requireAuth()` aligns this route with every other `/api/mobile/*` handler. Verifies authenticated callers can still reach it (no callsite breakage): `app/mobile/finance/page.tsx` is the sole consumer and uses a same-origin browser fetch — Better Auth's session cookie is set on every authenticated browser session and travels with the request automatically (no extra `Authorization` header is required). Acceptance criterion: anonymous `curl` returns 401; the existing `/mobile/finance` page renders unchanged for signed-in users. |
+| T-07.1-03-08 | Tampering | `/api/dashboard/trends` already has auth gate | accept | The route already imports `requireAuth()`; this plan only adds tz resolution after the existing gate. No new attack surface. |
+
+
+
+End-to-end checks for this plan:
+
+1. Static: `[ "$(grep -c 'AT TIME ZONE' app/api/mobile/dashboard/route.ts)" -ge 2 ]`
+2. Static: `[ "$(grep -c 'AT TIME ZONE' app/api/dashboard/overview/route.ts)" -ge 4 ]`
+3. Static: `[ "$(grep -c 'AT TIME ZONE' app/api/dashboard/trends/route.ts)" -ge 6 ]`
+4. Static: `[ "$(grep -c 'AT TIME ZONE' app/api/mobile/finance/route.ts)" -ge 5 ]`
+5. Static: `[ "$(grep -c 'AT TIME ZONE' app/api/mobile/engagement/trend/route.ts)" -ge 3 ]`
+6. Static: `! grep -E '::date = CURRENT_DATE' app/api/mobile/dashboard/route.ts app/api/dashboard/overview/route.ts`
+7. Static: `! grep -wE "CURRENT_DATE" app/api/mobile/engagement/trend/route.ts`
+8. Static: `! grep -wE "CURRENT_DATE" app/api/dashboard/trends/route.ts`
+9. Static: `! grep -E "DATE_TRUNC\\('(month|year)', NOW\\(\\)\\)" app/api/mobile/finance/route.ts`
+10. Static (auth-gate hardening): `grep -q "requireAuth" app/api/mobile/finance/route.ts`
+11. Static (no UTC-bucketed ticket-list filters were missed by SC#2 mapping): `! grep -rE "\\.create_date >= NOW\\(\\) - INTERVAL.*'(today|day|hour)" app/api/tickets/ app/api/mobile/tickets/`
+12. Type: per-file `npx tsc --noEmit --pretty` reports no NEW errors for the six modified files.
+13. Runtime (auth gate): `curl -s -o /dev/null -w '%{http_code}' http://localhost:3100/api/mobile/finance` returns `401`.
+14. Runtime (user-tz buckets): With `DEFAULT_TIMEZONE=UTC` and the calling user's `timezone='America/New_York'`, hitting `/api/mobile/engagement/trend?period=D7` returns exactly 7 points whose `date` strings are the most recent 7 calendar days in ET (verifiable by setting the user's tz to UTC vs ET and diffing the returned `date` arrays around midnight ET).
+15. Runtime (trends): With the same user-tz settings, `/api/dashboard/trends` returns `volumeByDay` with TREND_DAYS rows ending on today (ET).
+16. Storage: `SELECT data_type FROM information_schema.columns WHERE table_name IN ('tickets','qbo_invoices','time_entries','engagement_snapshots') AND column_name LIKE '%date%'` shows the same `timestamp without time zone` / `date` types as before this plan ran.
+
+
+
+- All six route files compute day/week/month boundaries against the calling user's tz
+- The shared helper `lib/services/user-timezone.ts` is the only source of truth for resolving tz from a session
+- Storage tz of every column on disk is unchanged
+- Rolling-window queries (`INTERVAL '24 hours'`, `INTERVAL '5 minutes'`, `INTERVAL '1 hour'`, `INTERVAL '12 months'`) are preserved unchanged
+- Engagement snapshot bucketing left UTC by the explicit TZ-02 carve-out documented in REQUIREMENTS.md and code
+- `/api/mobile/finance` now requires auth (aligned with every other `/api/mobile/*` route)
+- `/api/dashboard/trends` daily buckets are user-tz aligned (was missed by the original plan)
+- TypeScript compiles for every modified file
+
+
+
+
+
\ No newline at end of file
diff --git a/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-PLAN.md b/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-PLAN.md
new file mode 100644
index 0000000..56a023c
--- /dev/null
+++ b/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-PLAN.md
@@ -0,0 +1,531 @@
+---
+phase: 07.1-user-timezone-fix-inserted-urgent
+plan: 04
+type: execute
+wave: 2
+depends_on: [07.1-01]
+files_modified:
+ - lib/hooks/use-user-timezone.ts
+ - app/mobile/finance/page.tsx
+ - app/mobile/tickets/[id]/page.tsx
+autonomous: true
+requirements: [TZ-04, TZ-02]
+requirements_addressed: [TZ-04, TZ-02]
+
+must_haves:
+ truths:
+ - "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"
+ artifacts:
+ - path: "lib/hooks/use-user-timezone.ts"
+ provides: "Client hook reading user.timezone from useSession()"
+ exports: ["useUserTimezone", "formatInUserTimezone"]
+ - path: "app/mobile/finance/page.tsx"
+ provides: "Mobile finance page formats dates in the user's tz, not the browser's"
+ contains: "useUserTimezone"
+ - path: "app/mobile/tickets/[id]/page.tsx"
+ provides: "Mobile ticket detail formats timestamps in the user's tz"
+ contains: "useUserTimezone"
+ key_links:
+ - from: "lib/hooks/use-user-timezone.ts"
+ to: "useSession() from @/lib/auth-client"
+ via: "additionalField propagated by Better Auth Plan 01 config"
+ pattern: "useSession\\(\\)"
+ - from: "Mobile pages"
+ to: "useUserTimezone hook"
+ via: "import { useUserTimezone } from '@/lib/hooks/use-user-timezone'"
+ pattern: "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.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.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,373` — `toLocaleDateString` / `toLocaleString`
+- `app/mobile/tickets/[id]/page.tsx:49` — `toLocaleString`
+
+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:
+ 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:
+
+ ## 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.
+
+
+
+
+
+
+## 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. |
+
+
+
+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.
+
+
+
+- `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)
+
+
+
+
+
\ No newline at end of file
diff --git a/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-PLAN.md b/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-PLAN.md
new file mode 100644
index 0000000..76b690a
--- /dev/null
+++ b/.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-PLAN.md
@@ -0,0 +1,372 @@
+---
+phase: 07.1-user-timezone-fix-inserted-urgent
+plan: 05
+type: execute
+wave: 3
+depends_on: [07.1-04]
+files_modified:
+ - .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md
+autonomous: true
+requirements: [TZ-04, TZ-02]
+requirements_addressed: [TZ-04, TZ-02]
+
+must_haves:
+ truths:
+ - "Every 'browser-local zone leak' callsite identified by Plan 04 Task 3's audit (07.1-04-AUDIT.md) is migrated to consume `useUserTimezone()` from `@/lib/hooks/use-user-timezone`"
+ - "Each migrated client component imports `useUserTimezone` and threads `timeZone: tz` into every previously-leaking `toLocaleDateString(` / `toLocaleString(` / `toLocaleTimeString(` callsite in the same file"
+ - "Module-scope formatter helpers that previously had no access to the user's tz are converted to accept `tz: string` as an argument; every callsite passes `tz` resolved from `useUserTimezone()`"
+ - "Non-leak callsites (server-side route handlers, deliberate UTC pins, number formatters) are NOT modified"
+ - "After this plan ships, a codebase-wide grep for `toLocale*String(` / `Intl.DateTimeFormat` returns ZERO 'browser-local zone leak' callsites in client components — satisfying Phase 7.1 SC#4 (single source of truth)"
+ artifacts:
+ - path: ".planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md"
+ provides: "Per-file migration plan derived from 07.1-04-AUDIT.md, recording every leak callsite, its replacement, and a per-file acceptance grep"
+ contains: "Leak migration manifest"
+ key_links:
+ - from: "Each leak file"
+ to: "lib/hooks/use-user-timezone.ts"
+ via: "import { useUserTimezone } from '@/lib/hooks/use-user-timezone'"
+ pattern: "useUserTimezone"
+ - from: "Each leaking toLocale call"
+ to: "tz from useUserTimezone()"
+ via: "{ ...options, timeZone: tz }"
+ pattern: "timeZone: tz"
+---
+
+
+Close the codebase-wide adoption gap for `useUserTimezone()`. Plan 04 migrated
+the two reported-bug-surface mobile pages; Plan 04 Task 3 produced a complete
+audit (`07.1-04-AUDIT.md`) classifying every other `toLocale*String(` /
+`Intl.DateTimeFormat` callsite across `app/`, `components/`, and `lib/hooks/`.
+This plan migrates every callsite the audit classified as a "browser-local
+zone leak" so that Phase 7.1 SC#4 ("single source of truth — no scattered
+`Intl.DateTimeFormat` instantiations") is satisfied at the codebase scale.
+
+Purpose: Resolve the codebase-scale portion of TZ-04 + TZ-02 (client side).
+Plans 03 + 04 together cover the read paths and the hook itself; this plan
+finishes the migration work the audit revealed (>10 known leak callsites in
+admin/analyzer/engagement/dashboard pages and shared components).
+
+Output: A `07.1-05-MANIFEST.md` derived from the audit, plus edits to every
+file the audit classified as a leak. The manifest is the source of truth for
+`files_modified` (Plan 04's audit is what populates it) — at planning time
+the exact list isn't known; the executor MUST consume the audit and update
+this plan's `files_modified` array as the first action.
+
+Conditional execution: If `07.1-04-AUDIT.md` "Plan 05 dispatch" reports
+"Plan 05 is unnecessary. Mark in SUMMARY." (Leak count == 0), skip every task
+and return CLEAN immediately. Otherwise proceed.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/STATE.md
+@.planning/ROADMAP.md
+@.planning/REQUIREMENTS.md
+@CLAUDE.md
+@.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md
+@lib/hooks/use-user-timezone.ts
+
+
+This plan depends on Plan 04 — the hook (`@/lib/hooks/use-user-timezone`) and
+the audit file MUST exist before Plan 05 starts. The audit is the SINGLE
+source of truth for which files are migrated.
+
+The migration recipe for every leak callsite is:
+
+1. If the file is not already `'use client'`, the migration is impossible
+ (server components can't call `useUserTimezone`). Re-classify the callsite
+ as 'server_side' in a follow-up audit. (Audit step at planning time
+ already filtered out server route files.)
+2. Add at the top of the imports (next to other `@/lib` imports):
+ `import { useUserTimezone } from '@/lib/hooks/use-user-timezone';`
+3. Inside the default-exported component (or the hook entry point in a custom
+ component), call:
+ `const tz = useUserTimezone();`
+4. For each leaking call:
+
+ new Date(ts).toLocaleString('en-US', { month: 'short', ... })
+
+ →
+
+ new Date(ts).toLocaleString('en-US', { month: 'short', ..., timeZone: tz })
+
+ For module-scope helper functions (`function fmt(d) { return new Date(d).toLocaleString() }`):
+ - Convert to accept `tz: string` as a new parameter:
+ `function fmt(d, tz) { return new Date(d).toLocaleString(undefined, { timeZone: tz }) }`
+ - Update every callsite of that helper in the same file to pass `tz`.
+
+5. For DataTable column `render: (value) => new Date(value).toLocaleDateString()`
+ patterns (common in admin pages), the migration is to:
+ - Move the column definitions inside the component, OR
+ - Pass `tz` via a closure when the columns are constructed inside the
+ component, OR
+ - Use the `formatInUserTimezone` helper from
+ `@/lib/hooks/use-user-timezone` if column definitions stay at module
+ scope and `tz` can be threaded as a param to a column-builder function.
+
+6. Re-run the codebase-wide grep AFTER all migrations:
+
+ grep -rEn "Intl\\.DateTimeFormat|\\.toLocaleDateString\\(|\\.toLocaleTimeString\\(|\\.toLocaleString\\(" \\
+ app/ components/ lib/hooks/
+
+ The post-migration result must contain ONLY:
+ - `timeZone:` in the same call (migrated → satisfied)
+ - server-side route handlers under `app/api/**/route.ts` (deliberately
+ server, out of scope)
+ - `components/ui/calendar.tsx` (shadcn primitive — calendar-cell labels,
+ not user-visible dates)
+ - Number formatters (`.toLocaleString()` on `number`/`bigint` — not date
+ formatters)
+ - Files in the audit's "deliberate_utc" classification
+
+Browser environment variable:
+- `process.env.NEXT_PUBLIC_DEFAULT_TIMEZONE` is the client-readable equivalent
+ of `DEFAULT_TIMEZONE`. Same fallback semantics as `useUserTimezone()`.
+
+
+
+
+
+
+ Task 1: Build the migration manifest from the audit
+ .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md
+
+ - .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md (the input — Plan 04 Task 3's deliverable)
+ - lib/hooks/use-user-timezone.ts (the migration target)
+
+
+ Read `07.1-04-AUDIT.md`. If "Plan 05 dispatch" reports "Plan 05 is
+ unnecessary", write a one-line `07.1-05-MANIFEST.md` with `Leak count: 0
+ — Plan 05 skipped` and return CLEAN. Otherwise, build the manifest.
+
+ Group the leak callsites by file. For each file, produce a section:
+
+ ###
+
+ - 'use client' status:
+ - Pre-migration leak count:
+ - Post-migration acceptance grep:
+ [ "$(grep -c 'timeZone:' )" -ge ]
+ - Per-callsite plan:
+ - Line : `` → ``
+ - ...
+
+ Notes / risks:
+
+ Then write a `## Files to migrate` summary list at the top with `[ ]`
+ checkboxes — Task 2 ticks them off as it migrates each file.
+
+ After writing the manifest, update Plan 05's `files_modified` frontmatter
+ array to include EVERY file path enumerated in `## Files to migrate`,
+ PLUS the manifest path itself. (Note: this requires editing
+ `07.1-05-PLAN.md` in place. Use the `Edit` tool to update only the
+ `files_modified:` block.)
+
+ Notes:
+ - Do NOT skip module-scope helpers — converting them to accept `tz` as a
+ parameter is part of the migration. The audit may flag these as a
+ "Notes" gotcha; the manifest captures the exact transformation.
+ - Files where the migration would require >5 component-shape changes
+ (e.g., refactoring a class component to functional, or moving a large
+ module-scope formatter into the component) should be flagged as
+ "DEFER — out of scope for Plan 05" with a brief rationale and added to
+ a `## Deferred` list at the bottom of the manifest. The deferred set
+ becomes a v2 follow-up (a future phase).
+
+
+ test -f .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md && grep -q '## Files to migrate' .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md
+
+
+ - File exists at `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md`
+ - Either: contains `Leak count: 0 — Plan 05 skipped` (and Tasks 2+3 are skipped), OR
+ - Contains a `## Files to migrate` checklist with one entry per leak file from the audit
+ - Each leak file has a `Per-callsite plan:` block enumerating every leak callsite by line number with before/after snippets
+ - Plan 05's `files_modified` frontmatter has been updated to include every file in `## Files to migrate` plus the manifest path
+ - Any file deferred is listed under `## Deferred` with rationale
+
+
+ The manifest is the single source of truth for what Task 2 migrates and
+ what Task 3's verification grep checks. Plan 05's `files_modified`
+ accurately reflects every file this plan will touch.
+
+
+
+
+ Task 2: Migrate every leak callsite per the manifest
+ (see 07.1-05-MANIFEST.md `## Files to migrate` — populated by Task 1)
+
+ - .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md (Task 1's output — the per-file migration plan)
+ - lib/hooks/use-user-timezone.ts (the import target)
+ - app/mobile/finance/page.tsx, app/mobile/tickets/[id]/page.tsx (Plan 04's reference migrations — copy the call shape)
+
+
+ For each file in the manifest's `## Files to migrate` checklist, in the
+ order they appear:
+
+ 1. Open the file.
+ 2. If it does not already declare `'use client'` at the top, STOP and
+ move it to `## Deferred` in the manifest with rationale "would require
+ 'use client' conversion or component refactor — out of scope for Plan
+ 05". Do NOT add `'use client'` to a file that doesn't have it — that's
+ a non-trivial change in Pulse (server components are fine for static
+ shells; the user explicitly chose this layering).
+ 3. Add `import { useUserTimezone } from '@/lib/hooks/use-user-timezone';`
+ next to the other `@/lib` imports.
+ 4. Inside the component (or each component if the file exports multiple),
+ add `const tz = useUserTimezone();` near the top of the function body
+ (before any useState/useEffect calls).
+ 5. Apply each per-callsite transformation from the manifest verbatim.
+ 6. For module-scope helpers, convert to accept `tz: string` as a new
+ parameter and update every callsite in the same file.
+ 7. Tick off the file in the manifest's `## Files to migrate` checklist.
+ 8. Verify the file with the per-file acceptance grep recorded in the
+ manifest:
+ [ "$(grep -c 'timeZone:' )" -ge ]
+
+ Do NOT modify files outside the manifest's `## Files to migrate` list.
+ Do NOT modify files in `## Deferred`.
+
+ Type-check after each file (or at the end of the batch) to catch any
+ parameter-threading regressions:
+ ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E ""); [ -z "$ERR" ]
+
+ Notes:
+ - The DataTable column-render pattern (common in `app/admin/data-browser/*/page.tsx`)
+ may need a column-builder function that takes `tz` as a closure variable.
+ The manifest will have called this out per file. Do NOT introduce a
+ `useMemo` for column definitions unless the file already uses one
+ (premature optimization).
+ - Some helper functions (e.g., `relTime`, `formatDate`, `fmt`) are defined
+ at module scope in many files. Threading `tz` as an extra parameter is
+ intentional — no `React.useContext` workaround.
+ - The shared admin sync formatter (e.g., `app/admin/sync/datto-rmm/page.tsx:23`)
+ is a candidate for moving inside the component OR threading `tz`. The
+ lighter-touch fix is threading.
+
+
+ MANIFEST=.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md; if grep -q "Plan 05 skipped" "$MANIFEST"; then echo "skipped"; else FILES=$(grep -E "^- \[x\] " "$MANIFEST" | sed -E 's/^- \[x\] //' | tr '\n' ' '); ALL_OK=1; for f in $FILES; do if ! grep -q "useUserTimezone" "$f"; then echo "MISSING: $f"; ALL_OK=0; fi; done; [ "$ALL_OK" = "1" ]; fi
+
+
+ - Either: manifest reports "Plan 05 skipped" (Task 2 is a no-op) — accepted, OR
+ - Every file in the manifest's `## Files to migrate` checklist is checked off `[x]`
+ - Every checked-off file imports `useUserTimezone` from `@/lib/hooks/use-user-timezone`
+ - Every checked-off file calls `useUserTimezone()` inside the component
+ - Every per-file acceptance grep in the manifest passes
+ - `ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E ""); [ -z "$ERR" ]` for every migrated file
+
+
+ Every leak file in the audit has been migrated to consume
+ `useUserTimezone()`. Deferred files (refactor-blocking) are documented
+ explicitly. TypeScript compiles for every migrated file.
+
+
+
+
+ Task 3: Codebase-wide post-migration verification grep
+ (read-only verification; no file edits)
+
+ - .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md (the deferred list — informs the expected residue)
+ - .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md (the deliberate_utc + server_side classifications — informs the expected residue)
+
+
+ Re-run the codebase-wide leak discovery grep AFTER Task 2 finishes:
+
+ grep -rEn "Intl\\.DateTimeFormat|\\.toLocaleDateString\\(|\\.toLocaleTimeString\\(" \\
+ app/ components/ lib/hooks/ \\
+ | grep -v "node_modules" \\
+ | grep -v "components/ui/calendar.tsx" \\
+ > /tmp/tz-post-migration.txt
+
+ For each line in the output, classify:
+ - Has `timeZone:` in the same call → migrated ✓
+ - Lives under `app/api/**/route.ts` → server-side, out of scope ✓
+ - Listed in audit's `deliberate_utc` section → out of scope ✓
+ - Listed in manifest's `## Deferred` section → known follow-up ✓
+ - None of the above → REGRESSION. Stop and re-migrate.
+
+ Acceptable residue:
+ - Server-side route handlers (Plan 03's responsibility, but they don't
+ consume the client hook anyway)
+ - Deliberate UTC pins
+ - Deferred files (count must match the manifest's `## Deferred` count)
+
+ Document the residue list in `07.1-05-SUMMARY.md` for posterity.
+
+ Also run a final TypeScript check across all modified files:
+ FILES=$(grep -E "^- \\[x\\] " .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md | sed -E 's/^- \\[x\\] //')
+ for f in $FILES; do
+ ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "$f")
+ [ -z "$ERR" ] || { echo "TS errors in $f"; exit 1; }
+ done
+
+
+ RESIDUE=$(grep -rEn "Intl\.DateTimeFormat|\.toLocaleDateString\(|\.toLocaleTimeString\(" app/ components/ lib/hooks/ 2>/dev/null | grep -v node_modules | grep -v "components/ui/calendar.tsx" | grep -v "timeZone:" | grep -v "/route.ts:" | wc -l); MANIFEST=.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md; DEFERRED=$(grep -cE "^- " "$MANIFEST" 2>/dev/null | head -1); DELIBERATE=$(grep -cE "^\| " .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md 2>/dev/null); echo "post-migration residue lines: $RESIDUE — must equal deferred + deliberate-UTC count from audit/manifest"; [ "$RESIDUE" -le "$((DEFERRED + DELIBERATE))" ]
+
+
+ - Post-migration residue grep returns ≤ (deferred files in manifest + deliberate-UTC files in audit)
+ - Every residue line is accounted for by either: deferred classification, deliberate-UTC classification, or `timeZone:` already present
+ - No new "leak" callsite exists that wasn't classified by the audit OR migrated by Task 2
+ - All migrated files pass `npx tsc --noEmit --pretty` filtered to that file
+ - `07.1-05-SUMMARY.md` documents the residue list and any deferred-file rationale
+
+
+ The codebase-wide grep proves SC#4 is satisfied: every leak callsite is
+ either migrated, deferred (with rationale), or out-of-scope (server-side
+ or deliberate UTC). No new leak surfaces have been introduced.
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Server → client (session payload) | Same boundary as Plan 04 — tz string travels via Better Auth session cookie; client trusts it for formatting only |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-07.1-05-01 | Tampering | Tampered session payload with malformed tz | mitigate | Same as Plan 04: `useUserTimezone()` validates against `Intl.supportedValuesOf('timeZone')` and falls back to `NEXT_PUBLIC_DEFAULT_TIMEZONE || 'UTC'`. No new attack surface introduced. |
+| T-07.1-05-02 | Information Disclosure | tz exposed in client memory across more pages | accept | Same as Plan 04: tz is non-sensitive metadata. The change here just propagates the same exposure to additional pages — not a new threat. |
+| T-07.1-05-03 | Tampering | A migration accidentally drops or reformats the date | mitigate | Per-file acceptance grep in the manifest verifies that the count of `timeZone:` ≥ the pre-migration leak count. If a migration accidentally drops a `timeZone:` thread, the grep catches it. TypeScript also catches missing-arg regressions where module-scope helpers gained a `tz` parameter. |
+| T-07.1-05-04 | Repudiation | Inconsistent date display between users with different tz preferences | accept | Intentional — this is the whole point of the phase. Two users in different tzs SHOULD see different "today" buckets. |
+| T-07.1-05-05 | Elevation of Privilege | Component rendering server-only data with client-only hook | mitigate | Task 2's `'use client'` precondition: any file lacking the directive is moved to deferred. We do NOT silently add `'use client'` to a server component (would change rendering semantics). |
+
+
+
+End-to-end checks for this plan:
+
+1. Static (precondition): `test -f .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md`
+2. Static (manifest exists): `test -f .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md`
+3. Static (skip path): if manifest contains `Plan 05 skipped`, plan returns CLEAN with no other checks.
+4. Static (migration coverage): every file in manifest's `## Files to migrate` checklist is checked-off and contains `useUserTimezone`.
+5. Static (post-migration residue): see Task 3 verify.
+6. Type: `ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E ""); [ -z "$ERR" ]` for every migrated file.
+7. Runtime (spot check, with the dev server running and a logged-in user with `timezone='America/New_York'`): open one or two of the most-trafficked migrated pages (e.g., `/admin/audit/audit-log-table` consumer, `/analyzer/queue`, `/dashboard`); verify date strings respect the user-tz (set device tz to UTC, confirm rendered strings match ET).
+
+
+
+- Phase 7.1 SC#4 satisfied at codebase scale: no client-component leak callsites remain (only server-side, deliberate-UTC, and explicitly-deferred residues)
+- Every migrated file imports `useUserTimezone` and threads `timeZone:` into every formatter call
+- Module-scope helpers that previously had no tz access now accept `tz: string` as a parameter
+- TypeScript compiles for every migrated file
+- Deferred-file rationale is documented for any file the manifest excluded
+
+
+
+
+
\ No newline at end of file