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

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

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

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

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

44 KiB
Raw Blame History

phase plan type wave depends_on files_modified autonomous requirements requirements_addressed must_haves
07.1-user-timezone-fix-inserted-urgent 03 execute 2
07.1-01
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
true
TZ-02
TZ-02
truths artifacts key_links
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)
path provides exports
lib/services/user-timezone.ts Server-side helper getUserTimezone(session) returning a validated IANA string with safe fallback
getUserTimezone
DEFAULT_TIMEZONE_FALLBACK
path provides contains
app/api/mobile/dashboard/route.ts Dashboard KPIs scoped to user-tz day boundaries getUserTimezone
path provides contains
app/api/dashboard/overview/route.ts Desktop dashboard overview scoped to user-tz day boundaries getUserTimezone
path provides contains
app/api/dashboard/trends/route.ts Desktop dashboard trends with daily buckets aligned to the calling user's tz getUserTimezone
path provides contains
app/api/mobile/finance/route.ts Mobile finance summary scoped to user-tz month boundaries; now auth-gated via requireAuth() getUserTimezone
path provides contains
app/api/mobile/engagement/summary/route.ts Mobile engagement summary with user-tz window math (rolling time_entries only; snapshot bucketing remains UTC by design) getUserTimezone
path provides contains
app/api/mobile/engagement/trend/route.ts Mobile engagement trend bucketed in user-tz days getUserTimezone
from to via pattern
Each affected route session.user.timezone via lib/services/user-timezone.ts import { getUserTimezone } and call it after requireAuth() getUserTimezone
from to via pattern
SQL queries Postgres timezone-aware day boundaries (value AT TIME ZONE 'UTC') AT TIME ZONE $tz idiom AT TIME ZONE
Switch every server-side day/week/month boundary computation in the affected read paths from server UTC to the calling user's IANA timezone. Storage stays UTC; only the WHERE clauses and DATE_TRUNC arguments change. This plan also adds `requireAuth()` to `/api/mobile/finance` (a 5-line hardening that aligns it with every other `/api/mobile/*` route) so it can use the proper `getUserTimezone(session)` resolution path instead of the env-default fallback.

Purpose: Resolve TZ-02 server-side. The six routes touched here are the ones that surfaced the bug (dashboards and filters showing wrong dates) per the phase scope context. The Plan 04 client work follows up on TZ-02 client-side + TZ-04.

Output: A single shared helper lib/services/user-timezone.ts and edits to six existing route handlers (the four originally listed plus /api/dashboard/trends, which the first revision pass missed). No new endpoints. No schema changes.

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

@.planning/STATE.md @.planning/ROADMAP.md @.planning/REQUIREMENTS.md @CLAUDE.md @lib/auth-utils.ts @lib/auth.ts @app/api/mobile/dashboard/route.ts @app/api/dashboard/overview/route.ts @app/api/dashboard/trends/route.ts @app/api/mobile/finance/route.ts @app/api/mobile/engagement/summary/route.ts @app/api/mobile/engagement/trend/route.ts After Plan 01 ships, `session.user.timezone: string` is on the Better Auth `User` type. Before that, it's not. This plan therefore depends on Plan 01.

The Postgres idiom for "day boundary in user tz" is:

-- "Today" in user's local zone, comparing a UTC-stored timestamp:
WHERE create_date AT TIME ZONE $tz_param >= DATE_TRUNC('day', NOW() AT TIME ZONE $tz_param)
  AND create_date AT TIME ZONE $tz_param <  DATE_TRUNC('day', NOW() AT TIME ZONE $tz_param) + INTERVAL '1 day'

Or, more compactly:

WHERE (create_date AT TIME ZONE $tz_param)::date = (NOW() AT TIME ZONE $tz_param)::date

