wulf-pulse/app/api/dashboard/trends/route.ts
lorentz 5f4ccb9c56 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>
2026-05-14 21:58:35 -04:00

149 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* GET /api/dashboard/trends
* Operational trend data backing /dashboard's chart row + queue posture.
*
* volumeByDay — last 30 days, ticket creation count per day
* resolutionByDay — last 30 days, mean resolution hours per day completed
* queueHeatmap — open tickets grouped by (queue, priority)
* activeEngineers — top engineers today by hours logged
*
* All queries run in parallel. ~50 ms total against a warm DB.
*/
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';
const TREND_DAYS = 30;
const TOP_QUEUES = 10;
const TOP_ENGINEERS = 8;
export async function GET() {
const { session, error } = await requireAuth();
if (error) return error;
const tz = getUserTimezone(session);
const [volumeRes, resolutionRes, heatmapRes, engineersRes] = await Promise.all([
postgresClient.query<{ d: string; count: string }>(
`WITH days AS (
SELECT generate_series(
(NOW() AT TIME ZONE $1)::date - INTERVAL '${TREND_DAYS - 1} days',
(NOW() AT TIME ZONE $1)::date,
INTERVAL '1 day'
)::date AS d
)
SELECT d::text AS d,
COALESCE(COUNT(t.id), 0)::text AS count
FROM days
LEFT JOIN tickets t
ON ((t.create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = days.d
AND (t.is_deleted = false OR t.is_deleted IS NULL)
GROUP BY d
ORDER BY d`,
[tz],
),
postgresClient.query<{ d: string; avg_hours: string | null }>(
`WITH days AS (
SELECT generate_series(
(NOW() AT TIME ZONE $1)::date - INTERVAL '${TREND_DAYS - 1} days',
(NOW() AT TIME ZONE $1)::date,
INTERVAL '1 day'
)::date AS d
)
SELECT d::text AS d,
AVG(EXTRACT(EPOCH FROM (t.completed_date - t.create_date)) / 3600.0)::text AS avg_hours
FROM days
LEFT JOIN tickets t
ON ((t.completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = days.d
AND t.create_date IS NOT NULL
AND (t.is_deleted = false OR t.is_deleted IS NULL)
GROUP BY d
ORDER BY d`,
[tz],
),
// queueHeatmap: open-only counts (no day-boundary math) — tz does not apply.
postgresClient.query<{
queue_id: number | null;
queue_label: string | null;
priority: number | null;
count: string;
}>(
`SELECT t.queue_id,
q.label AS queue_label,
t.priority,
COUNT(*)::text AS count
FROM tickets t
LEFT JOIN queues q ON q.value = t.queue_id
WHERE t.completed_date IS NULL
AND (t.is_deleted = false OR t.is_deleted IS NULL)
GROUP BY t.queue_id, q.label, t.priority
ORDER BY COUNT(*) DESC`,
),
postgresClient.query<{
resource_id: string;
resource_name: string;
hours: string;
tickets_touched: string;
}>(
`SELECT te.resource_id::text,
COALESCE(NULLIF(TRIM(r.first_name || ' ' || COALESCE(r.last_name, '')), ''),
r.email,
'Resource ' || te.resource_id) AS resource_name,
SUM(te.hours_worked)::text AS hours,
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 $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
LIMIT ${TOP_ENGINEERS}`,
[tz],
),
]);
// Heatmap: top N queues by open volume × priority columns
const heatmapRows = heatmapRes.rows;
const queueTotals = new Map<number, { id: number; label: string; total: number }>();
for (const row of heatmapRows) {
if (row.queue_id == null) continue;
const t = queueTotals.get(row.queue_id) ?? {
id: row.queue_id,
label: row.queue_label ?? `Queue ${row.queue_id}`,
total: 0,
};
t.total += parseInt(row.count, 10);
queueTotals.set(row.queue_id, t);
}
const topQueues = [...queueTotals.values()]
.sort((a, b) => b.total - a.total)
.slice(0, TOP_QUEUES);
const heatmap = topQueues.map((q) => {
const cells: Record<number, number> = {};
for (const row of heatmapRows) {
if (row.queue_id !== q.id || row.priority == null) continue;
cells[row.priority] = (cells[row.priority] ?? 0) + parseInt(row.count, 10);
}
return { queueId: q.id, queueLabel: q.label, total: q.total, byPriority: cells };
});
return NextResponse.json({
volumeByDay: volumeRes.rows.map((r) => ({
date: r.d,
count: parseInt(r.count, 10),
})),
resolutionByDay: resolutionRes.rows.map((r) => ({
date: r.d,
avgHours: r.avg_hours == null ? null : Math.round(parseFloat(r.avg_hours) * 10) / 10,
})),
queueHeatmap: heatmap,
activeEngineers: engineersRes.rows.map((r) => ({
resourceId: r.resource_id,
name: r.resource_name,
hours: Math.round(parseFloat(r.hours) * 10) / 10,
ticketsTouched: parseInt(r.tickets_touched, 10),
})),
});
}