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>
44 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | requirements_addressed | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07.1-user-timezone-fix-inserted-urgent | 03 | execute | 2 |
|
|
true |
|
|
|
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 aTIMESTAMP WITHOUT TIME ZONEadjusted to that zone (correct for our purpose). - For a
TIMESTAMP WITHOUT TIME ZONEinput:value AT TIME ZONE 'America/New_York'ASSUMES the input is inAmerica/New_Yorkand returns atimestamptz. This is the wrong direction for us. - The Pulse
tickets,qbo_invoices,engagement_snapshots, andtime_entriestables useTIMESTAMP WITHOUT TIME ZONEfor their date columns (per the existing 069/070/079 migrations and confirmed by the routes using::datecasts 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 atimestampalready 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 atimestamptzrepresenting that instant; the secondAT TIME ZONE $1then 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.
// 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> |
- Static:
[ "$(grep -c 'AT TIME ZONE' app/api/mobile/dashboard/route.ts)" -ge 2 ] - Static:
[ "$(grep -c 'AT TIME ZONE' app/api/dashboard/overview/route.ts)" -ge 4 ] - Static:
[ "$(grep -c 'AT TIME ZONE' app/api/dashboard/trends/route.ts)" -ge 6 ] - Static:
[ "$(grep -c 'AT TIME ZONE' app/api/mobile/finance/route.ts)" -ge 5 ] - Static:
[ "$(grep -c 'AT TIME ZONE' app/api/mobile/engagement/trend/route.ts)" -ge 3 ] - Static:
! grep -E '::date = CURRENT_DATE' app/api/mobile/dashboard/route.ts app/api/dashboard/overview/route.ts - Static:
! grep -wE "CURRENT_DATE" app/api/mobile/engagement/trend/route.ts - Static:
! grep -wE "CURRENT_DATE" app/api/dashboard/trends/route.ts - Static:
! grep -E "DATE_TRUNC\\('(month|year)', NOW\\(\\)\\)" app/api/mobile/finance/route.ts - Static (auth-gate hardening):
grep -q "requireAuth" app/api/mobile/finance/route.ts - 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/ - Type: per-file
npx tsc --noEmit --prettyreports no NEW errors for the six modified files. - Runtime (auth gate):
curl -s -o /dev/null -w '%{http_code}' http://localhost:3100/api/mobile/financereturns401. - Runtime (user-tz buckets): With
DEFAULT_TIMEZONE=UTCand the calling user'stimezone='America/New_York', hitting/api/mobile/engagement/trend?period=D7returns exactly 7 points whosedatestrings are the most recent 7 calendar days in ET (verifiable by setting the user's tz to UTC vs ET and diffing the returneddatearrays around midnight ET). - Runtime (trends): With the same user-tz settings,
/api/dashboard/trendsreturnsvolumeByDaywith TREND_DAYS rows ending on today (ET). - 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 sametimestamp without time zone/datetypes 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.tsis 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/financenow requires auth (aligned with every other/api/mobile/*route)/api/dashboard/trendsdaily buckets are user-tz aligned (was missed by the original plan)- TypeScript compiles for every modified file </success_criteria>