Notes on Postgres AT TIME ZONE semantics:

  • For a TIMESTAMP WITH TIME ZONE (timestamptz) input: value AT TIME ZONE 'America/New_York' returns a TIMESTAMP WITHOUT TIME ZONE adjusted to that zone (correct for our purpose).
  • For a TIMESTAMP WITHOUT TIME ZONE input: value AT TIME ZONE 'America/New_York' ASSUMES the input is in America/New_York and returns a timestamptz. This is the wrong direction for us.
  • The Pulse tickets, qbo_invoices, engagement_snapshots, and time_entries tables use TIMESTAMP WITHOUT TIME ZONE for their date columns (per the existing 069/070/079 migrations and confirmed by the routes using ::date casts directly). UTC-stored. So the correct idiom is (value AT TIME ZONE 'UTC') AT TIME ZONE $tz.

Using a TWO-STEP convert is the safe canonical form regardless of column type:

-- "today" in user tz:
(NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date

-- "row in today (user tz)":
((create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date

This works for both timestamp and timestamptz columns:

  • For timestamptz, AT TIME ZONE 'UTC' returns a timestamp already in UTC.
  • For timestamp (assumed UTC, which is Pulse's convention per CLAUDE.md), AT TIME ZONE 'UTC' interprets the value as UTC and returns a timestamptz representing that instant; the second AT TIME ZONE $1 then shifts it to the user zone.

Use this two-step idiom in every replacement.

Postgres validates the IANA string at query time and throws invalid_parameter_value for unknown zones. Since session.user.timezone is constrained at write time by Plan 02's PUT validation (and at read time by getUserTimezone()'s safe fallback below), we never expect that error in practice — but it's also not catastrophic if it ever fires; the catch block returns 500 like any other DB error.

Task 1: Create lib/services/user-timezone.ts helper lib/services/user-timezone.ts - lib/auth-utils.ts (the `requireAuth` return shape and `UserWithRole` type — note `[key: string]: unknown` so `timezone` is accessible without a type cast) - lib/auth.ts (the additionalFields block, after Plan 01 — confirms `timezone` is on `session.user` and is a string) - CLAUDE.md (Layout / Conventions — `lib/services/` is the right home for shared server helpers) Create `lib/services/user-timezone.ts` with EXACTLY this content:
    // Server-side helper for resolving the calling user's IANA timezone.
    //
    // After Phase 7.1 Plan 01, `session.user.timezone` is a string populated
    // either from the stored `"user".timezone` column or from Better Auth's
    // additionalField `defaultValue` (`process.env.DEFAULT_TIMEZONE || 'UTC'`).
    //
    // This helper:
    //   - reads the value off a Better Auth session
    //   - validates it against `Intl.supportedValuesOf('timeZone')` (defence in
    //     depth — Plan 02 already validates writes, but a corrupt row from
    //     before this phase, or a manual SQL edit, must not crash dashboards)
    //   - falls back to `process.env.DEFAULT_TIMEZONE || 'UTC'` if invalid
    //
    // Use this in every API route that does day/week/month boundary math.

    export const DEFAULT_TIMEZONE_FALLBACK = (): string =>
      process.env.DEFAULT_TIMEZONE || 'UTC';

    type SessionLike = {
      user?: { timezone?: unknown } | null;
    } | null | undefined;

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

    /**
     * Returns a validated IANA timezone string for the given session.
     * Never throws; always returns a usable string (worst case: 'UTC').
     */
    export function getUserTimezone(session: SessionLike): string {
      const raw = session?.user?.timezone;
      if (isValidIanaTimezone(raw)) return raw;
      return DEFAULT_TIMEZONE_FALLBACK();
    }

Notes:
- Do NOT import from `@/lib/auth-utils` here (would create a circular module
  graph for routes that already import requireAuth). Accept a duck-typed
  session.
- The helper is intentionally pure and synchronous — no DB calls, no env
  lookups beyond the fallback. Routes already have the session in hand from
  requireAuth(), so we just pass it in.
- DEFAULT_TIMEZONE_FALLBACK is a function (not a const) so test code can
  override `process.env.DEFAULT_TIMEZONE` between calls without resetting
  module state.
ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "lib/services/user-timezone\.ts"); [ -z "$ERR" ] && grep -q "export function getUserTimezone" lib/services/user-timezone.ts && grep -q "Intl.supportedValuesOf('timeZone')" lib/services/user-timezone.ts && grep -q "DEFAULT_TIMEZONE_FALLBACK" lib/services/user-timezone.ts - File exists at `lib/services/user-timezone.ts` - Exports a function named `getUserTimezone` - Exports a function named `DEFAULT_TIMEZONE_FALLBACK` - Contains the literal `Intl.supportedValuesOf('timeZone')` - Contains the literal `process.env.DEFAULT_TIMEZONE || 'UTC'` - Does NOT import from `@/lib/auth-utils` (no circular) - Does NOT import `pg` or `postgresClient` (no DB) - `npx tsc --noEmit --pretty` reports no errors in this file `getUserTimezone(session)` is available to every server route. Given a session with `user.timezone === 'America/New_York'`, returns `'America/New_York'`. Given a session with garbage or missing tz, returns the env default (or `'UTC'`). Task 2: Migrate /api/mobile/dashboard and /api/dashboard/overview to user-tz day math app/api/mobile/dashboard/route.ts, app/api/dashboard/overview/route.ts - app/api/mobile/dashboard/route.ts (lines 48-133 — the SQL block; note the existing UTC anchors at lines 70-76: `create_date::date = CURRENT_DATE`, `completed_date::date = CURRENT_DATE`, and the SLA-breach `due_date_time < NOW()` line) - app/api/dashboard/overview/route.ts (lines 38-78 — same idiom: `create_date::date = CURRENT_DATE`, `CURRENT_DATE - INTERVAL '1 day'`, `CURRENT_DATE - INTERVAL '7 days'`) - lib/services/user-timezone.ts (created in Task 1 — the import target) - The interfaces block above (the `(value AT TIME ZONE 'UTC') AT TIME ZONE $tz`::date idiom — apply verbatim) Two route files. Edit them in this order:
--- A: app/api/mobile/dashboard/route.ts ---

1. Add import at the top of the imports block:
   `import { getUserTimezone } from '@/lib/services/user-timezone';`
2. After the `requireAuth()` line in `GET()`, add:
   `const tz = getUserTimezone(session);`
   (note: `requireAuth()` currently destructures only `error` — change it
   to `const { session, error } = await requireAuth(); if (error) return error;`)
3. The KPI snapshot query at lines 67-80 has TWO occurrences of
   `create_date::date = CURRENT_DATE` and `completed_date::date = CURRENT_DATE`,
   plus an `AND due_date_time < NOW()` clause. Replace as follows
   (parametrize tz as `$1`):

   Before:
       SELECT
         COUNT(*) FILTER (WHERE completed_date IS NULL)::text                 AS open_total,
         COUNT(*) FILTER (WHERE create_date::date = CURRENT_DATE)::text       AS opened_today,
         COUNT(*) FILTER (WHERE completed_date::date = CURRENT_DATE)::text    AS resolved_today,
         COUNT(*) FILTER (
           WHERE completed_date IS NULL
             AND due_date_time IS NOT NULL
             AND due_date_time < NOW()
         )::text                                                              AS sla_breaches
       FROM tickets
       WHERE (is_deleted = false OR is_deleted IS NULL)
         AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false)

   After (pass `[tz]` as the params arg to `postgresClient.query<...>`):
       SELECT
         COUNT(*) FILTER (WHERE completed_date IS NULL)::text                                                                AS open_total,
         COUNT(*) FILTER (WHERE ((create_date    AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date)::text  AS opened_today,
         COUNT(*) FILTER (WHERE ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date)::text  AS resolved_today,
         COUNT(*) FILTER (
           WHERE completed_date IS NULL
             AND due_date_time IS NOT NULL
             AND due_date_time < NOW()
         )::text                                                                                                              AS sla_breaches
       FROM tickets
       WHERE (is_deleted = false OR is_deleted IS NULL)
         AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false)

   The `due_date_time < NOW()` clause stays as-is (compares two
   UTC-relative instants — the SLA-breach concept is "is this ticket past
   its due time RIGHT NOW", which is timezone-independent).

4. The other queries in the Promise.all (failed backups 24h, stalled
   workflows 5min, analyzer 1h, RMM 1h, backup success 24h) all use
   `INTERVAL '24 hours'` / `INTERVAL '5 minutes'` / `INTERVAL '1 hour'`
   with `NOW() - INTERVAL ...`. These compare UTC instants to UTC
   instants — they are NOT day-boundary calculations. DO NOT MODIFY THEM.
   Add a code comment immediately above the failed-backups query
   confirming this:
       // INTERVAL '24 hours' here is rolling — not a calendar-day boundary —
       // so timezone does not apply. Do not migrate to user-tz.

--- B: app/api/dashboard/overview/route.ts ---

1. Add the import:
   `import { getUserTimezone } from '@/lib/services/user-timezone';`
2. Inside `GET()`, after the `requireAuth()` call, change the destructure
   to `const { session, error } = await requireAuth(); if (error) return error;`
   and add `const tz = getUserTimezone(session);`.
3. The `today` query at lines 38-57: same idiom as A.3 above. Replace
   `WHERE create_date::date = CURRENT_DATE` and
   `WHERE completed_date::date = CURRENT_DATE` with the user-tz forms,
   parametrize as `$1`, pass `[tz]`. The `due_date_time < NOW()` clause
   stays as-is.
4. The `yesterdayOpened` query at lines 59-65 currently:
       WHERE create_date::date = CURRENT_DATE - INTERVAL '1 day'
   Replace with (parametrized `[tz]`):
       WHERE ((create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - INTERVAL '1 day'
5. The `last7AvgResolvedRes` query at lines 67-78:
       WHERE completed_date >= CURRENT_DATE - INTERVAL '7 days'
         AND completed_date <  CURRENT_DATE
       GROUP BY completed_date::date
   Replace with:
       WHERE ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - INTERVAL '7 days'
         AND ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date <  (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
       GROUP BY ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date
   Pass `[tz]` to the query.
6. The remaining queries in this route (linkConflicts, itglueUnlinked,
   s1Unmapped, schedules, observations, audits, syncHealth, companies, ci,
   xref) do NOT use day-boundary math. DO NOT MODIFY THEM.

Both files: keep the existing `try/catch` shape, the existing
`NextResponse.json` envelope, the existing `Promise.all` ordering, and the
existing return shapes. Only the SQL strings and the new `tz` parameter
change. No new exports.
ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/(mobile/)?dashboard/(route|overview/route)\.ts"); [ -z "$ERR" ] && [ "$(grep -c 'AT TIME ZONE' app/api/mobile/dashboard/route.ts)" -ge 2 ] && [ "$(grep -c 'AT TIME ZONE' app/api/dashboard/overview/route.ts)" -ge 4 ] && grep -q "getUserTimezone" app/api/mobile/dashboard/route.ts && grep -q "getUserTimezone" app/api/dashboard/overview/route.ts && ! grep -E '::date = CURRENT_DATE|create_date::date = CURRENT_DATE - INTERVAL' app/api/dashboard/overview/route.ts && ! grep -E '::date = CURRENT_DATE' app/api/mobile/dashboard/route.ts - Both files import `getUserTimezone` from `@/lib/services/user-timezone` - Both files destructure `session` from `requireAuth()` and pass it to `getUserTimezone` - `app/api/mobile/dashboard/route.ts` no longer contains the literal `::date = CURRENT_DATE` - `app/api/dashboard/overview/route.ts` no longer contains `::date = CURRENT_DATE` (today, yesterday, or 7-day-avg variants) - `app/api/mobile/dashboard/route.ts` contains at least 2 occurrences of `AT TIME ZONE` - `app/api/dashboard/overview/route.ts` contains at least 4 occurrences of `AT TIME ZONE` - The `due_date_time < NOW()` clauses are PRESERVED (rolling-now SLA check is tz-independent) - The `INTERVAL '24 hours'` / `INTERVAL '5 minutes'` / `INTERVAL '1 hour'` queries are PRESERVED unchanged - `npx tsc --noEmit --pretty` reports no NEW errors in either file - Behavioral (manual once running): - With user.timezone = 'America/New_York' and a ticket created at 2026-05-07T03:30:00Z (which is 2026-05-06 23:30 ET), the `opened_today` count for that user includes that ticket on 2026-05-06 ET — NOT on 2026-05-07 ET `/api/mobile/dashboard` and `/api/dashboard/overview` compute "today", "yesterday", and "last 7 days" against the calling user's tz. SLA breach-and rolling-window metrics are unchanged. No new endpoints were added; no schema migrations ran. Task 3: Migrate /api/mobile/finance (with auth-gate hardening) and the engagement endpoints to user-tz boundaries app/api/mobile/finance/route.ts, app/api/mobile/engagement/summary/route.ts, app/api/mobile/engagement/trend/route.ts - app/api/mobile/finance/route.ts (full file — note it currently has NO requireAuth() call; this plan adds it) - app/mobile/finance/page.tsx (the consumer — confirm it uses a session-cookie-bearing fetch with no extra Authorization header; that's the default for browser fetches to same-origin Next.js routes, and Better Auth's session cookie travels automatically — no changes needed on the page) - lib/auth-utils.ts (`requireAuth()` shape — copy from `/api/mobile/engagement/summary/route.ts`) - app/api/mobile/engagement/summary/route.ts (the `interval` map at lines 53-58 and the `te.entry_date >= NOW() - INTERVAL '${interval}'` line at 122 — that's the calendar-window seam; also the `period_end = $2` join on snapshots, which is a stored DATE so tz doesn't apply there) - app/api/mobile/engagement/trend/route.ts (the `generate_series` block at lines 46-72 — `CURRENT_DATE` is the seam) - lib/services/user-timezone.ts (the helper from Task 1) Three files.
--- A: app/api/mobile/finance/route.ts ---

The route currently has NO auth. That has been an outstanding pre-existing
gap; this plan fixes it as a 5-line change because (a) every other
`/api/mobile/*` route already uses `requireAuth()`, (b) the consumer at
`app/mobile/finance/page.tsx` fetches via the browser with the Better
Auth session cookie automatically attached, so adding the gate does not
break the existing UI, and (c) once the gate is in place we can resolve
the calling user's tz the proper way (`getUserTimezone(session)`) instead
of the env-default fallback.

Steps:
1. Add imports:
   `import { requireAuth } from '@/lib/auth-utils';`
   `import { getUserTimezone } from '@/lib/services/user-timezone';`
2. As the FIRST line of the existing `GET()` handler (before
   `Promise.all`), add:
       const { session, error } = await requireAuth();
       if (error) return error;
       const tz = getUserTimezone(session);
   Add a code comment immediately above the requireAuth call:
       // Auth gate (Phase 7.1): aligns this route with every other
       // /api/mobile/* handler and lets us resolve the caller's tz from
       // the session. Browser callers carry the Better Auth session
       // cookie automatically, so the existing /mobile/finance page works
       // unchanged.
3. The `summary` query (lines 6-17): replace
       DATE_TRUNC('month', NOW())  →  DATE_TRUNC('month', NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)
       DATE_TRUNC('year',  NOW())  →  DATE_TRUNC('year',  NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)
   AND change the comparison operands so both sides are in the same tz —
   `txn_date` is `TIMESTAMP WITHOUT TIME ZONE` (UTC-stored), so:
       txn_date >= DATE_TRUNC('month', NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)
   compares a UTC timestamp to a `timestamp` (without tz) in user-zone —
   semantically wrong. Correct form (compare like with like):
       (txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= DATE_TRUNC('month', NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)
   Apply this transformation to BOTH `paid_mtd` and `paid_ytd` filters.
   Pass `[tz]` as the params arg to `postgresClient.query`.
4. The `aging` query (lines 18-29) uses `due_date >= CURRENT_DATE - 30`
   etc. `due_date` is a `DATE` (date-only, not a timestamp). For DATE
   columns, `CURRENT_DATE` is server-local (UTC in our deploy) and
   comparing user-tz "today" to a stored DATE column is the right move:
       CURRENT_DATE  →  (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
   Apply this transformation to all SIX comparisons in the aging query
   (`days_1_30`, `cnt_1_30`, `days_31_60`, `cnt_31_60`, `days_60_plus`,
   `cnt_60_plus`). Pass `[tz]` as params.
5. The `overdueInvoices` query (lines 37-43) has
       CURRENT_DATE - due_date::date as days_overdue
   Replace `CURRENT_DATE` with `(NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date`
   and pass `[tz]` as params.
6. The `topCustomers`, `recentPayments`, `monthlyRevenue` queries do not
   use any day-boundary date math relative to "today/this month/this year"
   (monthlyRevenue uses `>= NOW() - INTERVAL '12 months'` which is a
   rolling window, NOT a calendar boundary — leave it). DO NOT MODIFY
   THESE THREE.

--- B: app/api/mobile/engagement/summary/route.ts ---

1. Add import: `import { getUserTimezone } from '@/lib/services/user-timezone';`
2. After the existing `const { error: authError } = await requireAuth()`,
   change to:
       const { session, error: authError } = await requireAuth();
       if (authError) return authError;
       const tz = getUserTimezone(session);
3. The Autotask-hours query (lines 117-127) currently uses:
       WHERE te.entry_date >= NOW() - INTERVAL '${interval}'
   `entry_date` is `TIMESTAMP WITHOUT TIME ZONE` (UTC-stored). The
   `interval` is one of '7 days', '30 days', '90 days'. Change the WHERE
   to anchor on user-tz "today":
       WHERE (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1) - INTERVAL '${interval}'
   Pass `[tz]` as the params arg (currently no params; add the array).
   Keep the SQL injection comment that's already in the file — the
   `${interval}` interpolation safety still applies.
4. The other queries (`activeResult`, `graphHoursResult`) join on
   `period_end = $2` where `period_end` is a stored DATE (precomputed by
   the engagement-sync service against UTC). Period-bucket DATEs are
   NOT migrated by this phase — see "TZ-02 carve-out" note below. Add a
   code comment immediately above the `latestResult` query (around line
   37) — DO NOT MODIFY THESE QUERIES:
       // NOTE (TZ-02 carve-out, see REQUIREMENTS.md): engagement_snapshots
       // are bucketed by UTC at sync time by lib/services/engagement-sync-service.ts.
       // Per-user-tz snapshot bucketing is deferred to a future phase
       // (would require either per-request re-bucketing — expensive — or
       // per-user snapshot rebuild — doubles storage). The ≤24h drift on
       // active-users D7/D30/D90 + total MS Graph hours is acceptable for
       // an admin-overview surface. Only the rolling time_entries window
       // below is migrated to user-tz.

--- C: app/api/mobile/engagement/trend/route.ts ---

1. Add import: `import { getUserTimezone } from '@/lib/services/user-timezone';`
2. After the `requireAuth()` call, destructure session and resolve tz:
       const { session, error: authError } = await requireAuth();
       if (authError) return authError;
       const tz = getUserTimezone(session);
3. The `generate_series` SQL (lines 45-72) uses `CURRENT_DATE` four times.
   Replace EACH with `(NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date`,
   parametrized as `$1`. Specifically:
       (CURRENT_DATE - INTERVAL '${days - 1} days')::date
         →  ((NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date
       CURRENT_DATE,                    -- 2nd arg of generate_series
         →  (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date,
       te.entry_date >= CURRENT_DATE - INTERVAL '${days - 1} days'
         →  (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date >= ((NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date
       AND te.entry_date <= CURRENT_DATE
         →  AND (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date <= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
       GROUP BY te.entry_date::date
         →  GROUP BY (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
   And in the daily_hours.day reference downstream, use the same expression.
4. Pass `[tz]` as the params arg to `postgresClient.query(sql, [tz])`.
5. The existing comment at line 38 ("T-07-03 mitigation: period whitelist
   bounds the date range to max 90 days") still applies — keep it. Add an
   additional comment immediately below it:
       // TZ-02 (Phase 7.1): day buckets are aligned to the calling user's
       // IANA timezone via $1 (validated by getUserTimezone). Storage tz
       // for `time_entries.entry_date` remains UTC.
ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/mobile/(finance|engagement/(summary|trend))/route\.ts"); [ -z "$ERR" ] && grep -q "requireAuth" app/api/mobile/finance/route.ts && grep -q "getUserTimezone" app/api/mobile/finance/route.ts && grep -q "getUserTimezone" app/api/mobile/engagement/summary/route.ts && grep -q "getUserTimezone" app/api/mobile/engagement/trend/route.ts && [ "$(grep -c 'AT TIME ZONE' app/api/mobile/finance/route.ts)" -ge 5 ] && [ "$(grep -c 'AT TIME ZONE' app/api/mobile/engagement/trend/route.ts)" -ge 3 ] && ! grep -E "DATE_TRUNC\('(month|year)', NOW\(\)\)" app/api/mobile/finance/route.ts && ! grep -wE "CURRENT_DATE" app/api/mobile/engagement/trend/route.ts - `app/api/mobile/finance/route.ts` imports `requireAuth` AND `getUserTimezone`; calls both at the top of `GET()` - `app/api/mobile/finance/route.ts` no longer contains `DATE_TRUNC('month', NOW())` or `DATE_TRUNC('year', NOW())` (note the original had a double-space) - `app/api/mobile/finance/route.ts` contains at least 5 occurrences of `AT TIME ZONE` (paid_mtd, paid_ytd, six aging filters, days_overdue — actually MORE than 5; tolerance: ≥ 5) - `app/api/mobile/engagement/summary/route.ts` imports `getUserTimezone`, destructures `session` from `requireAuth()`, passes session to `getUserTimezone` - `app/api/mobile/engagement/summary/route.ts` `time_entries` query parametrizes `tz` and uses `AT TIME ZONE` on both sides of the `>=` comparison - `app/api/mobile/engagement/summary/route.ts` contains the `TZ-02 carve-out` comment block referencing REQUIREMENTS.md - `app/api/mobile/engagement/trend/route.ts` imports `getUserTimezone`, destructures session, passes to helper - `app/api/mobile/engagement/trend/route.ts` no longer contains the bare token `CURRENT_DATE` (every occurrence becomes the AT TIME ZONE expression). Verify: `! grep -wE "CURRENT_DATE" app/api/mobile/engagement/trend/route.ts` - `app/api/mobile/engagement/trend/route.ts` passes `[tz]` to `postgresClient.query` - `npx tsc --noEmit --pretty` reports no NEW errors in any of the three files - Behavioral (manual once running): - `curl -s -o /dev/null -w '%{http_code}' http://localhost:3100/api/mobile/finance` returns `401` (no auth header) - With user.timezone = 'America/New_York' and a time_entries row at 2026-05-07T03:30:00Z (= 2026-05-06 23:30 ET), `/api/mobile/engagement/trend?period=D7` puts that row in the 2026-05-06 bucket — NOT 2026-05-07 `/api/mobile/finance` is now auth-gated and computes month / aging / days-overdue boundaries against the calling user's tz. `/api/mobile/engagement/summary` (the rolling time_entries window only — snapshots remain UTC by the explicit TZ-02 carve-out) and `/api/mobile/engagement/trend` both compute their day boundaries in user-tz. Task 4: Migrate /api/dashboard/trends to user-tz day buckets app/api/dashboard/trends/route.ts - app/api/dashboard/trends/route.ts (lines 21-98 — the four queries; note the two `generate_series(CURRENT_DATE - INTERVAL '${TREND_DAYS - 1} days', CURRENT_DATE, INTERVAL '1 day')::date` blocks at lines 27-32 and 44-49, the `t.create_date::date = days.d` join at line 38, the `t.completed_date::date = days.d` join at line 55, and the `te.entry_date::date = CURRENT_DATE` filter at line 92) - lib/services/user-timezone.ts (helper from Task 1) - lib/auth-utils.ts (`requireAuth()` already used by this route at line 22 — verify with `grep -q requireAuth app/api/dashboard/trends/route.ts`) `/api/dashboard/trends` was missed in the original plan but powers the desktop dashboard's chart row + queue posture. Migrate its day-bucket math to the same `(value AT TIME ZONE 'UTC') AT TIME ZONE $1` two-step idiom used in Tasks 2 and 3.
Steps:
1. Confirm the route already calls `requireAuth()` (it does, line 22). If
   it doesn't, add it: import `requireAuth` from `@/lib/auth-utils`, call
   it as the first line of `GET()`, return `error` on failure.
2. Add import:
   `import { getUserTimezone } from '@/lib/services/user-timezone';`
3. Change the destructure on line 22 from `const { error } = await requireAuth();`
   to:
       const { session, error } = await requireAuth();
       if (error) return error;
       const tz = getUserTimezone(session);
4. The `volumeRes` query (lines 26-42) — apply the user-tz substitutions
   and parametrize `tz` as `$1`:
       generate_series(
         CURRENT_DATE - INTERVAL '${TREND_DAYS - 1} days',
         CURRENT_DATE,
         INTERVAL '1 day'
       )::date AS d
   →
       generate_series(
         (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - INTERVAL '${TREND_DAYS - 1} days',
         (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date,
         INTERVAL '1 day'
       )::date AS d
   And:
       ON t.create_date::date = days.d
   →
       ON ((t.create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = days.d
   Pass `[tz]` as the params arg.
5. The `resolutionRes` query (lines 43-60) — same idiom, applied to the
   `generate_series` block AND the `t.completed_date::date = days.d` join:
       ON t.completed_date::date = days.d
   →
       ON ((t.completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = days.d
   Pass `[tz]` as the params arg.
6. The `heatmapRes` query (lines 61-77) — does NOT use day-boundary math
   (filters on `t.completed_date IS NULL` only). DO NOT MODIFY.
7. The `engineersRes` query (lines 78-97) — `te.entry_date::date = CURRENT_DATE`
   filter on line 92. Replace with the user-tz form and parametrize `tz`
   as `$1`:
       WHERE te.entry_date::date = CURRENT_DATE
   →
       WHERE ((te.entry_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
   Pass `[tz]` as the params arg. Note the existing `LIMIT ${TOP_ENGINEERS}`
   is a server-side constant interpolation — leave it.

Notes:
- All four `postgresClient.query<...>(...)` calls take ONE bind value (`$1` =
  tz). Use `[tz]` consistently. The existing query signatures don't have
  a params arg today; add one.
- Keep the existing types on each `query<>` generic. No shape changes to
  the response.
- The `INTERVAL '${TREND_DAYS - 1} days'` interpolation is server-side
  constant — safe to leave as a JS template literal.
ERR=$(npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/dashboard/trends/route\.ts"); [ -z "$ERR" ] && grep -q requireAuth app/api/dashboard/trends/route.ts && grep -q "getUserTimezone" app/api/dashboard/trends/route.ts && [ "$(grep -c 'AT TIME ZONE' app/api/dashboard/trends/route.ts)" -ge 6 ] && ! grep -wE "CURRENT_DATE" app/api/dashboard/trends/route.ts && ! grep -E '\.create_date::date = days\.d|\.completed_date::date = days\.d|\.entry_date::date = CURRENT_DATE' app/api/dashboard/trends/route.ts - `app/api/dashboard/trends/route.ts` imports `getUserTimezone` from `@/lib/services/user-timezone` - The route destructures `session` from `requireAuth()` and resolves `tz` via `getUserTimezone(session)` - The route still calls `requireAuth()` first (auth gate preserved) - `app/api/dashboard/trends/route.ts` no longer contains the bare token `CURRENT_DATE` - `app/api/dashboard/trends/route.ts` no longer contains any of the literal patterns `t.create_date::date = days.d`, `t.completed_date::date = days.d`, or `te.entry_date::date = CURRENT_DATE` - `app/api/dashboard/trends/route.ts` contains at least 6 occurrences of `AT TIME ZONE` (two per migrated query × three migrated queries) - The `heatmapRes` query (queue/priority counts) is preserved unchanged - All migrated queries pass `[tz]` as the params arg - `npx tsc --noEmit --pretty` reports no NEW errors in this file - Behavioral (manual once running): - With user.timezone = 'America/New_York' and a ticket created at 2026-05-07T03:30:00Z, the `volumeByDay` count for 2026-05-06 (ET) includes that ticket — the 2026-05-07 (ET) bucket does not. - The trend covers exactly TREND_DAYS (30) consecutive ET days ending today (ET). `/api/dashboard/trends` returns daily ticket counts and time-entry hours with day buckets aligned to the calling user's timezone, not UTC. The queue/priority heatmap is unchanged.

<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>
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.

<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>
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.