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
+40 -44
View File
@@ -5,6 +5,8 @@ import {
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { paginate, paginateCursor, PaginationInputSchema, CursorInputSchema } from "../db/pagination.js";
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
@@ -13,13 +15,9 @@ import { controllerProcedure, createTRPCRouter, managerProcedure, protectedProce
export const projectRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
PaginationInputSchema.extend({
status: z.nativeEnum(ProjectStatus).optional(),
search: z.string().optional(),
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(500).default(50),
// Cursor-based pagination (additive — page/limit still supported)
cursor: z.string().optional(),
// Custom field JSONB filters
customFieldFilters: z.array(z.object({
key: z.string(),
@@ -29,7 +27,7 @@ export const projectRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const { status, search, page, limit, cursor, customFieldFilters } = input;
const { status, search, cursor, customFieldFilters } = input;
const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields }));
@@ -47,36 +45,35 @@ export const projectRouter = createTRPCRouter({
...(cfConditions.length > 0 ? { AND: cfConditions } : {}),
};
const skip = cursor ? 0 : (page - 1) * limit;
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
const [rawProjects, total] = await Promise.all([
ctx.db.project.findMany({
where: whereWithCursor,
skip,
take: limit + 1,
orderBy: [{ startDate: "asc" }, { id: "asc" }],
}),
ctx.db.project.count({ where }),
]);
const hasMore = rawProjects.length > limit;
const projects = hasMore ? rawProjects.slice(0, limit) : rawProjects;
const nextCursor = hasMore ? projects[projects.length - 1]!.id : null;
const result = await paginate(
({ skip, take }) =>
ctx.db.project.findMany({
where: whereWithCursor,
skip,
take,
orderBy: [{ startDate: "asc" }, { id: "asc" }],
}),
() => ctx.db.project.count({ where }),
input,
);
const { countsByProjectId } = await countPlanningEntries(ctx.db, {
projectIds: projects.map((project) => project.id),
projectIds: result.items.map((project) => project.id),
});
return {
projects: projects.map((project) => ({
projects: result.items.map((project) => ({
...project,
_count: {
allocations: countsByProjectId.get(project.id) ?? 0,
},
})),
total,
page,
limit,
nextCursor,
total: result.total,
page: result.page,
limit: result.limit,
nextCursor: result.nextCursor,
};
}),
@@ -161,10 +158,10 @@ export const projectRouter = createTRPCRouter({
.input(z.object({ id: z.string(), data: UpdateProjectSchema }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const existing = await ctx.db.project.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const existing = await findUniqueOrThrow(
ctx.db.project.findUnique({ where: { id: input.id } }),
"Project",
);
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>;
@@ -247,15 +244,13 @@ export const projectRouter = createTRPCRouter({
listWithCosts: controllerProcedure
.input(
z.object({
CursorInputSchema.extend({
status: z.nativeEnum(ProjectStatus).optional(),
search: z.string().optional(),
limit: z.number().int().min(1).max(500).default(50),
cursor: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const { status, search, limit, cursor } = input;
const { status, search, cursor } = input;
const where = {
...(status ? { status } : {}),
...(search
@@ -269,16 +264,17 @@ export const projectRouter = createTRPCRouter({
};
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
const rawProjects = await ctx.db.project.findMany({
where: whereWithCursor,
take: limit + 1,
orderBy: [{ startDate: "asc" }, { id: "asc" }],
});
const result = await paginateCursor(
({ take }) =>
ctx.db.project.findMany({
where: whereWithCursor,
take,
orderBy: [{ startDate: "asc" }, { id: "asc" }],
}),
input,
);
const hasMore = rawProjects.length > limit;
const projectsRaw = hasMore ? rawProjects.slice(0, limit) : rawProjects;
const nextCursor = hasMore ? projectsRaw[projectsRaw.length - 1]!.id : null;
const projectIds = projectsRaw.map((project) => project.id);
const projectIds = result.items.map((project) => project.id);
const bookings = projectIds.length
? await listAssignmentBookings(ctx.db, {
startDate: new Date("1900-01-01T00:00:00.000Z"),
@@ -288,7 +284,7 @@ export const projectRouter = createTRPCRouter({
: [];
// Compute cost + person days per project
const projects = projectsRaw.map((p) => {
const projects = result.items.map((p) => {
const projectBookings = bookings.filter((booking) => booking.projectId === p.id);
let totalCostCents = 0;
let totalPersonDays = 0;
@@ -311,6 +307,6 @@ export const projectRouter = createTRPCRouter({
};
});
return { projects, nextCursor };
return { projects, nextCursor: result.nextCursor };
}),
});