wulf-pulse/app/api/dashboard/trends/route.ts
lorentz 672f17b7f9 chore: check in pending work — queue preferences, QBO AR diagnostics, mobile engagement fixes, ops scripts
Bundles several in-progress efforts that were sitting uncommitted:
- User queue-preferences (migration 087, API route, popover component)
- QBO invoice soft-delete (migration 088) and AR diagnostics route
- Dashboard/mobile engagement route and page adjustments
- Docker Compose log-rotation config
- One-off ticket/RMM investigation scripts (scripts/)
- Planning docs: phase verification/pattern notes, mobile shell design spec
- .gitignore: exclude local scratch financial/inventory data and Claude Code
  worktree/local-settings runtime state (never meant for version control)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
2026-07-18 06:34:57 -04:00

258 lines
9.2 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 — 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<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 };
});
// 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<string, Engineer>();
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,
});
}