/** * 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 — working engineers today (top N by hours), each with their per-ticket time entries * ptoEngineers — engineers whose only time today is on PTO/Vacation allocation codes * * 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; // Autotask allocation codes treated as PTO/Vacation/time-off. // These are the internal codes used at Wulf for non-billable time-off entries // (vacation, personal day, etc.) — surfaced separately from working hours. const PTO_ALLOCATION_CODE_IDS = [91206, 91207, 91209]; 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. // Filters out queues the calling user has hidden via /api/me/queue-preferences. 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) AND (t.queue_id IS NULL OR t.queue_id NOT IN ( SELECT queue_id FROM user_queue_preferences WHERE user_id = $1 )) GROUP BY t.queue_id, q.label, t.priority ORDER BY COUNT(*) DESC`, [session!.user.id], ), postgresClient.query<{ resource_id: string; resource_name: string; hours: string; tickets_touched: string; ticket_id: string | null; ticket_number: string | null; ticket_title: string | null; ticket_description: string | null; ticket_status_label: string | null; ticket_hours: string | null; is_pto: boolean; pto_note: string | null; }>( `WITH today_entries AS ( SELECT te.resource_id, te.ticket_id, te.hours_worked, te.allocation_code_id, te.notes, te.title FROM time_entries te WHERE te.entry_date::date = (NOW() AT TIME ZONE $1)::date AND te.hours_worked > 0 ), engineer_totals AS ( SELECT resource_id, SUM(hours_worked) AS hours, COUNT(DISTINCT ticket_id) FILTER (WHERE ticket_id IS NOT NULL) AS tickets_touched, BOOL_OR(allocation_code_id = ANY($2::int[])) AS has_pto, BOOL_OR(allocation_code_id IS NULL OR NOT (allocation_code_id = ANY($2::int[]))) AS has_work, MAX(CASE WHEN allocation_code_id = ANY($2::int[]) THEN NULLIF(COALESCE(notes, title), '') END) AS pto_note FROM today_entries GROUP BY resource_id ), ticket_totals AS ( SELECT te.resource_id, te.ticket_id, SUM(te.hours_worked) AS ticket_hours FROM today_entries te WHERE te.ticket_id IS NOT NULL GROUP BY te.resource_id, te.ticket_id ) SELECT et.resource_id::text, COALESCE(NULLIF(TRIM(r.first_name || ' ' || COALESCE(r.last_name, '')), ''), r.email, 'Resource ' || et.resource_id) AS resource_name, et.hours::text AS hours, et.tickets_touched::text AS tickets_touched, tt.ticket_id::text AS ticket_id, t.ticket_number, t.title AS ticket_title, t.description AS ticket_description, s.label AS ticket_status_label, tt.ticket_hours::text AS ticket_hours, (et.has_pto AND NOT et.has_work) AS is_pto, et.pto_note FROM engineer_totals et LEFT JOIN resources r ON r.id = et.resource_id LEFT JOIN ticket_totals tt ON tt.resource_id = et.resource_id LEFT JOIN tickets t ON t.id = tt.ticket_id LEFT JOIN statuses s ON s.value = t.status ORDER BY (et.has_pto AND NOT et.has_work) ASC, et.hours DESC, et.resource_id, tt.ticket_hours DESC NULLS LAST`, [tz, PTO_ALLOCATION_CODE_IDS], ), ]); // Heatmap: top N queues by open volume × priority columns const heatmapRows = heatmapRes.rows; const queueTotals = new Map(); 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 = {}; 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 }; }); // Fold the joined engineer/ticket rows into per-engineer records. type Ticket = { id: string; ticketNumber: string | null; title: string | null; description: string | null; statusLabel: string | null; hours: number; }; type Engineer = { resourceId: string; name: string; hours: number; ticketsTouched: number; tickets: Ticket[]; isPto: boolean; ptoNote: string | null; }; const engineerById = new Map(); for (const row of engineersRes.rows) { let eng = engineerById.get(row.resource_id); if (!eng) { eng = { resourceId: row.resource_id, name: row.resource_name, hours: Math.round(parseFloat(row.hours) * 10) / 10, ticketsTouched: parseInt(row.tickets_touched, 10), tickets: [], isPto: row.is_pto, ptoNote: row.pto_note, }; engineerById.set(row.resource_id, eng); } if (row.ticket_id) { eng.tickets.push({ id: row.ticket_id, ticketNumber: row.ticket_number, title: row.ticket_title, description: row.ticket_description, statusLabel: row.ticket_status_label, hours: row.ticket_hours == null ? 0 : Math.round(parseFloat(row.ticket_hours) * 10) / 10, }); } } const allEngineers = [...engineerById.values()]; const activeEngineers = allEngineers .filter((e) => !e.isPto) .sort((a, b) => b.hours - a.hours) .slice(0, TOP_ENGINEERS); const ptoEngineers = allEngineers .filter((e) => e.isPto) .sort((a, b) => a.name.localeCompare(b.name)); 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, ptoEngineers, }); }