docs(phase-07.1): plan urgent user timezone fix (TZ-01..TZ-04)

Insert Phase 7.1 between Phase 7 and Phase 8 to address dashboards/filters
rendering wrong dates because day/week boundary math runs in server UTC
instead of the viewing user's timezone. Persistence stays UTC; only the
read/display path changes.

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lorentz 2026-05-07 07:02:07 -04:00
parent 0dec54ca4f
commit f9ab954518
8 changed files with 2263 additions and 8 deletions

View file

@ -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)*
</content>
</invoke>

View file

@ -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 37 (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`*
</content>
</invoke>

View file

@ -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%

View file

@ -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"
---
<objective>
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`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Existing additionalField pattern from lib/auth.ts:84-96 — extend, do NOT replace -->
```typescript
// lib/auth.ts current shape:
user: {
additionalFields: {
role: {
type: "string",
defaultValue: "user",
},
requires_setup: {
type: "boolean",
defaultValue: false,
},
},
},
```
<!-- Existing user table from migrations/012_create_auth_tables.sql:5-18 -->
```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()
);
```
<!-- Migration idiom from migrations/081_integration_settings.sql — IF NOT EXISTS, no destructive ops -->
<!-- Highest existing migration number: 082_company_scope.sql → next is 083 -->
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create migration 083_add_user_timezone.sql</name>
<files>migrations/083_add_user_timezone.sql</files>
<read_first>
- 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)
</read_first>
<action>
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`.
</action>
<verify>
<automated>test -f migrations/083_add_user_timezone.sql &amp;&amp; grep -q 'ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT' migrations/083_add_user_timezone.sql &amp;&amp; grep -q "ON COLUMN \"user\".timezone IS" migrations/083_add_user_timezone.sql</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>
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.
</done>
</task>
<task type="auto">
<name>Task 2: Extend Better Auth additionalFields with timezone</name>
<files>lib/auth.ts</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>ERR=$(npx tsc --noEmit --pretty 2&gt;&amp;1 | grep -E "lib/auth\.(ts|tsx)"); [ -z "$ERR" ] &amp;&amp; grep -A1 "timezone:" lib/auth.ts | grep -q 'process.env.DEFAULT_TIMEZONE || "UTC"'</automated>
</verify>
<acceptance_criteria>
- `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)
</acceptance_criteria>
<done>
`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.
</done>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<verification>
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.
</verification>
<success_criteria>
- `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)
</success_criteria>
<output>
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`?).
</output>
</content>
</invoke>

View file

@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
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<RowShape>(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.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Confirm middleware.ts does not whitelist /api/me</name>
<files>middleware.ts</files>
<read_first>
- 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_first>
<action>
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.
</action>
<verify>
<automated>! grep -nE '"/api/me' middleware.ts</automated>
</verify>
<acceptance_criteria>
- Command `grep -nE '"/api/me' middleware.ts` exits non-zero (no matches)
- middleware.ts is unchanged (`git diff --quiet middleware.ts`)
</acceptance_criteria>
<done>
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.
</done>
</task>
<task type="auto">
<name>Task 2: Create app/api/me/timezone/route.ts (GET + PUT)</name>
<files>app/api/me/timezone/route.ts</files>
<read_first>
- 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)
</read_first>
<action>
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<NextResponse> {
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<NextResponse> {
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.
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- 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"}`
</acceptance_criteria>
<done>
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`.
</done>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<verification>
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"}`
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
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?).
</output>
</content>
</invoke>

View file

@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
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.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create lib/services/user-timezone.ts helper</name>
<files>lib/services/user-timezone.ts</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>
`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'`).
</done>
</task>
<task type="auto">
<name>Task 2: Migrate /api/mobile/dashboard and /api/dashboard/overview to user-tz day math</name>
<files>app/api/mobile/dashboard/route.ts, app/api/dashboard/overview/route.ts</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>
`/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.
</done>
</task>
<task type="auto">
<name>Task 3: Migrate /api/mobile/finance (with auth-gate hardening) and the engagement endpoints to user-tz boundaries</name>
<files>app/api/mobile/finance/route.ts, app/api/mobile/engagement/summary/route.ts, app/api/mobile/engagement/trend/route.ts</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>
`/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.
</done>
</task>
<task type="auto">
<name>Task 4: Migrate /api/dashboard/trends to user-tz day buckets</name>
<files>app/api/dashboard/trends/route.ts</files>
<read_first>
- 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`)
</read_first>
<action>
`/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.
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- `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).
</acceptance_criteria>
<done>
`/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.
</done>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<verification>
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.
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
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.
</output>
</content>
</invoke>

View file

