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