Skip to main content

Documentation Index

Fetch the complete documentation index at: https://grantmaster.dev/llms.txt

Use this file to discover all available pages before exploring further.

Testability Refactor - Multi-Component Implementation

Overview

This document tracks the testability refactoring of the 5 largest components in the GrantMaster codebase, applying a proven pattern to reduce complexity, improve testability, and enable maintainability.

Status Summary

ComponentOriginal LOCStatusNew LOCReductionTestable Units
GrantApplicationPage910COMPLETE13785%20+
Expenses.tsx1,263COMPLETE34972%10+
Projects.tsx1,411COMPLETE29979%32+
WorkflowPage.tsx2,528WIRED1,98921%50+
EventBusPage.tsx1,491WIRED50366%42+
ExtensionsPage.tsx1,800PENDING---
geminiService.ts752COMPLETE50633%31+
AuditorReviewPanelPage.tsx1,500PENDING---
DocumentBrainContext.tsx1,400PENDING---
Billing.tsx1,400PENDING---
Grantors.tsx1,400PENDING---
Users.tsx1,134PENDING---
ContractorsDashboard.tsx1,183PENDING---
GrantTrackingPage.tsx943PENDING---
notificationService.ts1,100PENDING---
Webhooks.tsx1,150PENDING---
TOTAL~24,000~20% DONETBDTBD185+

The Pattern: 7-Phase Refactoring

This proven pattern has been successfully applied to 2 components (GrantApplicationPage, Expenses.tsx) with excellent results.

Phase 1: Extract Types & Constants (1 day)

Goal: Create type-safe foundation with Zod validation Files to create:
types/
  └── [feature].types.ts      # Zod schemas + TypeScript types

constants/
  └── [feature]Defaults.ts    # Constants, defaults, colors
Template:
// types/[feature].types.ts
import { z } from 'zod';

export const [Feature]FormDataSchema = z.object({
  // Define Zod schema
});

export type [Feature]FormData = z.infer<typeof [Feature]FormDataSchema>;

export interface [Feature]Filters {
  searchTerm: string;
  // ... other filters
}

export interface [Feature]FilterResult {
  filtered[Features]: [Feature][];
  matchedCount: number;
}

Phase 2: Extract Business Logic Hooks (3-4 days)

Goal: Separate business logic into testable hooks with DI Hooks to create (typical):
  • use[Feature]Filters - Pure filtering logic
  • use[Feature]State - State management
  • use[Feature]Actions - CRUD operations with DI
  • use[Feature]Validation - Validation logic (if complex)
Template:
// hooks/use[Feature]Filters.ts
import { useMemo } from 'react';

export function use[Feature]Filters(
  items: [Feature][],
  searchTerm: string,
  filters: any
): [Feature]FilterResult {
  return useMemo(() => {
    const filtered = items.filter(/* ... */);
    return {
      filtered[Features]: filtered,
      matchedCount: filtered.length,
    };
  }, [items, searchTerm, filters]);
}
DI Pattern for hooks with external dependencies:
export interface I[Feature]Service {
  someMethod(params: any): Promise<Result>;
}

export function use[Feature]Actions(
  service: I[Feature]Service,  // Injectable!
  onSuccess: (msg: string) => void,
  onError: (msg: string) => void
) {
  const performAction = async () => {
    const result = await service.someMethod(params);
    // ...
  };
  
  return { performAction };
}

Phase 3: Verify Service DI Support (1 day)

Goal: Ensure existing services support dependency injection Check that services accept injectable dependencies in constructor:
class [Feature]Service extends BaseService {
  constructor(
    logger: ILogger = defaultLogger,
    eventBus: IEventBus = defaultEventBus,
    timeProvider: ITimeProvider = defaultTimeProvider,
    firestoreClient?: IFirestoreClient
  ) {
    super(logger, eventBus, timeProvider, firestoreClient);
  }
}
✅ If already implemented, move to Phase 4 ⚠️ If not, add DI support before continuing

Phase 4: Extract Presentational Components (4-5 days)

Goal: Create pure UI components with no hooks Components to extract (typical 5-8):
  • [Feature]StatsCard - Stats display
  • [Feature]FiltersBar - Search/filter UI
  • [Feature]Table - Data table
  • [Feature]Card - Card view item
  • [Feature]DetailView - Detail modal/panel
  • Plus 3-5 more specific to feature
Template:
interface [Feature]TableProps {
  items: [Feature][];
  onEdit: (item: [Feature]) => void;
  onDelete: (item: [Feature]) => void;
  // ... other handlers
}

export const [Feature]Table: React.FC<[Feature]TableProps> = ({
  items,
  onEdit,
  onDelete,
}) => {
  // Pure rendering - NO HOOKS!
  return <table>...</table>;
};

Phase 5: Create Container Components (2-3 days)

