From 703406a76b20f1a6f2446b573678b0f2283a0765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 23:34:03 +0200 Subject: [PATCH] feat(api): explain dashboard chargeability by chapter --- .../assistant-tools-dashboard-detail.test.ts | 55 +++++- .../assistant-tools-dashboard-test-helpers.ts | 81 +++++++++ .../dashboard-procedure-support.test.ts | 63 ++++++- .../src/__tests__/dashboard-router.test.ts | 62 ++++++- .../src/router/dashboard-procedure-support.ts | 161 +++++++++++++++--- .../src/__tests__/dashboard.test.ts | 2 + .../dashboard/get-chargeability-overview.ts | 10 +- 7 files changed, 403 insertions(+), 31 deletions(-) create mode 100644 packages/api/src/__tests__/assistant-tools-dashboard-test-helpers.ts diff --git a/packages/api/src/__tests__/assistant-tools-dashboard-detail.test.ts b/packages/api/src/__tests__/assistant-tools-dashboard-detail.test.ts index ef87d21..f4246c2 100644 --- a/packages/api/src/__tests__/assistant-tools-dashboard-detail.test.ts +++ b/packages/api/src/__tests__/assistant-tools-dashboard-detail.test.ts @@ -4,6 +4,7 @@ import { SystemRole } from "@capakraken/shared"; import { createToolContext, executeTool, + getDashboardChargeabilityOverview, getDashboardDemand, getDashboardOverview, getDashboardPeakTimes, @@ -139,6 +140,38 @@ describe("assistant dashboard tools detail aggregation", () => { }, }, ]); + vi.mocked(getDashboardChargeabilityOverview).mockResolvedValue({ + rows: [ + { + id: "res_1", + eid: "pparker", + displayName: "Peter Parker", + chapter: "Delivery", + chargeabilityTarget: 78, + actualChargeability: 70, + expectedChargeability: 76, + derivation: { + weeklyAvailabilityHours: 40, + baseWorkingDays: 22, + effectiveWorkingDayEquivalent: 21, + baseAvailableHours: 176, + effectiveAvailableHours: 168, + publicHolidayCount: 1, + publicHolidayWorkdayCount: 1, + publicHolidayHoursDeduction: 8, + absenceDayEquivalent: 0, + absenceHoursDeduction: 0, + actualBookedHours: 117.6, + expectedBookedHours: 127.7, + targetBookedHours: 131, + unassignedHours: 40.3, + }, + }, + ], + top: [], + watchlist: [], + month: "2026-03", + }); const ctx = createToolContext( { @@ -263,8 +296,28 @@ describe("assistant dashboard tools detail aggregation", () => { chargeabilityByChapter: [ { chapter: "Delivery", - headcount: 4, + headcount: 1, + avgTargetPct: 78, + avgActualPct: 70, + avgExpectedPct: 76, + gapToTargetPct: 8, avgTarget: "78%", + avgActual: "70%", + avgExpected: "76%", + explainability: { + month: "2026-03", + resourceCount: 1, + derivedHeadcount: 1, + baseAvailableHours: 176, + effectiveAvailableHours: 168, + actualBookedHours: 117.6, + expectedBookedHours: 127.7, + targetBookedHours: 131, + publicHolidayHoursDeduction: 8, + absenceDayEquivalent: 0, + absenceHoursDeduction: 0, + unassignedHours: 40.3, + }, }, ], }); diff --git a/packages/api/src/__tests__/assistant-tools-dashboard-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-dashboard-test-helpers.ts new file mode 100644 index 0000000..b82da95 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-dashboard-test-helpers.ts @@ -0,0 +1,81 @@ +import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { vi } from "vitest"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardDemand: vi.fn().mockResolvedValue([]), + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardChargeabilityOverview: vi.fn().mockResolvedValue({ + rows: [], + top: [], + watchlist: [], + month: "2026-03", + }), + getDashboardOverview: vi.fn(), + getDashboardSkillGapSummary: vi.fn().mockResolvedValue({ + roleGaps: [], + totalOpenPositions: 0, + skillSupplyTop10: [], + resourcesByRole: [], + }), + getDashboardProjectHealth: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + getDashboardTopValueResources: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock("../lib/cache.js", () => ({ + cacheGet: vi.fn().mockResolvedValue(null), + cacheSet: vi.fn().mockResolvedValue(undefined), + cacheInvalidate: vi.fn().mockResolvedValue(undefined), + invalidateDashboardCache: vi.fn().mockResolvedValue(undefined), +})); + +import { + getDashboardChargeabilityOverview as getDashboardChargeabilityOverviewMock, + getDashboardDemand as getDashboardDemandMock, + getDashboardOverview as getDashboardOverviewMock, + getDashboardProjectHealth as getDashboardProjectHealthMock, + getDashboardPeakTimes as getDashboardPeakTimesMock, + getDashboardSkillGapSummary as getDashboardSkillGapSummaryMock, + getDashboardTopValueResources as getDashboardTopValueResourcesMock, +} from "@capakraken/application"; +import { executeTool as executeAssistantTool, type ToolContext } from "../router/assistant-tools.js"; + +export const executeTool = executeAssistantTool; +export const getDashboardChargeabilityOverview = getDashboardChargeabilityOverviewMock; +export const getDashboardDemand = getDashboardDemandMock; +export const getDashboardOverview = getDashboardOverviewMock; +export const getDashboardProjectHealth = getDashboardProjectHealthMock; +export const getDashboardPeakTimes = getDashboardPeakTimesMock; +export const getDashboardSkillGapSummary = getDashboardSkillGapSummaryMock; +export const getDashboardTopValueResources = getDashboardTopValueResourcesMock; + +export function createToolContext( + db: Record, + options?: { + permissions?: PermissionKey[]; + userRole?: SystemRole; + }, +): ToolContext { + const userRole = options?.userRole ?? SystemRole.ADMIN; + return { + db: db as ToolContext["db"], + userId: "user_1", + userRole, + permissions: new Set(options?.permissions ?? []), + session: { + user: { email: "assistant@example.com", name: "Assistant User", image: null }, + expires: "2026-03-29T00:00:00.000Z", + }, + dbUser: { + id: "user_1", + systemRole: userRole, + permissionOverrides: null, + }, + roleDefaults: null, + }; +} diff --git a/packages/api/src/__tests__/dashboard-procedure-support.test.ts b/packages/api/src/__tests__/dashboard-procedure-support.test.ts index 10695dc..12f9fce 100644 --- a/packages/api/src/__tests__/dashboard-procedure-support.test.ts +++ b/packages/api/src/__tests__/dashboard-procedure-support.test.ts @@ -225,6 +225,38 @@ describe("dashboard procedure support", () => { }, }, ]); + vi.mocked(getDashboardChargeabilityOverview).mockResolvedValue({ + rows: [ + { + id: "res_1", + eid: "E-001", + displayName: "Ada Lovelace", + chapter: "CGI", + chargeabilityTarget: 78, + actualChargeability: 64, + expectedChargeability: 72, + derivation: { + weeklyAvailabilityHours: 40, + baseWorkingDays: 23, + effectiveWorkingDayEquivalent: 21, + baseAvailableHours: 184, + effectiveAvailableHours: 168, + publicHolidayCount: 1, + publicHolidayWorkdayCount: 1, + publicHolidayHoursDeduction: 8, + absenceDayEquivalent: 1, + absenceHoursDeduction: 8, + actualBookedHours: 107.5, + expectedBookedHours: 121, + targetBookedHours: 131, + unassignedHours: 47, + }, + }, + ], + top: [], + watchlist: [], + month: "2026-03", + }); try { const result = await getDashboardDetail(createContext(), { section: "all" }); @@ -298,11 +330,40 @@ describe("dashboard procedure support", () => { chargeabilityByChapter: [ { chapter: "CGI", - headcount: 5, + headcount: 1, + avgTargetPct: 78, + avgActualPct: 64, + avgExpectedPct: 72, + gapToTargetPct: 14, avgTarget: "78%", + avgActual: "64%", + avgExpected: "72%", + explainability: { + month: "2026-03", + resourceCount: 1, + derivedHeadcount: 1, + baseAvailableHours: 184, + effectiveAvailableHours: 168, + actualBookedHours: 107.5, + expectedBookedHours: 121, + targetBookedHours: 131, + publicHolidayHoursDeduction: 8, + absenceDayEquivalent: 1, + absenceHoursDeduction: 8, + unassignedHours: 47, + }, }, ], }); + + expect(getDashboardChargeabilityOverview).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + includeProposed: false, + topN: 10, + watchlistThreshold: 15, + }), + ); } finally { vi.useRealTimers(); } diff --git a/packages/api/src/__tests__/dashboard-router.test.ts b/packages/api/src/__tests__/dashboard-router.test.ts index 5aeb215..8579853 100644 --- a/packages/api/src/__tests__/dashboard-router.test.ts +++ b/packages/api/src/__tests__/dashboard-router.test.ts @@ -838,6 +838,38 @@ describe("dashboard router", () => { }, }, ]); + vi.mocked(getDashboardChargeabilityOverview).mockResolvedValue({ + rows: [ + { + id: "res_1", + eid: "pparker", + displayName: "Peter Parker", + chapter: "Delivery", + chargeabilityTarget: 78, + actualChargeability: 70, + expectedChargeability: 76, + derivation: { + weeklyAvailabilityHours: 40, + baseWorkingDays: 22, + effectiveWorkingDayEquivalent: 21, + baseAvailableHours: 176, + effectiveAvailableHours: 168, + publicHolidayCount: 1, + publicHolidayWorkdayCount: 1, + publicHolidayHoursDeduction: 8, + absenceDayEquivalent: 0, + absenceHoursDeduction: 0, + actualBookedHours: 117.6, + expectedBookedHours: 127.7, + targetBookedHours: 131, + unassignedHours: 40.3, + }, + }, + ], + top: [], + watchlist: [], + month: "2026-03", + }); const caller = createControllerCaller({}); const result = await caller.getDetail({ section: "all" }); @@ -929,8 +961,28 @@ describe("dashboard router", () => { chargeabilityByChapter: [ { chapter: "Delivery", - headcount: 4, + headcount: 1, + avgTargetPct: 78, + avgActualPct: 70, + avgExpectedPct: 76, + gapToTargetPct: 8, avgTarget: "78%", + avgActual: "70%", + avgExpected: "76%", + explainability: { + month: "2026-03", + resourceCount: 1, + derivedHeadcount: 1, + baseAvailableHours: 176, + effectiveAvailableHours: 168, + actualBookedHours: 117.6, + expectedBookedHours: 127.7, + targetBookedHours: 131, + publicHolidayHoursDeduction: 8, + absenceDayEquivalent: 0, + absenceHoursDeduction: 0, + unassignedHours: 40.3, + }, }, ], }); @@ -943,6 +995,14 @@ describe("dashboard router", () => { groupBy: "project", }), ); + expect(getDashboardChargeabilityOverview).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + includeProposed: false, + topN: 10, + watchlistThreshold: 15, + }), + ); }); }); }); diff --git a/packages/api/src/router/dashboard-procedure-support.ts b/packages/api/src/router/dashboard-procedure-support.ts index 858ae50..d47fa1e 100644 --- a/packages/api/src/router/dashboard-procedure-support.ts +++ b/packages/api/src/router/dashboard-procedure-support.ts @@ -74,11 +74,16 @@ type DashboardTopValueResourcesInput = z.infer; type DashboardDetailInput = z.infer; type DashboardChargeabilityOverviewInput = z.infer; +type DashboardChargeabilityOverviewRead = Awaited>; function round1(value: number): number { return Math.round(value * 10) / 10; } +function formatPct(value: number): string { + return `${Math.round(value)}%`; +} + function mapStatisticsDetail(overview: Awaited>) { return { activeResources: overview.activeResources, @@ -148,6 +153,25 @@ async function getDemandCached( return result; } +async function getChargeabilityOverviewCached( + db: Parameters[0], + input: DashboardChargeabilityOverviewInput, +): Promise { + const cacheKey = `chargeability:${input.includeProposed}:${input.topN}:${input.watchlistThreshold}:${(input.countryIds ?? []).join(",")}:${input.departed ?? ""}`; + const cached = await cacheGet(cacheKey); + if (cached) return cached; + + const result = await getDashboardChargeabilityOverview(db, { + includeProposed: input.includeProposed, + topN: input.topN, + watchlistThreshold: input.watchlistThreshold, + ...(input.countryIds !== undefined ? { countryIds: input.countryIds } : {}), + ...(input.departed !== undefined ? { departed: input.departed } : {}), + }); + await cacheSet(cacheKey, result, DEFAULT_TTL); + return result; +} + async function getTopValueResourcesCached( db: Parameters[0], input: { limit: number; userRole: string }, @@ -174,6 +198,101 @@ function getUserRole(ctx: DashboardProcedureContext) { ?? "USER"; } +function mapChargeabilityByChapter( + rows: DashboardChargeabilityOverviewRead["rows"], + month: string, +) { + const chapterMap = new Map(); + + for (const row of rows) { + const chapter = row.chapter ?? "Unassigned"; + const summary = chapterMap.get(chapter) ?? { + headcount: 0, + avgTargetSum: 0, + avgActualSum: 0, + avgExpectedSum: 0, + derivedHeadcount: 0, + baseAvailableHours: 0, + effectiveAvailableHours: 0, + actualBookedHours: 0, + expectedBookedHours: 0, + targetBookedHours: 0, + publicHolidayHoursDeduction: 0, + absenceDayEquivalent: 0, + absenceHoursDeduction: 0, + unassignedHours: 0, + }; + + summary.headcount += 1; + summary.avgTargetSum += row.chargeabilityTarget; + summary.avgActualSum += row.actualChargeability; + summary.avgExpectedSum += row.expectedChargeability; + + if (row.derivation) { + summary.derivedHeadcount += 1; + summary.baseAvailableHours += row.derivation.baseAvailableHours; + summary.effectiveAvailableHours += row.derivation.effectiveAvailableHours; + summary.actualBookedHours += row.derivation.actualBookedHours; + summary.expectedBookedHours += row.derivation.expectedBookedHours; + summary.targetBookedHours += row.derivation.targetBookedHours; + summary.publicHolidayHoursDeduction += row.derivation.publicHolidayHoursDeduction; + summary.absenceDayEquivalent += row.derivation.absenceDayEquivalent; + summary.absenceHoursDeduction += row.derivation.absenceHoursDeduction; + summary.unassignedHours += row.derivation.unassignedHours; + } + + chapterMap.set(chapter, summary); + } + + return [...chapterMap.entries()] + .map(([chapter, summary]) => ({ + chapter, + headcount: summary.headcount, + avgTargetPct: Math.round(summary.avgTargetSum / summary.headcount), + avgActualPct: Math.round(summary.avgActualSum / summary.headcount), + avgExpectedPct: Math.round(summary.avgExpectedSum / summary.headcount), + gapToTargetPct: Math.round((summary.avgTargetSum - summary.avgActualSum) / summary.headcount), + avgTarget: formatPct(summary.avgTargetSum / summary.headcount), + avgActual: formatPct(summary.avgActualSum / summary.headcount), + avgExpected: formatPct(summary.avgExpectedSum / summary.headcount), + explainability: summary.derivedHeadcount > 0 + ? { + month, + resourceCount: summary.headcount, + derivedHeadcount: summary.derivedHeadcount, + baseAvailableHours: round1(summary.baseAvailableHours), + effectiveAvailableHours: round1(summary.effectiveAvailableHours), + actualBookedHours: round1(summary.actualBookedHours), + expectedBookedHours: round1(summary.expectedBookedHours), + targetBookedHours: round1(summary.targetBookedHours), + publicHolidayHoursDeduction: round1(summary.publicHolidayHoursDeduction), + absenceDayEquivalent: round1(summary.absenceDayEquivalent), + absenceHoursDeduction: round1(summary.absenceHoursDeduction), + unassignedHours: round1(summary.unassignedHours), + } + : null, + })) + .sort((left, right) => ( + right.headcount - left.headcount + || left.chapter.localeCompare(right.chapter) + )); +} + export async function getDashboardOverviewRead(ctx: DashboardProcedureContext) { return getOverviewCached(ctx.db); } @@ -211,7 +330,6 @@ export async function getDashboardDetail(ctx: DashboardProcedureContext, input: section === "all" || section === "peak_times" || section === "demand_pipeline" - || section === "chargeability_overview" ); const overview = needsOverview ? await getOverviewCached(ctx.db) : null; const now = new Date(); @@ -309,11 +427,15 @@ export async function getDashboardDetail(ctx: DashboardProcedureContext, input: } if (section === "all" || section === "chargeability_overview") { - result.chargeabilityByChapter = (overview?.chapterUtilization ?? []).map((chapter) => ({ - chapter: chapter.chapter ?? "Unassigned", - headcount: chapter.resourceCount, - avgTarget: `${Math.round(chapter.avgChargeabilityTarget)}%`, - })); + const chargeabilityOverview = await getChargeabilityOverviewCached(ctx.db, { + includeProposed: false, + topN: 10, + watchlistThreshold: 15, + }); + result.chargeabilityByChapter = mapChargeabilityByChapter( + chargeabilityOverview.rows, + chargeabilityOverview.month, + ); } return result; @@ -323,32 +445,17 @@ 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 } : {}), - }), + getChargeabilityOverviewCached(ctx.db, input), getAnonymizationDirectory(ctx.db), ]); + const { rows: _rows, top, watchlist, ...rest } = overview; - const result = { - ...overview, - top: anonymizeResources(overview.top, directory), - watchlist: anonymizeResources(overview.watchlist, directory), + return { + ...rest, + top: anonymizeResources(top, directory), + watchlist: anonymizeResources(watchlist, directory), }; - await cacheSet(cacheKey, result, DEFAULT_TTL); - return result; } export async function getDashboardBudgetForecastRead( diff --git a/packages/application/src/__tests__/dashboard.test.ts b/packages/application/src/__tests__/dashboard.test.ts index c172226..3797ab9 100644 --- a/packages/application/src/__tests__/dashboard.test.ts +++ b/packages/application/src/__tests__/dashboard.test.ts @@ -646,6 +646,7 @@ describe("dashboard use-cases", () => { }), }), ); + expect(result.rows).toHaveLength(1); expect(result.top).toHaveLength(1); expect(result.top[0]).toEqual( expect.objectContaining({ @@ -778,6 +779,7 @@ describe("dashboard use-cases", () => { watchlistThreshold: 15, }); + expect(result.rows).toHaveLength(1); expect(result.top[0]?.actualChargeability).toBe(0); expect(result.top[0]?.expectedChargeability).toBe(0); expect(result.top[0]).toEqual( diff --git a/packages/application/src/use-cases/dashboard/get-chargeability-overview.ts b/packages/application/src/use-cases/dashboard/get-chargeability-overview.ts index ea371c3..acdbdb9 100644 --- a/packages/application/src/use-cases/dashboard/get-chargeability-overview.ts +++ b/packages/application/src/use-cases/dashboard/get-chargeability-overview.ts @@ -65,6 +65,13 @@ export interface DashboardChargeabilityRow { derivation?: DashboardChargeabilityDerivation; } +export interface DashboardChargeabilityOverview { + rows: DashboardChargeabilityRow[]; + top: DashboardChargeabilityRow[]; + watchlist: DashboardChargeabilityRow[]; + month: string; +} + function toIsoDate(value: Date): string { return value.toISOString().slice(0, 10); } @@ -160,7 +167,7 @@ function summarizeDerivation( export async function getDashboardChargeabilityOverview( db: PrismaClient, input: GetDashboardChargeabilityOverviewInput, -) { +): Promise { const now = input.now ?? new Date(); const start = new Date(now.getFullYear(), now.getMonth(), 1); const end = new Date(now.getFullYear(), now.getMonth() + 1, 0); @@ -294,6 +301,7 @@ export async function getDashboardChargeabilityOverview( }); return { + rows: stats, top: [...stats] .sort((left, right) => right.actualChargeability - left.actualChargeability), watchlist: [...stats]