From f9ab9545187290f62c5721cfd8e1b2196376dc36 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 7 May 2026 07:02:07 -0400 Subject: [PATCH] docs(phase-07.1): plan urgent user timezone fix (TZ-01..TZ-04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insert Phase 7.1 between Phase 7 and Phase 8 to address dashboards/filters rendering wrong dates because day/week boundary math runs in server UTC instead of the viewing user's timezone. Persistence stays UTC; only the read/display path changes. 5 plans in 3 waves: - 07.1-01 (Wave 1): migration 083 + Better Auth additionalField timezone - 07.1-02 (Wave 1): /api/me/timezone GET+PUT with IANA validation - 07.1-03 (Wave 2): server-side AT TIME ZONE migration across 6 routes, including auth-gate fix on /api/mobile/finance and trends route - 07.1-04 (Wave 2): useUserTimezone() hook + 2 mobile pages + codebase audit - 07.1-05 (Wave 3): codebase-wide useUserTimezone() adoption per audit Add Phase 9 stub (User Profile & Preferences) to roadmap for the picker UI that reuses 7.1's hook + endpoint. REQUIREMENTS.md TZ-02 carves out engagement_snapshots UTC bucketing as a documented exception (≤24h drift acceptable for admin overview). Co-Authored-By: Claude Opus 4.7 (1M context) --- .planning/REQUIREMENTS.md | 12 +- .planning/ROADMAP.md | 35 + .planning/STATE.md | 14 +- .../07.1-01-PLAN.md | 296 ++++++++ .../07.1-02-PLAN.md | 342 +++++++++ .../07.1-03-PLAN.md | 669 ++++++++++++++++++ .../07.1-04-PLAN.md | 531 ++++++++++++++ .../07.1-05-PLAN.md | 372 ++++++++++ 8 files changed, 2263 insertions(+), 8 deletions(-) create mode 100644 .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-01-PLAN.md create mode 100644 .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-02-PLAN.md create mode 100644 .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-03-PLAN.md create mode 100644 .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-PLAN.md create mode 100644 .planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-PLAN.md 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) + + + +After completion, create `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-01-SUMMARY.md` +documenting: the migration filename, the additionalField shape, and any gotchas +encountered (e.g. did `scripts/apply-migrations` exist and behave as expected? +which env vars need to be set in `.env.local` for `DEFAULT_TIMEZONE`?). + + + \ 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 + + + +After completion, create `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-02-SUMMARY.md` +documenting: the exact response shapes for GET and PUT, the validation rule +(IANA whitelist + length cap), the threat-model dispositions actually +implemented, and any deviation from the plan (e.g. did the middleware audit +turn up something unexpected?). + + + \ 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 + + + +After completion, create `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-03-SUMMARY.md` +documenting: the helper signature, the canonical SQL idiom used (the two-step +`AT TIME ZONE 'UTC' AT TIME ZONE $1` form), the list of routes migrated +(including `/api/dashboard/trends`), the queries explicitly preserved (rolling +windows, snapshot joins, queue heatmap), the new `/api/mobile/finance` auth +gate, the engagement-snapshots TZ-02 carve-out, and the behavioral test result +for at least one user-tz vs UTC midnight scenario. + + + \ 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) + + + +After completion, create `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-SUMMARY.md` +documenting: the hook signature, the migrated callsites (file + line numbers +before vs after), the audit results (leak count, explicit-zone count, etc.), +the Plan 05 dispatch decision (create / skip), and the behavioral test +result for two-browser-same-user tz consistency. + + + \ 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 + + + +After completion, create `.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-SUMMARY.md` +documenting: the manifest's `## Files to migrate` count vs the leak count +from the audit (should match minus deferred), the per-file before/after leak +counts, the deferred file list with rationale, the post-migration residue +grep result, and any TypeScript regressions caught + fixed during migration. + + + \ No newline at end of file