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
+89 -25
View File
@@ -13,9 +13,10 @@ import { calculateAllocation, computeBudgetStatus, validateShift } from "@planar
import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import {
loadProjectPlanningReadModel,
PROJECT_PLANNING_ASSIGNMENT_INCLUDE,
TIMELINE_ASSIGNMENT_INCLUDE,
PROJECT_PLANNING_DEMAND_INCLUDE,
} from "./project-planning-read-model.js";
import {
@@ -25,6 +26,7 @@ import {
} from "../sse/event-bus.js";
import { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
type ShiftDbClient = Pick<
PrismaClient,
@@ -33,7 +35,7 @@ type ShiftDbClient = Pick<
type TimelineEntriesDbClient = Pick<
PrismaClient,
"demandRequirement" | "assignment"
"demandRequirement" | "assignment" | "resource"
>;
type TimelineEntriesFilters = {
@@ -41,6 +43,8 @@ type TimelineEntriesFilters = {
endDate: Date;
resourceIds?: string[] | undefined;
projectIds?: string[] | undefined;
chapters?: string[] | undefined;
eids?: string[] | undefined;
};
function getAssignmentResourceIds(
@@ -59,10 +63,34 @@ async function loadTimelineEntriesReadModel(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,
) {
const { startDate, endDate, resourceIds, projectIds } = input;
const { startDate, endDate, resourceIds, projectIds, chapters, eids } = input;
// When resource-level filters are active (resourceIds, chapters, or eids),
// resolve matching resource IDs so we can push the filter to the DB query.
const effectiveResourceIds = await (async () => {
if (resourceIds && resourceIds.length > 0) return resourceIds;
const hasChapters = chapters && chapters.length > 0;
const hasEids = eids && eids.length > 0;
if (!hasChapters && !hasEids) return undefined;
const matching = await db.resource.findMany({
where: {
...(hasChapters && hasEids
? { AND: [{ chapter: { in: chapters } }, { eid: { in: eids } }] }
: hasChapters
? { chapter: { in: chapters } }
: { eid: { in: eids! } }),
},
select: { id: true },
});
return matching.map((r) => r.id);
})();
// When filtering by resource (either explicit resourceIds or derived from chapters),
// demands without a resource are excluded.
const excludeDemands = effectiveResourceIds !== undefined;
const [demandRequirements, assignments] = await Promise.all([
resourceIds && resourceIds.length > 0
excludeDemands
? Promise.resolve([])
: db.demandRequirement.findMany({
where: {
@@ -79,10 +107,10 @@ async function loadTimelineEntriesReadModel(
status: { not: "CANCELLED" },
startDate: { lte: endDate },
endDate: { gte: startDate },
...(resourceIds ? { resourceId: { in: resourceIds } } : {}),
...(effectiveResourceIds ? { resourceId: { in: effectiveResourceIds } } : {}),
...(projectIds ? { projectId: { in: projectIds } } : {}),
},
include: PROJECT_PLANNING_ASSIGNMENT_INCLUDE,
include: TIMELINE_ASSIGNMENT_INCLUDE,
orderBy: [{ startDate: "asc" }, { resourceId: "asc" }],
}),
]);
@@ -144,6 +172,19 @@ async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) {
};
}
function anonymizeResourceOnEntry<T extends { resource?: { id: string } | null }>(
entry: T,
directory: Awaited<ReturnType<typeof getAnonymizationDirectory>>,
): T {
if (!entry.resource) {
return entry;
}
return {
...entry,
resource: anonymizeResource(entry.resource, directory),
};
}
export const timelineRouter = createTRPCRouter({
/**
* Get all timeline entries (projects + allocations) for a date range.
@@ -156,11 +197,14 @@ export const timelineRouter = createTRPCRouter({
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => {
const readModel = await loadTimelineEntriesReadModel(ctx.db, input);
return readModel.allocations;
const directory = await getAnonymizationDirectory(ctx.db);
return readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory));
}),
getEntriesView: protectedProcedure
@@ -170,9 +214,22 @@ export const timelineRouter = createTRPCRouter({
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => loadTimelineEntriesReadModel(ctx.db, input)),
.query(async ({ ctx, input }) => {
const [readModel, directory] = await Promise.all([
loadTimelineEntriesReadModel(ctx.db, input),
getAnonymizationDirectory(ctx.db),
]);
return {
...readModel,
allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
};
}),
/**
* Get full project context for a project:
@@ -218,12 +275,20 @@ export const timelineRouter = createTRPCRouter({
resourceIds,
});
const directory = await getAnonymizationDirectory(ctx.db);
return {
project,
allocations: planningRead.readModel.allocations,
allocations: planningRead.readModel.allocations.map((allocation) =>
anonymizeResourceOnEntry(allocation, directory),
),
demands: planningRead.readModel.demands,
assignments: planningRead.readModel.assignments,
allResourceAllocations,
assignments: planningRead.readModel.assignments.map((assignment) =>
anonymizeResourceOnEntry(assignment, directory),
),
allResourceAllocations: allResourceAllocations.map((allocation) =>
anonymizeResourceOnEntry(allocation, directory),
),
resourceIds,
};
}),
@@ -572,20 +637,19 @@ export const timelineRouter = createTRPCRouter({
getBudgetStatus: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const project = await ctx.db.project.findUnique({
where: { id: input.projectId },
select: {
id: true,
budgetCents: true,
winProbability: true,
startDate: true,
endDate: true,
},
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const project = await findUniqueOrThrow(
ctx.db.project.findUnique({
where: { id: input.projectId },
select: {
id: true,
budgetCents: true,
winProbability: true,
startDate: true,
endDate: true,
},
}),
"Project",
);
const bookings = await listAssignmentBookings(ctx.db, {
startDate: project.startDate,