Architecture
Bioloupe Forecasting Architecture
Section titled “Bioloupe Forecasting Architecture”Technical architecture for the pharmaceutical forecasting application.
Table of Contents
Section titled “Table of Contents”- Overview
- Key Domain Concepts
- Technology Stack
- Project Structure
- State Management
- Data Fetching
- Runtime Validation
- Model Management
- Report Export
- Component Architecture
- Core Calculations
- Type System
- Authentication
- Development Rules
- Error Handling & Monitoring
1. Overview
Section titled “1. Overview”Purpose
Section titled “Purpose”Bioloupe Forecasting models pharmaceutical drug revenue:
- Patient flow through therapy lines (1L → 2L → 3L+)
- Market share based on launch timing, best-in-class status, competition
- Revenue projections with pricing, compliance, treatment duration
- Uncertainty analysis via Monte Carlo simulation
Architecture Principles
Section titled “Architecture Principles”| Principle | Implementation |
|---|---|
| Normalized State | Jotai atoms with composite string keys for O(1) lookups |
| Computed Values | Peak share calculated on-the-fly, with optional custom override |
| Section Isolation | Each section creates atoms via factory pattern |
| Pure Computations | Business logic in computations.ts, testable outside React |
| Type Safety | Strict TypeScript with explicit return types |
How State Flows (Quick Onboarding)
Section titled “How State Flows (Quick Onboarding)”atoms.ts (orchestrator) │ │ 1. Defines primitive atoms (geo, indication, rows, year) │ │ 2. Passes them as parameters to section factories │ ├── createConfigAtoms(inputs) → config derived atoms │ ├── createIncidenceAtoms(inputs) → incidence derived atoms │ ├── createModelAtoms(inputs) → model derived atoms │ └── createMonteCarloAtoms(inputs) → monte carlo derived atoms │ │ 3. Bundles all returned atoms into `forecastingAtoms` │ └── Components import `forecastingAtoms` and consume via useAtomValue/useSetAtomFactories receive primitive atoms as parameters (dependency injection) to avoid circular module imports — atoms.ts imports from factories, so factories can’t import back. Components are pure consumers and import freely. See Section Factory Pattern for details.
2. Key Domain Concepts
Section titled “2. Key Domain Concepts”Understanding these pharmaceutical forecasting terms is essential for working on this codebase.
Core Terminology
Section titled “Core Terminology”| Concept | Description |
|---|---|
| Incidence | New disease cases per year in a population |
| Addressable Population | Patients who can potentially receive treatment |
| Therapy Line | Sequential treatment stage (1L = first-line, 2L = second-line, 3L+ = third and beyond) |
| Peak Share | Maximum market share within the therapy class (computed from launch order, best-in-class, delay). Effective Peak Share = Class × Drug share, overridable via Custom Input toggle. |
| Loss of Exclusivity (LoE) | When patent protection ends and generics/biosimilars enter market |
| Drug Treatment Rate | Percentage of eligible patients who actually receive treatment |
| Progression Rate | Percentage of patients progressing from one therapy line to the next (labeled “Progression rate from prior line” in UI for 2L+) |
| Molecule Type | Biologic vs Small Molecule (affects post-LoE erosion rates) |
| Uptake Curve | Rate at which product achieves peak share after launch (e.g., “3 Year Medium”) |
| Retreatment Factor | Patients staying on current therapy (reduces pool for next line) |
| Class Share | Percentage of patients clinically suitable for a therapy class (default 100%) |
Disease Categories
Section titled “Disease Categories”The model handles two disease types differently:
| Category | Stage Split | Patient Flow | Examples |
|---|---|---|---|
| Solid Tumors | Yes - Early Stage + Metastatic | Applies relapse rates, stage mix | Breast Cancer, Lung Cancer, Melanoma |
| Hematology | No - Full incidence flows directly | Simpler flow to therapy lines | Leukemia, Lymphoma, Multiple Myeloma |
Patient Flow Diagram
Section titled “Patient Flow Diagram”Incidence (new patients/year) │ ▼ Stage Split (Solid Tumors only) ├── Early Stage × (1 + Early→Early Relapse) └── Metastatic + (Early × Early→Met Relapse) │ ▼ × Healthcare Access % │ ▼ Addressable Population │ ▼ 1L: × Treatment Rate → × Market Share → New Patients │ ▼ 2L: Pool × Transition Rate × Treatment Rate → × Market Share → New Patients │ ▼ 3L+: Pool × Transition Rate → × Market Share → New Patients (NO Treatment Rate)Note: Treatment Rate applies to 1L and 2L only. Lines 3L+ do not apply Treatment Rate.
3. Technology Stack
Section titled “3. Technology Stack”Core Technologies
Section titled “Core Technologies”| Technology | Version | Purpose |
|---|---|---|
| React | 19 | UI framework |
| TypeScript | 5.9 | Type safety (strict mode) |
| Vite | 7 | Build tool, dev server (port 3001) |
| Jotai | 2.x | Atomic state management |
| jotai-tanstack-query | 0.11 | Bridges React Query cache to Jotai atoms |
| TanStack Query | 5.x | Server state, caching |
| Tailwind CSS | 3.x | Utility-first styling |
| Radix UI | - | Accessible component primitives |
| Chart.js | 4.5 | Data visualization |
| simple-statistics | 7.8 | Monte Carlo percentiles |
| Web Workers | - | Off-thread Monte Carlo simulation (>1K) |
| docx | 9.5 | Client-side DOCX report generation |
| Sentry | 10.x | Error monitoring and performance tracing |
Development Tools
Section titled “Development Tools”| Tool | Purpose |
|---|---|
| Biome | Linting + formatting (2-space, double quotes, semicolons) |
| pnpm | Package manager |
| @sentry/vite-plugin | Source map upload + React component annotation |
4. Project Structure
Section titled “4. Project Structure”src/├── App.tsx # Main app with auth gate├── instrument.ts # Sentry initialization (must be first import in main.tsx)├── main.tsx # Entry: Sentry hooks > ErrorBoundary > QueryClient > AuthProvider > App├── globals.css # Tailwind global styles│├── components/ui/ # Radix UI + custom wrappers│ ├── button.tsx│ ├── input.tsx│ ├── number-input.tsx # NumberInput with suffix support│ ├── section-card.tsx # Collapsible section wrapper│ ├── section-nav.tsx # Fixed left-side navigation│ └── ...│├── contexts/│ └── auth-context.tsx # JWT cookie authentication│├── hooks/│ ├── useActiveSection.ts # Intersection Observer for scroll tracking│ ├── useForecasting.ts # React Query hooks for model CRUD│ └── useStatistics.ts # React Query hook for API data│├── lib/│ ├── api.ts # API namespace (GET/POST/PUT/DELETE)│ ├── config.ts # Environment config (VITE_* vars)│ ├── export-csv.ts # CSV export utility│ ├── schemas/ # Zod validation schemas│ └── utils.ts # cn() for className merging│└── features/reports/forecasting/ ├── atoms.ts # Central Jotai state (reference + instance) ├── model-atoms.ts # Model persistence atoms (save/load/dirty tracking) ├── types.ts # Type definitions ├── constants.ts # Defaults, limits ├── computations.ts # Pure calculation functions ├── utils.ts # Logger, formatNumber() ├── Forecasting.tsx # Main component ├── section-config.ts # Section IDs and labels for nav │ ├── reporting/ # DOCX report export pipeline │ ├── types.ts # Report data contracts │ ├── collectReportData.ts # Pure data collector (no hooks) │ ├── captureChartPngs.ts # Chart image capture helper │ ├── buildDocxReport.ts # DOCX document builder │ ├── exportReport.ts # Orchestrator (collect → capture → build → download) │ └── index.ts # Barrel exports │ └── sections/ # Feature sections (Configuration, Model, MonteCarlo, IncidenceEvolution have atoms.ts factories) ├── Configuration/ # Lines, assumptions, market │ ├── atoms.ts # createConfigAtoms(inputs) │ └── ... ├── Model/ # Patient flow & sizing ├── SalesChart/ # Revenue visualization ├── MonteCarlo/ # Simulation & uncertainty │ ├── monteCarlo.worker.ts # Web Worker for heavy simulations (>1K) │ ├── worker-types.ts # Worker message types │ ├── SimulationCountBadge.tsx # Configurable sim count UI (badge + popover) │ └── ... ├── Tornado/ # Sensitivity analysis └── IncidenceEvolution/ # Population trends5. State Management
Section titled “5. State Management”Jotai = atomic state management. Each “atom” is a reactive variable—when it changes, only components using that atom re-render.
Jotai Architecture
Section titled “Jotai Architecture”State is managed with Jotai atoms using a normalized store pattern:
┌─────────────────────────────────────────────────────────────────────┐│ Jotai Atoms │├─────────────────────────────────────────────────────────────────────┤│ ││ ┌──────────────────┐ ┌──────────────────┐ ││ │ Reference Rows │ │ Instance Rows │ ││ │ (API data) │ │ (User edits) │ ││ │ - Immutable │ │ - Editable │ ││ └──────────────────┘ └──────────────────┘ ││ │ │ ││ └──────────┬───────────┘ ││ ▼ ││ ┌──────────────────────────────────────────────────────────────┐ ││ │ Normalized Flat Map Storage │ ││ │ │ ││ │ rows: Map<CompositeKey, RowData> │ ││ │ Key format: "geo:indication:line:field" │ ││ │ Query via: getValue(rows, query, fieldName) │ ││ └──────────────────────────────────────────────────────────────┘ ││ │└─────────────────────────────────────────────────────────────────────┘Hook Selection
Section titled “Hook Selection”| Hook | Use Case | Re-renders |
|---|---|---|
useAtomValue(atom) | Read-only access | On atom change only |
useSetAtom(atom) | Write-only access | Never |
useAtom(atom) | Read + write | On atom change |
Rule: Prefer useAtomValue/useSetAtom over useAtom for performance.
Atom Types
Section titled “Atom Types”import { atom } from "jotai";
// Primitive atom - writable stateconst countAtom = atom<number>(0);
// Derived atom (read-only) - computed from other atomsconst doubleAtom = atom((get) => get(countAtom) * 2);
// Write-only atom - actions/mutationsconst incrementAtom = atom(null, (get, set) => { set(countAtom, get(countAtom) + 1);});Section Factory Pattern
Section titled “Section Factory Pattern”Why? Direct atom imports in factory files create circular dependencies:
// ❌ BAD: sections/config/atoms.ts imports atoms from ../../atoms.tsimport { instanceRowsAtom } from "../../atoms"; // atoms.ts also imports from sections → circular!Solution: Section atom factories receive atoms as function parameters:
// ✅ GOOD: Factory receives dependenciesexport function createConfigAtoms(inputs: { rowsAtom: typeof rowsAtom; selectedIndicationAtom: typeof selectedIndicationAtom;}) { const linesAtom = atom((get) => { const rows = get(inputs.rowsAtom); const indication = get(inputs.selectedIndicationAtom); return filterLinesByIndication(rows, indication); });
return { linesAtom };}Orchestrator wires it together (in atoms.ts):
atoms.ts (orchestrator) │ ├── Creates: instanceRowsAtom, referenceRowsAtom │ ├── Calls: createConfigAtoms({ instanceRowsAtom }) │ → returns configAtom, settingsAtom │ └── Calls: createModelAtoms({ instanceRowsAtom, configAtoms }) → returns patientFlowAtom, marketShareAtomKey Rule: Section atom factories receive dependencies via parameters — they don’t import atoms from the orchestrator. Section components (.tsx files) may freely import forecastingAtoms and context atoms (geoAtom, indicationAtom, etc.) from atoms.ts — this is the intended consumption pattern with no circular dependency risk.
Factory Pattern Summary
Section titled “Factory Pattern Summary”Each factory takes a few primitive atoms (raw ingredients) and returns derived atoms — both read atoms (computed state) and write atoms (actions). Then atoms.ts bundles them into one object:
// atoms.ts — the orchestrator assembles all sectionsreturn { config, // createConfigAtoms → lines, assumptions, population, defaults, actions incidence, // createIncidenceAtoms → evolution data, erosion rates, setters model, // createModelAtoms → year labels, disease config monteCarlo, // createMonteCarloAtoms → simulation variables, toggles // Also: standalone atoms (not in factory): numSimulationsAtom (1K–100K), mcIsRunningAtom};// This becomes `forecastingAtoms` — the single namespace every component imports from.What flows in each direction:
| Direction | What | Content |
|---|---|---|
atoms.ts → factories | Primitive atoms | geoAtom, indicationAtom, instanceRowsAtom, selectedYearAtom, diseaseConfigAtomFamily |
Factories → atoms.ts | Derived atoms | Read atoms (computed state) + write atoms (actions) per section |
atoms.ts → components | forecastingAtoms namespace | Components consume via useAtomValue / useSetAtom |
The factory pattern keeps domain logic co-located with domain UI while the orchestrator provides a single import point for consumers. Without factories, all derived atoms would live inside atoms.ts, and section atom files would need direct imports back — creating circular module dependencies.
Instance vs Reference Data
Section titled “Instance vs Reference Data”| Type | Source | Editable | Usage |
|---|---|---|---|
| Reference | API (/api/statistics) | No | Default values, disease configs |
| Instance | User input | Yes | User’s custom values |
Reference data provides defaults; instance data stores user modifications.
Default Value Tracking
Section titled “Default Value Tracking”InstanceRow supports metadata for tracking default vs user-modified values:
interface InstanceRow { // ... existing fields metadata?: { isDefault?: boolean };}
// Helper to check if a value is still at defaultfunction getIsDefault(rows, geo, indication, line, year, name): boolean;// Returns true if:// - No row exists (row was deleted → using computed default)// - Row exists with metadata.isDefault === true (initialized with default)// Returns false only when row exists with isDefault !== true (user modified)Default Value Highlighting (isDefault Pattern)
Section titled “Default Value Highlighting (isDefault Pattern)”UI components (Input, NumberInput, Select, MonthPicker) accept isDefault?: boolean prop:
true: appliesbg-amber-50 dark:bg-amber-950/30(yellow highlight)false/undefined: normal styling
Purpose: Yellow highlighting indicates “suggested values” - computed or default values that the user hasn’t explicitly changed. This provides visual feedback that:
- The value is system-generated or recommended
- The user can override it with their own value
- Once overridden, the highlight disappears (value is no longer at default)
Common use cases:
- Computed fields (e.g., Peak Share calculated from launch order + best in class + delay)
- API-provided defaults that the user may want to customize
- Values inherited from parent configurations
Tracking defaults: The lineFieldDefaults atom (Configuration section) tracks which fields are still at their default values per line. Components read this to determine isDefault status.
Computed Fields
Section titled “Computed Fields”Some fields are computed on-the-fly rather than stored with defaults:
| Field | Computation |
|---|---|
peakShare | calculatePeakShare(launchOrder, bestInClass, delay, numCompetitors) |
For computed fields, use hasOverride() instead of getIsDefault():
// Regular field: check if stored value is at defaulthealthcareAccess: isDefault("healthcareAccess"),
// Computed field: check if user has NOT overridden (no row = using computed default)peakShare: !hasOverride(rows, g, i, lineId, null, "peakShare"),The pattern:
- No row exists → Using computed value → IS at default → Show yellow
- Row exists → User overrode → NOT at default → No yellow
Common gotchas:
NumberInputonly firesonChangewhen the value actually differs from the prop (not on every blur). This prevents spurious row creation that would breakisDefault/hasOverridechecks.- The
write()helper in Configuration atoms skips no-op upserts — if the new value equals the existing row’s value, no write occurs. This is defense-in-depth against unnecessary atom updates.
Custom Lines
Section titled “Custom Lines”Users can add custom therapy lines (“Default Lines”) that flow sequentially from existing reference lines:
- Storage: Custom line IDs stored in
customLineIdsarray at geo/indication level - Category: Custom lines use category
"custom" - Patient Flow: 100% transition rate from last reference line by default
- Persistence: Custom lines are saved/loaded with the model
- Names: Reference line names are read-only in the LinePanel; only custom lines have editable name inputs
- Deletion: Only custom lines can be deleted (reference lines can only be toggled)
// Adding a custom lineconst newLineId = addCustomLine(); // Returns new line ID like "custom-1706000000000"
// Deleting a custom linedeleteCustomLine(lineId); // Only works for custom- prefixed IDsCustom lines appear below a dashed divider in the PatientFlowTree, separate from reference lines (Early Stage/Metastatic for Solid Tumors, or Therapy lines for Hematology).
InfoButton Component
Section titled “InfoButton Component”A reusable tooltip component for providing contextual help across the application. Based on shadcn/ui Tooltip.
Usage:
import { InfoButton } from "@/components/ui/info-button";
// Simple text tooltip (gray icon)<InfoButton>Explanation text here</InfoButton>
// Amber variant (for warnings/notices)<InfoButton variant="amber">Warning text</InfoButton>
// With reference data sources (shows source citations below description)<InfoButton sources={sources} defaultValue={85}> Percentage of patients with access to healthcare</InfoButton>
// Rich content with larger icon<InfoButton variant="amber" size="md"> <div className="space-y-2"> <p className="font-semibold">Title</p> <ul className="list-disc list-inside"> <li>Item 1</li> <li>Item 2</li> </ul> </div></InfoButton>Props:
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | required | Tooltip content (string or JSX) |
variant | "default" | "amber" | "default" | Icon color variant |
size | "sm" | "md" | "sm" | Icon size (sm: h-4, md: h-5) |
className | string | - | Additional CSS classes for the button |
sources | SourceEntry[] | null | - | Reference data sources (displays below description) |
defaultValue | string | number | null | - | Default value to display with sources |
Styling:
- Grey tooltip background (
bg-gray-100 dark:bg-gray-800) - Tooltip appears on the right side of the icon
- Accessible with
cursor-helpandaria-label="More information" - Icon colors: default (gray), amber (warning/info banners)
Sources Display:
When sources is provided, InfoButton renders:
- Description text (children)
- Dashed divider
- Default value (if provided)
- Source cards with name, URL (clickable), sample size, and comments
useReferenceSource Hook
Section titled “useReferenceSource Hook”Lookup sources and default values from reference data for variables in the current geo/indication context.
import { useReferenceSource } from "@/features/reports/forecasting/hooks/useReferenceSource";
// For indication-level variables (no line parameter)const { sources, value } = useReferenceSource("healthcareAccess");
// For line-specific variablesconst { sources, value } = useReferenceSource("transitionRate", "2L");Returns: { sources: SourceEntry[] | null, value: string | number | number[] | null }
How it works:
- Reads from
referenceRowsAtom(cached API data) - Filters by current
geoAtomandindicationAtomcontext - Matches by variable name and optional line ID
- Returns sources and default value, or null if not found
6. Data Fetching
Section titled “6. Data Fetching”Rate Limiting & Retry Configuration
Section titled “Rate Limiting & Retry Configuration”The app uses TanStack Query with exponential backoff for API resilience:
// main.tsx - Global QueryClient configurationconst queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes retry: 3, // Retry failed queries up to 3 times retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), refetchOnWindowFocus: false, }, mutations: { retry: 1, // User actions: only 1 retry retryDelay: 1000, // Fixed 1s delay }, },});Retry Strategy:
- Queries: 3 retries with exponential backoff (1s → 2s → 4s → 8s…, max 30s)
- Mutations: 1 retry with fixed 1s delay (user actions shouldn’t silently retry too much)
Two patterns are used depending on whether data feeds into Jotai atoms:
Pattern 1: jotai-tanstack-query (for atom-derived data)
Section titled “Pattern 1: jotai-tanstack-query (for atom-derived data)”Use atomWithQuery when data feeds into Jotai derived atoms:
// atoms.ts - Reference data → derived atomsimport { atomWithQuery } from "jotai-tanstack-query";
export const statisticsQueryAtom = atomWithQuery(() => ({ queryKey: STATISTICS_QUERY_KEY, queryFn: fetchStatistics,}));
// Derived atom reads from query atomexport const referenceRowsAtom = atom<ReferenceRow[]>((get) => { const result = get(statisticsQueryAtom); return result.data ?? [];});Pattern 2: React Query Hooks (for CRUD operations)
Section titled “Pattern 2: React Query Hooks (for CRUD operations)”Use standard React Query hooks for operations that don’t feed into atoms:
// hooks/useForecasting.ts - Model CRUD operationsimport { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
export function useForecasting() { return useQuery({ queryKey: ["forecasting"], queryFn: fetchModels });}
export function useCreateModel() { const queryClient = useQueryClient(); return useMutation({ mutationFn: createModel, onSuccess: () => queryClient.invalidateQueries({ queryKey: ["forecasting"] }), });}When to Use Which
Section titled “When to Use Which”| Pattern | Use Case | Example |
|---|---|---|
atomWithQuery | Data feeds into Jotai derived atoms | Statistics, disease configs |
useQuery/useMutation | CRUD operations, standalone queries | Model save/load/delete |
Data Flow (atomWithQuery)
Section titled “Data Flow (atomWithQuery)”atomWithQuery (statisticsQueryAtom) │ ▼ React Query cache (5min stale time) │ ▼ Derived atoms (referenceRowsAtom) │ ▼ Components read via useAtomValueAPI Endpoints
Section titled “API Endpoints”| Endpoint | Method | Purpose |
|---|---|---|
/api/statistics | GET | Reference data for forecasting |
/api/forecasting | GET | List saved models |
/api/forecasting | POST | Create new model |
/api/forecasting/:id | PUT | Update model |
/api/forecasting/:id | DELETE | Delete model |
/api/check_session | GET | Verify authentication |
7. Runtime Validation
Section titled “7. Runtime Validation”Zod schemas in src/lib/schemas/forecasting.ts validate data at two layers:
Layer 1 — API Boundaries
Section titled “Layer 1 — API Boundaries”Validates external data immediately after fetch, before it enters the atom store:
| Call Site | Schema | On Failure |
|---|---|---|
useStatistics.ts | ApiStatisticsResponseSchema | Throws (blocks load) |
useForecasting.ts | FullSnapshotSchema | Throws (blocks load) |
model-atoms.ts | FullSnapshotSchema | Throws (blocks load) |
Layer 2 — Atom Read/Write Boundaries
Section titled “Layer 2 — Atom Read/Write Boundaries”Validates high-risk complex fields (arrays of objects like events, customVariables) on read, and array fields on write via setValueAtom. Catches corrupted data that would otherwise propagate silently via bare as T casts. Internal bulk writes via upsertRowsAtom bypass validation since they construct known-good data from constants/templates.
Read-side: The read() helper in Configuration/atoms.ts accepts an optional z.ZodType<T> schema parameter. When provided, it runs safeParse and returns null on failure (fail-safe — callers fall through to ?? default). High-risk fields (events, customVariables) are validated; simple scalars use bare casts (gradual opt-in).
Write-side: setValueAtom in atoms.ts validates high-risk fields before writing via the WRITE_SCHEMAS map. On failure, the write is silently rejected and an error is logged.
Shared Schemas
Section titled “Shared Schemas”| Schema | Validates | Used By |
|---|---|---|
MarketEventsArraySchema | MarketEvent[] | Read + write validation |
VariablesArraySchema | Variable[] | Read + write validation |
NumberArraySchema | number[] | Write validation (incidence, erosion, price evolution) |
FullSnapshotSchema | Complete model snapshot | API boundary validation |
All error logging uses z.prettifyError() (Zod v4 API).
8. Model Management
Section titled “8. Model Management”The application supports saving, loading, renaming, and deleting forecasting models via the backend API.
Data Flow
Section titled “Data Flow”User Action (Save/Load) │ ▼useModelManagement hook │ ├── Save: createFullSnapshot() → API POST/PUT │ └── Load: queryClient.fetchQuery() → loadModelAtom │ ▼ loadSnapshotAtom │ ┌──────────────────────────────┼──────────────────────────────┐ │ │ │ ▼ ▼ ▼ instanceRowsAtom Context Atoms lastSavedSnapshotAtom (user data) (UI state) (dirty tracking)Snapshot Structure
Section titled “Snapshot Structure”Models are persisted as FullSnapshot:
interface FullSnapshot { instanceRows: InstanceRow[]; // All user-editable data context: ModelContext; // UI state to restore}
interface ModelContext { activeGeo: Geography; activeIndication: string; selectedYear: number; geoSelection: Record<Geography, boolean>; indicationSelection: Record<string, boolean>;}Biomarker Atoms
Section titled “Biomarker Atoms”| Atom | Type | Description |
|---|---|---|
availableBiomarkersAtom | Derived (read) | Extracts BiomarkerDef[] from reference rows for current geo/indication |
activeBiomarkerAtom | Derived (read) | Current biomarker ID from instance rows, or null |
biomarkerFactorAtom | Derived (read) | Computed 0-1 multiplier: (percentage/100) × (testingRate/100) when active, else 1.0 |
toggleBiomarkerAtom | Write | Activates/deactivates biomarker (sets/clears active ID only) |
setBiomarkerFieldAtom | Write | Updates biomarker percentage or testing rate override |
resetBiomarkerFieldAtom | Write | Resets a biomarker field override to its reference value |
Data flow: User toggles biomarker → activeBiomarkerAtom updates → biomarkerFactorAtom derives multiplier → computation functions apply factor to first-line addressable population in patient flow funnel. Biomarker does not modify incidence; it is a funnel multiplier applied after addressable calculation.
CSV convention: Biomarker data uses bm:<biomarkerId> in the LINE column (e.g., bm:hpvPositive). These rows are filtered out of getReferenceLines() so they don’t appear as therapy lines.
Core Atoms
Section titled “Core Atoms”| Atom | Purpose |
|---|---|
createFullSnapshotAtom | Creates snapshot from current state for saving |
loadSnapshotAtom | Hydrates all atoms from loaded snapshot |
loadModelAtom | Orchestrates model loading (fetch + hydrate) |
isDirtyAtom | Tracks unsaved changes |
lastSavedSnapshotAtom | Stores JSON of last saved state for comparison |
Loading Process
Section titled “Loading Process”export const loadSnapshotAtom = atom( null, (_get, set, snapshot: FullSnapshot) => { // 1. Load instance rows (main data) set(loadInstanceDataAtom, snapshot.instanceRows as InstanceRow[]);
// 2. Restore context state if (snapshot.context) { set(setGeoAtom, snapshot.context.activeGeo); set(setIndicationAtom, snapshot.context.activeIndication); set(setSelectedYearAtom, snapshot.context.selectedYear); set(setGeoSelectionAtom, snapshot.context.geoSelection); set( setIndicationSelectionAtom, snapshot.context.indicationSelection, ); }
// 3. Mark as clean (just loaded) set(lastSavedSnapshotAtom, JSON.stringify(snapshot.instanceRows)); set(isDirtyAtom, false); },);useModelManagement Hook
Section titled “useModelManagement Hook”Orchestrates all model operations:
const { // View state showSelector, // Show model selector vs editor setShowSelector,
// Current model currentModel, // { id, name, savedAt } | null isDirty, // Has unsaved changes
// Saved models list savedModels, // SavedModel[] isLoadingModels, // Loading models list modelsError, // Error | null existingModelNames, // string[] (for validation)
// Dialog states saveDialogOpen, setSaveDialogOpen, deleteDialogOpen, setDeleteDialogOpen, unsavedDialogOpen, setUnsavedDialogOpen, renameDialogOpen, setRenameDialogOpen,
// Model being acted on modelToDelete, // SavedModel | null modelToRename, // SavedModel | null
// Loading states isSaving, // Create or update in progress isDeleting, isLoadingModel, // Fetching full model data isRenaming,
// Primary actions handleCreateNew, // Create new (checks dirty) handleLoadModel, // Load existing model handleRenameModel, // Open rename dialog for model handleDeleteModel, // Open delete dialog for model handleSave, // Save (update existing or open save dialog) handleSaveWithName, // Create with name handleBackToSelector, // Return to selector (checks dirty)
// Dialog actions confirmDelete, confirmRename, handleUnsavedSave, handleUnsavedDiscard,
// Retry refetchModels,} = useModelManagement();Model Name Validation
Section titled “Model Name Validation”Shared validation in components/model-management/validation.ts:
| Rule | Constraint | Error Message |
|---|---|---|
| Required | Non-empty after trim | ”Model name is required” |
| Min length | >= 3 characters | ”Model name must be at least 3 characters” |
| Max length | <= 100 characters | ”Model name must be 100 characters or less” |
| Allowed chars | [a-zA-Z0-9\s\-_]+ | ”Model name can only contain letters, numbers, spaces, hyphens, and underscores” |
| Uniqueness | Case-insensitive unique | ”A model with this name already exists” |
API Endpoints
Section titled “API Endpoints”| Endpoint | Method | Purpose |
|---|---|---|
/api/forecasting | GET | List all models |
/api/forecasting | POST | Create new model |
/api/forecasting/:id | GET | Get model by ID |
/api/forecasting/:id | PUT | Update model (data or name) |
/api/forecasting/:id | DELETE | Delete model |
Components
Section titled “Components”| Component | Purpose |
|---|---|
ModelSelector | Dropdown to select/load models |
ModelCard | Displays current model info with action buttons |
SaveDialog | Modal for saving with name input |
RenameDialog | Modal for renaming existing model |
DeleteDialog | Confirmation for deletion |
UnsavedChangesDialog | Warns before losing unsaved changes |
9. Report Export
Section titled “9. Report Export”The application generates DOCX reports for the currently selected geography + indication. The pipeline is split into pure, testable functions.
Pipeline Architecture
Section titled “Pipeline Architecture”useReportExport (hook — reads atoms, calls handle getters) │ ▼exportReport (orchestrator) │ ├── 1. collectReportData() — pure, sync — assembles tables + sources ├── 2. captureChartPngs() — sync — reads chart canvas refs ├── 3. buildDocxReport() — pure — creates docx Document └── 4. Packer.toBlob() → downloadBlob() — browser downloadSection Selection
Section titled “Section Selection”Users can select which sections to include in the exported report via ExportReportPopover:
useExportSectionshook: Manages a nested checkbox tree with parent/child selection and indeterminate statesuseReportExportaccepts aselectedSections: Set<ExportSectionId>parameter to filter outputbuildDocxReportandcollectReportDatafilter their output by the selected sectionsExportReportPopoverrenders inEditorHeaderActionsas the export trigger UI
Key Design Decisions
Section titled “Key Design Decisions”| Decision | Rationale |
|---|---|
collectReportData is a pure function | Testable without React; all atom values passed as parameters |
| MC/Tornado data read from section handles | MC results live in component useState, Tornado in useMemo — not in atoms |
| Chart images captured from Chart.js canvas | toBase64Image("image/png") — resolution controlled by devicePixelRatio: 2 in chart options |
Source deduplication via JSON.stringify key | Handles identical sources referenced from multiple fields |
| Deterministic narrative templates | No AI-generated prose — fixed descriptions per section |
| Stage-level error wrapping | Each pipeline stage throws descriptive Error with { cause } for actionable messages |
Module Structure
Section titled “Module Structure”| File | Purpose |
|---|---|
reporting/types.ts | ReportData, ReportContext, ChartImages, DeduplicatedSource, etc. |
reporting/collectReportData.ts | Pure function: builds assumptions, population, lines, incidence tables + sources appendix |
reporting/captureChartPngs.ts | Safe per-chart capture with fallback metadata |
reporting/buildDocxReport.ts | Builds Document with cover, config, incidence, model, sales, MC, tornado, sources sections |
reporting/exportReport.ts | Orchestrates pipeline and triggers browser download |
components/ExportReportPopover.tsx | Section selection UI with nested checkbox tree for export |
hooks/useExportSections.ts | Manages section selection state (parent/child, indeterminate) |
Section Handle Pattern
Section titled “Section Handle Pattern”Each section component exposes data via useImperativeHandle:
// Model (table only — no chart)type ModelHandle = { exportCSV: () => void; getExportRows: () => CSVRow[];};
// SalesChart (chart section)type ChartSectionHandle = ModelHandle & { exportImage: () => void; getImageDataUrl: () => string | null;};
// MonteCarlo (chart section + simulation control)type MonteCarloHandle = ChartSectionHandle & { runManual: () => void; // Triggers main-thread (≤1K) or Web Worker (>1K) simulation cancelRun: () => void; // Terminates running Web Worker};
// Tornado adds year contexttype TornadoHandle = ChartSectionHandle & { getExportMeta: () => { tornadoYear: number };};Hook: useReportExport
Section titled “Hook: useReportExport”Bridges Jotai atoms and section refs to the export pipeline:
const { exportReport, isExporting, canExportReport } = useReportExport({ modelRef, salesChartRef, monteCarloRef, tornadoRef,});Reads atoms: geoAtom, indicationAtom, selectedYearAtom, currentModelAtom, config atoms, incidence atoms, referenceRowsAtom. Passes everything to collectReportData as plain parameters.
Output Format
Section titled “Output Format”- File:
forecasting-report-<geo>-<indication>-<YYYY-MM-DD>.docx - Sections: Cover → Configuration → Incidence → Model → Sales → Monte Carlo → Tornado → Sources
- Charts: Embedded PNG images (600×340px) with italic placeholder text when unavailable
10. Component Architecture
Section titled “10. Component Architecture”Section Components
Section titled “Section Components”| Section | Description | Key Atoms |
|---|---|---|
| Configuration | Therapy line setup, market assumptions, patient tree | assumptions, population, lines, biomarkerFactor |
| Model | Patient flow calculations, sales by therapy line | Computed from config |
| SalesChart | Stacked bar revenue visualization (Chart.js) | Sales data per line |
| MonteCarlo | Probabilistic simulation (1K–100K iterations, configurable) | simulationVariables, numSimulationsAtom, mcIsRunningAtom, results |
| Tornado | Sensitivity analysis (variable impact ranking) | Uses MonteCarlo variables |
| IncidenceEvolution | Population growth projections, erosion rates | incidenceEvolution, erosionRates |
Component Hierarchy
Section titled “Component Hierarchy”App├── AuthProvider│ └── Forecasting│ ├── SectionNav (fixed left sidebar, md+ only)│ ├── Header│ ├── EditorHeaderActions (All Models, Rename, Delete, Export Report, Save)│ └── main (conditional on hasIndication)│ ├── SectionCard id="section-configuration"│ │ └── Configuration│ ├── SectionCard id="section-model"│ │ └── Model│ ├── SectionCard id="section-sales"│ │ └── SalesChart│ ├── SectionCard id="section-montecarlo"│ │ ├── headerMeta: SimulationCountBadge (badge + popover)│ │ └── MonteCarlo│ ├── SectionCard id="section-sensitivity"│ │ └── Tornado│ └── SectionCard id="section-incidence"│ └── IncidenceEvolutionLinePanel Sub-Components
Section titled “LinePanel Sub-Components”FirstLineExtras (LinePanel.tsx) is an isolated sub-component that renders the Biomarker card only for first-line panels. It is extracted so that biomarker atom subscriptions are only active when mounted, preventing unnecessary re-renders on non-first-line panels (L2, L3, custom lines).
When rendered: isFirstEarlyLine || isFirstMetLine || isFirstTherapyLine
Position: First item inside the “Patient Flow” section, before Healthcare Access and other built-in variables.
Contains:
- Biomarker card (
bg-[#F9FAFA] dark:bg-muted/30) withDnaicon, toggle, and editable sub-fields (percentage, testing rate) usingborder-primary/20indent guide
SectionCard Component
Section titled “SectionCard Component”Collapsible wrapper for each section with optional ID for scroll targeting.
Props:
titleTooltip?: ReactNode— renders anInfoButtonbeside the titleheaderMeta?: ReactNode— content between title/tooltip and header actions (e.g., badges, status indicators)
<SectionCard id="section-montecarlo" title="Monte Carlo Simulation" titleTooltip="Explanation shown on hover." headerMeta={<SimulationCountBadge isRunning={mcIsRunning} onRun={...} onCancel={...} />} headerAction={<ExportButton ... />}> <MonteCarlo /></SectionCard>SectionNav Component
Section titled “SectionNav Component”Fixed left-side navigation with scroll tracking:
- Uses
useActiveSectionhook (IntersectionObserver) - Hidden on xs/sm, visible on md+ (
hidden md:block) - Smooth scroll to section on click
11. Core Calculations
Section titled “11. Core Calculations”Business logic lives in pure functions in computations.ts. See FORECASTING_MODEL.md for detailed formulas and COMPUTATION_DAG.md for dependency graphs.
Note: Early stage sub-variables (earlyStageLocalizedPercent, earlyStageLocallyAdvancedPercent) are UI-level granularity only. The computation layer always uses the combined earlyStagePercent — sub-variables do not affect any calculation functions.
Key Formulas
Section titled “Key Formulas”Incidence Calculation
Section titled “Incidence Calculation”YearIncidence = BaseIncidence × (Pop[Year] / Pop[BaseYear]) × RegionalFactorAddressable Population (Solid Tumors)
Section titled “Addressable Population (Solid Tumors)”EarlyAddressable = Incidence × EarlyStage% × (1 + Early→EarlyRelapse%) × HealthcareAccess%MetAddressable = (Incidence × Met% + Incidence × Early% × Early→MetRelapse%) × HealthcareAccess%Biomarker Filter (optional)
Section titled “Biomarker Filter (optional)”When a biomarker is active, first-line addressable is multiplied by BiomarkerFactor = (Percentage / 100) × (TestingRate / 100). Computed in biomarkerFactorAtom; applied via calculateLineFunnelForDisplay.
Peak Share (computed, with optional custom override)
Section titled “Peak Share (computed, with optional custom override)”Peak Share represents maximum market share within the therapy class. The actual market share used in the Forecasting Model is the Effective Peak Share:
PeakShare (within class) = BaseShare[LaunchOrder, Competitors] + BestInClassBonus - DelayPenaltyDelayPenalty = Delay > 3 quarters ? Delay × 0.5 : 0
EffectivePeakShare = CustomEffectivePeakShare (if Custom Input toggled on) OR PeakShare × (ClassShare / 100) (Bioloupe Guidance — default)The UI displays guidance values plus a Custom Input toggle:
- “Peak Share (within class)” — computed value, editable with rollback to computed default
- “Effective Peak Share (Class x Drug Share)” — read-only computed value (bold, key driver)
- “Custom Input” toggle — when enabled, user overrides the effective peak share with
customEffectivePeakShare(stored in instance rows). Guidance rows dim to indicate they are not driving the model. The custom override field shows amber background when its value matches guidance.
Patient Flow by Line
Section titled “Patient Flow by Line”1L: Eligible = Addressable × TreatmentRate% NewPatients = Eligible × MarketShare%
2L: Pool = 1L_Eligible - (1L_NewPatients × Retreatment) Eligible = Pool × TransitionRate% × TreatmentRate% NewPatients = Eligible × MarketShare%
3L+: Pool = Prior_Eligible - (Prior_NewPatients × Retreatment) Eligible = Pool × TransitionRate% // NO TreatmentRate applied NewPatients = Eligible × MarketShare%Revenue Calculation
Section titled “Revenue Calculation”LineSales ($M) = Σ(CohortPatients[yearOffset] × NetPrice × Compliance% × MonthsThisYear[yearOffset]) / 1,000,000Key Constants
Section titled “Key Constants”| Constant | Value | Purpose |
|---|---|---|
PROJECTION_DATA_LENGTH | 20 | Years of projection (base + 19) |
REVENUE_SCALE_DIVISOR | 1,000,000 | Convert to millions |
COMPETITION_THRESHOLD_QUARTERS | 3 | When delay penalty kicks in |
COMPETITION_FACTOR_MULTIPLIER | 0.5 | Delay penalty per quarter |
COHORT_YEARS | 5 | Patient cohorts to aggregate |
MAX_CUSTOM_VARIABLES | 5 | Custom variables per line |
Default Values
Section titled “Default Values”| Field | Default | Notes |
|---|---|---|
| Treatment Rate | 100% | Applied to 1L and 2L only |
| Transition Rate | 100% | Between therapy lines |
| Healthcare Access | 95% | Varies by geography |
| Compliance | 100% | Patient adherence |
| Months of Therapy | 12 | Distributed across cohort years (≤12 per year) |
| Launch Price | $6,000/month | Per indication (from constants.ts) |
| Annual Price Change | 2% | Year-over-year |
| Market Exclusivity | 12 years | Before LoE |
12. Type System
Section titled “12. Type System”Base Types
Section titled “Base Types”// Geography - known values plus extensibilitytype Geography = "USA" | "EU5" | "Japan" | "China" | (string & {});
// Indication = disease nametype Indication = string;
// Line ID (e.g., "1L", "2L", "adjuvant")type LineId = string;Key Domain Types
Section titled “Key Domain Types”// Disease configuration from APIinterface DiseaseConfig { type: string; incidence: IncidenceConfig; lines: LineConfig[]; specialPopulations?: Record<string, number | number[]>;}
// Stage mix within IncidenceConfig (solid tumors only)interface StageMix { earlyStagePercent: number; metStagePercent: number; unknownStagePercent?: number; // Patients with unknown stage (defaults to 0) earlyToEarlyRelapse: number; earlyToMetRelapse: number; earlyStageLocalizedPercent?: number; // Optional sub-breakdown earlyStageLocallyAdvancedPercent?: number; // Optional sub-breakdown}// When both sub-variables are present, earlyStagePercent = localized + locallyAdvanced (auto-sum).// Sub-variables are display-only; computations always use the combined earlyStagePercent.
// Therapy line configurationinterface LineConfig { id: string; displayName: string; category: string; transitionRate: number; treatmentRate: number; healthcareAccess?: number;}
// Market data per therapy lineinterface MarketLineData { lineId: string; lineName: string; category: string; isSelected: boolean; launch: string; // ISO date launchOrder: number; // 1-10 delayVsCompetition: number; // Quarters bestInClass: boolean; speedToPeak: string; monthsOfTherapy: number; compliance: number; // Percentage events: MarketEvent[];}
// Monte Carlo variableinterface Variable { id: string; name: string; value: number; changeable: boolean; min: number; max: number; startYear: number | null; isBuiltIn?: boolean;}
// Market event for tracking impacts on therapy linesinterface MarketEvent { id?: string; // Unique identifier (generated on creation) name?: string; impactPercent: number; startDate: string; // YYYY-MM format endDate?: string;}TypeScript Rules
Section titled “TypeScript Rules”- NEVER use
any- useunknownif truly unknown - Explicit return types for all functions
- Use
ReactElementfor component returns (notJSX.Element) - Path aliases:
@/→./src/
// CORRECTimport { type ReactElement } from 'react';function MyComponent(): ReactElement { return <div />;}
// FORBIDDEN (JSX.Element doesn't exist in React 19)function MyComponent(): JSX.Element { ... }13. Authentication
Section titled “13. Authentication”JWT Cookie Auth
Section titled “JWT Cookie Auth”Authentication uses JWT cookies shared with the BioLoupe platform:
import { useAuth } from "@/contexts/auth-context";
const { isLoggedIn, isLoading, user, logout, redirectToLogin } = useAuth();User Type
Section titled “User Type”interface User { id: number; email: string; firstName: string; lastName: string; role?: string;}Auth Flow
Section titled “Auth Flow”- App mounts → check session via
/api/check_session - If authenticated → show app
- If not authenticated → redirect to BioLoupe login (
VITE_BIOLOUPE_URL) - After login → redirect back with JWT cookie set
API Integration
Section titled “API Integration”All API calls include credentials for cookie auth:
// lib/api.ts - Namespace export patternasync function get( path: string, options?: { queryParams?: Record<string, string>; signal?: AbortSignal },) { const response = await fetch(buildUrl(path, options?.queryParams), { method: "GET", headers: buildHeaders(), credentials: "include", // Include JWT cookie signal: options?.signal, }); if (!response.ok) { throw new Error( extractErrorMessage( await response.json().catch(() => ({})), response.status, ), ); } return response;}
// Usage unchanged: Api.get(), Api.post(), etc.export const Api = { get, post, patch, put, delete: del };Environment Variables
Section titled “Environment Variables”Environment variables are read from .env files via Vite:
| Variable | Purpose | Example |
|---|---|---|
VITE_API_URL | Data-gov API endpoint | http://localhost:3010 (dev) |
VITE_BIOLOUPE_URL | BioLoupe client for auth redirects | http://localhost:8080 (dev) |
VITE_ENABLE_PRICING | Feature flag for pricing section | false |
See src/lib/config.ts for how these are consumed via getApiUrl() and getBioloupeUrl().
Usage: Import from @/lib/config:
import { getApiUrl, getBioloupeUrl } from "@/lib/config";14. Development Rules
Section titled “14. Development Rules”Code Constraints
Section titled “Code Constraints”| Rule | Details |
|---|---|
No any type | Use unknown if truly unknown |
| Explicit return types | All functions must have return types |
| Max file size | 500 lines/file, 200 lines/component, 50 lines/function |
| Section atom factories use DI | Receive atom deps via parameters, not direct imports |
| Peak share is computed | Derive from inputs; user can override effective value via customEffectivePeakShare |
| Use proper Jotai hooks | useAtomValue for read, useSetAtom for write |
| Path aliases | @/ → ./src/, @/lib → ./src/lib |
| React 19 types | Use ReactElement (not JSX.Element) |
Forbidden Practices
Section titled “Forbidden Practices”| Practice | Alternative |
|---|---|
any type | unknown or proper typing |
@ts-ignore | Fix the type issue |
JSX.Element | ReactElement |
| Files > 500 lines | Split into modules |
| Components > 200 lines | Extract sub-components |
| Business logic in components | Pure functions in computations.ts |
Section atom factories importing atoms from atoms.ts | Receive atoms via factory parameters |
| Nested state objects | Normalized flat maps with composite keys |
| Direct fetch calls | Use Api namespace |
| Hardcoded API URLs | Use VITE_API_URL environment variable |
useAtom when only reading | useAtomValue (prevents re-renders) |
useAtom when only writing | useSetAtom (prevents re-renders) |
| Async atoms for data fetching | React Query + setter atoms |
Pre-Commit Checklist
Section titled “Pre-Commit Checklist”Before committing, ensure:
-
pnpm lintpasses -
pnpm buildsucceeds - All states handled (loading, error, empty, success)
- No
anytypes or@ts-ignore - Pure computations are testable outside React
- Section atom factories use dependency injection (no direct atom imports from
atoms.ts)
Commands
Section titled “Commands”pnpm dev # Start dev server (port 3001)pnpm build # TypeScript check + Vite buildpnpm lint # Check with Biomepnpm lint:fix # Auto-fix with Biomepnpm format # Format code with Biomepnpm preview # Preview production build15. Error Handling & Monitoring
Section titled “15. Error Handling & Monitoring”Architecture
Section titled “Architecture”Layer 4: Global Handlers (instrument.ts) └── window "unhandledrejection" → Sentry.captureException
Layer 3: React 19 Error Hooks (createRoot options) ├── onUncaughtError → reactErrorHandler() → Sentry ├── onCaughtError → reactErrorHandler() → Sentry └── onRecoverableError → reactErrorHandler() → Sentry
Layer 2: App-Level Error Boundary (ErrorBoundary.tsx — unchanged) └── Fallback UI + page reload (React 19 hooks capture for Sentry automatically)
Layer 1: React Query + API (smart retry, export pipeline errors) ├── Smart retry: skip auth errors (401/403) └── Export pipeline: stage-level errors with { cause }Sentry Configuration
Section titled “Sentry Configuration”- SDK:
@sentry/react— initialized insrc/instrument.ts(must be first import inmain.tsx) - Features: Error Monitoring + Tracing (no Session Replay, no Profiling)
- Disabled when:
VITE_SENTRY_DSNis empty (local dev default) - Source maps: Hidden sourcemaps uploaded via
@sentry/vite-plugin(CI only, guarded bySENTRY_AUTH_TOKEN) - PII:
sendDefaultPii: false— pharmaceutical app, conservative default
Environment Variables
Section titled “Environment Variables”| Variable | Runtime | Purpose |
|---|---|---|
VITE_SENTRY_DSN | Browser | Sentry DSN (empty = disabled) |
VITE_APP_VERSION | Browser | Release version for Sentry |
SENTRY_ORG | CI build | Sentry org slug (source map upload) |
SENTRY_PROJECT | CI build | Sentry project slug |
SENTRY_AUTH_TOKEN | CI build | Sentry auth token (source map upload) |
Key Design Decisions
Section titled “Key Design Decisions”| Decision | Rationale |
|---|---|
No Sentry.captureException in ErrorBoundary | React 19 onCaughtError already captures — would duplicate |
| No Sentry bridge in Logger | Logger called from componentDidCatch (already captured) and mutation callbacks (toast errors) |
| Smart retry skips auth errors by message regex | API throws Error with user-friendly messages; status codes not preserved on the Error object |
| Export pipeline stage-level errors | Actionable messages surface to user via existing toast in useReportExport |