Files
CapaKraken/packages/api/src/router/timeline-allocation-fragment-support.ts
T
Hartmut 1df208dbcc feat(timeline): add pulse animation for in-flight drag mutations
Allocation bars that have active optimistic overrides (post-drag,
awaiting server confirmation) now pulse subtly via animate-pulse.
The pending set is derived from the existing optimisticAllocations
map keys, requiring no additional state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:28:46 +02:00

296 lines
9.4 KiB
TypeScript

import {
createAssignmentFragment,
deleteAllocationEntry,
loadAllocationEntry,
updateAssignment,
} from "@capakraken/application";
import type { PrismaClient } from "@capakraken/db";
import { AllocationStatus } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
const ALLOCATION_GROUP_ID_KEY = "allocationGroupId";
const ALLOCATION_ORIGIN_ID_KEY = "allocationOriginId";
const ALLOCATION_FRAGMENTED_KEY = "isFragmentedAllocation";
function toUtcCalendarDate(value: Date): Date {
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()));
}
function addDays(date: Date, days: number): Date {
const next = toUtcCalendarDate(date);
next.setUTCDate(next.getUTCDate() + days);
return next;
}
function toSharedAllocationStatus(status: unknown): AllocationStatus {
switch (status) {
case "PROPOSED":
return AllocationStatus.PROPOSED;
case "CONFIRMED":
return AllocationStatus.CONFIRMED;
case "ACTIVE":
return AllocationStatus.ACTIVE;
case "COMPLETED":
return AllocationStatus.COMPLETED;
case "CANCELLED":
return AllocationStatus.CANCELLED;
default:
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unsupported allocation status "${String(status)}" for fragmented assignment creation.`,
});
}
}
function readFragmentMetadata(metadata: unknown, allocationId: string) {
const record =
metadata && typeof metadata === "object" && !Array.isArray(metadata)
? (metadata as Record<string, unknown>)
: {};
const groupId =
typeof record[ALLOCATION_GROUP_ID_KEY] === "string" && record[ALLOCATION_GROUP_ID_KEY].length > 0
? (record[ALLOCATION_GROUP_ID_KEY] as string)
: crypto.randomUUID();
const originId =
typeof record[ALLOCATION_ORIGIN_ID_KEY] === "string" && record[ALLOCATION_ORIGIN_ID_KEY].length > 0
? (record[ALLOCATION_ORIGIN_ID_KEY] as string)
: allocationId;
return {
groupId,
metadata: {
...record,
[ALLOCATION_GROUP_ID_KEY]: groupId,
[ALLOCATION_ORIGIN_ID_KEY]: originId,
[ALLOCATION_FRAGMENTED_KEY]: true,
},
};
}
export interface TimelineAllocationCarveResult {
action: "updated" | "split" | "deleted";
allocationGroupId: string;
updatedAllocationIds: string[];
createdAllocationIds: string[];
deletedAllocationIds: string[];
projectId: string;
resourceId: string | null;
}
export interface TimelineAllocationExtractResult {
action: "unchanged" | "extracted";
allocationGroupId: string;
extractedAllocationId: string;
updatedAllocationIds: string[];
createdAllocationIds: string[];
projectId: string;
resourceId: string | null;
}
export async function carveTimelineAllocationRange(input: {
db: PrismaClient;
allocationId: string;
startDate: Date;
endDate: Date;
}): Promise<TimelineAllocationCarveResult> {
const resolved = await loadAllocationEntry(input.db, input.allocationId);
if (resolved.kind !== "assignment") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Only staffed assignments can currently be split into fragments.",
});
}
const carveStart = toUtcCalendarDate(input.startDate);
const carveEnd = toUtcCalendarDate(input.endDate);
if (carveEnd < carveStart) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Carve end date must be on or after the carve start date.",
});
}
const assignment = resolved.assignment;
const assignmentStart = toUtcCalendarDate(assignment.startDate);
const assignmentEnd = toUtcCalendarDate(assignment.endDate);
if (carveStart < assignmentStart || carveEnd > assignmentEnd) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The requested carve range must be fully inside the existing allocation.",
});
}
const { groupId, metadata } = readFragmentMetadata(assignment.metadata, assignment.id);
const leftEnd = addDays(carveStart, -1);
const rightStart = addDays(carveEnd, 1);
const hasLeftFragment = leftEnd >= assignmentStart;
const hasRightFragment = rightStart <= assignmentEnd;
return input.db.$transaction(async (tx) => {
if (!hasLeftFragment && !hasRightFragment) {
await deleteAllocationEntry(
tx as unknown as Parameters<typeof deleteAllocationEntry>[0],
resolved,
);
return {
action: "deleted" as const,
allocationGroupId: groupId,
updatedAllocationIds: [],
createdAllocationIds: [],
deletedAllocationIds: [assignment.id],
projectId: assignment.projectId,
resourceId: assignment.resourceId,
};
}
const updatedAllocationIds: string[] = [];
const createdAllocationIds: string[] = [];
const keptStart = hasLeftFragment ? assignmentStart : rightStart;
const keptEnd = hasLeftFragment ? leftEnd : assignmentEnd;
const updated = await updateAssignment(
tx as unknown as Parameters<typeof updateAssignment>[0],
assignment.id,
{
startDate: keptStart,
endDate: keptEnd,
metadata,
},
);
updatedAllocationIds.push(updated.id);
if (hasLeftFragment && hasRightFragment) {
const created = await createAssignmentFragment(
tx as unknown as Parameters<typeof createAssignmentFragment>[0],
{
demandRequirementId: assignment.demandRequirementId ?? undefined,
resourceId: assignment.resourceId,
projectId: assignment.projectId,
startDate: rightStart,
endDate: assignmentEnd,
hoursPerDay: assignment.hoursPerDay,
percentage: assignment.percentage,
role: assignment.role ?? undefined,
roleId: assignment.roleId ?? undefined,
dailyCostCents: assignment.dailyCostCents,
status: toSharedAllocationStatus(assignment.status),
metadata,
},
);
createdAllocationIds.push(created.id);
}
return {
action: hasLeftFragment && hasRightFragment ? "split" as const : "updated" as const,
allocationGroupId: groupId,
updatedAllocationIds,
createdAllocationIds,
deletedAllocationIds: [],
projectId: assignment.projectId,
resourceId: assignment.resourceId,
};
});
}
export async function extractTimelineAllocationFragment(input: {
db: PrismaClient;
allocationId: string;
startDate: Date;
endDate: Date;
}): Promise<TimelineAllocationExtractResult> {
const resolved = await loadAllocationEntry(input.db, input.allocationId);
if (resolved.kind !== "assignment") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Only staffed assignments can currently be extracted into fragments.",
});
}
const extractStart = toUtcCalendarDate(input.startDate);
const extractEnd = toUtcCalendarDate(input.endDate);
if (extractEnd < extractStart) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Extract end date must be on or after the extract start date.",
});
}
const assignment = resolved.assignment;
const assignmentStart = toUtcCalendarDate(assignment.startDate);
const assignmentEnd = toUtcCalendarDate(assignment.endDate);
if (extractStart < assignmentStart || extractEnd > assignmentEnd) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The requested extract range must be fully inside the existing allocation.",
});
}
const { groupId, metadata } = readFragmentMetadata(assignment.metadata, assignment.id);
const hasLeftFragment = extractStart > assignmentStart;
const hasRightFragment = extractEnd < assignmentEnd;
if (!hasLeftFragment && !hasRightFragment) {
return {
action: "unchanged",
allocationGroupId: groupId,
extractedAllocationId: assignment.id,
updatedAllocationIds: [],
createdAllocationIds: [],
projectId: assignment.projectId,
resourceId: assignment.resourceId,
};
}
return input.db.$transaction(async (tx) => {
const createdAllocationIds: string[] = [];
const updated = await updateAssignment(
tx as unknown as Parameters<typeof updateAssignment>[0],
assignment.id,
{
startDate: extractStart,
endDate: extractEnd,
metadata,
},
);
const fragmentBase = {
demandRequirementId: assignment.demandRequirementId ?? undefined,
resourceId: assignment.resourceId,
projectId: assignment.projectId,
hoursPerDay: assignment.hoursPerDay,
percentage: assignment.percentage,
role: assignment.role ?? undefined,
roleId: assignment.roleId ?? undefined,
dailyCostCents: assignment.dailyCostCents,
status: toSharedAllocationStatus(assignment.status),
metadata,
};
const txClient = tx as unknown as Parameters<typeof createAssignmentFragment>[0];
const fragments = await Promise.all([
hasLeftFragment
? createAssignmentFragment(txClient, { ...fragmentBase, startDate: assignmentStart, endDate: addDays(extractStart, -1) })
: null,
hasRightFragment
? createAssignmentFragment(txClient, { ...fragmentBase, startDate: addDays(extractEnd, 1), endDate: assignmentEnd })
: null,
]);
for (const frag of fragments) {
if (frag) createdAllocationIds.push(frag.id);
}
return {
action: "extracted" as const,
allocationGroupId: groupId,
extractedAllocationId: updated.id,
updatedAllocationIds: [updated.id],
createdAllocationIds,
projectId: assignment.projectId,
resourceId: assignment.resourceId,
};
});
}