16 KiB
CapaKraken v2 Refactoring Plan
Date: 2026-03-14 Status: Proposed Purpose: Consolidate duplicated patterns, optimize timeline performance, and simplify component architecture for long-term maintainability.
Guiding Principles
- No feature changes — behavior stays identical before and after each phase.
- Each phase is independently shippable and ends with green tests.
- Phases are ordered by risk-adjusted impact: quick wins first, structural changes last.
- Every extraction must reduce net LOC or measurably improve a metric.
Phase 1 — Quick Wins (Consolidation)
Goal: Remove the most common code duplication across the codebase without changing any component boundaries.
1.1 Centralize formatMoney()
Problem: 7 near-identical formatMoney(cents, currency?) implementations scattered across estimate and dashboard components.
Files affected:
apps/web/src/components/estimates/EstimateWorkspaceClient.tsxapps/web/src/components/estimates/EstimateWorkspaceDraftEditor.tsxapps/web/src/components/estimates/VersionCompare.tsxapps/web/src/components/estimates/CommercialTermsEditor.tsxapps/web/src/components/estimates/EstimateWizard.tsxapps/web/src/app/(app)/estimates/EstimatesClient.tsxapps/web/src/components/dashboard/widgets/StatCardsWidget.tsx
Target: apps/web/src/lib/format.ts — add formatMoney(cents: number, currency?: string): string
Acceptance criteria:
formatMoneyexported from~/lib/format.ts- All 7 local copies removed, replaced by import from
~/lib/format.ts pnpm --filter @capakraken/web exec tsc --noEmitpasses- Visual output unchanged (same
de-DElocale, samemaximumFractionDigits: 0)
1.2 Extract findUniqueOrThrow() helper
Problem: 19+ router files repeat the same findUnique → if (!result) throw NOT_FOUND pattern.
Target: packages/api/src/db/helpers.ts
export async function findUniqueOrThrow<T>(
query: Promise<T | null>,
entityName: string,
): Promise<T> {
const result = await query;
if (!result) {
throw new TRPCError({ code: "NOT_FOUND", message: `${entityName} not found` });
}
return result;
}
Files affected: All router files in packages/api/src/router/ that use the pattern.
Acceptance criteria:
- Helper exported from
packages/api/src/db/helpers.ts - At least 15 router files migrated to use the helper (19 files migrated)
pnpm --filter @capakraken/api exec vitest runpasses (191 tests)pnpm --filter @capakraken/api exec tsc --noEmitpasses
1.3 Shared Prisma select constants
Problem: role: { select: { id: true, name: true, color: true } } appears 5+ times. Similar patterns for project and resource selects.
Target: packages/api/src/db/selects.ts
export const ROLE_SELECT = { id: true, name: true, color: true } as const;
export const PROJECT_BRIEF_SELECT = { id: true, name: true, shortCode: true, status: true } as const;
export const RESOURCE_BRIEF_SELECT = { id: true, displayName: true, eid: true, chapter: true } as const;
Acceptance criteria:
- Select constants exported from
packages/api/src/db/selects.ts - All routers using inline
{ id: true, name: true, color: true }for roles migrated - TypeScript compiles, tests pass
1.4 useInvalidatePlanningViews() hook
Problem: 3 modal components copy the same 8-line invalidatePlanningViews() block.
Files affected:
apps/web/src/components/allocations/AllocationModal.tsxapps/web/src/components/resources/ResourceModal.tsxapps/web/src/components/projects/ProjectModal.tsx
Target: apps/web/src/hooks/useInvalidatePlanningViews.ts
Acceptance criteria:
- Hook exported from
~/hooks/useInvalidatePlanningViews.ts - AllocationModal uses the hook (ResourceModal/ProjectModal only have single-line invalidations — not candidates)
- TypeScript compiles, invalidation behavior unchanged
1.5 Status badge style consolidation
Problem: 4+ components define their own STATUS_BADGE: Record<string, string> color maps.
Files affected:
apps/web/src/components/allocations/AllocationsClient.tsxapps/web/src/components/vacations/VacationClient.tsxapps/web/src/components/vacations/MyVacationsClient.tsxapps/web/src/components/dashboard/widgets/ProjectTableWidget.tsx
Target: apps/web/src/lib/status-styles.ts (or packages/ui/src/statusStyles.ts if cross-package)
Acceptance criteria:
- Typed status style maps exported from a single source (
~/lib/status-styles.ts) - All 4 consumers import from the shared module
- Dark mode classes preserved exactly
- TypeScript compiles
1.6 Add missing database indexes
Problem: Common query filter combinations lack composite indexes, causing sequential scans on large datasets.
Target: packages/db/prisma/schema.prisma
model Assignment {
// existing indexes...
@@index([resourceId, status, startDate])
@@index([projectId, startDate, endDate])
}
model DemandRequirement {
// existing indexes...
@@index([projectId, status, startDate])
}
model Vacation {
// existing indexes...
@@index([resourceId, status, startDate, endDate])
}
Acceptance criteria:
- Indexes added to schema
pnpm db:pushsucceeds- No existing queries broken (191 API tests pass, 254 engine tests pass)
- Timeline list query explain plan shows index usage (manual verification on staging)
Phase 2 — Timeline Component Split
Goal: Break TimelineView.tsx (1,903 lines) into focused sub-components and reduce re-render scope.
2.1 Create TimelineContext
Problem: 20+ values passed via props through 5+ component levels. Any prop change re-renders the entire tree.
Target: apps/web/src/components/timeline/TimelineContext.tsx
interface TimelineContextValue {
assignments: AllocationEntry[];
demands: AllocationEntry[];
vacationsByResource: Map<string, VacationEntry[]>;
resources: ResourceBrief[];
resourceMap: Map<string, ResourceBrief>;
viewStart: Date;
viewEnd: Date;
dayWidth: number;
filter: TimelineFilter;
canEdit: boolean;
}
Acceptance criteria:
TimelineContextcreated withuseTimelineContext()hook- All data-fetching and filter state lives in the context provider
- Child components access data via
useTimelineContext()instead of props - No behavioral change — identical rendering output
- TypeScript compiles
2.2 Extract TimelineResourcePanel
Problem: Resource-view rendering logic (~700 lines within TimelineView) is interleaved with project-view logic.
Source lines: Approximately lines 1304–1430+ of current TimelineView.tsx (the resourceViewContent useMemo and its renderers).
Target: apps/web/src/components/timeline/TimelineResourcePanel.tsx
Acceptance criteria:
- Resource row rendering extracted into
TimelineResourcePanel - Uses
useTimelineContext()for data access TimelineView.tsxreduced from 1,903 to 538 lines (72% reduction)- Virtualizer (
@tanstack/react-virtual) works correctly in the extracted component - Drag-to-shift interactions still work
- Visual regression: no layout or behavior change
2.3 Extract TimelineProjectPanel
Problem: Project-group rendering logic (~600 lines) is embedded in TimelineView.tsx.
Target: apps/web/src/components/timeline/TimelineProjectPanel.tsx
Acceptance criteria:
- Project group rows and open-demand blocks extracted into
TimelineProjectPanel - Uses
useTimelineContext()for data access TimelineView.tsxreduced to 538 lines (orchestrator + drag/tooltip/popover)- Visual regression: no layout or behavior change
2.4 Split oversized useMemo chains
Problem: resourceViewContent useMemo has 28 dependencies — any change invalidates the entire memo.
Target: Replace with 3 smaller memos inside TimelineResourcePanel:
| Memo | Dependencies | Responsibility |
|---|---|---|
resourceRows |
resources, filter, allocsByResource | Which rows to render |
vacationBlocks |
vacationsByResource, viewStart, viewEnd, dayWidth | Vacation bar positions |
assignmentBlocks |
allocsByResource, viewStart, viewEnd, dayWidth | Assignment bar positions |
Acceptance criteria:
- No single
useMemohas more than 10 dependencies (max 6) - React DevTools Profiler shows reduced re-render count on filter toggle (manual verification)
- Identical visual output
Phase 3 — Timeline Query Performance
Goal: Reduce data payload and move filtering from client to server.
3.1 Server-side filtering for getEntriesView
Problem: The timeline loads ALL entries for a date range, then filters by resource/project/status client-side. On large datasets (500+ resources, 2000+ assignments) this sends megabytes of unnecessary data.
Target: packages/api/src/router/timeline.ts — extend getEntriesView input:
getEntriesView.input(z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(), // NEW
projectIds: z.array(z.string()).optional(), // NEW
chapters: z.array(z.string()).optional(), // NEW
}))
Acceptance criteria:
getEntriesViewaccepts optional filter arrays (resourceIds, projectIds, chapters, eids)- Prisma
whereclause includes filters when provided - Client passes active filters from
TimelineFilterstate - Payload size reduced by 50%+ when filters are active (manual verification)
- Unfiltered queries behave exactly as before (backward compatible)
- TypeScript compiles
3.2 Remove availability from default timeline resource select
Problem: availability is a JSONB field (weekly hour maps) loaded for every resource in timeline queries but only used in capacity analysis views.
Target: packages/application/src/use-cases/allocation/project-planning-read-model.ts
Remove availability from PROJECT_PLANNING_ASSIGNMENT_INCLUDE.resource.select. Load it separately only in capacity-analysis queries.
Acceptance criteria:
availabilityremoved from timeline resource select (newTIMELINE_ASSIGNMENT_INCLUDE)- Staffing/capacity queries still load
availabilitywhere needed (PROJECT_PLANNING_ASSIGNMENT_INCLUDE) - Timeline rendering unchanged (availability was never used in rendering)
- Payload size reduced (manual verification)
3.3 SSE event debouncing
Problem: Rapid allocation updates (e.g., batch status change) emit one SSE event per record, causing N re-fetches.
Target: packages/api/src/sse/event-bus.ts
Add a 50ms debounce buffer: batch events within the window, then emit a single aggregated event.
Acceptance criteria:
- Batch operations emit 1 SSE event instead of N (50ms debounce buffer)
- Single-record operations still emit immediately (50ms imperceptible)
- Timeline re-fetches only once for batch operations
- SSE tests updated (7 new tests in event-bus-debounce.test.ts)
Phase 4 — Estimate Workspace Simplification
Goal: Break EstimateWorkspaceClient.tsx (1,298 lines) and EstimateWorkspaceDraftEditor.tsx (1,205 lines) into focused tab components.
4.1 Extract tab content components from EstimateWorkspaceClient
Problem: 6 inline tab components (OverviewTab, AssumptionsTab, ScopeTab, StaffingTab, FinancialsTab, VersionsTab) are defined inside a single file.
Target: Create individual files:
apps/web/src/components/estimates/tabs/OverviewTab.tsxapps/web/src/components/estimates/tabs/AssumptionsTab.tsxapps/web/src/components/estimates/tabs/ScopeTab.tsxapps/web/src/components/estimates/tabs/StaffingTab.tsxapps/web/src/components/estimates/tabs/FinancialsTab.tsxapps/web/src/components/estimates/tabs/VersionsTab.tsxapps/web/src/components/estimates/tabs/ExportsTab.tsx
Acceptance criteria:
- Each tab is a separate file with its own imports (7 tab files created)
EstimateWorkspaceClient.tsxreduced to 306 lines (tab orchestrator + header + status bar)- No behavioral change
- TypeScript compiles
4.2 Extract draft editor section editors
Problem: EstimateWorkspaceDraftEditor.tsx (1,205 lines) contains inline editors for assumptions, scope items, and demand lines.
Target: Create individual editor components:
apps/web/src/components/estimates/editors/AssumptionEditor.tsxapps/web/src/components/estimates/editors/ScopeItemEditor.tsxapps/web/src/components/estimates/editors/DemandLineEditor.tsx
Acceptance criteria:
- Each editor is a separate file (3 editor files created)
EstimateWorkspaceDraftEditor.tsxreduced to 581 lines (orchestrator + save logic)- Edit/save flow unchanged
- TypeScript compiles
Phase 5 — Package Boundary Cleanup
Goal: Ensure each package has a clear single responsibility.
5.1 Move seed and migration tooling to packages/db
Problem: update-blueprints.ts (1,272 lines) and seed.ts (1,228 lines) live in packages/application but are database-specific operations.
Acceptance criteria:
- Both files already in
packages/db/src/(no move needed) - Import paths correct
pnpm db:seedworks- No circular dependencies
5.2 Split commit-dispo-import-batch.ts
Problem: Single 1,112-line file doing validation, map building, placement, and persistence.
Target: Split into:
validate-dispo-batch.ts— input validation and denormalization checksbuild-dispo-maps.ts— chargeability and reference data map constructiondetermine-placement.ts— placement context and assignment logiccommit-dispo-import-batch.ts— orchestrator (300 lines max)
Acceptance criteria:
- Each extracted module has a clear single responsibility (3 modules: validate, build-maps, determine-placement)
commit-dispo-import-batch.tsorchestrates via function calls (1,112 → 573 lines)- All 67 application tests pass
- No behavioral change
5.3 Extract shared pagination helper
Problem: 20+ router procedures duplicate cursor-based pagination logic.
Target: packages/api/src/db/pagination.ts
export interface PaginationInput {
page?: number;
limit?: number;
cursor?: string;
}
export interface PaginatedResult<T> {
items: T[];
total: number;
hasMore: boolean;
nextCursor?: string;
}
export async function paginate<T extends { id: string }>(
findMany: () => Promise<T[]>,
count: () => Promise<number>,
input: PaginationInput,
): Promise<PaginatedResult<T>>;
Acceptance criteria:
- Pagination helper exported (
paginate+paginateCursorinpackages/api/src/db/pagination.ts) - 2 router procedures migrated (project.list, project.listWithCosts — others use custom patterns)
- All 209 API tests pass (11 new pagination tests)
- Pagination behavior unchanged
Execution Constraints
| Rule | Rationale |
|---|---|
| One phase at a time | Prevents merge conflicts across structural changes |
| Green tests before moving to next phase | Each phase is independently safe to ship |
| No feature additions during refactoring | Scope creep defeats the purpose |
| Phase 2 and 3 may run in parallel | Timeline component split (frontend) is independent of query optimization (backend) |
| Phase 4 and 5 may run in parallel | Estimate components and package boundaries don't overlap |
Verification Checklist (Per Phase)
pnpm --filter @capakraken/engine exec vitest run— 254+ tests passpnpm --filter @capakraken/api exec vitest run— 187+ tests passpnpm --filter @capakraken/application exec vitest run— 67+ tests passpnpm --filter @capakraken/web exec tsc --noEmit— zero errorspnpm --filter @capakraken/api exec tsc --noEmit— zero errors (excluding pre-existing dispo-import issues if Phase 5.2 not yet done)- Dev server starts and serves pages without 500 errors
- Manual smoke test: timeline renders, estimate workspace tabs work, allocation CRUD works
Metrics to Track
| Metric | Current | Target (Post-Refactor) |
|---|---|---|
TimelineView.tsx lines |
1,903 | < 350 |
EstimateWorkspaceClient.tsx lines |
1,298 | < 250 |
formatMoney copies |
7 | 1 |
| Timeline payload size (filtered, 50 resources) | ~2 MB (estimated) | < 500 KB |
useMemo max dependency count |
28 | < 10 |
| API test count | 187 | 200+ (add tests for new helpers) |
| Total test count | 508 | 520+ |