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:
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user