fix(dashboard): correct NOW() timezone conversion for KPI/trend queries

NOW() returns TIMESTAMPTZ. The pattern
  (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $userTz)::date
double-converts: first strips the tz designation (keeping UTC wall-clock as
naive TIMESTAMP), then re-interprets that wall-clock as user-local
(pushing UTC into the user-tz's UTC equivalent). For non-UTC users this
gives the WRONG date — e.g. NY user at 9pm sees "today = tomorrow's UTC
date", so opened-today returns 0.

The column-side pattern ((col AT TIME ZONE 'UTC') AT TIME ZONE $userTz)
is correct because the columns are TIMESTAMP without TZ (stored as UTC) —
only the NOW() side was buggy. Replace with (NOW() AT TIME ZONE $userTz)
everywhere.

Affects: dashboard overview/trends, mobile dashboard/engagement/finance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lorentz 2026-05-14 21:58:35 -04:00
parent f32403ee35
commit 5f4ccb9c56
6 changed files with 28 additions and 28 deletions

View file

@ -49,8 +49,8 @@ export async function GET() {
}>(
`
SELECT
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 ((create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() 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 $1)::date)::text AS resolved_today,
COUNT(*) FILTER (WHERE completed_date IS NULL)::text AS open_total,
COUNT(*) FILTER (
WHERE completed_date IS NULL
@ -68,7 +68,7 @@ export async function GET() {
`
SELECT COUNT(*)::text AS count
FROM tickets
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'
WHERE ((create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE $1)::date - INTERVAL '1 day'
AND (is_deleted = false OR is_deleted IS NULL)
AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false)
`,
@ -81,8 +81,8 @@ export async function GET() {
FROM (
SELECT ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date AS d, COUNT(*) AS daily_count
FROM tickets
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
WHERE ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date >= (NOW() AT TIME ZONE $1)::date - INTERVAL '7 days'
AND ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date < (NOW() AT TIME ZONE $1)::date
AND (is_deleted = false OR is_deleted IS NULL)
AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false)
GROUP BY ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date

View file

@ -28,8 +28,8 @@ export async function GET() {
postgresClient.query<{ d: string; count: string }>(
`WITH days AS (
SELECT 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,
(NOW() AT TIME ZONE $1)::date - INTERVAL '${TREND_DAYS - 1} days',
(NOW() AT TIME ZONE $1)::date,
INTERVAL '1 day'
)::date AS d
)
@ -46,8 +46,8 @@ export async function GET() {
postgresClient.query<{ d: string; avg_hours: string | null }>(
`WITH days AS (
SELECT 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,
(NOW() AT TIME ZONE $1)::date - INTERVAL '${TREND_DAYS - 1} days',
(NOW() AT TIME ZONE $1)::date,
INTERVAL '1 day'
)::date AS d
)
@ -94,7 +94,7 @@ export async function GET() {
COUNT(DISTINCT te.ticket_id)::text AS tickets_touched
FROM time_entries te
LEFT JOIN resources r ON r.id = te.resource_id
WHERE ((te.entry_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
WHERE ((te.entry_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE $1)::date
AND te.hours_worked > 0
GROUP BY te.resource_id, r.first_name, r.last_name, r.email
ORDER BY SUM(te.hours_worked) DESC

View file

@ -72,8 +72,8 @@ export async function GET() {
`
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 ((create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() 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 $1)::date)::text AS resolved_today,
COUNT(*) FILTER (
WHERE completed_date IS NULL
AND due_date_time IS NOT NULL

View file

@ -131,7 +131,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
FROM time_entries te
JOIN resources r ON r.id = te.resource_id AND (r.is_deleted = false OR r.is_deleted IS NULL)
JOIN graph_users gu ON LOWER(gu.email) = LOWER(r.email)
WHERE (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1) - INTERVAL '${interval}'
WHERE (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= (NOW() AT TIME ZONE $1) - INTERVAL '${interval}'
AND (te.is_deleted = false OR te.is_deleted IS NULL)
AND gu.account_enabled = true
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'

View file

@ -50,8 +50,8 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
const sql = `
WITH day_series AS (
SELECT generate_series(
((NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date,
(NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date,
((NOW() AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date,
(NOW() AT TIME ZONE $1)::date,
INTERVAL '1 day'
)::date AS day
),
@ -61,8 +61,8 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
JOIN resources r ON r.id = te.resource_id
AND (r.is_deleted = false OR r.is_deleted IS NULL)
JOIN graph_users gu ON LOWER(gu.email) = LOWER(r.email)
WHERE (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 AT TIME ZONE 'UTC' AT TIME ZONE $1)::date <= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
WHERE (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date >= ((NOW() AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date
AND (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date <= (NOW() AT TIME ZONE $1)::date
AND (te.is_deleted = false OR te.is_deleted IS NULL)
AND gu.account_enabled = true
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'

View file

@ -23,8 +23,8 @@ export async function GET() {
COUNT(*) FILTER (WHERE status = 'Open') as current_count,
SUM(balance) FILTER (WHERE status = 'Overdue') as overdue_balance,
COUNT(*) FILTER (WHERE status = 'Overdue') as overdue_count,
SUM(total_amt) FILTER (WHERE status = 'Paid' AND (txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= DATE_TRUNC('month', NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)) as paid_mtd,
SUM(total_amt) FILTER (WHERE status = 'Paid' AND (txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= DATE_TRUNC('year', NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)) as paid_ytd
SUM(total_amt) FILTER (WHERE status = 'Paid' AND (txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= DATE_TRUNC('month', NOW() AT TIME ZONE $1)) as paid_mtd,
SUM(total_amt) FILTER (WHERE status = 'Paid' AND (txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= DATE_TRUNC('year', NOW() AT TIME ZONE $1)) as paid_ytd
FROM qbo_invoices
`,
[tz],
@ -32,14 +32,14 @@ export async function GET() {
postgresClient.query(
`
SELECT
SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 30) as days_1_30,
COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 30) as cnt_1_30,
SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 30
AND due_date >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 60) as days_31_60,
COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 30
AND due_date >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 60) as cnt_31_60,
SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 60) as days_60_plus,
COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 60) as cnt_60_plus
SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date >= (NOW() AT TIME ZONE $1)::date - 30) as days_1_30,
COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date >= (NOW() AT TIME ZONE $1)::date - 30) as cnt_1_30,
SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE $1)::date - 30
AND due_date >= (NOW() AT TIME ZONE $1)::date - 60) as days_31_60,
COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE $1)::date - 30
AND due_date >= (NOW() AT TIME ZONE $1)::date - 60) as cnt_31_60,
SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE $1)::date - 60) as days_60_plus,
COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE $1)::date - 60) as cnt_60_plus
FROM qbo_invoices
`,
[tz],
@ -54,7 +54,7 @@ export async function GET() {
postgresClient.query(
`
SELECT id, doc_number, txn_date, due_date, customer_ref_name, total_amt, balance, status,
(NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - due_date::date as days_overdue
(NOW() AT TIME ZONE $1)::date - due_date::date as days_overdue
FROM qbo_invoices
WHERE status IN ('Open','Overdue')
ORDER BY status DESC, balance DESC LIMIT 30