refactor(api): extract timeline project budget mapping

This commit is contained in:
2026-03-31 15:18:43 +02:00
parent a018d04251
commit ea10851fe4
3 changed files with 166 additions and 34 deletions
@@ -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,
});
});
});
@@ -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<TBudgetStatus extends object>(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 },
@@ -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,
};
});
}),
};