refactor(api): extract timeline project context response support

This commit is contained in:
2026-03-31 17:26:42 +02:00
parent ad4b334f20
commit aa442829b9
5 changed files with 350 additions and 331 deletions
@@ -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"],
});
});
});
@@ -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([
{
@@ -0,0 +1,123 @@
import {
anonymizeResourceOnEntry,
fmtDate,
summarizeTimelineEntries,
} from "./timeline-read-shared.js";
import {
formatHolidayOverlays,
summarizeHolidayOverlays,
} from "./timeline-holiday-read.js";
type TimelineAnonymizationDirectory = Parameters<typeof anonymizeResourceOnEntry>[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<typeof formatHolidayOverlays>;
}) {
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<typeof formatHolidayOverlays>;
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,
};
}
@@ -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<typeof anonymizeResourceOnEntry>[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<typeof formatHolidayOverlays>;
}) {
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<typeof formatHolidayOverlays>;
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<typeof loadTimelineHolidayOverlays>[0],
input: {
@@ -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,