refactor(api): extract dashboard procedures

This commit is contained in:
2026-03-31 20:54:54 +02:00
parent 6837568ffe
commit 9d6fffc775
5 changed files with 745 additions and 384 deletions
@@ -0,0 +1,253 @@
import { SystemRole } from "@capakraken/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
getDashboardOverview: vi.fn(),
getDashboardPeakTimes: vi.fn(),
getDashboardDemand: vi.fn(),
getDashboardTopValueResources: vi.fn(),
getDashboardChargeabilityOverview: vi.fn(),
getDashboardBudgetForecast: vi.fn(),
getDashboardSkillGaps: vi.fn(),
getDashboardSkillGapSummary: vi.fn(),
getDashboardProjectHealth: vi.fn(),
};
});
vi.mock("../lib/cache.js", () => ({
cacheGet: vi.fn().mockResolvedValue(null),
cacheSet: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../lib/anonymization.js", () => ({
anonymizeResources: vi.fn((resources: unknown[]) => resources.map((resource) => ({ ...resource, anonymized: true }))),
getAnonymizationDirectory: vi.fn().mockResolvedValue({}),
}));
import {
getDashboardChargeabilityOverview,
getDashboardDemand,
getDashboardOverview,
getDashboardPeakTimes,
getDashboardTopValueResources,
} from "@capakraken/application";
import { anonymizeResources } from "../lib/anonymization.js";
import {
getDashboardChargeabilityOverviewRead,
getDashboardDetail,
getDashboardStatisticsDetail,
} from "../router/dashboard-procedure-support.js";
function createContext() {
return {
db: {} as never,
session: {
user: { email: "controller@example.com", name: "Controller", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
dbUser: {
id: "user_1",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
};
}
describe("dashboard procedure support", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("derives statistics detail from the canonical overview", async () => {
vi.mocked(getDashboardOverview).mockResolvedValue({
totalResources: 12,
activeResources: 10,
inactiveResources: 2,
totalProjects: 7,
activeProjects: 4,
inactiveProjects: 3,
totalAllocations: 21,
activeAllocations: 18,
cancelledAllocations: 3,
approvedVacations: 6,
totalEstimates: 9,
budgetSummary: {
totalBudgetCents: 1_234_56,
totalCostCents: 654_32,
avgUtilizationPercent: 53,
},
budgetBasis: {
remainingBudgetCents: 58_024,
budgetedProjects: 5,
unbudgetedProjects: 2,
trackedAssignmentCount: 18,
windowStart: null,
windowEnd: null,
},
projectsByStatus: [
{ status: "ACTIVE", count: 4 },
{ status: "DRAFT", count: 2 },
{ status: "DONE", count: 1 },
],
chapterUtilization: [
{ chapter: "CGI", resourceCount: 5, avgChargeabilityTarget: 78 },
{ chapter: "Compositing", resourceCount: 3, avgChargeabilityTarget: 74 },
],
recentActivity: [],
});
const result = await getDashboardStatisticsDetail(createContext());
expect(result).toEqual({
activeResources: 10,
totalProjects: 7,
activeProjects: 4,
totalAllocations: 21,
approvedVacations: 6,
totalEstimates: 9,
totalBudget: "1.234,56 EUR",
projectsByStatus: {
ACTIVE: 4,
DRAFT: 2,
DONE: 1,
},
topChapters: [
{ chapter: "CGI", count: 5 },
{ chapter: "Compositing", count: 3 },
],
});
});
it("builds the assistant-facing dashboard detail payload", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-15T00:00:00.000Z"));
vi.mocked(getDashboardOverview).mockResolvedValue({
totalResources: 12,
activeResources: 10,
inactiveResources: 2,
totalProjects: 7,
activeProjects: 4,
inactiveProjects: 3,
totalAllocations: 21,
activeAllocations: 18,
cancelledAllocations: 3,
approvedVacations: 6,
totalEstimates: 9,
budgetSummary: {
totalBudgetCents: 1_234_56,
totalCostCents: 654_32,
avgUtilizationPercent: 53,
},
budgetBasis: {
remainingBudgetCents: 58_024,
budgetedProjects: 5,
unbudgetedProjects: 2,
trackedAssignmentCount: 18,
windowStart: "2026-03-01T00:00:00.000Z",
windowEnd: "2026-06-30T00:00:00.000Z",
},
projectsByStatus: [],
chapterUtilization: [
{ chapter: "CGI", resourceCount: 5, avgChargeabilityTarget: 78 },
],
recentActivity: [],
});
vi.mocked(getDashboardPeakTimes).mockResolvedValue([
{ period: "2026-03", totalHours: 160.34, capacityHours: 200.12, utilizationPct: 80 },
]);
vi.mocked(getDashboardTopValueResources).mockResolvedValue([
{
id: "res_1",
eid: "E-001",
displayName: "Ada Lovelace",
chapter: "CGI",
lcrCents: 12345,
valueScore: 98,
countryCode: "DE",
federalState: "BY",
metroCityName: "Munich",
},
]);
vi.mocked(getDashboardDemand).mockResolvedValue([
{
name: "Apollo",
shortCode: "APO",
requiredFTEs: 3,
resourceCount: 1,
allocatedHours: 80,
derivation: { calendarLocations: [{ countryCode: "DE" }] },
},
]);
try {
const result = await getDashboardDetail(createContext(), { section: "all" });
expect(result).toEqual({
peakTimes: [
{
month: "2026-03",
totalHours: 160.3,
totalHoursPerDay: 160.3,
capacityHours: 200.1,
utilizationPct: 80,
},
],
topResources: [
{
name: "Ada Lovelace",
eid: "E-001",
chapter: "CGI",
lcr: "123,45 EUR",
valueScore: 98,
countryCode: "DE",
federalState: "BY",
metroCityName: "Munich",
},
],
demandPipeline: [
{
project: "Apollo (APO)",
needed: 2,
requiredFTEs: 3,
allocatedResources: 1,
allocatedHours: 80,
calendarLocations: [{ countryCode: "DE" }],
},
],
chargeabilityByChapter: [
{
chapter: "CGI",
headcount: 5,
avgTarget: "78%",
},
],
});
} finally {
vi.useRealTimers();
}
});
it("anonymizes chargeability overview payloads before returning them", async () => {
vi.mocked(getDashboardChargeabilityOverview).mockResolvedValue({
avgChargeability: 72,
top: [{ id: "res_1" }],
watchlist: [{ id: "res_2" }],
});
const result = await getDashboardChargeabilityOverviewRead(createContext(), {
includeProposed: false,
topN: 10,
watchlistThreshold: 15,
});
expect(anonymizeResources).toHaveBeenCalledTimes(2);
expect(result).toEqual({
avgChargeability: 72,
top: [{ id: "res_1", anonymized: true }],
watchlist: [{ id: "res_2", anonymized: true }],
});
});
});
@@ -348,8 +348,28 @@ describe("dashboard router", () => {
describe("getTopValueResources", () => {
it("returns sorted resources with default limit", async () => {
const resources = [
{ id: "res_1", displayName: "Alice", valueScore: 95 },
{ id: "res_2", displayName: "Bob", valueScore: 88 },
{
id: "res_1",
eid: "alice",
displayName: "Alice",
chapter: "Delivery",
valueScore: 95,
lcrCents: 12_300,
countryCode: "DE",
federalState: "BY",
metroCityName: "Munich",
},
{
id: "res_2",
eid: "bob",
displayName: "Bob",
chapter: "Data",
valueScore: 88,
lcrCents: 10_800,
countryCode: "US",
federalState: "CA",
metroCityName: "San Francisco",
},
];
vi.mocked(getDashboardTopValueResources).mockResolvedValue(resources);
@@ -357,7 +377,7 @@ describe("dashboard router", () => {
const caller = createControllerCaller({});
const result = await caller.getTopValueResources({ limit: 10 });
expect(result).toHaveLength(2);
expect(result).toEqual(resources);
expect(getDashboardTopValueResources).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ limit: 10 }),
@@ -576,6 +596,9 @@ describe("dashboard router", () => {
chapter: "Delivery",
valueScore: 91,
lcrCents: 9_500,
countryCode: "DE",
federalState: "BY",
metroCityName: "Augsburg",
},
]);
vi.mocked(getDashboardDemand).mockResolvedValue([
@@ -620,6 +643,9 @@ describe("dashboard router", () => {
chapter: "Delivery",
lcr: "95,00 EUR",
valueScore: 91,
countryCode: "DE",
federalState: "BY",
metroCityName: "Augsburg",
},
],
demandPipeline: [
@@ -0,0 +1,425 @@
import {
getDashboardBudgetForecast,
getDashboardChargeabilityOverview,
getDashboardDemand,
getDashboardOverview,
getDashboardPeakTimes,
getDashboardProjectHealth,
getDashboardSkillGaps,
getDashboardSkillGapSummary,
getDashboardTopValueResources,
} from "@capakraken/application";
import { z } from "zod";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
import { cacheGet, cacheSet } from "../lib/cache.js";
import { fmtEur } from "../lib/format-utils.js";
import type { TRPCContext } from "../trpc.js";
const DEFAULT_TTL = 60;
type DashboardProcedureContext = Pick<TRPCContext, "db" | "dbUser" | "session">;
type TopValueResourceRow = {
id: string;
eid: string;
displayName: string;
chapter: string | null;
valueScore: number | null;
lcrCents: number;
countryCode: string | null;
federalState: string | null;
metroCityName: string | null;
};
export const dashboardPeakTimesInputSchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
granularity: z.enum(["week", "month"]).default("month"),
groupBy: z.enum(["project", "chapter", "resource"]).default("project"),
});
export const dashboardTopValueResourcesInputSchema = z.object({
limit: z.number().int().min(1).max(50).default(10),
});
export const dashboardDemandInputSchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
groupBy: z.enum(["project", "person", "chapter"]).default("project"),
});
export const dashboardDetailInputSchema = z.object({
section: z.string().optional().default("all"),
});
export const dashboardChargeabilityOverviewInputSchema = z.object({
includeProposed: z.boolean().default(false),
topN: z.number().int().min(1).max(50).default(10),
watchlistThreshold: z.number().default(15),
countryIds: z.array(z.string()).optional(),
departed: z.boolean().optional(),
});
type DashboardPeakTimesInput = z.infer<typeof dashboardPeakTimesInputSchema>;
type DashboardTopValueResourcesInput = z.infer<typeof dashboardTopValueResourcesInputSchema>;
type DashboardDemandInput = z.infer<typeof dashboardDemandInputSchema>;
type DashboardDetailInput = z.infer<typeof dashboardDetailInputSchema>;
type DashboardChargeabilityOverviewInput = z.infer<typeof dashboardChargeabilityOverviewInputSchema>;
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
function mapProjectHealthDetailRows(rows: Awaited<ReturnType<typeof getDashboardProjectHealth>>) {
const projects = rows
.map((project) => {
const overall = project.compositeScore;
return {
projectId: project.id,
projectName: project.projectName,
shortCode: project.shortCode,
status: project.status,
overall,
budget: project.budgetHealth,
staffing: project.staffingHealth,
timeline: project.timelineHealth,
rating: overall >= 80 ? "healthy" : overall >= 50 ? "at_risk" : "critical",
};
})
.sort((left, right) => left.overall - right.overall);
return {
projects,
summary: {
healthy: projects.filter((project) => project.rating === "healthy").length,
atRisk: projects.filter((project) => project.rating === "at_risk").length,
critical: projects.filter((project) => project.rating === "critical").length,
},
};
}
function mapBudgetForecastDetailRows(rows: Awaited<ReturnType<typeof getDashboardBudgetForecast>>) {
return {
forecasts: rows.map((forecast) => ({
projectId: forecast.projectId ?? null,
projectName: forecast.projectName,
shortCode: forecast.shortCode,
clientId: forecast.clientId,
clientName: forecast.clientName,
budget: fmtEur(forecast.budgetCents),
budgetCents: forecast.budgetCents,
spent: fmtEur(forecast.spentCents),
spentCents: forecast.spentCents,
remaining: fmtEur(forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents)),
remainingCents: forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents),
projected: forecast.burnRate > 0
? fmtEur(forecast.spentCents + Math.max(0, forecast.budgetCents - forecast.spentCents))
: fmtEur(forecast.spentCents),
projectedCents: forecast.burnRate > 0
? Math.max(forecast.spentCents, forecast.budgetCents)
: forecast.spentCents,
burnRate: fmtEur(forecast.burnRate),
burnRateCents: forecast.burnRate,
utilization: `${forecast.pctUsed}%`,
estimatedExhaustionDate: forecast.estimatedExhaustionDate,
activeAssignmentCount: forecast.activeAssignmentCount ?? null,
calendarLocations: forecast.calendarLocations ?? [],
burnStatus: forecast.pctUsed >= 100
? "ahead"
: forecast.burnRate > 0
? "on_track"
: "not_started",
})),
};
}
function mapStatisticsDetail(overview: Awaited<ReturnType<typeof getDashboardOverview>>) {
return {
activeResources: overview.activeResources,
totalProjects: overview.totalProjects,
activeProjects: overview.activeProjects,
totalAllocations: overview.totalAllocations,
approvedVacations: overview.approvedVacations,
totalEstimates: overview.totalEstimates,
totalBudget: overview.budgetSummary.totalBudgetCents > 0
? fmtEur(overview.budgetSummary.totalBudgetCents)
: "N/A",
projectsByStatus: Object.fromEntries(
overview.projectsByStatus.map((entry) => [entry.status, entry.count]),
),
topChapters: [...overview.chapterUtilization]
.sort((left, right) => right.resourceCount - left.resourceCount)
.slice(0, 10)
.map((chapter) => ({
chapter: chapter.chapter,
count: chapter.resourceCount,
})),
};
}
async function getOverviewCached(db: Parameters<typeof getDashboardOverview>[0]) {
const cacheKey = "overview";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardOverview>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardOverview(db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
async function getPeakTimesCached(
db: Parameters<typeof getDashboardPeakTimes>[0],
input: DashboardPeakTimesInput,
) {
const cacheKey = `peakTimes:${input.startDate}:${input.endDate}:${input.granularity}:${input.groupBy}`;
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardPeakTimes>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardPeakTimes(db, {
startDate: new Date(input.startDate),
endDate: new Date(input.endDate),
granularity: input.granularity,
groupBy: input.groupBy,
});
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
async function getDemandCached(
db: Parameters<typeof getDashboardDemand>[0],
input: DashboardDemandInput,
) {
const cacheKey = `demand:${input.startDate}:${input.endDate}:${input.groupBy}`;
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardDemand>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardDemand(db, {
startDate: new Date(input.startDate),
endDate: new Date(input.endDate),
groupBy: input.groupBy,
});
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
async function getTopValueResourcesCached(
db: Parameters<typeof getDashboardTopValueResources>[0],
input: { limit: number; userRole: string },
): Promise<TopValueResourceRow[]> {
const cacheKey = `topValue:${input.limit}:${input.userRole}`;
const cached = await cacheGet<TopValueResourceRow[]>(cacheKey);
if (cached) return cached;
const [resources, directory] = await Promise.all([
getDashboardTopValueResources(db, {
limit: input.limit,
userRole: input.userRole,
}),
getAnonymizationDirectory(db),
]);
const result: TopValueResourceRow[] = anonymizeResources(resources, directory);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
function getUserRole(ctx: DashboardProcedureContext) {
return (ctx.session?.user as { role?: string } | undefined)?.role
?? ctx.dbUser?.systemRole
?? "USER";
}
export async function getDashboardOverviewRead(ctx: DashboardProcedureContext) {
return getOverviewCached(ctx.db);
}
export async function getDashboardStatisticsDetail(ctx: DashboardProcedureContext) {
const overview = await getOverviewCached(ctx.db);
return mapStatisticsDetail(overview);
}
export async function getDashboardPeakTimesRead(
ctx: DashboardProcedureContext,
input: DashboardPeakTimesInput,
) {
return getPeakTimesCached(ctx.db, input);
}
export async function getDashboardTopValueResourcesRead(
ctx: DashboardProcedureContext,
input: DashboardTopValueResourcesInput,
) {
return getTopValueResourcesCached(ctx.db, { limit: input.limit, userRole: getUserRole(ctx) });
}
export async function getDashboardDemandRead(
ctx: DashboardProcedureContext,
input: DashboardDemandInput,
) {
return getDemandCached(ctx.db, input);
}
export async function getDashboardDetail(ctx: DashboardProcedureContext, input: DashboardDetailInput) {
const section = input.section;
const result: Record<string, unknown> = {};
const needsOverview = (
section === "all"
|| section === "peak_times"
|| section === "demand_pipeline"
|| section === "chargeability_overview"
);
const overview = needsOverview ? await getOverviewCached(ctx.db) : null;
const now = new Date();
const rangeStart = overview?.budgetBasis.windowStart
? new Date(overview.budgetBasis.windowStart)
: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const rangeEnd = overview?.budgetBasis.windowEnd
? new Date(overview.budgetBasis.windowEnd)
: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 5, 0));
const userRole = getUserRole(ctx);
if (section === "all" || section === "peak_times") {
const peakTimes = await getPeakTimesCached(ctx.db, {
startDate: rangeStart.toISOString(),
endDate: rangeEnd.toISOString(),
granularity: "month",
groupBy: "project",
});
result.peakTimes = [...peakTimes]
.sort((left, right) => right.totalHours - left.totalHours)
.slice(0, 6)
.map((entry) => ({
month: entry.period,
totalHours: round1(entry.totalHours),
totalHoursPerDay: round1(entry.totalHours),
capacityHours: round1(entry.capacityHours),
utilizationPct: entry.utilizationPct ?? null,
}));
}
if (section === "all" || section === "top_resources") {
const resources = await getTopValueResourcesCached(ctx.db, { limit: 10, userRole });
result.topResources = resources.map((resource) => ({
name: resource.displayName,
eid: resource.eid,
chapter: resource.chapter ?? null,
lcr: fmtEur(resource.lcrCents),
valueScore: resource.valueScore ?? null,
countryCode: resource.countryCode ?? null,
federalState: resource.federalState ?? null,
metroCityName: resource.metroCityName ?? null,
}));
}
if (section === "all" || section === "demand_pipeline") {
const demandRows = await getDemandCached(ctx.db, {
startDate: rangeStart.toISOString(),
endDate: rangeEnd.toISOString(),
groupBy: "project",
});
result.demandPipeline = demandRows
.map((row) => ({
project: `${row.name} (${row.shortCode})`,
needed: Math.max(0, round1(row.requiredFTEs - row.resourceCount)),
requiredFTEs: row.requiredFTEs,
allocatedResources: row.resourceCount,
allocatedHours: row.allocatedHours,
calendarLocations: row.derivation?.calendarLocations ?? [],
}))
.filter((row) => row.needed > 0)
.sort((left, right) => right.needed - left.needed)
.slice(0, 15);
}
if (section === "all" || section === "chargeability_overview") {
result.chargeabilityByChapter = (overview?.chapterUtilization ?? []).map((chapter) => ({
chapter: chapter.chapter ?? "Unassigned",
headcount: chapter.resourceCount,
avgTarget: `${Math.round(chapter.avgChargeabilityTarget)}%`,
}));
}
return result;
}
export async function getDashboardChargeabilityOverviewRead(
ctx: DashboardProcedureContext,
input: DashboardChargeabilityOverviewInput,
) {
const cacheKey = `chargeability:${input.includeProposed}:${input.topN}:${input.watchlistThreshold}:${(input.countryIds ?? []).join(",")}:${input.departed ?? ""}`;
const cached = await cacheGet<{
top: unknown[];
watchlist: unknown[];
[key: string]: unknown;
}>(cacheKey);
if (cached) return cached;
const [overview, directory] = await Promise.all([
getDashboardChargeabilityOverview(ctx.db, {
includeProposed: input.includeProposed,
topN: input.topN,
watchlistThreshold: input.watchlistThreshold,
...(input.countryIds !== undefined ? { countryIds: input.countryIds } : {}),
...(input.departed !== undefined ? { departed: input.departed } : {}),
}),
getAnonymizationDirectory(ctx.db),
]);
const result = {
...overview,
top: anonymizeResources(overview.top, directory),
watchlist: anonymizeResources(overview.watchlist, directory),
};
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
export async function getDashboardBudgetForecastRead(ctx: DashboardProcedureContext) {
const cacheKey = "budgetForecast";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardBudgetForecast>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardBudgetForecast(ctx.db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
export async function getDashboardBudgetForecastDetail(ctx: DashboardProcedureContext) {
const budgetForecast = await getDashboardBudgetForecast(ctx.db);
return mapBudgetForecastDetailRows(budgetForecast);
}
export async function getDashboardSkillGapsRead(ctx: DashboardProcedureContext) {
const cacheKey = "skillGaps";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardSkillGaps>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardSkillGaps(ctx.db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
export async function getDashboardSkillGapSummaryRead(ctx: DashboardProcedureContext) {
const cacheKey = "skillGapSummary";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardSkillGapSummary>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardSkillGapSummary(ctx.db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
export async function getDashboardProjectHealthRead(ctx: DashboardProcedureContext) {
const cacheKey = "projectHealth";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardProjectHealth>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardProjectHealth(ctx.db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
export async function getDashboardProjectHealthDetail(ctx: DashboardProcedureContext) {
const projectHealth = await getDashboardProjectHealth(ctx.db);
return mapProjectHealthDetailRows(projectHealth);
}
+37 -380
View File
@@ -1,402 +1,59 @@
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import {
getDashboardChargeabilityOverview,
getDashboardDemand,
getDashboardOverview,
getDashboardPeakTimes,
getDashboardTopValueResources,
getDashboardBudgetForecast,
getDashboardSkillGaps,
getDashboardSkillGapSummary,
getDashboardProjectHealth,
} from "@capakraken/application";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
import { cacheGet, cacheSet } from "../lib/cache.js";
import { fmtEur } from "../lib/format-utils.js";
const DEFAULT_TTL = 60; // seconds
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
function mapProjectHealthDetailRows(rows: Awaited<ReturnType<typeof getDashboardProjectHealth>>) {
const projects = rows
.map((project) => {
const overall = project.compositeScore;
return {
projectId: project.id,
projectName: project.projectName,
shortCode: project.shortCode,
status: project.status,
overall,
budget: project.budgetHealth,
staffing: project.staffingHealth,
timeline: project.timelineHealth,
rating: overall >= 80 ? "healthy" : overall >= 50 ? "at_risk" : "critical",
};
})
.sort((left, right) => left.overall - right.overall);
return {
projects,
summary: {
healthy: projects.filter((project) => project.rating === "healthy").length,
atRisk: projects.filter((project) => project.rating === "at_risk").length,
critical: projects.filter((project) => project.rating === "critical").length,
},
};
}
function mapBudgetForecastDetailRows(rows: Awaited<ReturnType<typeof getDashboardBudgetForecast>>) {
return {
forecasts: rows.map((forecast) => ({
projectId: forecast.projectId ?? null,
projectName: forecast.projectName,
shortCode: forecast.shortCode,
clientId: forecast.clientId,
clientName: forecast.clientName,
budget: fmtEur(forecast.budgetCents),
budgetCents: forecast.budgetCents,
spent: fmtEur(forecast.spentCents),
spentCents: forecast.spentCents,
remaining: fmtEur(forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents)),
remainingCents: forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents),
projected: forecast.burnRate > 0
? fmtEur(forecast.spentCents + Math.max(0, forecast.budgetCents - forecast.spentCents))
: fmtEur(forecast.spentCents),
projectedCents: forecast.burnRate > 0
? Math.max(forecast.spentCents, forecast.budgetCents)
: forecast.spentCents,
burnRate: fmtEur(forecast.burnRate),
burnRateCents: forecast.burnRate,
utilization: `${forecast.pctUsed}%`,
estimatedExhaustionDate: forecast.estimatedExhaustionDate,
activeAssignmentCount: forecast.activeAssignmentCount ?? null,
calendarLocations: forecast.calendarLocations ?? [],
burnStatus: forecast.pctUsed >= 100
? "ahead"
: forecast.burnRate > 0
? "on_track"
: "not_started",
})),
};
}
function mapStatisticsDetail(overview: Awaited<ReturnType<typeof getDashboardOverview>>) {
return {
activeResources: overview.activeResources,
totalProjects: overview.totalProjects,
activeProjects: overview.activeProjects,
totalAllocations: overview.totalAllocations,
approvedVacations: overview.approvedVacations,
totalEstimates: overview.totalEstimates,
totalBudget: overview.budgetSummary.totalBudgetCents > 0
? fmtEur(overview.budgetSummary.totalBudgetCents)
: "N/A",
projectsByStatus: Object.fromEntries(
overview.projectsByStatus.map((entry) => [entry.status, entry.count]),
),
topChapters: [...overview.chapterUtilization]
.sort((left, right) => right.resourceCount - left.resourceCount)
.slice(0, 10)
.map((chapter) => ({
chapter: chapter.chapter,
count: chapter.resourceCount,
})),
};
}
async function getOverviewCached(db: Parameters<typeof getDashboardOverview>[0]) {
const cacheKey = "overview";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardOverview>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardOverview(db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
async function getPeakTimesCached(
db: Parameters<typeof getDashboardPeakTimes>[0],
input: { startDate: string; endDate: string; granularity: "week" | "month"; groupBy: "project" | "chapter" | "resource" },
) {
const cacheKey = `peakTimes:${input.startDate}:${input.endDate}:${input.granularity}:${input.groupBy}`;
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardPeakTimes>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardPeakTimes(db, {
startDate: new Date(input.startDate),
endDate: new Date(input.endDate),
granularity: input.granularity,
groupBy: input.groupBy,
});
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
async function getDemandCached(
db: Parameters<typeof getDashboardDemand>[0],
input: { startDate: string; endDate: string; groupBy: "project" | "person" | "chapter" },
) {
const cacheKey = `demand:${input.startDate}:${input.endDate}:${input.groupBy}`;
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardDemand>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardDemand(db, {
startDate: new Date(input.startDate),
endDate: new Date(input.endDate),
groupBy: input.groupBy,
});
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
async function getTopValueResourcesCached(
db: Parameters<typeof getDashboardTopValueResources>[0],
input: { limit: number; userRole: string },
) {
const cacheKey = `topValue:${input.limit}:${input.userRole}`;
const cached = await cacheGet<Awaited<ReturnType<typeof anonymizeResources>>>(cacheKey);
if (cached) return cached;
const [resources, directory] = await Promise.all([
getDashboardTopValueResources(db, {
limit: input.limit,
userRole: input.userRole,
}),
getAnonymizationDirectory(db),
]);
const result = anonymizeResources(resources, directory);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
dashboardChargeabilityOverviewInputSchema,
dashboardDemandInputSchema,
dashboardDetailInputSchema,
dashboardPeakTimesInputSchema,
dashboardTopValueResourcesInputSchema,
getDashboardBudgetForecastDetail,
getDashboardBudgetForecastRead,
getDashboardChargeabilityOverviewRead,
getDashboardDemandRead,
getDashboardDetail,
getDashboardOverviewRead,
getDashboardPeakTimesRead,
getDashboardProjectHealthDetail,
getDashboardProjectHealthRead,
getDashboardSkillGapSummaryRead,
getDashboardSkillGapsRead,
getDashboardStatisticsDetail,
getDashboardTopValueResourcesRead,
} from "./dashboard-procedure-support.js";
export const dashboardRouter = createTRPCRouter({
getOverview: controllerProcedure.query(async ({ ctx }) => {
return getOverviewCached(ctx.db);
}),
getOverview: controllerProcedure.query(({ ctx }) => getDashboardOverviewRead(ctx)),
getStatisticsDetail: controllerProcedure.query(async ({ ctx }) => {
const overview = await getOverviewCached(ctx.db);
return mapStatisticsDetail(overview);
}),
getStatisticsDetail: controllerProcedure.query(({ ctx }) => getDashboardStatisticsDetail(ctx)),
getPeakTimes: controllerProcedure
.input(
z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
granularity: z.enum(["week", "month"]).default("month"),
groupBy: z.enum(["project", "chapter", "resource"]).default("project"),
}),
)
.query(async ({ ctx, input }) => {
return getPeakTimesCached(ctx.db, input);
}),
.input(dashboardPeakTimesInputSchema)
.query(({ ctx, input }) => getDashboardPeakTimesRead(ctx, input)),
getTopValueResources: controllerProcedure
.input(z.object({ limit: z.number().int().min(1).max(50).default(10) }))
.query(async ({ ctx, input }) => {
const userRole =
(ctx.session.user as { role?: string } | undefined)?.role ?? "USER";
return getTopValueResourcesCached(ctx.db, { limit: input.limit, userRole });
}),
.input(dashboardTopValueResourcesInputSchema)
.query(({ ctx, input }) => getDashboardTopValueResourcesRead(ctx, input)),
getDemand: controllerProcedure
.input(
z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
groupBy: z.enum(["project", "person", "chapter"]).default("project"),
}),
)
.query(async ({ ctx, input }) => {
return getDemandCached(ctx.db, input);
}),
.input(dashboardDemandInputSchema)
.query(({ ctx, input }) => getDashboardDemandRead(ctx, input)),
getDetail: controllerProcedure
.input(z.object({ section: z.string().optional().default("all") }))
.query(async ({ ctx, input }) => {
const section = input.section;
const result: Record<string, unknown> = {};
const needsOverview =
section === "all"
|| section === "peak_times"
|| section === "demand_pipeline"
|| section === "chargeability_overview";
const overview = needsOverview ? await getOverviewCached(ctx.db) : null;
const now = new Date();
const rangeStart = overview?.budgetBasis.windowStart
? new Date(overview.budgetBasis.windowStart)
: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const rangeEnd = overview?.budgetBasis.windowEnd
? new Date(overview.budgetBasis.windowEnd)
: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 5, 0));
const userRole =
(ctx.session.user as { role?: string } | undefined)?.role
?? ctx.dbUser?.systemRole
?? "USER";
if (section === "all" || section === "peak_times") {
const peakTimes = await getPeakTimesCached(ctx.db, {
startDate: rangeStart.toISOString(),
endDate: rangeEnd.toISOString(),
granularity: "month",
groupBy: "project",
});
result.peakTimes = [...peakTimes]
.sort((left, right) => right.totalHours - left.totalHours)
.slice(0, 6)
.map((entry) => ({
month: entry.period,
totalHours: round1(entry.totalHours),
totalHoursPerDay: round1(entry.totalHours),
capacityHours: round1(entry.capacityHours),
utilizationPct: entry.utilizationPct ?? null,
}));
}
if (section === "all" || section === "top_resources") {
const resources = await getTopValueResourcesCached(ctx.db, { limit: 10, userRole });
result.topResources = resources.map((resource) => {
const topResource = resource as {
displayName: string;
eid: string;
chapter: string | null;
lcrCents: number;
valueScore: number | null;
};
return {
name: topResource.displayName,
eid: topResource.eid,
chapter: topResource.chapter ?? null,
lcr: fmtEur(topResource.lcrCents),
valueScore: topResource.valueScore ?? null,
};
});
}
if (section === "all" || section === "demand_pipeline") {
const demandRows = await getDemandCached(ctx.db, {
startDate: rangeStart.toISOString(),
endDate: rangeEnd.toISOString(),
groupBy: "project",
});
result.demandPipeline = demandRows
.map((row) => ({
project: `${row.name} (${row.shortCode})`,
needed: Math.max(0, round1(row.requiredFTEs - row.resourceCount)),
requiredFTEs: row.requiredFTEs,
allocatedResources: row.resourceCount,
allocatedHours: row.allocatedHours,
calendarLocations: row.derivation?.calendarLocations ?? [],
}))
.filter((row) => row.needed > 0)
.sort((left, right) => right.needed - left.needed)
.slice(0, 15);
}
if (section === "all" || section === "chargeability_overview") {
result.chargeabilityByChapter = (overview?.chapterUtilization ?? []).map((chapter) => ({
chapter: chapter.chapter ?? "Unassigned",
headcount: chapter.resourceCount,
avgTarget: `${Math.round(chapter.avgChargeabilityTarget)}%`,
}));
}
return result;
}),
.input(dashboardDetailInputSchema)
.query(({ ctx, input }) => getDashboardDetail(ctx, input)),
getChargeabilityOverview: controllerProcedure
.input(
z.object({
includeProposed: z.boolean().default(false),
topN: z.number().int().min(1).max(50).default(10),
watchlistThreshold: z.number().default(15),
countryIds: z.array(z.string()).optional(),
departed: z.boolean().optional(),
}),
)
.query(async ({ ctx, input }) => {
const cacheKey = `chargeability:${input.includeProposed}:${input.topN}:${input.watchlistThreshold}:${(input.countryIds ?? []).join(",")}:${input.departed ?? ""}`;
type ChargeResult = Awaited<ReturnType<typeof getDashboardChargeabilityOverview>>;
const cached = await cacheGet<{
top: unknown[];
watchlist: unknown[];
[key: string]: unknown;
}>(cacheKey);
if (cached) return cached;
.input(dashboardChargeabilityOverviewInputSchema)
.query(({ ctx, input }) => getDashboardChargeabilityOverviewRead(ctx, input)),
const [overview, directory] = await Promise.all([
getDashboardChargeabilityOverview(ctx.db, {
includeProposed: input.includeProposed,
topN: input.topN,
watchlistThreshold: input.watchlistThreshold,
...(input.countryIds !== undefined ? { countryIds: input.countryIds } : {}),
...(input.departed !== undefined ? { departed: input.departed } : {}),
}),
getAnonymizationDirectory(ctx.db),
]);
getBudgetForecast: controllerProcedure.query(({ ctx }) => getDashboardBudgetForecastRead(ctx)),
const result = {
...overview,
top: anonymizeResources(overview.top, directory),
watchlist: anonymizeResources(overview.watchlist, directory),
};
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}),
getBudgetForecastDetail: controllerProcedure.query(({ ctx }) => getDashboardBudgetForecastDetail(ctx)),
getBudgetForecast: controllerProcedure.query(async ({ ctx }) => {
const cacheKey = "budgetForecast";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardBudgetForecast>>>(cacheKey);
if (cached) return cached;
getSkillGaps: controllerProcedure.query(({ ctx }) => getDashboardSkillGapsRead(ctx)),
const result = await getDashboardBudgetForecast(ctx.db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}),
getSkillGapSummary: controllerProcedure.query(({ ctx }) => getDashboardSkillGapSummaryRead(ctx)),
getBudgetForecastDetail: controllerProcedure.query(async ({ ctx }) => {
const budgetForecast = await getDashboardBudgetForecast(ctx.db);
return mapBudgetForecastDetailRows(budgetForecast);
}),
getProjectHealth: controllerProcedure.query(({ ctx }) => getDashboardProjectHealthRead(ctx)),
getSkillGaps: controllerProcedure.query(async ({ ctx }) => {
const cacheKey = "skillGaps";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardSkillGaps>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardSkillGaps(ctx.db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}),
getSkillGapSummary: controllerProcedure.query(async ({ ctx }) => {
const cacheKey = "skillGapSummary";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardSkillGapSummary>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardSkillGapSummary(ctx.db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}),
getProjectHealth: controllerProcedure.query(async ({ ctx }) => {
const cacheKey = "projectHealth";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardProjectHealth>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardProjectHealth(ctx.db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}),
getProjectHealthDetail: controllerProcedure.query(async ({ ctx }) => {
const projectHealth = await getDashboardProjectHealth(ctx.db);
return mapProjectHealthDetailRows(projectHealth);
}),
getProjectHealthDetail: controllerProcedure.query(({ ctx }) => getDashboardProjectHealthDetail(ctx)),
});