From e1de9a3a98982bec4fb6bb0ec5bd3b7aec639a50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 15:00:01 +0200 Subject: [PATCH] refactor(api): extract timeline project context support --- .../timeline-project-context-support.test.ts | 181 ++++++++++++++++++ .../timeline-project-context-support.ts | 160 ++++++++++++++++ .../api/src/router/timeline-project-read.ts | 130 ++++--------- 3 files changed, 379 insertions(+), 92 deletions(-) create mode 100644 packages/api/src/__tests__/timeline-project-context-support.test.ts create mode 100644 packages/api/src/router/timeline-project-context-support.ts diff --git a/packages/api/src/__tests__/timeline-project-context-support.test.ts b/packages/api/src/__tests__/timeline-project-context-support.test.ts new file mode 100644 index 0000000..dfdb378 --- /dev/null +++ b/packages/api/src/__tests__/timeline-project-context-support.test.ts @@ -0,0 +1,181 @@ +import { TRPCError } from "@trpc/server"; +import { describe, expect, it } from "vitest"; +import { + buildTimelineProjectAssignmentConflicts, + buildTimelineProjectContextSummary, + resolveTimelineProjectContextPeriod, +} from "../router/timeline-project-context-support.js"; + +describe("timeline project context support", () => { + it("derives the detail period from explicit input and validates order", () => { + expect(resolveTimelineProjectContextPeriod({ + requestedStartDate: "2026-04-03", + requestedEndDate: "2026-04-12", + projectStartDate: new Date("2026-04-01T00:00:00.000Z"), + projectEndDate: new Date("2026-04-10T00:00:00.000Z"), + })).toEqual({ + startDate: new Date("2026-04-03T00:00:00.000Z"), + endDate: new Date("2026-04-12T00:00:00.000Z"), + }); + + expect(() => + resolveTimelineProjectContextPeriod({ + requestedStartDate: "2026-04-12", + requestedEndDate: "2026-04-03", + }), + ).toThrowError(new TRPCError({ + code: "BAD_REQUEST", + message: "endDate must be on or after startDate.", + })); + }); + + it("builds assignment conflict summaries with cross-project overlap counts", () => { + expect(buildTimelineProjectAssignmentConflicts({ + projectId: "project_1", + assignments: [ + { + id: "assignment_1", + resourceId: "resource_1", + resource: { displayName: "Alice" }, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + hoursPerDay: 8, + }, + { + id: "placeholder_1", + resourceId: null, + resource: null, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + hoursPerDay: 4, + }, + ], + allResourceAllocations: [ + { + id: "assignment_1", + resourceId: "resource_1", + projectId: "project_1", + project: { name: "Current", shortCode: "CUR" }, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + hoursPerDay: 8, + status: "ACTIVE", + }, + { + id: "booking_2", + resourceId: "resource_1", + projectId: "project_2", + project: { name: "Other", shortCode: "OTH" }, + startDate: new Date("2026-04-04T00:00:00.000Z"), + endDate: new Date("2026-04-07T00:00:00.000Z"), + hoursPerDay: 6, + status: "ACTIVE", + }, + { + id: "booking_3", + resourceId: "resource_1", + projectId: "project_1", + project: { name: "Current", shortCode: "CUR" }, + startDate: new Date("2026-04-02T00:00:00.000Z"), + endDate: new Date("2026-04-03T00:00:00.000Z"), + hoursPerDay: 2, + status: "PROPOSED", + }, + ], + })).toEqual([ + { + assignmentId: "assignment_1", + resourceId: "resource_1", + resourceName: "Alice", + startDate: "2026-04-01", + endDate: "2026-04-05", + hoursPerDay: 8, + overlapCount: 2, + crossProjectOverlapCount: 1, + overlaps: [ + { + id: "booking_2", + projectId: "project_2", + projectName: "Other", + projectShortCode: "OTH", + startDate: "2026-04-04", + endDate: "2026-04-07", + hoursPerDay: 6, + status: "ACTIVE", + sameProject: false, + }, + { + id: "booking_3", + projectId: "project_1", + projectName: "Current", + projectShortCode: "CUR", + startDate: "2026-04-02", + endDate: "2026-04-03", + hoursPerDay: 2, + status: "PROPOSED", + sameProject: true, + }, + ], + }, + ]); + }); + + it("combines timeline counts with holiday overlay summary data", () => { + expect(buildTimelineProjectContextSummary({ + allocations: [{ projectId: "project_1", resourceId: "resource_1" }], + demands: [{ projectId: "project_1" }], + assignments: [{ projectId: "project_1", resourceId: "resource_1" }], + resourceIds: ["resource_1", "resource_2"], + allResourceAllocations: [{ id: "booking_1" }, { id: "booking_2" }], + assignmentConflicts: [ + { crossProjectOverlapCount: 0 }, + { crossProjectOverlapCount: 2 }, + ], + holidayOverlays: [ + { + id: "overlay_1", + resourceId: "resource_1", + startDate: "2026-04-03", + endDate: "2026-04-03", + note: "Holiday", + scope: "COUNTRY", + calendarName: "DE", + sourceType: "CALENDAR", + countryCode: "DE", + countryName: "Germany", + federalState: null, + metroCityName: null, + }, + { + id: "overlay_2", + resourceId: "resource_2", + startDate: "2026-04-04", + endDate: "2026-04-04", + note: "Holiday", + scope: "CITY", + calendarName: "Berlin", + sourceType: "CALENDAR", + countryCode: "DE", + countryName: "Germany", + federalState: "BE", + metroCityName: "Berlin", + }, + ], + })).toEqual({ + allocationCount: 1, + demandCount: 1, + assignmentCount: 1, + projectCount: 1, + resourceCount: 1, + resourceIds: 2, + allResourceAllocationCount: 2, + conflictedAssignmentCount: 1, + overlayCount: 2, + holidayResourceCount: 2, + byScope: [ + { scope: "CITY", count: 1 }, + { scope: "COUNTRY", count: 1 }, + ], + }); + }); +}); diff --git a/packages/api/src/router/timeline-project-context-support.ts b/packages/api/src/router/timeline-project-context-support.ts new file mode 100644 index 0000000..286ab30 --- /dev/null +++ b/packages/api/src/router/timeline-project-context-support.ts @@ -0,0 +1,160 @@ +import { TRPCError } from "@trpc/server"; +import { summarizeHolidayOverlays, type formatHolidayOverlays } from "./timeline-holiday-read.js"; +import { + createTimelineDateRange, + fmtDate, + rangesOverlap, + summarizeTimelineEntries, + toDate, +} from "./timeline-read-shared.js"; + +export interface ResolveTimelineProjectContextPeriodInput { + requestedStartDate?: string | undefined; + requestedEndDate?: string | undefined; + durationDays?: number | undefined; + projectStartDate?: Date | null | undefined; + projectEndDate?: Date | null | undefined; + firstAssignmentStartDate?: Date | string | null | undefined; + firstDemandStartDate?: Date | string | null | undefined; +} + +export function resolveTimelineProjectContextPeriod( + input: ResolveTimelineProjectContextPeriodInput, +) { + const startDate = input.requestedStartDate + ? createTimelineDateRange({ + startDate: input.requestedStartDate, + durationDays: 1, + }).startDate + : input.projectStartDate + ?? (input.firstAssignmentStartDate ? toDate(input.firstAssignmentStartDate) : null) + ?? (input.firstDemandStartDate ? toDate(input.firstDemandStartDate) : null) + ?? createTimelineDateRange({ durationDays: 1 }).startDate; + + const endDate = input.requestedEndDate + ? createTimelineDateRange({ + startDate: fmtDate(startDate) ?? undefined, + endDate: input.requestedEndDate, + }).endDate + : input.projectEndDate + ?? createTimelineDateRange({ + startDate: fmtDate(startDate) ?? undefined, + durationDays: input.durationDays ?? 21, + }).endDate; + + if (endDate < startDate) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "endDate must be on or after startDate.", + }); + } + + return { startDate, endDate }; +} + +export interface TimelineProjectAssignmentConflict { + assignmentId: string; + resourceId: string; + resourceName: string | null; + startDate: string | null; + endDate: string | null; + hoursPerDay: number; + overlapCount: number; + crossProjectOverlapCount: number; + overlaps: Array<{ + id: string; + projectId: string | null; + projectName: string | null; + projectShortCode: string | null; + startDate: string | null; + endDate: string | null; + hoursPerDay: number; + status: string; + sameProject: boolean; + }>; +} + +export function buildTimelineProjectAssignmentConflicts(input: { + projectId: string; + assignments: Array<{ + id: string; + resourceId: string | null; + resource?: { displayName?: string | null } | null; + startDate: Date | string; + endDate: Date | string; + hoursPerDay: number; + }>; + allResourceAllocations: Array<{ + id: string; + resourceId: string | null; + projectId: string | null; + project?: { name?: string | null; shortCode?: string | null } | null; + startDate: Date | string; + endDate: Date | string; + hoursPerDay: number; + status: string; + }>; +}): TimelineProjectAssignmentConflict[] { + return input.assignments + .filter((assignment) => assignment.resourceId && assignment.resource) + .map((assignment) => { + const overlaps = input.allResourceAllocations + .filter((booking) => ( + booking.resourceId === assignment.resourceId + && booking.id !== assignment.id + && rangesOverlap( + toDate(booking.startDate), + toDate(booking.endDate), + toDate(assignment.startDate), + toDate(assignment.endDate), + ) + )) + .map((booking) => ({ + id: booking.id, + projectId: booking.projectId, + projectName: booking.project?.name ?? null, + projectShortCode: booking.project?.shortCode ?? null, + startDate: fmtDate(toDate(booking.startDate)), + endDate: fmtDate(toDate(booking.endDate)), + hoursPerDay: booking.hoursPerDay, + status: booking.status, + sameProject: booking.projectId === input.projectId, + })); + + return { + assignmentId: assignment.id, + resourceId: assignment.resourceId!, + resourceName: assignment.resource?.displayName ?? null, + startDate: fmtDate(toDate(assignment.startDate)), + endDate: fmtDate(toDate(assignment.endDate)), + hoursPerDay: assignment.hoursPerDay, + overlapCount: overlaps.length, + crossProjectOverlapCount: overlaps.filter((booking) => !booking.sameProject).length, + overlaps, + }; + }); +} + +export function buildTimelineProjectContextSummary(input: { + allocations: Array<{ projectId: string | null; resourceId: string | null }>; + demands: Array<{ projectId: string | null }>; + assignments: Array<{ projectId: string | null; resourceId: string | null }>; + resourceIds: string[]; + allResourceAllocations: unknown[]; + assignmentConflicts: Array<{ crossProjectOverlapCount: number }>; + holidayOverlays: ReturnType; +}) { + return { + ...summarizeTimelineEntries({ + allocations: input.allocations, + demands: input.demands, + assignments: input.assignments, + }), + resourceIds: input.resourceIds.length, + allResourceAllocationCount: input.allResourceAllocations.length, + conflictedAssignmentCount: input.assignmentConflicts.filter( + (item) => item.crossProjectOverlapCount > 0, + ).length, + ...summarizeHolidayOverlays(input.holidayOverlays), + }; +} diff --git a/packages/api/src/router/timeline-project-read.ts b/packages/api/src/router/timeline-project-read.ts index f30323e..2cce64b 100644 --- a/packages/api/src/router/timeline-project-read.ts +++ b/packages/api/src/router/timeline-project-read.ts @@ -1,24 +1,25 @@ import { listAssignmentBookings } from "@capakraken/application"; -import { computeBudgetStatus, validateShift } from "@capakraken/engine"; +import { computeBudgetStatus } from "@capakraken/engine"; import { ShiftProjectSchema } from "@capakraken/shared"; -import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { getAnonymizationDirectory } from "../lib/anonymization.js"; import { controllerProcedure } from "../trpc.js"; import { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; +import { + buildTimelineProjectAssignmentConflicts, + buildTimelineProjectContextSummary, + resolveTimelineProjectContextPeriod, +} from "./timeline-project-context-support.js"; import { buildTimelineShiftPlan } from "./timeline-shift-planning.js"; -import { loadTimelineHolidayOverlays, formatHolidayOverlays, summarizeHolidayOverlays } from "./timeline-holiday-read.js"; +import { loadTimelineHolidayOverlays, formatHolidayOverlays } from "./timeline-holiday-read.js"; import { anonymizeResourceOnEntry, - createTimelineDateRange, fmtDate, getAssignmentResourceIds, - rangesOverlap, ShiftDbClient, - summarizeTimelineEntries, - toDate, } from "./timeline-read-shared.js"; +import { buildTimelineProjectShiftValidation } from "./timeline-shift-support.js"; export async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) { const [project, planningRead] = await Promise.all([ @@ -124,19 +125,12 @@ export async function previewTimelineProjectShift( newEndDate: Date; }, ) { - const { project, shiftPlan } = await loadProjectShiftContext(db, input.projectId); + const context = await loadProjectShiftContext(db, input.projectId); - return validateShift({ - project: { - id: project.id, - budgetCents: project.budgetCents, - winProbability: project.winProbability, - startDate: project.startDate, - endDate: project.endDate, - }, + return buildTimelineProjectShiftValidation({ + context, newStartDate: input.newStartDate, newEndDate: input.newEndDate, - allocations: shiftPlan.validationAllocations, }); } @@ -182,94 +176,46 @@ export const timelineProjectReadProcedures = { .query(async ({ ctx, input }) => { const projectContext = await loadTimelineProjectContext(ctx.db, input.projectId); const directory = await getAnonymizationDirectory(ctx.db); - - const derivedStartDate = input.startDate - ? createTimelineDateRange({ startDate: input.startDate, durationDays: 1 }).startDate - : projectContext.project.startDate - ?? projectContext.assignments[0]?.startDate - ?? projectContext.demands[0]?.startDate - ?? createTimelineDateRange({ durationDays: 1 }).startDate; - const derivedEndDate = input.endDate - ? createTimelineDateRange({ startDate: fmtDate(derivedStartDate) ?? undefined, endDate: input.endDate }).endDate - : projectContext.project.endDate - ?? createTimelineDateRange({ - startDate: fmtDate(derivedStartDate) ?? undefined, - durationDays: input.durationDays ?? 21, - }).endDate; - - if (derivedEndDate < derivedStartDate) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "endDate must be on or after startDate.", - }); - } + const period = resolveTimelineProjectContextPeriod({ + requestedStartDate: input.startDate, + requestedEndDate: input.endDate, + durationDays: input.durationDays, + projectStartDate: projectContext.project.startDate, + projectEndDate: projectContext.project.endDate, + firstAssignmentStartDate: projectContext.assignments[0]?.startDate, + firstDemandStartDate: projectContext.demands[0]?.startDate, + }); const holidayOverlays = projectContext.resourceIds.length > 0 ? await loadTimelineHolidayOverlays(ctx.db, { - startDate: derivedStartDate, - endDate: derivedEndDate, + startDate: period.startDate, + endDate: period.endDate, resourceIds: projectContext.resourceIds, projectIds: [input.projectId], }) : []; const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays); - - const assignmentConflicts = projectContext.assignments - .filter((assignment) => assignment.resourceId && assignment.resource) - .map((assignment) => { - const overlaps = projectContext.allResourceAllocations - .filter((booking) => ( - booking.resourceId === assignment.resourceId - && booking.id !== assignment.id - && rangesOverlap( - toDate(booking.startDate), - toDate(booking.endDate), - toDate(assignment.startDate), - toDate(assignment.endDate), - ) - )) - .map((booking) => ({ - id: booking.id, - projectId: booking.projectId, - projectName: booking.project?.name ?? null, - projectShortCode: booking.project?.shortCode ?? null, - startDate: fmtDate(toDate(booking.startDate)), - endDate: fmtDate(toDate(booking.endDate)), - hoursPerDay: booking.hoursPerDay, - status: booking.status, - sameProject: booking.projectId === input.projectId, - })); - - return { - assignmentId: assignment.id, - resourceId: assignment.resourceId!, - resourceName: assignment.resource?.displayName ?? null, - startDate: fmtDate(toDate(assignment.startDate)), - endDate: fmtDate(toDate(assignment.endDate)), - hoursPerDay: assignment.hoursPerDay, - overlapCount: overlaps.length, - crossProjectOverlapCount: overlaps.filter((booking) => !booking.sameProject).length, - overlaps, - }; - }); + const assignmentConflicts = buildTimelineProjectAssignmentConflicts({ + projectId: input.projectId, + assignments: projectContext.assignments, + allResourceAllocations: projectContext.allResourceAllocations, + }); return { project: projectContext.project, period: { - startDate: fmtDate(derivedStartDate), - endDate: fmtDate(derivedEndDate), - }, - summary: { - ...summarizeTimelineEntries({ - allocations: projectContext.allocations, - demands: projectContext.demands, - assignments: projectContext.assignments, - }), - resourceIds: projectContext.resourceIds.length, - allResourceAllocationCount: projectContext.allResourceAllocations.length, - conflictedAssignmentCount: assignmentConflicts.filter((item) => item.crossProjectOverlapCount > 0).length, - ...summarizeHolidayOverlays(formattedHolidayOverlays), + startDate: fmtDate(period.startDate), + endDate: fmtDate(period.endDate), }, + summary: buildTimelineProjectContextSummary({ + allocations: projectContext.allocations, + demands: projectContext.demands, + assignments: projectContext.assignments, + resourceIds: projectContext.resourceIds, + allResourceAllocations: projectContext.allResourceAllocations, + assignmentConflicts, + holidayOverlays: formattedHolidayOverlays, + }), allocations: projectContext.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory), ),