refactor: complete v2 refactoring plan (Phases 1-5)

Phase 1 — Quick Wins: centralize formatMoney/formatCents, extract
findUniqueOrThrow helper (19 routers), shared Prisma select constants,
useInvalidatePlanningViews hook, status badge consolidation, composite
DB indexes.

Phase 2 — Timeline Split: extract TimelineContext, TimelineResourcePanel,
TimelineProjectPanel; split 28-dep useMemo into 3 focused memos.
TimelineView.tsx reduced from 1,903 to 538 lines.

Phase 3 — Query Performance: server-side filtering for getEntriesView,
remove availability from timeline resource select, SSE event debouncing
(50ms batch window).

Phase 4 — Estimate Workspace: extract 7 tab components and 3 editor
components. EstimateWorkspaceClient 1,298→306 lines,
EstimateWorkspaceDraftEditor 1,205→581 lines.

Phase 5 — Package Cleanup: split commit-dispo-import-batch (1,112→573
lines), extract shared pagination helper with 11 tests.

All tests pass: 209 API, 254 engine, 67 application.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-14 23:03:42 +01:00
parent 4dabb9d4ce
commit ad0855902b
65 changed files with 7108 additions and 4740 deletions
@@ -0,0 +1,222 @@
import type { WeekdayAvailability } from "@planarchy/shared";
import {
createWeekdayAvailabilityFromFte,
normalizeDispoRoleToken,
} from "@planarchy/shared";
import type { TxClient, MergedStagedResource } from "./commit-dispo-batch-types.js";
import { deriveRoleTokens } from "./shared.js";
function asNullableString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function normalizeFallbackRoleName(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function asObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function mergeScalar<T>(
current: T | null,
incoming: T | null | undefined,
): T | null {
return incoming ?? current;
}
export function inferRoleNameFromResource(resource: MergedStagedResource): string | null {
const explicitRoleName = Array.from(resource.roleTokens)
.map((token) => normalizeDispoRoleToken(token))
.find((roleName): roleName is string => Boolean(roleName));
if (explicitRoleName) {
return explicitRoleName;
}
const derivedRoleName = deriveRoleTokens(
resource.chapter,
asNullableString(resource.rawPayload.department),
asNullableString(resource.rawPayload.mainSkillset),
asNullableString(resource.rawPayload.sapOrgUnitLevelSix),
asNullableString(resource.rawPayload.sapOrgUnitLevelSeven),
asNullableString(resource.rawPayload.sapEmployeeName),
)
.map((token) => normalizeDispoRoleToken(token))
.find((roleName): roleName is string => Boolean(roleName));
if (derivedRoleName) {
return derivedRoleName;
}
if (resource.chapter === "Art Direction") {
return "Art Director";
}
if (resource.chapter === "Project Management") {
return "Project Manager";
}
const fallbackRoleLabel =
asNullableString(resource.rawPayload.department) ??
asNullableString(resource.rawPayload.mainSkillset) ??
asNullableString(resource.chapter) ??
asNullableString(resource.rawPayload.sapOrgUnitLevelSeven) ??
asNullableString(resource.rawPayload.sapOrgUnitLevelSix);
return fallbackRoleLabel ? normalizeFallbackRoleName(fallbackRoleLabel) : null;
}
export function mergeStagedResources(
rows: Awaited<ReturnType<TxClient["stagedResource"]["findMany"]>>,
): Map<string, MergedStagedResource> {
const sourcePriority = new Map([
["CHARGEABILITY", 1],
["ROSTER", 2],
]);
const ordered = [...rows].sort(
(left, right) =>
(sourcePriority.get(left.sourceKind) ?? 0) - (sourcePriority.get(right.sourceKind) ?? 0),
);
const merged = new Map<string, MergedStagedResource>();
for (const row of ordered) {
const existing = merged.get(row.canonicalExternalId);
const rawPayload = asObject(row.rawPayload);
if (!existing) {
merged.set(row.canonicalExternalId, {
availability: row.availability ?? null,
canonicalExternalId: row.canonicalExternalId,
chapter: row.chapter ?? null,
chargeabilityTarget: row.chargeabilityTarget ?? null,
clientUnitName: row.clientUnitName ?? null,
countryCode: row.countryCode ?? null,
displayName: row.displayName ?? null,
email: row.email ?? null,
fte: row.fte ?? null,
lcrCents: row.lcrCents ?? null,
managementLevelGroupName: row.managementLevelGroupName ?? null,
managementLevelName: row.managementLevelName ?? null,
metroCityName: row.metroCityName ?? null,
rawPayload,
resourceType: row.resourceType ?? null,
roleTokens: new Set(row.roleTokens),
sourceKinds: [row.sourceKind],
ucrCents: row.ucrCents ?? null,
vacationDaysPerYear: isFiniteNumber(rawPayload.vacationDaysPerYear)
? rawPayload.vacationDaysPerYear
: null,
warnings: [...row.warnings],
});
continue;
}
existing.availability = mergeScalar(existing.availability, row.availability);
existing.chapter = mergeScalar(existing.chapter, row.chapter);
existing.chargeabilityTarget = mergeScalar(existing.chargeabilityTarget, row.chargeabilityTarget);
existing.clientUnitName = mergeScalar(existing.clientUnitName, row.clientUnitName);
existing.countryCode = mergeScalar(existing.countryCode, row.countryCode);
existing.displayName = mergeScalar(existing.displayName, row.displayName);
existing.email = mergeScalar(existing.email, row.email);
existing.fte = mergeScalar(existing.fte, row.fte);
existing.lcrCents = mergeScalar(existing.lcrCents, row.lcrCents);
existing.managementLevelGroupName = mergeScalar(
existing.managementLevelGroupName,
row.managementLevelGroupName,
);
existing.managementLevelName = mergeScalar(existing.managementLevelName, row.managementLevelName);
existing.metroCityName = mergeScalar(existing.metroCityName, row.metroCityName);
existing.resourceType = mergeScalar(existing.resourceType, row.resourceType);
existing.ucrCents = mergeScalar(existing.ucrCents, row.ucrCents);
if (existing.availability === null && row.availability !== null) {
existing.availability = row.availability;
}
for (const roleToken of row.roleTokens) {
existing.roleTokens.add(roleToken);
}
existing.sourceKinds.push(row.sourceKind);
existing.warnings.push(...row.warnings);
existing.rawPayload = {
...existing.rawPayload,
...rawPayload,
};
if (isFiniteNumber(rawPayload.vacationDaysPerYear)) {
existing.vacationDaysPerYear = rawPayload.vacationDaysPerYear;
}
}
return merged;
}
export function parseWeekdayAvailability(
value: unknown,
fallbackFte: number | null,
): WeekdayAvailability {
const fallback = createWeekdayAvailabilityFromFte(fallbackFte ?? 1);
const source = asObject(value);
return {
monday: isFiniteNumber(source.monday) ? source.monday : fallback.monday,
tuesday: isFiniteNumber(source.tuesday) ? source.tuesday : fallback.tuesday,
wednesday: isFiniteNumber(source.wednesday) ? source.wednesday : fallback.wednesday,
thursday: isFiniteNumber(source.thursday) ? source.thursday : fallback.thursday,
friday: isFiniteNumber(source.friday) ? source.friday : fallback.friday,
};
}
export interface ReferenceDataMaps {
clientIdByCode: Map<string, string>;
clientIdByName: Map<string, string>;
countryIdByCode: Map<string, string>;
managementLevelGroupByName: Map<string, { id: string; name: string; targetPercentage: number }>;
managementLevelIdByName: Map<string, string>;
metroCityIdByName: Map<string, string>;
orgUnitIdByLevelAndName: Map<string, string>;
roleIdByName: Map<string, string>;
utilizationCategoryIdByCode: Map<string, string>;
}
export function buildReferenceDataMaps(data: {
clients: { id: string; code: string | null; name: string }[];
countries: { id: string; code: string }[];
managementLevelGroups: { id: string; name: string; targetPercentage: number }[];
managementLevels: { id: string; name: string }[];
metroCities: { id: string; name: string }[];
orgUnits: { id: string; level: number; name: string }[];
roles: { id: string; name: string }[];
utilizationCategories: { id: string; code: string }[];
}): ReferenceDataMaps {
return {
clientIdByCode: new Map(
data.clients.filter((client) => client.code).map((client) => [client.code!, client.id]),
),
clientIdByName: new Map(
data.clients.map((client) => [client.name.toLowerCase(), client.id]),
),
countryIdByCode: new Map(
data.countries.map((country) => [country.code, country.id]),
),
managementLevelGroupByName: new Map(
data.managementLevelGroups.map((group) => [group.name, group]),
),
managementLevelIdByName: new Map(
data.managementLevels.map((level) => [level.name, level.id]),
),
metroCityIdByName: new Map(
data.metroCities.map((metroCity) => [metroCity.name.toLowerCase(), metroCity.id]),
),
orgUnitIdByLevelAndName: new Map(
data.orgUnits.map((orgUnit) => [`${orgUnit.level}:${orgUnit.name.toLowerCase()}`, orgUnit.id]),
),
roleIdByName: new Map(
data.roles.map((role) => [role.name, role.id]),
),
utilizationCategoryIdByCode: new Map(
data.utilizationCategories.map((category) => [category.code, category.id]),
),
};
}
@@ -0,0 +1,68 @@
import type { Prisma, PrismaClient } from "@planarchy/db";
export type CommitDbClient = Pick<
PrismaClient,
| "$transaction"
| "assignment"
| "client"
| "country"
| "importBatch"
| "managementLevel"
| "managementLevelGroup"
| "metroCity"
| "orgUnit"
| "project"
| "resource"
| "resourceRole"
| "role"
| "stagedAssignment"
| "stagedAvailabilityRule"
| "stagedProject"
| "stagedResource"
| "stagedUnresolvedRecord"
| "stagedVacation"
| "utilizationCategory"
| "user"
| "vacation"
| "vacationEntitlement"
>;
export type TxClient = Parameters<Parameters<CommitDbClient["$transaction"]>[0]>[0];
export interface MergedStagedResource {
availability: Prisma.InputJsonValue | null;
canonicalExternalId: string;
chapter: string | null;
chargeabilityTarget: number | null;
clientUnitName: string | null;
countryCode: string | null;
displayName: string | null;
email: string | null;
fte: number | null;
lcrCents: number | null;
managementLevelGroupName: string | null;
managementLevelName: string | null;
metroCityName: string | null;
rawPayload: Record<string, unknown>;
resourceType: NonNullable<Awaited<ReturnType<TxClient["stagedResource"]["findMany"]>>[number]["resourceType"]> | null;
roleTokens: Set<string>;
sourceKinds: string[];
ucrCents: number | null;
vacationDaysPerYear: number | null;
warnings: string[];
}
export interface AggregatedAssignment {
endDate: Date;
hoursPerDay: number;
percentage: number;
projectId: string;
projectShortCode: string;
resourceId: string;
resourceKey: string;
roleId: string;
roleName: string;
sourceDates: string[];
startDate: Date;
utilizationCategoryCode: string | null;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
import type { WeekdayAvailability } from "@planarchy/shared";
import {
DISPO_INTERNAL_PROJECT_BUCKETS,
normalizeDispoRoleToken,
} from "@planarchy/shared";
import type { TxClient, AggregatedAssignment } from "./commit-dispo-batch-types.js";
import { deriveTbdDispoProjectIdentity } from "./tbd-projects.js";
function asObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function normalizeDate(date: Date): Date {
return new Date(`${date.toISOString().slice(0, 10)}T00:00:00.000Z`);
}
function getDateKey(date: Date): string {
return normalizeDate(date).toISOString().slice(0, 10);
}
function addDays(date: Date, days: number): Date {
const next = normalizeDate(date);
next.setUTCDate(next.getUTCDate() + days);
return next;
}
function roundToOneDecimal(value: number): number {
return Math.round(value * 10) / 10;
}
const WEEKDAY_KEYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"] as const;
const WORKDAY_KEYS = ["monday", "tuesday", "wednesday", "thursday", "friday"] as const;
function resolveInternalProjectShortCode(utilizationCategoryCode: string | null): string | null {
return (
DISPO_INTERNAL_PROJECT_BUCKETS.find(
(bucket) => bucket.utilizationCategoryCode === utilizationCategoryCode,
)?.shortCode ?? null
);
}
export function aggregateAssignments(
rows: Awaited<ReturnType<TxClient["stagedAssignment"]["findMany"]>>,
resourceIdByKey: ReadonlyMap<string, string>,
projectIdByShortCode: ReadonlyMap<string, string>,
roleIdByName: ReadonlyMap<string, string>,
resourceRoleNameByKey: ReadonlyMap<string, string>,
importTbdProjects: boolean,
): AggregatedAssignment[] {
const resolvedRows = rows
.filter((row) => !row.isUnassigned && (!row.isTbd || importTbdProjects))
.map((row) => {
const projectShortCode = row.isInternal
? resolveInternalProjectShortCode(row.utilizationCategoryCode)
: row.isTbd
? deriveTbdDispoProjectIdentity(
String(asObject(row.rawPayload).rawToken ?? ""),
row.utilizationCategoryCode ?? null,
).shortCode
: (row.projectKey ?? null);
const roleName =
row.roleName ??
normalizeDispoRoleToken(row.roleToken) ??
resourceRoleNameByKey.get(row.resourceExternalId) ??
null;
const resourceId = resourceIdByKey.get(row.resourceExternalId);
const projectId = projectShortCode ? projectIdByShortCode.get(projectShortCode) : null;
const roleId = roleName ? roleIdByName.get(roleName) : null;
if (!resourceId) {
throw new Error(`Unable to resolve resource "${row.resourceExternalId}" during assignment commit`);
}
if (!projectShortCode || !projectId) {
throw new Error(
`Unable to resolve project for assignment resource "${row.resourceExternalId}" on ${getDateKey(row.assignmentDate ?? row.startDate ?? new Date())}`,
);
}
if (!roleName || !roleId) {
throw new Error(
`Unable to resolve role for assignment resource "${row.resourceExternalId}" on ${getDateKey(row.assignmentDate ?? row.startDate ?? new Date())}`,
);
}
if (row.assignmentDate === null || row.hoursPerDay === null || row.percentage === null) {
throw new Error(`Assignment row "${row.id}" is missing normalized date or load information`);
}
return {
assignmentDate: normalizeDate(row.assignmentDate),
hoursPerDay: row.hoursPerDay,
percentage: row.percentage,
projectId,
projectShortCode,
resourceId,
resourceKey: row.resourceExternalId,
roleId,
roleName,
utilizationCategoryCode: row.utilizationCategoryCode ?? null,
};
})
.sort((left, right) =>
left.resourceKey.localeCompare(right.resourceKey) ||
left.projectShortCode.localeCompare(right.projectShortCode) ||
left.roleName.localeCompare(right.roleName) ||
left.assignmentDate.getTime() - right.assignmentDate.getTime(),
);
const aggregated: AggregatedAssignment[] = [];
for (const row of resolvedRows) {
const previous = aggregated.at(-1);
const canMerge = previous &&
previous.resourceId === row.resourceId &&
previous.projectId === row.projectId &&
previous.roleId === row.roleId &&
previous.hoursPerDay === row.hoursPerDay &&
previous.percentage === row.percentage &&
previous.endDate.getTime() === addDays(row.assignmentDate, -1).getTime();
if (canMerge) {
previous.endDate = row.assignmentDate;
previous.sourceDates.push(getDateKey(row.assignmentDate));
continue;
}
aggregated.push({
endDate: row.assignmentDate,
hoursPerDay: row.hoursPerDay,
percentage: row.percentage,
projectId: row.projectId,
projectShortCode: row.projectShortCode,
resourceId: row.resourceId,
resourceKey: row.resourceKey,
roleId: row.roleId,
roleName: row.roleName,
sourceDates: [getDateKey(row.assignmentDate)],
startDate: row.assignmentDate,
utilizationCategoryCode: row.utilizationCategoryCode,
});
}
return aggregated;
}
export function deriveOverlayAvailability(
baseAvailability: WeekdayAvailability,
rules: Awaited<ReturnType<TxClient["stagedAvailabilityRule"]["findMany"]>>,
): WeekdayAvailability {
const next = { ...baseAvailability };
const weekdayVotes = new Map<string, Map<number, number>>();
for (const rule of rules) {
const date = rule.effectiveStartDate ?? rule.effectiveEndDate;
if (!date) {
continue;
}
const weekdayIndex = normalizeDate(date).getUTCDay();
if (weekdayIndex === 0 || weekdayIndex === 6) {
continue;
}
const weekdayKey = WEEKDAY_KEYS[weekdayIndex] as (typeof WORKDAY_KEYS)[number] | undefined;
if (!weekdayKey || !WORKDAY_KEYS.includes(weekdayKey)) {
continue;
}
const availableHours = rule.availableHours ?? (
rule.percentage !== null && rule.percentage !== undefined
? roundToOneDecimal((rule.percentage / 100) * 8)
: null
);
if (availableHours === null) {
continue;
}
const hoursMap = weekdayVotes.get(weekdayKey) ?? new Map<number, number>();
hoursMap.set(availableHours, (hoursMap.get(availableHours) ?? 0) + 1);
weekdayVotes.set(weekdayKey, hoursMap);
}
for (const weekdayKey of WORKDAY_KEYS) {
const hoursMap = weekdayVotes.get(weekdayKey);
if (!hoursMap || hoursMap.size === 0) {
continue;
}
const firstEntry = [...hoursMap.entries()].sort(
(left, right) => right[1] - left[1] || left[0] - right[0],
)[0];
if (!firstEntry) {
continue;
}
const [resolvedHours] = firstEntry;
next[weekdayKey] = Math.min(next[weekdayKey], resolvedHours);
}
return next;
}
@@ -0,0 +1,79 @@
import type { CommitDbClient } from "./commit-dispo-batch-types.js";
import { StagedRecordStatus } from "@planarchy/db";
function asObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function isAllowedUnresolvedRecord(
record: Awaited<ReturnType<CommitDbClient["stagedUnresolvedRecord"]["findMany"]>>[number],
): boolean {
const message = record.message.toLowerCase();
const hint = (record.resolutionHint ?? "").toLowerCase();
const rawToken = String(asObject(record.normalizedData).rawToken ?? "").toLowerCase();
return record.recordType === "PROJECT" && (
message.includes("[tbd]") ||
hint.includes("[tbd]") ||
rawToken.includes("[tbd]")
);
}
export interface BatchValidationResult {
batchId: string;
batchSummary: unknown;
blockingUnresolved: number;
skippedTbdUnresolved: number;
}
export async function validateDispoBatch(
db: CommitDbClient,
input: {
allowTbdUnresolved?: boolean;
importBatchId: string;
importTbdProjects?: boolean;
},
): Promise<BatchValidationResult> {
const batch = await db.importBatch.findUnique({
where: { id: input.importBatchId },
select: { id: true, status: true, summary: true },
});
if (!batch) {
throw new Error(`Import batch "${input.importBatchId}" not found`);
}
if (!["STAGED", "REVIEW_READY", "APPROVED"].includes(batch.status)) {
throw new Error(`Import batch "${batch.id}" is not ready to commit from status "${batch.status}"`);
}
const unresolved = await db.stagedUnresolvedRecord.findMany({
where: {
importBatchId: batch.id,
status: StagedRecordStatus.UNRESOLVED,
},
});
const blockingUnresolved = unresolved.filter(
(record) =>
!(
(input.allowTbdUnresolved ?? true) ||
(input.importTbdProjects && isAllowedUnresolvedRecord(record))
) || !isAllowedUnresolvedRecord(record),
);
const skippedTbdUnresolved = unresolved.length - blockingUnresolved.length;
if (blockingUnresolved.length > 0) {
throw new Error(
`Import batch "${batch.id}" still has ${blockingUnresolved.length} blocking unresolved staged record(s)`,
);
}
return {
batchId: batch.id,
batchSummary: batch.summary,
blockingUnresolved: blockingUnresolved.length,
skippedTbdUnresolved,
};
}