Files
CapaKraken/docs/refactor-v2-plan.md

16 KiB
Raw Permalink Blame History

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.tsx
  • apps/web/src/components/estimates/EstimateWorkspaceDraftEditor.tsx
  • apps/web/src/components/estimates/VersionCompare.tsx
  • apps/web/src/components/estimates/CommercialTermsEditor.tsx
  • apps/web/src/components/estimates/EstimateWizard.tsx
  • apps/web/src/app/(app)/estimates/EstimatesClient.tsx
  • apps/web/src/components/dashboard/widgets/StatCardsWidget.tsx

Target: apps/web/src/lib/format.ts — add formatMoney(cents: number, currency?: string): string

Acceptance criteria:

  • formatMoney exported from ~/lib/format.ts
  • All 7 local copies removed, replaced by import from ~/lib/format.ts
  • pnpm --filter @capakraken/web exec tsc --noEmit passes
  • Visual output unchanged (same de-DE locale, same maximumFractionDigits: 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 run passes (191 tests)
  • pnpm --filter @capakraken/api exec tsc --noEmit passes

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.tsx
  • apps/web/src/components/resources/ResourceModal.tsx
  • apps/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.tsx
  • apps/web/src/components/vacations/VacationClient.tsx
  • apps/web/src/components/vacations/MyVacationsClient.tsx
  • apps/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:push succeeds
  • 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:

  • TimelineContext created with useTimelineContext() 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 13041430+ 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.tsx reduced 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.tsx reduced 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 useMemo has 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:

  • getEntriesView accepts optional filter arrays (resourceIds, projectIds, chapters, eids)
  • Prisma where clause includes filters when provided
  • Client passes active filters from TimelineFilter state
  • 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:

  • availability removed from timeline resource select (new TIMELINE_ASSIGNMENT_INCLUDE)
  • Staffing/capacity queries still load availability where 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.tsx
  • apps/web/src/components/estimates/tabs/AssumptionsTab.tsx
  • apps/web/src/components/estimates/tabs/ScopeTab.tsx
  • apps/web/src/components/estimates/tabs/StaffingTab.tsx
  • apps/web/src/components/estimates/tabs/FinancialsTab.tsx
  • apps/web/src/components/estimates/tabs/VersionsTab.tsx
  • apps/web/src/components/estimates/tabs/ExportsTab.tsx

Acceptance criteria:

  • Each tab is a separate file with its own imports (7 tab files created)
  • EstimateWorkspaceClient.tsx reduced 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.tsx
  • apps/web/src/components/estimates/editors/ScopeItemEditor.tsx
  • apps/web/src/components/estimates/editors/DemandLineEditor.tsx

Acceptance criteria:

  • Each editor is a separate file (3 editor files created)
  • EstimateWorkspaceDraftEditor.tsx reduced 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:seed works
  • 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 checks
  • build-dispo-maps.ts — chargeability and reference data map construction
  • determine-placement.ts — placement context and assignment logic
  • commit-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.ts orchestrates 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 + paginateCursor in packages/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 pass
  • pnpm --filter @capakraken/api exec vitest run — 187+ tests pass
  • pnpm --filter @capakraken/application exec vitest run — 67+ tests pass
  • pnpm --filter @capakraken/web exec tsc --noEmit — zero errors
  • pnpm --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+