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 4eb039a..6a2bc77 100644 --- a/packages/api/src/__tests__/timeline-project-context-support.test.ts +++ b/packages/api/src/__tests__/timeline-project-context-support.test.ts @@ -1,5 +1,18 @@ import { TRPCError } from "@trpc/server"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../router/timeline-holiday-read.js", async () => { + const actual = await vi.importActual( + "../router/timeline-holiday-read.js", + ); + + return { + ...actual, + loadTimelineHolidayOverlays: vi.fn(), + formatHolidayOverlays: vi.fn(actual.formatHolidayOverlays), + }; +}); + import { buildTimelineBudgetStatusAllocations, buildTimelineBudgetStatusResponse, @@ -9,10 +22,22 @@ import { buildTimelineProjectContextSummary, buildTimelineShiftValidationBookings, buildTimelineShiftPreviewDetailResponse, + loadTimelineProjectContextDetailArtifacts, resolveTimelineProjectContextPeriod, } from "../router/timeline-project-context-support.js"; +import { + formatHolidayOverlays, + loadTimelineHolidayOverlays, +} from "../router/timeline-holiday-read.js"; + +const loadTimelineHolidayOverlaysMock = vi.mocked(loadTimelineHolidayOverlays); +const formatHolidayOverlaysMock = vi.mocked(formatHolidayOverlays); describe("timeline project context support", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("derives the detail period from explicit input and validates order", () => { expect(resolveTimelineProjectContextPeriod({ requestedStartDate: "2026-04-03", @@ -451,4 +476,127 @@ describe("timeline project context support", () => { budgetCents: 120000, }); }); + + it("loads detail artifacts with formatted holiday overlays only when resources exist", async () => { + loadTimelineHolidayOverlaysMock.mockResolvedValueOnce([ + { + id: "overlay_1", + resourceId: "resource_1", + startDate: new Date("2026-04-03T00:00:00.000Z"), + endDate: new Date("2026-04-03T00:00:00.000Z"), + note: "Holiday", + scope: "COUNTRY", + calendarName: "DE", + sourceType: "CALENDAR", + countryCode: "DE", + countryName: "Germany", + federalState: null, + metroCityName: null, + }, + ] as never); + + const artifacts = await loadTimelineProjectContextDetailArtifacts({} as never, { + projectId: "project_1", + requestedStartDate: "2026-04-01", + durationDays: 5, + projectStartDate: new Date("2026-04-01T00:00:00.000Z"), + projectEndDate: new Date("2026-04-10T00:00:00.000Z"), + firstAssignmentStartDate: new Date("2026-04-02T00:00:00.000Z"), + firstDemandStartDate: new Date("2026-04-01T00:00:00.000Z"), + 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, + }, + ], + allResourceAllocations: [ + { + id: "booking_1", + 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", + }, + ], + resourceIds: ["resource_1"], + }); + + expect(loadTimelineHolidayOverlaysMock).toHaveBeenCalledWith( + {}, + { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + resourceIds: ["resource_1"], + projectIds: ["project_1"], + }, + ); + expect(formatHolidayOverlaysMock).toHaveBeenCalledTimes(1); + expect(artifacts).toEqual({ + period: { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + }, + 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, + }, + ], + assignmentConflicts: [ + { + assignmentId: "assignment_1", + resourceId: "resource_1", + resourceName: "Alice", + startDate: "2026-04-01", + endDate: "2026-04-05", + hoursPerDay: 8, + overlapCount: 1, + crossProjectOverlapCount: 1, + overlaps: [ + { + id: "booking_1", + projectId: "project_2", + projectName: "Other", + projectShortCode: "OTH", + startDate: "2026-04-04", + endDate: "2026-04-07", + hoursPerDay: 6, + status: "ACTIVE", + sameProject: false, + }, + ], + }, + ], + }); + + const emptyArtifacts = await loadTimelineProjectContextDetailArtifacts({} as never, { + projectId: "project_1", + requestedStartDate: "2026-04-01", + durationDays: 5, + assignments: [], + allResourceAllocations: [], + resourceIds: [], + }); + + expect(loadTimelineHolidayOverlaysMock).toHaveBeenCalledTimes(1); + expect(emptyArtifacts.holidayOverlays).toEqual([]); + expect(emptyArtifacts.assignmentConflicts).toEqual([]); + }); }); diff --git a/packages/api/src/router/timeline-project-context-support.ts b/packages/api/src/router/timeline-project-context-support.ts index dfa8b87..8c3d083 100644 --- a/packages/api/src/router/timeline-project-context-support.ts +++ b/packages/api/src/router/timeline-project-context-support.ts @@ -1,6 +1,10 @@ import { TRPCError } from "@trpc/server"; import type { Allocation } from "@capakraken/shared"; -import { summarizeHolidayOverlays, type formatHolidayOverlays } from "./timeline-holiday-read.js"; +import { + formatHolidayOverlays, + loadTimelineHolidayOverlays, + summarizeHolidayOverlays, +} from "./timeline-holiday-read.js"; import { anonymizeResourceOnEntry, createTimelineDateRange, @@ -316,6 +320,70 @@ export function buildTimelineProjectContextDetailResponse< }; } +export async function loadTimelineProjectContextDetailArtifacts( + db: Parameters[0], + input: { + projectId: string; + 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; + 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; + }>; + resourceIds: string[]; + }, +) { + const period = resolveTimelineProjectContextPeriod({ + requestedStartDate: input.requestedStartDate, + requestedEndDate: input.requestedEndDate, + durationDays: input.durationDays, + projectStartDate: input.projectStartDate, + projectEndDate: input.projectEndDate, + firstAssignmentStartDate: input.firstAssignmentStartDate, + firstDemandStartDate: input.firstDemandStartDate, + }); + + const holidayOverlays = input.resourceIds.length > 0 + ? formatHolidayOverlays(await loadTimelineHolidayOverlays(db, { + startDate: period.startDate, + endDate: period.endDate, + resourceIds: input.resourceIds, + projectIds: [input.projectId], + })) + : []; + + const assignmentConflicts = buildTimelineProjectAssignmentConflicts({ + projectId: input.projectId, + assignments: input.assignments, + allResourceAllocations: input.allResourceAllocations, + }); + + return { + period, + holidayOverlays, + assignmentConflicts, + }; +} + export function buildTimelineShiftPreviewDetailResponse(input: { project: { id: string; diff --git a/packages/api/src/router/timeline-project-read.ts b/packages/api/src/router/timeline-project-read.ts index 01ccb17..9203f57 100644 --- a/packages/api/src/router/timeline-project-read.ts +++ b/packages/api/src/router/timeline-project-read.ts @@ -11,13 +11,11 @@ import { buildTimelineBudgetStatusResponse, buildTimelineProjectContextDetailResponse, buildTimelineProjectContextResponse, - buildTimelineProjectAssignmentConflicts, + loadTimelineProjectContextDetailArtifacts, 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 { getAssignmentResourceIds, ShiftDbClient } from "./timeline-read-shared.js"; import { buildTimelineProjectShiftValidation } from "./timeline-shift-support.js"; @@ -163,30 +161,20 @@ export const timelineProjectReadProcedures = { .query(async ({ ctx, input }) => { const projectContext = await loadTimelineProjectContext(ctx.db, input.projectId); const directory = await getAnonymizationDirectory(ctx.db); - 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: period.startDate, - endDate: period.endDate, - resourceIds: projectContext.resourceIds, - projectIds: [input.projectId], - }) - : []; - const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays); - const assignmentConflicts = buildTimelineProjectAssignmentConflicts({ - projectId: input.projectId, - assignments: projectContext.assignments, - allResourceAllocations: projectContext.allResourceAllocations, - }); + const { period, holidayOverlays, assignmentConflicts } = + await loadTimelineProjectContextDetailArtifacts(ctx.db, { + projectId: input.projectId, + 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, + assignments: projectContext.assignments, + allResourceAllocations: projectContext.allResourceAllocations, + resourceIds: projectContext.resourceIds, + }); return buildTimelineProjectContextDetailResponse({ project: projectContext.project, @@ -197,7 +185,7 @@ export const timelineProjectReadProcedures = { allResourceAllocations: projectContext.allResourceAllocations, resourceIds: projectContext.resourceIds, assignmentConflicts, - holidayOverlays: formattedHolidayOverlays, + holidayOverlays, directory, }); }),