refactor(api): extract timeline project context support

This commit is contained in:
2026-03-31 15:00:01 +02:00
parent b669de54e1
commit e1de9a3a98
3 changed files with 379 additions and 92 deletions
@@ -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 },
],
});
});
});
@@ -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<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),
};
}
@@ -1,24 +1,25 @@
import { listAssignmentBookings } from "@capakraken/application"; import { listAssignmentBookings } from "@capakraken/application";
import { computeBudgetStatus, validateShift } from "@capakraken/engine"; import { computeBudgetStatus } from "@capakraken/engine";
import { ShiftProjectSchema } from "@capakraken/shared"; import { ShiftProjectSchema } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js"; import { findUniqueOrThrow } from "../db/helpers.js";
import { getAnonymizationDirectory } from "../lib/anonymization.js"; import { getAnonymizationDirectory } from "../lib/anonymization.js";
import { controllerProcedure } from "../trpc.js"; import { controllerProcedure } from "../trpc.js";
import { loadProjectPlanningReadModel } from "./project-planning-read-model.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 { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
import { loadTimelineHolidayOverlays, formatHolidayOverlays, summarizeHolidayOverlays } from "./timeline-holiday-read.js"; import { loadTimelineHolidayOverlays, formatHolidayOverlays } from "./timeline-holiday-read.js";
import { import {
anonymizeResourceOnEntry, anonymizeResourceOnEntry,
createTimelineDateRange,
fmtDate, fmtDate,
getAssignmentResourceIds, getAssignmentResourceIds,
rangesOverlap,
ShiftDbClient, ShiftDbClient,
summarizeTimelineEntries,
toDate,
} from "./timeline-read-shared.js"; } from "./timeline-read-shared.js";
import { buildTimelineProjectShiftValidation } from "./timeline-shift-support.js";
export async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) { export async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) {
const [project, planningRead] = await Promise.all([ const [project, planningRead] = await Promise.all([
@@ -124,19 +125,12 @@ export async function previewTimelineProjectShift(
newEndDate: Date; newEndDate: Date;
}, },
) { ) {
const { project, shiftPlan } = await loadProjectShiftContext(db, input.projectId); const context = await loadProjectShiftContext(db, input.projectId);
return validateShift({ return buildTimelineProjectShiftValidation({
project: { context,
id: project.id,
budgetCents: project.budgetCents,
winProbability: project.winProbability,
startDate: project.startDate,
endDate: project.endDate,
},
newStartDate: input.newStartDate, newStartDate: input.newStartDate,
newEndDate: input.newEndDate, newEndDate: input.newEndDate,
allocations: shiftPlan.validationAllocations,
}); });
} }
@@ -182,94 +176,46 @@ 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 derivedStartDate = input.startDate requestedStartDate: input.startDate,
? createTimelineDateRange({ startDate: input.startDate, durationDays: 1 }).startDate requestedEndDate: input.endDate,
: projectContext.project.startDate durationDays: input.durationDays,
?? projectContext.assignments[0]?.startDate projectStartDate: projectContext.project.startDate,
?? projectContext.demands[0]?.startDate projectEndDate: projectContext.project.endDate,
?? createTimelineDateRange({ durationDays: 1 }).startDate; firstAssignmentStartDate: projectContext.assignments[0]?.startDate,
const derivedEndDate = input.endDate firstDemandStartDate: projectContext.demands[0]?.startDate,
? 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 holidayOverlays = projectContext.resourceIds.length > 0 const holidayOverlays = projectContext.resourceIds.length > 0
? await loadTimelineHolidayOverlays(ctx.db, { ? await loadTimelineHolidayOverlays(ctx.db, {
startDate: derivedStartDate, startDate: period.startDate,
endDate: derivedEndDate, endDate: period.endDate,
resourceIds: projectContext.resourceIds, resourceIds: projectContext.resourceIds,
projectIds: [input.projectId], projectIds: [input.projectId],
}) })
: []; : [];
const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays); const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays);
const assignmentConflicts = buildTimelineProjectAssignmentConflicts({
const assignmentConflicts = projectContext.assignments projectId: input.projectId,
.filter((assignment) => assignment.resourceId && assignment.resource) assignments: projectContext.assignments,
.map((assignment) => { allResourceAllocations: projectContext.allResourceAllocations,
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,
};
});
return { return {
project: projectContext.project, project: projectContext.project,
period: { period: {
startDate: fmtDate(derivedStartDate), startDate: fmtDate(period.startDate),
endDate: fmtDate(derivedEndDate), endDate: fmtDate(period.endDate),
},
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),
}, },
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) => allocations: projectContext.allocations.map((allocation) =>
anonymizeResourceOnEntry(allocation, directory), anonymizeResourceOnEntry(allocation, directory),
), ),