@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@CLAUDE.md
@lib/auth.ts
@lib/auth-client.ts
@components/auth/auth-provider.tsx
@app/mobile/finance/page.tsx
@app/mobile/tickets/[id]/page.tsx
<interfaces>
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.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create lib/hooks/use-user-timezone.ts</name>
<files>lib/hooks/use-user-timezone.ts</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- 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'`.
</acceptance_criteria>
<done>
`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).
</done>
</task>
<task type="auto">
<name>Task 2: Migrate app/mobile/finance/page.tsx and app/mobile/tickets/[id]/page.tsx to useUserTimezone</name>
<files>app/mobile/finance/page.tsx, app/mobile/tickets/[id]/page.tsx</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>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 ]</automated>
</verify>
<acceptance_criteria>
- `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).
</acceptance_criteria>
<done>
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.
</done>
</task>
<task type="auto">
<name>Task 3: Codebase-wide audit + classification of remaining toLocale* / Intl.DateTimeFormat callsites</name>
<files>.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md</files>
<read_first>
- 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)
</read_first>
<action>
Produce a one-pass audit of every `Intl.DateTimeFormat` /
`toLocaleString(` / `toLocaleDateString(` / `toLocaleTimeString(` callsite
across `app/`, `components/`, and `lib/hooks/`, EXCLUDING the two files
Task 2 just migrated. Classify each callsite, then write the result to
`07.1-04-AUDIT.md` so Plan 05 can consume it.
Steps:
1. Run the full discovery grep:
grep -rEn "Intl\.DateTimeFormat|\.toLocaleDateString\(|\.toLocaleTimeString\(|\.toLocaleString\(" app/ components/ lib/hooks/ \
| grep -v "node_modules" \
| grep -v "app/mobile/finance/page.tsx" \
| grep -v "app/mobile/tickets/\[id\]/page.tsx" \
| grep -v "components/ui/calendar.tsx" \
> /tmp/tz-audit-raw.txt
(`components/ui/calendar.tsx` is a shadcn/ui primitive; its
`toLocaleString("default", { month: "short" })` call is a calendar-cell
label, not a user-visible date — exclude.)
2. For each line in `/tmp/tz-audit-raw.txt`, classify into ONE of:
- **leak**: `toLocaleString(` / `toLocaleDateString(` / `toLocaleTimeString(`
on a Date instance with NO `timeZone:` option in the same call. These
render in the device's local zone — the bug TZ-02 is patching.
Examples: `new Date(ts).toLocaleString()`,
`d.toLocaleDateString('en-US', { month: 'short' })`.
- **explicit_zone**: a `timeZone:` option IS passed in the same call
(e.g., `{ timeZone: 'UTC' }` for deliberate UTC display, or
`{ timeZone: tz }` already migrated). Leave as-is.
- **number_format**: `.toLocaleString()` called on a `number` /
`bigint` (formatted thousand separators, NOT a date). Recognizable
because the callee is not a `Date` instance — e.g., `count.toLocaleString()`,
`value.toLocaleString()`, `summary.organizations?.toLocaleString()`.
These are not date callsites; ignore.
- **server_side**: file path matches `app/api/**/route.ts` or otherwise
runs in Node (not a React component). Out of scope for the client
hook. Server-side formatting belongs to Plan 03's `getUserTimezone`
server helper if it ever needs to format dates server-side; today the
only such callsites are the analyzer prompt builders
(`app/api/veeam/ticket-analysis/run/route.ts`,
`app/api/veeam/rpo-analyze/route.ts`) which are deliberately
locale-only (LLM input). Do NOT migrate.
- **deliberate_utc**: file already passes `{ timeZone: 'UTC' }` for a
specific reason (e.g., `components/mobile/EngagementHoursSparkline.tsx:39`
pins UTC because the data points are stored as UTC dates and the
sparkline is a 7/30-day shape, not a clock). Leave as-is.
3. Write the inventory to
`.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-AUDIT.md`
with EXACTLY this structure:
# Phase 7.1 — Codebase-wide tz audit (Plan 04 Task 3)
Discovery date: <ISO date>
Excluded: `app/mobile/finance/page.tsx`,
`app/mobile/tickets/[id]/page.tsx`,
`components/ui/calendar.tsx`,
`node_modules/**`
## Leak callsites (must migrate via Plan 05)
| File | Line | Snippet | Notes |
|------|------|---------|-------|
| app/foo/page.tsx | 42 | `new Date(ts).toLocaleDateString()` | client component, default-zone leak |
| ... | ... | ... | ... |
## Explicit-zone callsites (no migration)
| File | Line | Snippet |
## Number-format callsites (not date — ignore)
Count: <N>
## Server-side callsites (out of scope)
| File | Line | Reason |
| app/api/veeam/ticket-analysis/run/route.ts | 78,90,97,98 | LLM prompt builder — locale-only by design |
| ... | ... | ... |
## Deliberate UTC callsites
| File | Line | Reason |
| components/mobile/EngagementHoursSparkline.tsx | 39 | UTC pin for sparkline shape (not a clock) |
## Summary
- Leak count: N
- Explicit-zone count: N
- Server-side count: N
- Deliberate-UTC count: N
## Plan 05 dispatch
- If Leak count == 0: Plan 05 is unnecessary. Mark in SUMMARY.
- If Leak count > 0: Plan 05 (sibling, depends_on `07.1-04`) closes
every Leak file in this audit. Plan 05's `files_modified` is the
unique set of leak file paths above.
4. Do NOT modify any of the leak files in this task — only inventory them.
Plan 05 owns the migration. The audit file IS the deliverable.
Notes:
- This task is intentionally scoped to discovery + classification, not
migration. It's the bridge between Plan 04 (two reported-bug-surface
pages) and Plan 05 (codebase-wide adoption).
- The audit file becomes the SOURCE OF TRUTH for Plan 05's
`files_modified` and Plan 05's per-file acceptance criteria.
- From the planning-time grep, the leak count is >10 (a `grep -rEn` across
`app/`, `components/`, `lib/hooks/` returned ~96 candidate lines; many
are number formatters, but Mimecast / engagement / analyzer / dashboard
pages alone yield >10 confirmed Date-instance leaks). Plan 05 is
therefore expected to be created. Confirm by counting the rows in the
"Leak callsites" table.
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>
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.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Server → client (session payload) | tz string travels via Better Auth session cookie; client trusts it for formatting only |
| Browser → display | tz misuse only affects what the user themselves sees on their own screen — no cross-user impact |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07.1-04-01 | Tampering | Tampered session payload with malformed tz crashing toLocaleString | mitigate | `useUserTimezone()` validates against `Intl.supportedValuesOf('timeZone')` before returning; falls back to env default. `toLocaleString` with the validated value cannot throw. |
| T-07.1-04-02 | Information Disclosure | tz exposed in client memory | accept | Same disposition as T-07.1-01-02: tz is non-sensitive metadata. |
| T-07.1-04-03 | Denial of Service | Calling `Intl.supportedValuesOf` on every hook call | accept | Hook is called per-render; V8 caches internally; ~600-entry array. Negligible. |
| T-07.1-04-04 | Tampering | NEXT_PUBLIC_DEFAULT_TIMEZONE override at build time | accept | Public env var is intentionally operator-controlled; same trust level as the server-side `DEFAULT_TIMEZONE`. Out of scope. |
| T-07.1-04-05 | Spoofing | Client showing one user's tz while session has another's | mitigate | Hook reads exclusively from `useSession()`; Better Auth invalidates sessions on sign-out. No cross-session leakage. |
| T-07.1-04-06 | Information Disclosure | Audit file leaks file paths / snippets | accept | The audit file lives in `.planning/` (already part of the planning artefact tree), references only file paths and short code snippets that exist in the public repo, no secrets. |
</threat_model>
<verification>
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.
</verification>
<success_criteria>
- `lib/hooks/use-user-timezone.ts` is the single canonical source of truth for client tz
- `app/mobile/finance/page.tsx` and `app/mobile/tickets/[id]/page.tsx` both consume it; every date formatter passes `timeZone:`
- TypeScript compiles for all three migrated files
- A complete codebase-wide leak audit is committed; Plan 05 dispatch decision is recorded
- Plan 05 closes the codebase-wide adoption gap to satisfy SC#4 (single source of truth)
</success_criteria>
<output>
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.
</output>
</content>
</invoke>

