From ea10851fe45b083385ac1d0420e7be8fadb61e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 15:18:43 +0200 Subject: [PATCH] refactor(api): extract timeline project budget mapping --- .../timeline-project-context-support.test.ts | 80 +++++++++++++++++++ .../timeline-project-context-support.ts | 74 +++++++++++++++++ .../api/src/router/timeline-project-read.ts | 46 +++-------- 3 files changed, 166 insertions(+), 34 deletions(-) diff --git a/packages/api/src/__tests__/timeline-project-context-support.test.ts b/packages/api/src/__tests__/timeline-project-context-support.test.ts index 248540b..4eb039a 100644 --- a/packages/api/src/__tests__/timeline-project-context-support.test.ts +++ b/packages/api/src/__tests__/timeline-project-context-support.test.ts @@ -1,10 +1,13 @@ import { TRPCError } from "@trpc/server"; import { describe, expect, it } from "vitest"; import { + buildTimelineBudgetStatusAllocations, + buildTimelineBudgetStatusResponse, buildTimelineProjectAssignmentConflicts, buildTimelineProjectContextDetailResponse, buildTimelineProjectContextResponse, buildTimelineProjectContextSummary, + buildTimelineShiftValidationBookings, buildTimelineShiftPreviewDetailResponse, resolveTimelineProjectContextPeriod, } from "../router/timeline-project-context-support.js"; @@ -371,4 +374,81 @@ describe("timeline project context support", () => { preview: { valid: true }, }); }); + + it("maps shift validation bookings and budget status payloads", () => { + expect( + buildTimelineShiftValidationBookings([ + { + id: "booking_1", + resourceId: "resource_1", + projectId: "project_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + hoursPerDay: 8, + status: "CONFIRMED", + }, + { + id: "booking_2", + resourceId: null, + projectId: "project_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + hoursPerDay: 4, + status: "PROPOSED", + }, + ]), + ).toEqual([ + { + id: "booking_1", + resourceId: "resource_1", + projectId: "project_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + hoursPerDay: 8, + status: "CONFIRMED", + }, + ]); + + expect( + buildTimelineBudgetStatusAllocations([ + { + status: "CONFIRMED", + dailyCostCents: 40000, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + hoursPerDay: 8, + }, + ]), + ).toEqual([ + { + status: "CONFIRMED", + dailyCostCents: 40000, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + hoursPerDay: 8, + }, + ]); + + expect( + buildTimelineBudgetStatusResponse({ + project: { + name: "Project One", + shortCode: "PRJ", + budgetCents: 120000, + }, + budgetStatus: { + withinBudget: true, + varianceCents: 2000, + }, + totalAllocations: 3, + }), + ).toEqual({ + withinBudget: true, + varianceCents: 2000, + projectName: "Project One", + projectCode: "PRJ", + totalAllocations: 3, + budgetCents: 120000, + }); + }); }); diff --git a/packages/api/src/router/timeline-project-context-support.ts b/packages/api/src/router/timeline-project-context-support.ts index 1fcb266..dfa8b87 100644 --- a/packages/api/src/router/timeline-project-context-support.ts +++ b/packages/api/src/router/timeline-project-context-support.ts @@ -1,4 +1,5 @@ import { TRPCError } from "@trpc/server"; +import type { Allocation } from "@capakraken/shared"; import { summarizeHolidayOverlays, type formatHolidayOverlays } from "./timeline-holiday-read.js"; import { anonymizeResourceOnEntry, @@ -162,6 +163,79 @@ export function buildTimelineProjectContextSummary(input: { }; } +export function buildTimelineShiftValidationBookings( + bookings: Array<{ + id: string; + resourceId: string | null; + projectId: string | null; + startDate: Date; + endDate: Date; + hoursPerDay: number; + status: string; + }>, +) { + return bookings + .filter( + ( + booking, + ): booking is typeof booking & { resourceId: string; projectId: string } => + booking.resourceId !== null && booking.projectId !== null, + ) + .map((booking) => ({ + id: booking.id, + resourceId: booking.resourceId, + projectId: booking.projectId, + startDate: booking.startDate, + endDate: booking.endDate, + hoursPerDay: booking.hoursPerDay, + status: booking.status, + })); +} + +export function buildTimelineBudgetStatusAllocations( + bookings: Array<{ + status: string; + dailyCostCents: number; + startDate: Date; + endDate: Date; + hoursPerDay: number; + }>, +): Pick< + Allocation, + "status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay" +>[] { + return bookings.map((booking) => ({ + status: booking.status as Allocation["status"], + dailyCostCents: booking.dailyCostCents, + startDate: booking.startDate, + endDate: booking.endDate, + hoursPerDay: booking.hoursPerDay, + })); +} + +export function buildTimelineBudgetStatusResponse(input: { + project: { + name: string; + shortCode: string | null; + budgetCents: number; + }; + budgetStatus: TBudgetStatus; + totalAllocations: number; +}): TBudgetStatus & { + projectName: string; + projectCode: string; + totalAllocations: number; + budgetCents: number; +} { + return { + ...input.budgetStatus, + projectName: input.project.name, + projectCode: input.project.shortCode ?? "", + totalAllocations: input.totalAllocations, + budgetCents: input.project.budgetCents, + }; +} + export function buildTimelineProjectContextResponse< TProject, TAllocation extends { resource?: { id: string } | null }, diff --git a/packages/api/src/router/timeline-project-read.ts b/packages/api/src/router/timeline-project-read.ts index 418d422..01ccb17 100644 --- a/packages/api/src/router/timeline-project-read.ts +++ b/packages/api/src/router/timeline-project-read.ts @@ -7,21 +7,18 @@ import { getAnonymizationDirectory } from "../lib/anonymization.js"; import { controllerProcedure } from "../trpc.js"; import { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; import { + buildTimelineBudgetStatusAllocations, + buildTimelineBudgetStatusResponse, buildTimelineProjectContextDetailResponse, buildTimelineProjectContextResponse, buildTimelineProjectAssignmentConflicts, - buildTimelineProjectContextSummary, + buildTimelineShiftValidationBookings, buildTimelineShiftPreviewDetailResponse, resolveTimelineProjectContextPeriod, } from "./timeline-project-context-support.js"; import { buildTimelineShiftPlan } from "./timeline-shift-planning.js"; import { loadTimelineHolidayOverlays, formatHolidayOverlays } from "./timeline-holiday-read.js"; -import { - anonymizeResourceOnEntry, - fmtDate, - getAssignmentResourceIds, - ShiftDbClient, -} from "./timeline-read-shared.js"; +import { getAssignmentResourceIds, ShiftDbClient } from "./timeline-read-shared.js"; import { buildTimelineProjectShiftValidation } from "./timeline-shift-support.js"; export async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) { @@ -48,19 +45,11 @@ export async function loadProjectShiftContext(db: ShiftDbClient, projectId: stri const allAssignmentWindows = resourceIds.length === 0 ? [] - : ( + : buildTimelineShiftValidationBookings( await listAssignmentBookings(db, { resourceIds, - }) - ).map((booking) => ({ - id: booking.id, - resourceId: booking.resourceId!, - projectId: booking.projectId, - startDate: booking.startDate, - endDate: booking.endDate, - hoursPerDay: booking.hoursPerDay, - status: booking.status, - })); + }), + ); const shiftPlan = buildTimelineShiftPlan({ demandRequirements, @@ -275,26 +264,15 @@ export const timelineProjectReadProcedures = { const budgetStatus = computeBudgetStatus( project.budgetCents, project.winProbability, - bookings.map((booking) => ({ - status: booking.status, - dailyCostCents: booking.dailyCostCents, - startDate: booking.startDate, - endDate: booking.endDate, - hoursPerDay: booking.hoursPerDay, - })) as unknown as Pick< - import("@capakraken/shared").Allocation, - "status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay" - >[], + buildTimelineBudgetStatusAllocations(bookings), project.startDate, project.endDate, ); - return { - ...budgetStatus, - projectName: project.name, - projectCode: project.shortCode, + return buildTimelineBudgetStatusResponse({ + project, + budgetStatus, totalAllocations: bookings.length, - budgetCents: project.budgetCents, - }; + }); }), };