diff --git a/packages/api/src/__tests__/timeline-project-context-response-support.test.ts b/packages/api/src/__tests__/timeline-project-context-response-support.test.ts new file mode 100644 index 0000000..6593642 --- /dev/null +++ b/packages/api/src/__tests__/timeline-project-context-response-support.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from "vitest"; +import { + buildTimelineProjectContextDetailResponse, + buildTimelineProjectContextResponse, + buildTimelineProjectContextSummary, +} from "../router/timeline-project-context-response-support.js"; + +describe("timeline project context response support", () => { + 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 }, + ], + }); + }); + + it("builds anonymized project context responses and detail summaries", () => { + const directory = { + config: { enabled: true, mode: "alias", domain: "example.test" }, + byResourceId: new Map([ + [ + "resource_1", + { + displayName: "Anon Alice", + eid: "ANON-001", + email: "anon-alice@example.test", + }, + ], + ]), + byAliasEid: new Map([["anon-001", "resource_1"]]), + }; + + expect(buildTimelineProjectContextResponse({ + project: { id: "project_1" }, + allocations: [ + { + id: "allocation_1", + projectId: "project_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Alice", eid: "E-001" }, + }, + ], + demands: [{ id: "demand_1", projectId: "project_1" }], + assignments: [ + { + id: "assignment_1", + projectId: "project_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Alice", eid: "E-001" }, + }, + ], + allResourceAllocations: [ + { + id: "booking_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Alice", eid: "E-001" }, + }, + ], + resourceIds: ["resource_1"], + directory: directory as never, + })).toEqual({ + project: { id: "project_1" }, + allocations: [ + { + id: "allocation_1", + projectId: "project_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Anon Alice", eid: "ANON-001" }, + }, + ], + demands: [{ id: "demand_1", projectId: "project_1" }], + assignments: [ + { + id: "assignment_1", + projectId: "project_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Anon Alice", eid: "ANON-001" }, + }, + ], + allResourceAllocations: [ + { + id: "booking_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Anon Alice", eid: "ANON-001" }, + }, + ], + resourceIds: ["resource_1"], + }); + + expect(buildTimelineProjectContextDetailResponse({ + project: { id: "project_1" }, + period: { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + }, + allocations: [ + { + id: "allocation_1", + projectId: "project_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Alice", eid: "E-001" }, + }, + ], + demands: [{ id: "demand_1", projectId: "project_1" }], + assignments: [ + { + id: "assignment_1", + projectId: "project_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Alice", eid: "E-001" }, + }, + ], + allResourceAllocations: [ + { + id: "booking_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Alice", eid: "E-001" }, + }, + ], + resourceIds: ["resource_1"], + assignmentConflicts: [{ crossProjectOverlapCount: 1, assignmentId: "assignment_1" }], + holidayOverlays: [], + directory: directory as never, + })).toEqual({ + project: { id: "project_1" }, + period: { + startDate: "2026-04-01", + endDate: "2026-04-05", + }, + summary: { + allocationCount: 1, + demandCount: 1, + assignmentCount: 1, + projectCount: 1, + resourceCount: 1, + resourceIds: 1, + allResourceAllocationCount: 1, + conflictedAssignmentCount: 1, + overlayCount: 0, + holidayResourceCount: 0, + byScope: [], + }, + allocations: [ + { + id: "allocation_1", + projectId: "project_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Anon Alice", eid: "ANON-001" }, + }, + ], + demands: [{ id: "demand_1", projectId: "project_1" }], + assignments: [ + { + id: "assignment_1", + projectId: "project_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Anon Alice", eid: "ANON-001" }, + }, + ], + allResourceAllocations: [ + { + id: "booking_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Anon Alice", eid: "ANON-001" }, + }, + ], + assignmentConflicts: [{ crossProjectOverlapCount: 1, assignmentId: "assignment_1" }], + holidayOverlays: [], + resourceIds: ["resource_1"], + }); + }); +}); 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 00eea48..d8fa5fb 100644 --- a/packages/api/src/__tests__/timeline-project-context-support.test.ts +++ b/packages/api/src/__tests__/timeline-project-context-support.test.ts @@ -14,9 +14,6 @@ vi.mock("../router/timeline-holiday-read.js", async () => { }); import { - buildTimelineProjectContextDetailResponse, - buildTimelineProjectContextResponse, - buildTimelineProjectContextSummary, loadTimelineProjectContextDetailArtifacts, resolveTimelineProjectContextPeriod, } from "../router/timeline-project-context-support.js"; @@ -147,222 +144,6 @@ describe("timeline project context support", () => { ]); }); - 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 }, - ], - }); - }); - - it("builds anonymized project context responses and detail summaries", () => { - const directory = { - config: { enabled: true, mode: "alias", domain: "example.test" }, - byResourceId: new Map([ - [ - "resource_1", - { - displayName: "Anon Alice", - eid: "ANON-001", - email: "anon-alice@example.test", - }, - ], - ]), - byAliasEid: new Map([["anon-001", "resource_1"]]), - }; - - expect(buildTimelineProjectContextResponse({ - project: { id: "project_1" }, - allocations: [ - { - id: "allocation_1", - projectId: "project_1", - resourceId: "resource_1", - resource: { id: "resource_1", displayName: "Alice", eid: "E-001" }, - }, - ], - demands: [{ id: "demand_1", projectId: "project_1" }], - assignments: [ - { - id: "assignment_1", - projectId: "project_1", - resourceId: "resource_1", - resource: { id: "resource_1", displayName: "Alice", eid: "E-001" }, - }, - ], - allResourceAllocations: [ - { - id: "booking_1", - resourceId: "resource_1", - resource: { id: "resource_1", displayName: "Alice", eid: "E-001" }, - }, - ], - resourceIds: ["resource_1"], - directory: directory as never, - })).toEqual({ - project: { id: "project_1" }, - allocations: [ - { - id: "allocation_1", - projectId: "project_1", - resourceId: "resource_1", - resource: { id: "resource_1", displayName: "Anon Alice", eid: "ANON-001" }, - }, - ], - demands: [{ id: "demand_1", projectId: "project_1" }], - assignments: [ - { - id: "assignment_1", - projectId: "project_1", - resourceId: "resource_1", - resource: { id: "resource_1", displayName: "Anon Alice", eid: "ANON-001" }, - }, - ], - allResourceAllocations: [ - { - id: "booking_1", - resourceId: "resource_1", - resource: { id: "resource_1", displayName: "Anon Alice", eid: "ANON-001" }, - }, - ], - resourceIds: ["resource_1"], - }); - - expect(buildTimelineProjectContextDetailResponse({ - project: { id: "project_1" }, - period: { - startDate: new Date("2026-04-01T00:00:00.000Z"), - endDate: new Date("2026-04-05T00:00:00.000Z"), - }, - allocations: [ - { - id: "allocation_1", - projectId: "project_1", - resourceId: "resource_1", - resource: { id: "resource_1", displayName: "Alice", eid: "E-001" }, - }, - ], - demands: [{ id: "demand_1", projectId: "project_1" }], - assignments: [ - { - id: "assignment_1", - projectId: "project_1", - resourceId: "resource_1", - resource: { id: "resource_1", displayName: "Alice", eid: "E-001" }, - }, - ], - allResourceAllocations: [ - { - id: "booking_1", - resourceId: "resource_1", - resource: { id: "resource_1", displayName: "Alice", eid: "E-001" }, - }, - ], - resourceIds: ["resource_1"], - assignmentConflicts: [{ crossProjectOverlapCount: 1, assignmentId: "assignment_1" }], - holidayOverlays: [], - directory: directory as never, - })).toEqual({ - project: { id: "project_1" }, - period: { - startDate: "2026-04-01", - endDate: "2026-04-05", - }, - summary: { - allocationCount: 1, - demandCount: 1, - assignmentCount: 1, - projectCount: 1, - resourceCount: 1, - resourceIds: 1, - allResourceAllocationCount: 1, - conflictedAssignmentCount: 1, - overlayCount: 0, - holidayResourceCount: 0, - byScope: [], - }, - allocations: [ - { - id: "allocation_1", - projectId: "project_1", - resourceId: "resource_1", - resource: { id: "resource_1", displayName: "Anon Alice", eid: "ANON-001" }, - }, - ], - demands: [{ id: "demand_1", projectId: "project_1" }], - assignments: [ - { - id: "assignment_1", - projectId: "project_1", - resourceId: "resource_1", - resource: { id: "resource_1", displayName: "Anon Alice", eid: "ANON-001" }, - }, - ], - allResourceAllocations: [ - { - id: "booking_1", - resourceId: "resource_1", - resource: { id: "resource_1", displayName: "Anon Alice", eid: "ANON-001" }, - }, - ], - assignmentConflicts: [{ crossProjectOverlapCount: 1, assignmentId: "assignment_1" }], - holidayOverlays: [], - resourceIds: ["resource_1"], - }); - }); - - it("loads detail artifacts with formatted holiday overlays only when resources exist", async () => { loadTimelineHolidayOverlaysMock.mockResolvedValueOnce([ { diff --git a/packages/api/src/router/timeline-project-context-response-support.ts b/packages/api/src/router/timeline-project-context-response-support.ts new file mode 100644 index 0000000..7553a9e --- /dev/null +++ b/packages/api/src/router/timeline-project-context-response-support.ts @@ -0,0 +1,123 @@ +import { + anonymizeResourceOnEntry, + fmtDate, + summarizeTimelineEntries, +} from "./timeline-read-shared.js"; +import { + formatHolidayOverlays, + summarizeHolidayOverlays, +} from "./timeline-holiday-read.js"; + +type TimelineAnonymizationDirectory = Parameters[1]; + +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), + }; +} + +export function buildTimelineProjectContextResponse< + TProject, + TAllocation extends { resource?: { id: string } | null }, + TDemand, + TAssignment extends { resource?: { id: string } | null }, + TBooking extends { resource?: { id: string } | null }, +>(input: { + project: TProject; + allocations: TAllocation[]; + demands: TDemand[]; + assignments: TAssignment[]; + allResourceAllocations: TBooking[]; + resourceIds: string[]; + directory: TimelineAnonymizationDirectory; +}) { + return { + project: input.project, + allocations: input.allocations.map((allocation) => + anonymizeResourceOnEntry(allocation, input.directory), + ), + demands: input.demands, + assignments: input.assignments.map((assignment) => + anonymizeResourceOnEntry(assignment, input.directory), + ), + allResourceAllocations: input.allResourceAllocations.map((allocation) => + anonymizeResourceOnEntry(allocation, input.directory), + ), + resourceIds: input.resourceIds, + }; +} + +export function buildTimelineProjectContextDetailResponse< + TProject, + TAllocation extends { + projectId: string | null; + resourceId: string | null; + resource?: { id: string } | null; + }, + TDemand extends { projectId: string | null }, + TAssignment extends { + projectId: string | null; + resourceId: string | null; + resource?: { id: string } | null; + }, + TBooking extends { resource?: { id: string } | null }, + TConflict extends { crossProjectOverlapCount: number }, +>(input: { + project: TProject; + period: { startDate: Date; endDate: Date }; + allocations: TAllocation[]; + demands: TDemand[]; + assignments: TAssignment[]; + allResourceAllocations: TBooking[]; + resourceIds: string[]; + assignmentConflicts: TConflict[]; + holidayOverlays: ReturnType; + directory: TimelineAnonymizationDirectory; +}) { + const base = buildTimelineProjectContextResponse({ + project: input.project, + allocations: input.allocations, + demands: input.demands, + assignments: input.assignments, + allResourceAllocations: input.allResourceAllocations, + resourceIds: input.resourceIds, + directory: input.directory, + }); + + return { + ...base, + period: { + startDate: fmtDate(input.period.startDate), + endDate: fmtDate(input.period.endDate), + }, + summary: buildTimelineProjectContextSummary({ + allocations: input.allocations, + demands: input.demands, + assignments: input.assignments, + resourceIds: input.resourceIds, + allResourceAllocations: input.allResourceAllocations, + assignmentConflicts: input.assignmentConflicts, + holidayOverlays: input.holidayOverlays, + }), + assignmentConflicts: input.assignmentConflicts, + holidayOverlays: input.holidayOverlays, + }; +} diff --git a/packages/api/src/router/timeline-project-context-support.ts b/packages/api/src/router/timeline-project-context-support.ts index c78917f..7145d52 100644 --- a/packages/api/src/router/timeline-project-context-support.ts +++ b/packages/api/src/router/timeline-project-context-support.ts @@ -1,23 +1,17 @@ import { TRPCError } from "@trpc/server"; import { buildTimelineProjectAssignmentConflicts, - type TimelineProjectAssignmentConflict, } from "./timeline-project-conflict-support.js"; import { formatHolidayOverlays, loadTimelineHolidayOverlays, - summarizeHolidayOverlays, } from "./timeline-holiday-read.js"; import { - anonymizeResourceOnEntry, createTimelineDateRange, fmtDate, - summarizeTimelineEntries, toDate, } from "./timeline-read-shared.js"; -type TimelineAnonymizationDirectory = Parameters[1]; - export interface ResolveTimelineProjectContextPeriodInput { requestedStartDate?: string | undefined; requestedEndDate?: string | undefined; @@ -62,110 +56,6 @@ export function resolveTimelineProjectContextPeriod( return { startDate, endDate }; } -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), - }; -} - -export function buildTimelineProjectContextResponse< - TProject, - TAllocation extends { resource?: { id: string } | null }, - TDemand, - TAssignment extends { resource?: { id: string } | null }, - TBooking extends { resource?: { id: string } | null }, ->(input: { - project: TProject; - allocations: TAllocation[]; - demands: TDemand[]; - assignments: TAssignment[]; - allResourceAllocations: TBooking[]; - resourceIds: string[]; - directory: TimelineAnonymizationDirectory; -}) { - return { - project: input.project, - allocations: input.allocations.map((allocation) => - anonymizeResourceOnEntry(allocation, input.directory), - ), - demands: input.demands, - assignments: input.assignments.map((assignment) => - anonymizeResourceOnEntry(assignment, input.directory), - ), - allResourceAllocations: input.allResourceAllocations.map((allocation) => - anonymizeResourceOnEntry(allocation, input.directory), - ), - resourceIds: input.resourceIds, - }; -} - -export function buildTimelineProjectContextDetailResponse< - TProject, - TAllocation extends { projectId: string | null; resourceId: string | null; resource?: { id: string } | null }, - TDemand extends { projectId: string | null }, - TAssignment extends { projectId: string | null; resourceId: string | null; resource?: { id: string } | null }, - TBooking extends { resource?: { id: string } | null }, - TConflict extends { crossProjectOverlapCount: number }, ->(input: { - project: TProject; - period: { startDate: Date; endDate: Date }; - allocations: TAllocation[]; - demands: TDemand[]; - assignments: TAssignment[]; - allResourceAllocations: TBooking[]; - resourceIds: string[]; - assignmentConflicts: TConflict[]; - holidayOverlays: ReturnType; - directory: TimelineAnonymizationDirectory; -}) { - const base = buildTimelineProjectContextResponse({ - project: input.project, - allocations: input.allocations, - demands: input.demands, - assignments: input.assignments, - allResourceAllocations: input.allResourceAllocations, - resourceIds: input.resourceIds, - directory: input.directory, - }); - - return { - ...base, - period: { - startDate: fmtDate(input.period.startDate), - endDate: fmtDate(input.period.endDate), - }, - summary: buildTimelineProjectContextSummary({ - allocations: input.allocations, - demands: input.demands, - assignments: input.assignments, - resourceIds: input.resourceIds, - allResourceAllocations: input.allResourceAllocations, - assignmentConflicts: input.assignmentConflicts, - holidayOverlays: input.holidayOverlays, - }), - assignmentConflicts: input.assignmentConflicts, - holidayOverlays: input.holidayOverlays, - }; -} - export async function loadTimelineProjectContextDetailArtifacts( db: Parameters[0], input: { diff --git a/packages/api/src/router/timeline-project-read.ts b/packages/api/src/router/timeline-project-read.ts index e27f9e1..d23f64d 100644 --- a/packages/api/src/router/timeline-project-read.ts +++ b/packages/api/src/router/timeline-project-read.ts @@ -6,10 +6,12 @@ import { getAnonymizationDirectory } from "../lib/anonymization.js"; import { controllerProcedure } from "../trpc.js"; import { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; import { - buildTimelineProjectContextDetailResponse, - buildTimelineProjectContextResponse, loadTimelineProjectContextDetailArtifacts, } from "./timeline-project-context-support.js"; +import { + buildTimelineProjectContextDetailResponse, + buildTimelineProjectContextResponse, +} from "./timeline-project-context-response-support.js"; import { buildTimelineBudgetStatusAllocations, buildTimelineBudgetStatusResponse,