Goal: Orchestrate hooks and pass data to pure components Optional: Can skip if orchestrating directly in page component
export const [Feature]ListContainer: React.FC<Props> = (props) => {
  // Use hooks
  const { filtered } = use[Feature]Filters(items, search, filter);
  const { performAction } = use[Feature]Actions(service, onSuccess, onError);
  
  // Pass to pure component
  return (
    <[Feature]Table
      items={filtered}
      onEdit={handleEdit}
      onDelete={handleDelete}
    />
  );
};

Phase 6: Refactor Page Component (1-2 days)

Goal: Reduce to ~200 lines of orchestration Structure:
const [Feature]sNew: React.FC<Props> = (props) => {
  // Contexts
  const { addToast } = useToast();
  const { hasPermission } = useRBAC();
  
  // Simple UI state (10-15 useState max)
  const [searchTerm, setSearchTerm] = useState('');
  const [selectedItem, setSelectedItem] = useState(null);
  
  // Business logic hooks
  const { filtered } = use[Feature]Filters(items, searchTerm);
  const { performAction } = use[Feature]Actions(/* ... */);
  
  // Handlers (delegate to hooks)
  const handleAction = async (item) => {
    await performAction(item);
  };
  
  // Render
  return (
    <PageLayout>
      <PageHeader ... />
      <[Feature]StatsCard ... />
      <[Feature]FiltersBar ... />
      <[Feature]Table ... />
      {/* Modals */}
    </PageLayout>
  );
};

Phase 7: Add Tests & Documentation (2-3 days)

Files to create:
__tests__/
  ├── factories/
  │   └── [feature]Factory.ts         # Test data factories
  ├── use[Feature]Filters.test.ts     # Hook tests
  └── [Feature]Table.test.tsx         # Component tests

README.md                              # Feature documentation
Test factory template:
let counter = 1;

export function createMock[Feature](overrides = {}): [Feature] {
  return {
    id: `[feature]-${counter++}`,
    name: 'Test [Feature]',
    // ... defaults
    ...overrides,
  };
}

export function createMock[Features](count: number, overrides = {}) {
  return Array.from({ length: count }, (_, i) =>
    createMock[Feature]({ name: `[Feature] ${i + 1}`, ...overrides })
  );
}

Component-Specific Guidance

Projects.tsx (1,411 lines) - COMPLETE

  • Before: 1,411 lines, 0 testable units, all logic inline (form state, budget CRUD, team CRUD, modal state, routing)
  • After: 299 page lines + ~1,200 extracted hook/component lines, 32 tests passing
  • Reduction: 1,112 lines removed from page (79%). All business logic now testable in isolation.
  • Extracted units:
    • useProjectForm (245 LOC — form state, budget line CRUD, team member CRUD, submit validation, computed budget values)
    • useProjectModal (147 LOC, DI: IDeadlineService, ISubscriptionLimits — modal state, deadline fetching, subscription limits, delete confirmation)
    • usePortfolioRouting (67 LOC — URL-based portfolio tabs, legacy redirect, tab definitions)
    • ProjectEditModal (250 LOC — modal shell, tab navigation, routes to all 8 tab components)
    • ProjectSettingsTab (419 LOC — funding source, project identity, tier, phase, accounting, budget health, budget lines)
    • ProjectTeamTab (321 LOC — PM selector, team member form, member list with role badges, team stats)
  • Tests: 32 passing (15 useProjectForm, 10 useProjectModal, 7 usePortfolioRouting)
  • Status: Complete — hooks wired into page, inline logic replaced.

GeminiServiceImpl.ts (752 lines) - COMPLETE

  • Before: 752 lines, 0 testable units, all logic inline (prompts, API calls, JSON parsing, usage tracking, cloud function routing)
  • After: 506 impl lines + ~550 extracted lines (9 prompt builders, 2 utilities, 3 adapters, 1 DI interface), 31 tests passing
  • Reduction: 246 lines removed from impl (33%). All business logic now testable in isolation via DI.
  • Key improvements:
    • DI interfaces (IAIClient, ICloudFunctionCaller, IUsageTracker) — no real API calls in tests
    • parseJsonResponse utility — replaced 10x duplicated code-fence stripping
    • callWithRetry utility — extracted from private method to standalone, fully tested
    • 9 prompt builders — pure functions, trivially testable (journal, compliance, report, forecast, receipt, eligibility, assistant, proposal, M&E)
    • 3 production adaptersGoogleGenAIAdapter, FirebaseCloudFunctionAdapter, UsageTrackingAdapter
  • Tests: 31 passing (8 parseJsonResponse, 6 callWithRetry, 7 journalPrompts, 10 GeminiServiceImpl)
  • Status: Complete — DI wired, facade unchanged, 0 consumer changes needed.

Users.tsx (1,134 lines) - PENDING

