diff --git a/app/api/dashboard/overview/route.ts b/app/api/dashboard/overview/route.ts index 9188fe0..e7c1fb9 100644 --- a/app/api/dashboard/overview/route.ts +++ b/app/api/dashboard/overview/route.ts @@ -13,10 +13,12 @@ import { NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth-utils'; import postgresClient from '@/lib/services/postgres-client'; +import { getUserTimezone } from '@/lib/services/user-timezone'; export async function GET() { - const { error } = await requireAuth(); + const { session, error } = await requireAuth(); if (error) return error; + const tz = getUserTimezone(session); type Counts = { count: string }; @@ -35,47 +37,59 @@ export async function GET() { ciRes, xrefRes, ] = await Promise.all([ - /* today snapshot — single row, all four KPIs */ + /* today snapshot — single row, all four KPIs. + Day-boundary counts are anchored to the calling user's tz via $1. + The `due_date_time < NOW()` clause stays as-is (rolling-now SLA check + is timezone-independent). */ postgresClient.query<{ opened_today: string; resolved_today: string; open_total: string; sla_breaches: string; - }>(` + }>( + ` SELECT - 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)::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)::text AS open_total, COUNT(*) FILTER ( WHERE completed_date IS NULL AND due_date_time IS NOT NULL AND due_date_time < NOW() - )::text AS sla_breaches + )::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) - `), - /* yesterday's opened count for the today-vs-yesterday delta */ - postgresClient.query<{ count: string }>(` + `, + [tz], + ), + /* yesterday's opened count for the today-vs-yesterday delta (user-tz) */ + postgresClient.query<{ count: string }>( + ` SELECT COUNT(*)::text AS count FROM tickets - WHERE create_date::date = CURRENT_DATE - INTERVAL '1 day' + 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' AND (is_deleted = false OR is_deleted IS NULL) AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false) - `), - /* 7-day average resolved (excluding today) for the resolved delta */ - postgresClient.query<{ avg_resolved: string }>(` + `, + [tz], + ), + /* 7-day average resolved (excluding today) for the resolved delta (user-tz) */ + postgresClient.query<{ avg_resolved: string }>( + ` SELECT COALESCE(AVG(daily_count), 0)::text AS avg_resolved FROM ( - SELECT completed_date::date AS d, COUNT(*) AS daily_count + SELECT ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date AS d, COUNT(*) AS daily_count FROM tickets - WHERE completed_date >= CURRENT_DATE - INTERVAL '7 days' - AND completed_date < CURRENT_DATE + 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 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::date + GROUP BY ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date ) sub - `), + `, + [tz], + ), postgresClient.query( `SELECT COUNT(*)::text AS count FROM device_link_review WHERE resolved_at IS NULL` ), diff --git a/app/api/mobile/dashboard/route.ts b/app/api/mobile/dashboard/route.ts index e582f8a..bee9a61 100644 --- a/app/api/mobile/dashboard/route.ts +++ b/app/api/mobile/dashboard/route.ts @@ -11,6 +11,7 @@ import { NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth-utils'; import postgresClient from '@/lib/services/postgres-client'; +import { getUserTimezone } from '@/lib/services/user-timezone'; // ─── Response shape ────────────────────────────────────────────────────────── @@ -46,8 +47,9 @@ export interface MobileDashboardResponse { // ─── Handler ───────────────────────────────────────────────────────────────── export async function GET() { - const { error } = await requireAuth(); + const { session, error } = await requireAuth(); if (error) return error; + const tz = getUserTimezone(session); try { const [ @@ -58,27 +60,34 @@ export async function GET() { rmmRes, backupSuccessRes, ] = await Promise.all([ - /* 1. KPI snapshot — four counts in one row, scoped companies excluded */ + /* 1. KPI snapshot — four counts in one row, scoped companies excluded. + Day-boundary counts (opened_today, resolved_today) are anchored to + the calling user's IANA timezone via $1; storage tz unchanged. */ postgresClient.query<{ open_total: string; opened_today: string; resolved_today: string; sla_breaches: string; - }>(` + }>( + ` 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)::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 + )::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) - `), + `, + [tz], + ), + // INTERVAL '24 hours' here is rolling — not a calendar-day boundary — + // so timezone does not apply. Do not migrate to user-tz. /* 2. Failed backups in the last 24 hours (Needs Attention) */ postgresClient.query<{ count: string }>(` SELECT COUNT(*)::text AS count FROM (