View file

@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
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()`.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Build the migration manifest from the audit</name>
<files>.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-05-MANIFEST.md</files>
<read_first>
- .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_first>
<action>
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:
### <file path>
- 'use client' status: <yes|no needs `'use client'` added or refactor>
- Pre-migration leak count: <N>
- Post-migration acceptance grep:
[ "$(grep -c 'timeZone:' <file>)" -ge <N> ]
- Per-callsite plan:
- Line <L>: `<snippet before>``<snippet after>`
- ...
Notes / risks: <any per-file gotchas e.g., DataTable column factory
at module scope, helper that needs `tz` parameter threading>
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).
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>
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.
</done>
</task>
<task type="auto">
<name>Task 2: Migrate every leak callsite per the manifest</name>
<files>(see 07.1-05-MANIFEST.md `## Files to migrate` — populated by Task 1)</files>
<read_first>
- .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)
</read_first>
<action>
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:' <file>)" -ge <pre-migration leak count> ]
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 "<file_path>"); [ -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.
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- 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 "<each migrated file>"); [ -z "$ERR" ]` for every migrated file
</acceptance_criteria>
<done>
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.
</done>
</task>
<task type="auto">
<name>Task 3: Codebase-wide post-migration verification grep</name>
<files>(read-only verification; no file edits)</files>
<read_first>
- .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)
</read_first>
<action>
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
</action>
<verify>
<automated>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))" ]</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>
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.
</done>
</task>
</tasks>
<threat_model>
## 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). |
</threat_model>
<verification>
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 "<each modified file>"); [ -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).
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
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.
</output>
</content>
</invoke>