Key concerns to extract:
  • User CRUD operations
  • Role assignment (RBAC)
  • Department management
  • Invitation workflow
  • Contractor vs employee distinction
Hooks to create:
  • useUserFilters (search/role/department filtering)
  • useUserRoles (role assignment logic with DI)
  • useUserInvitations (invite workflow)
  • useUserValidation (email/permission validation)
Components to extract:
  • UserStatsCard (user count by role/department)
  • UserFiltersBar (search/role/department filters)
  • UserTable (user list)
  • UserCard (card view)
  • RoleAssignmentModal (role editor)
Estimated effort: 2-2.5 weeks Target LOC: ~180 lines (84% reduction)

ContractorsDashboard.tsx (1,183 lines) - PENDING

Key concerns to extract:
  • Contractor management
  • Payment tracking
  • Contract management
  • Journal approval
  • Performance metrics
Hooks to create:
  • useContractorFilters (search/status filtering)
  • useContractorPayments (payment tracking)
  • useContractorContracts (contract lifecycle)
  • useContractorMetrics (performance calculations)
Components to extract:
  • ContractorStatsCard (payments, hours, contracts)
  • ContractorFiltersBar (search/status filters)
  • ContractorTable (contractor list)
  • ContractorCard (card view with quick actions)
  • PaymentHistoryView (payment timeline)
Estimated effort: 2 weeks Target LOC: ~180 lines (85% reduction)

GrantTrackingPage.tsx (943 lines) - PENDING

Key concerns to extract:
  • Grant pipeline management
  • Stage transitions
  • Probability scoring
  • Opportunity tracking
  • Forecast calculations
Hooks to create:
  • useGrantFilters (search/stage/probability filtering)
  • useGrantPipeline (stage transitions with DI)
  • useGrantMetrics (forecast calculations)
  • useGrantDragDrop (kanban logic)
Components to extract:
  • GrantStatsCard (pipeline metrics, forecast)
  • GrantFiltersBar (search/stage/probability filters)
  • GrantKanbanBoard (pipeline board)
  • GrantCard (opportunity card)
  • GrantForecastChart (revenue forecast)
Estimated effort: 1.5-2 weeks Target LOC: ~150 lines (84% reduction)

Execution Plan

Week 1-3: Projects.tsx ✅ COMPLETE

  • ✅ Phase 1-7: All extraction, wiring, and testing complete
  • 1,411 → 299 lines (79% reduction), 32 tests

Week 4-6: Users.tsx

  • Apply 7-phase pattern
  • Leverage learnings from Projects

Week 7-8: ContractorsDashboard.tsx

  • Apply 7-phase pattern
  • Similar structure to Users

Week 9-10: GrantTrackingPage.tsx

  • Apply 7-phase pattern
  • Reuse kanban patterns from Projects

Quick Start Checklist (Per Component)

Use this checklist when starting a new refactor:
## [Component Name] Refactor Checklist

### Pre-refactor
- [ ] Read entire component (understand structure)
- [ ] Identify state variables (list all useState)
- [ ] Identify API calls (list all service methods)
- [ ] Identify complex logic (filtering, calculations, workflows)
- [ ] Take screenshot (for visual regression testing)

### Phase 1: Types & Constants
- [ ] Create types/[feature].types.ts with Zod schemas
- [ ] Create constants/[feature]Defaults.ts
- [ ] Extract all inline constants

### Phase 2: Business Logic Hooks
- [ ] Create use[Feature]Filters (pure calculation)
- [ ] Create use[Feature]State (state management)
- [ ] Create use[Feature]Actions (CRUD with DI)
- [ ] Create additional domain-specific hooks

### Phase 3: Service DI
- [ ] Verify service has DI support
- [ ] Add DI if missing
- [ ] Create service interface for hooks

### Phase 4: Presentational Components
- [ ] Create [Feature]StatsCard
- [ ] Create [Feature]FiltersBar
- [ ] Create [Feature]Table
- [ ] Create [Feature]Card
- [ ] Create 3-5 additional components

### Phase 5: Container Components (Optional)
- [ ] Create [Feature]ListContainer (if needed)
- [ ] Create [Feature]DetailContainer (if needed)

### Phase 6: Page Refactor
- [ ] Create [Component].new.tsx
- [ ] Reduce to ~200 lines
- [ ] Test all functionality
- [ ] Compare screenshot (visual regression)

### Phase 7: Tests & Documentation
- [ ] Create __tests__/factories/[feature]Factory.ts
- [ ] Write 3-5 hook tests
- [ ] Write 2-3 component tests
- [ ] Create README.md
- [ ] Document migration steps

### Migration
- [ ] Test new component thoroughly
- [ ] Backup original: mv [Component].tsx [Component].old.tsx
- [ ] Activate new: mv [Component].new.tsx [Component].tsx
- [ ] Run full test suite
- [ ] Deploy to staging
- [ ] Monitor for issues
- [ ] Delete backup after 1 week

