import { TRPCError } from "@trpc/server"; import type { Allocation } from "@capakraken/shared"; import { formatHolidayOverlays, loadTimelineHolidayOverlays, summarizeHolidayOverlays, } from "./timeline-holiday-read.js"; import { anonymizeResourceOnEntry, createTimelineDateRange, fmtDate, rangesOverlap, summarizeTimelineEntries, toDate, } from "./timeline-read-shared.js"; type TimelineAnonymizationDirectory = Parameters[1]; 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; }) { 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 buildTimelineShiftValidationBookings( bookings: Array<{ id: string; resourceId: string | null; projectId: string | null; startDate: Date; endDate: Date; hoursPerDay: number; status: string; }>, ) { return bookings .filter( ( booking, ): booking is typeof booking & { resourceId: string; projectId: string } => booking.resourceId !== null && booking.projectId !== null, ) .map((booking) => ({ id: booking.id, resourceId: booking.resourceId, projectId: booking.projectId, startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, status: booking.status, })); } export function buildTimelineBudgetStatusAllocations( bookings: Array<{ status: string; dailyCostCents: number; startDate: Date; endDate: Date; hoursPerDay: number; }>, ): Pick< Allocation, "status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay" >[] { return bookings.map((booking) => ({ status: booking.status as Allocation["status"], dailyCostCents: booking.dailyCostCents, startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, })); } export function buildTimelineBudgetStatusResponse(input: { project: { name: string; shortCode: string | null; budgetCents: number; }; budgetStatus: TBudgetStatus; totalAllocations: number; }): TBudgetStatus & { projectName: string; projectCode: string; totalAllocations: number; budgetCents: number; } { return { ...input.budgetStatus, projectName: input.project.name, projectCode: input.project.shortCode ?? "", totalAllocations: input.totalAllocations, budgetCents: input.project.budgetCents, }; } 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: { 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; name: string; shortCode: string | null; status: string; responsiblePerson: string | null; startDate: Date; endDate: Date; }; requestedShift: { newStartDate: Date; newEndDate: Date; }; preview: TPreview; }) { return { project: { id: input.project.id, name: input.project.name, shortCode: input.project.shortCode, status: input.project.status, responsiblePerson: input.project.responsiblePerson, startDate: fmtDate(input.project.startDate), endDate: fmtDate(input.project.endDate), }, requestedShift: { newStartDate: fmtDate(input.requestedShift.newStartDate), newEndDate: fmtDate(input.requestedShift.newEndDate), }, preview: input.preview, }; }