refactor(api): extract timeline project detail artifact loader

This commit is contained in:
2026-03-31 15:44:13 +02:00
parent 231d3f3124
commit 49bf3f3214
3 changed files with 234 additions and 30 deletions
@@ -1,5 +1,18 @@
import { TRPCError } from "@trpc/server"; 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<typeof import("../router/timeline-holiday-read.js")>(
"../router/timeline-holiday-read.js",
);
return {
...actual,
loadTimelineHolidayOverlays: vi.fn(),
formatHolidayOverlays: vi.fn(actual.formatHolidayOverlays),
};
});
import { import {
buildTimelineBudgetStatusAllocations, buildTimelineBudgetStatusAllocations,
buildTimelineBudgetStatusResponse, buildTimelineBudgetStatusResponse,
@@ -9,10 +22,22 @@ import {
buildTimelineProjectContextSummary, buildTimelineProjectContextSummary,
buildTimelineShiftValidationBookings, buildTimelineShiftValidationBookings,
buildTimelineShiftPreviewDetailResponse, buildTimelineShiftPreviewDetailResponse,
loadTimelineProjectContextDetailArtifacts,
resolveTimelineProjectContextPeriod, resolveTimelineProjectContextPeriod,
} from "../router/timeline-project-context-support.js"; } 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", () => { describe("timeline project context support", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("derives the detail period from explicit input and validates order", () => { it("derives the detail period from explicit input and validates order", () => {
expect(resolveTimelineProjectContextPeriod({ expect(resolveTimelineProjectContextPeriod({
requestedStartDate: "2026-04-03", requestedStartDate: "2026-04-03",
@@ -451,4 +476,127 @@ describe("timeline project context support", () => {
budgetCents: 120000, 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([]);
});
}); });
@@ -1,6 +1,10 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import type { Allocation } from "@capakraken/shared"; 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 { import {
anonymizeResourceOnEntry, anonymizeResourceOnEntry,
createTimelineDateRange, createTimelineDateRange,
@@ -316,6 +320,70 @@ export function buildTimelineProjectContextDetailResponse<
}; };
} }
export async function loadTimelineProjectContextDetailArtifacts(
db: Parameters<typeof loadTimelineHolidayOverlays>[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<TPreview>(input: { export function buildTimelineShiftPreviewDetailResponse<TPreview>(input: {
project: { project: {
id: string; id: string;
@@ -11,13 +11,11 @@ import {
buildTimelineBudgetStatusResponse, buildTimelineBudgetStatusResponse,
buildTimelineProjectContextDetailResponse, buildTimelineProjectContextDetailResponse,
buildTimelineProjectContextResponse, buildTimelineProjectContextResponse,
buildTimelineProjectAssignmentConflicts, loadTimelineProjectContextDetailArtifacts,
buildTimelineShiftValidationBookings, buildTimelineShiftValidationBookings,
buildTimelineShiftPreviewDetailResponse, buildTimelineShiftPreviewDetailResponse,
resolveTimelineProjectContextPeriod,
} from "./timeline-project-context-support.js"; } from "./timeline-project-context-support.js";
import { buildTimelineShiftPlan } from "./timeline-shift-planning.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 { getAssignmentResourceIds, ShiftDbClient } from "./timeline-read-shared.js";
import { buildTimelineProjectShiftValidation } from "./timeline-shift-support.js"; import { buildTimelineProjectShiftValidation } from "./timeline-shift-support.js";
@@ -163,7 +161,9 @@ export const timelineProjectReadProcedures = {
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const projectContext = await loadTimelineProjectContext(ctx.db, input.projectId); const projectContext = await loadTimelineProjectContext(ctx.db, input.projectId);
const directory = await getAnonymizationDirectory(ctx.db); const directory = await getAnonymizationDirectory(ctx.db);
const period = resolveTimelineProjectContextPeriod({ const { period, holidayOverlays, assignmentConflicts } =
await loadTimelineProjectContextDetailArtifacts(ctx.db, {
projectId: input.projectId,
requestedStartDate: input.startDate, requestedStartDate: input.startDate,
requestedEndDate: input.endDate, requestedEndDate: input.endDate,
durationDays: input.durationDays, durationDays: input.durationDays,
@@ -171,21 +171,9 @@ export const timelineProjectReadProcedures = {
projectEndDate: projectContext.project.endDate, projectEndDate: projectContext.project.endDate,
firstAssignmentStartDate: projectContext.assignments[0]?.startDate, firstAssignmentStartDate: projectContext.assignments[0]?.startDate,
firstDemandStartDate: projectContext.demands[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, assignments: projectContext.assignments,
allResourceAllocations: projectContext.allResourceAllocations, allResourceAllocations: projectContext.allResourceAllocations,
resourceIds: projectContext.resourceIds,
}); });
return buildTimelineProjectContextDetailResponse({ return buildTimelineProjectContextDetailResponse({
@@ -197,7 +185,7 @@ export const timelineProjectReadProcedures = {
allResourceAllocations: projectContext.allResourceAllocations, allResourceAllocations: projectContext.allResourceAllocations,
resourceIds: projectContext.resourceIds, resourceIds: projectContext.resourceIds,
assignmentConflicts, assignmentConflicts,
holidayOverlays: formattedHolidayOverlays, holidayOverlays,
directory, directory,
}); });
}), }),