Benefits Realized (Completed Components)

GrantApplicationPage

  • Before: 910 lines, 0 testable units
  • After: 137 lines, 20+ testable units
  • Result: 85% reduction, full DI support, comprehensive tests

Expenses.tsx

  • Before: 1,263 lines, 0 testable units
  • After: 349 lines, 10+ testable units
  • Result: 72% reduction, 4 business logic hooks, 3 pure components

WorkflowPage.tsx (Logic Extracted + Wired)

  • Before: 2,528 lines, 0 testable units, all logic inline in 5 inner arrow functions
  • After: 1,989 page lines + 1,117 extracted hook/component lines, 50 tests passing
  • Reduction: 539 lines removed from page (21%). All business logic now testable in isolation.
  • Extracted units:
    • useTaskData (54 LOC, DI: IDeadlineService)
    • useTaskFilters (30 LOC, pure memo — search + status normalization)
    • useTaskStats (73 LOC, pure memo — stats, focus items, weekly balance)
    • useNotificationFilters (172 LOC, pure memo — category, search, sort, counts + 5 helpers)
    • useMilestoneAnalysis (196 LOC, pure memo — risk score, impact, distribution, timeline)
    • useGoogleCalendarSync (141 LOC, DI: IGoogleCalendarService)
    • RejectionModal (66 LOC), SnoozeModal (59 LOC) — pure presentational
    • workflowServiceAdapters.ts (39 LOC), workflowDefaults.ts (86 LOC), workflow.types.ts (150 LOC)
    • workflowFactory.ts (75 LOC) — mock builders
  • Status: Complete — hooks wired into page, inline logic replaced, dead imports removed.
  • Next step: Extract 5 inner tab components into their own files for further page LOC reduction.

EventBusPage.tsx (Logic Extracted + Wired)

  • Before: 1,491 lines, 0 testable units, all logic inline (fetching, filtering, metrics, utils, mock data, modal)
  • After: 503 page lines + ~900 extracted feature lines, 42 tests passing
  • Reduction: 988 lines removed from page (66%). All business logic now testable in isolation.
  • Extracted units:
    • useEventFetcher (59 LOC, DI: IEventBusDataSource)
    • useEventFilters (44 LOC, pure memo — org/type/severity/date/search filtering)
    • useEventMetrics (46 LOC, pure memo — severityCounts, topEventTypes, activeOrgCount, distinctTypeCount)
    • useAutoRefresh (22 LOC, timer management)
    • usePayloadExplorer (42 LOC — redact/copy/search state)
    • SeverityPill (22 LOC), EventRow (98 LOC, React.memo), EventDetailModal (176 LOC) — pure presentational
    • firestoreEventSource.ts (28 LOC) — DI adapter
    • eventbusDefaults.ts (219 LOC), eventbusMockData.ts (258 LOC), eventbusUtils.ts (194 LOC)
    • eventbus.types.ts (56 LOC) — types + DI interface
    • eventbusFactory.ts (52 LOC) — test mock builders
  • Status: Complete — hooks wired into page, inline logic replaced, 42 tests passing.

Combined Impact

  • Total LOC removed: 4,033 lines + 3,550+ lines of logic extracted to testable hooks/modules
  • Testable units created: 185+
  • Test coverage: 0% → 70%+ potential
  • Maintainability: Dramatically improved
  • Onboarding time: Estimated 50% reduction

Key Learnings

What Works Well

  1. Dependency Injection interfaces - Makes hooks fully testable
  2. Pure calculation hooks - Easy to test, no mocking needed
  3. Props-only components - Simplifies component testing
  4. Zod schemas - Catches data issues early
  5. Test factories - Speeds up test writing

Common Pitfalls

  1. ⚠️ Skipping DI - Makes hooks hard to test
  2. ⚠️ Over-extracting - Don’t create hooks for trivial logic
  3. ⚠️ Under-testing - Write tests as you extract, not after
  4. ⚠️ Breaking changes - Keep original interface intact
  5. ⚠️ No migration path - Always create .new.tsx first

Best Practices

  1. 💡 Start with filters - Easy win, builds confidence
  2. 💡 DI from the start - Don’t retrofit later
  3. 💡 Test as you go - Write tests immediately after extraction
  4. 💡 Document patterns - Update this file with learnings
  5. 💡 Visual regression - Take screenshots before/after

References

  • GrantApplicationPage refactor: src/features/grants/application/README.md
  • Expenses refactor: src/features/expenses/README.md
  • BaseService pattern: docs/engineering/architecture/base-service-and-eventbus.md
  • Testing guide: docs/engineering/testing/testing-strategy.md

Questions?

See completed refactors (GrantApplicationPage, Expenses.tsx) for reference implementations.