Skip to content

Architecture

Technical architecture for the pharmaceutical forecasting application.

  1. Overview
  2. Key Domain Concepts
  3. Technology Stack
  4. Project Structure
  5. State Management
  6. Data Fetching
  7. Runtime Validation
  8. Model Management
  9. Report Export
  10. Component Architecture
  11. Core Calculations
  12. Type System
  13. Authentication
  14. Development Rules
  15. Error Handling & Monitoring

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
PrincipleImplementation
Normalized StateJotai atoms with composite string keys for O(1) lookups
Computed ValuesPeak share calculated on-the-fly, with optional custom override
Section IsolationEach section creates atoms via factory pattern
Pure ComputationsBusiness logic in computations.ts, testable outside React
Type SafetyStrict TypeScript with explicit return types
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/useSetAtom

Factories 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.


Understanding these pharmaceutical forecasting terms is essential for working on this codebase.

ConceptDescription
IncidenceNew disease cases per year in a population
Addressable PopulationPatients who can potentially receive treatment
Therapy LineSequential treatment stage (1L = first-line, 2L = second-line, 3L+ = third and beyond)
Peak ShareMaximum 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 RatePercentage of eligible patients who actually receive treatment
Progression RatePercentage of patients progressing from one therapy line to the next (labeled “Progression rate from prior line” in UI for 2L+)
Molecule TypeBiologic vs Small Molecule (affects post-LoE erosion rates)
Uptake CurveRate at which product achieves peak share after launch (e.g., “3 Year Medium”)
Retreatment FactorPatients staying on current therapy (reduces pool for next line)
Class SharePercentage of patients clinically suitable for a therapy class (default 100%)

The model handles two disease types differently:

CategoryStage SplitPatient FlowExamples
Solid TumorsYes - Early Stage + MetastaticApplies relapse rates, stage mixBreast Cancer, Lung Cancer, Melanoma
HematologyNo - Full incidence flows directlySimpler flow to therapy linesLeukemia, Lymphoma, Multiple Myeloma
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.


TechnologyVersionPurpose
React19UI framework
TypeScript5.9Type safety (strict mode)
Vite7Build tool, dev server (port 3001)
Jotai2.xAtomic state management
jotai-tanstack-query0.11Bridges React Query cache to Jotai atoms
TanStack Query5.xServer state, caching
Tailwind CSS3.xUtility-first styling
Radix UI-Accessible component primitives
Chart.js4.5Data visualization
simple-statistics7.8Monte Carlo percentiles
Web Workers-Off-thread Monte Carlo simulation (>1K)
docx9.5Client-side DOCX report generation
Sentry10.xError monitoring and performance tracing
ToolPurpose
BiomeLinting + formatting (2-space, double quotes, semicolons)
pnpmPackage manager
@sentry/vite-pluginSource map upload + React component annotation

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 trends

Jotai = atomic state management. Each “atom” is a reactive variable—when it changes, only components using that atom re-render.

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) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
HookUse CaseRe-renders
useAtomValue(atom)Read-only accessOn atom change only
useSetAtom(atom)Write-only accessNever
useAtom(atom)Read + writeOn atom change

Rule: Prefer useAtomValue/useSetAtom over useAtom for performance.

import { atom } from "jotai";
// Primitive atom - writable state
const countAtom = atom<number>(0);
// Derived atom (read-only) - computed from other atoms
const doubleAtom = atom((get) => get(countAtom) * 2);
// Write-only atom - actions/mutations
const incrementAtom = atom(null, (get, set) => {
set(countAtom, get(countAtom) + 1);
});

Why? Direct atom imports in factory files create circular dependencies:

// ❌ BAD: sections/config/atoms.ts imports atoms from ../../atoms.ts
import { instanceRowsAtom } from "../../atoms"; // atoms.ts also imports from sections → circular!

Solution: Section atom factories receive atoms as function parameters:

// ✅ GOOD: Factory receives dependencies
export 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, marketShareAtom

Key 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.

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 sections
return {
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:

DirectionWhatContent
atoms.ts → factoriesPrimitive atomsgeoAtom, indicationAtom, instanceRowsAtom, selectedYearAtom, diseaseConfigAtomFamily
Factories → atoms.tsDerived atomsRead atoms (computed state) + write atoms (actions) per section
atoms.ts → componentsforecastingAtoms namespaceComponents 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.

TypeSourceEditableUsage
ReferenceAPI (/api/statistics)NoDefault values, disease configs
InstanceUser inputYesUser’s custom values

Reference data provides defaults; instance data stores user modifications.

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 default
function 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: applies bg-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:

  1. The value is system-generated or recommended
  2. The user can override it with their own value
  3. 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.

Some fields are computed on-the-fly rather than stored with defaults:

FieldComputation
peakSharecalculatePeakShare(launchOrder, bestInClass, delay, numCompetitors)

For computed fields, use hasOverride() instead of getIsDefault():

// Regular field: check if stored value is at default
healthcareAccess: 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:

  • NumberInput only fires onChange when the value actually differs from the prop (not on every blur). This prevents spurious row creation that would break isDefault/hasOverride checks.
  • 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.

Users can add custom therapy lines (“Default Lines”) that flow sequentially from existing reference lines:

  • Storage: Custom line IDs stored in customLineIds array 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 line
const newLineId = addCustomLine(); // Returns new line ID like "custom-1706000000000"
// Deleting a custom line
deleteCustomLine(lineId); // Only works for custom- prefixed IDs

Custom lines appear below a dashed divider in the PatientFlowTree, separate from reference lines (Early Stage/Metastatic for Solid Tumors, or Therapy lines for Hematology).

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:

PropTypeDefaultDescription
childrenReactNoderequiredTooltip content (string or JSX)
variant"default" | "amber""default"Icon color variant
size"sm" | "md""sm"Icon size (sm: h-4, md: h-5)
classNamestring-Additional CSS classes for the button
sourcesSourceEntry[] | null-Reference data sources (displays below description)
defaultValuestring | 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-help and aria-label="More information"
  • Icon colors: default (gray), amber (warning/info banners)

Sources Display:

When sources is provided, InfoButton renders:

  1. Description text (children)
  2. Dashed divider
  3. Default value (if provided)
  4. Source cards with name, URL (clickable), sample size, and comments

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 variables
const { sources, value } = useReferenceSource("transitionRate", "2L");

Returns: { sources: SourceEntry[] | null, value: string | number | number[] | null }

How it works:

  1. Reads from referenceRowsAtom (cached API data)
  2. Filters by current geoAtom and indicationAtom context
  3. Matches by variable name and optional line ID
  4. Returns sources and default value, or null if not found

The app uses TanStack Query with exponential backoff for API resilience:

// main.tsx - Global QueryClient configuration
const 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 atoms
import { atomWithQuery } from "jotai-tanstack-query";
export const statisticsQueryAtom = atomWithQuery(() => ({
queryKey: STATISTICS_QUERY_KEY,
queryFn: fetchStatistics,
}));
// Derived atom reads from query atom
export 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 operations
import { 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"] }),
});
}
PatternUse CaseExample
atomWithQueryData feeds into Jotai derived atomsStatistics, disease configs
useQuery/useMutationCRUD operations, standalone queriesModel save/load/delete
atomWithQuery (statisticsQueryAtom)
React Query cache (5min stale time)
Derived atoms (referenceRowsAtom)
Components read via useAtomValue
EndpointMethodPurpose
/api/statisticsGETReference data for forecasting
/api/forecastingGETList saved models
/api/forecastingPOSTCreate new model
/api/forecasting/:idPUTUpdate model
/api/forecasting/:idDELETEDelete model
/api/check_sessionGETVerify authentication

Zod schemas in src/lib/schemas/forecasting.ts validate data at two layers:

Validates external data immediately after fetch, before it enters the atom store:

Call SiteSchemaOn Failure
useStatistics.tsApiStatisticsResponseSchemaThrows (blocks load)
useForecasting.tsFullSnapshotSchemaThrows (blocks load)
model-atoms.tsFullSnapshotSchemaThrows (blocks load)

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.

SchemaValidatesUsed By
MarketEventsArraySchemaMarketEvent[]Read + write validation
VariablesArraySchemaVariable[]Read + write validation
NumberArraySchemanumber[]Write validation (incidence, erosion, price evolution)
FullSnapshotSchemaComplete model snapshotAPI boundary validation

All error logging uses z.prettifyError() (Zod v4 API).


The application supports saving, loading, renaming, and deleting forecasting models via the backend API.

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)

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>;
}
AtomTypeDescription
availableBiomarkersAtomDerived (read)Extracts BiomarkerDef[] from reference rows for current geo/indication
activeBiomarkerAtomDerived (read)Current biomarker ID from instance rows, or null
biomarkerFactorAtomDerived (read)Computed 0-1 multiplier: (percentage/100) × (testingRate/100) when active, else 1.0
toggleBiomarkerAtomWriteActivates/deactivates biomarker (sets/clears active ID only)
setBiomarkerFieldAtomWriteUpdates biomarker percentage or testing rate override
resetBiomarkerFieldAtomWriteResets 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.

AtomPurpose
createFullSnapshotAtomCreates snapshot from current state for saving
loadSnapshotAtomHydrates all atoms from loaded snapshot
loadModelAtomOrchestrates model loading (fetch + hydrate)
isDirtyAtomTracks unsaved changes
lastSavedSnapshotAtomStores JSON of last saved state for comparison
model-atoms.ts
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);
},
);

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();

Shared validation in components/model-management/validation.ts:

RuleConstraintError Message
RequiredNon-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”
UniquenessCase-insensitive unique”A model with this name already exists”
EndpointMethodPurpose
/api/forecastingGETList all models
/api/forecastingPOSTCreate new model
/api/forecasting/:idGETGet model by ID
/api/forecasting/:idPUTUpdate model (data or name)
/api/forecasting/:idDELETEDelete model
ComponentPurpose
ModelSelectorDropdown to select/load models
ModelCardDisplays current model info with action buttons
SaveDialogModal for saving with name input
RenameDialogModal for renaming existing model
DeleteDialogConfirmation for deletion
UnsavedChangesDialogWarns before losing unsaved changes

The application generates DOCX reports for the currently selected geography + indication. The pipeline is split into pure, testable functions.

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 download

Users can select which sections to include in the exported report via ExportReportPopover:

  • useExportSections hook: Manages a nested checkbox tree with parent/child selection and indeterminate states
  • useReportExport accepts a selectedSections: Set<ExportSectionId> parameter to filter output
  • buildDocxReport and collectReportData filter their output by the selected sections
  • ExportReportPopover renders in EditorHeaderActions as the export trigger UI
DecisionRationale
collectReportData is a pure functionTestable without React; all atom values passed as parameters
MC/Tornado data read from section handlesMC results live in component useState, Tornado in useMemo — not in atoms
Chart images captured from Chart.js canvastoBase64Image("image/png") — resolution controlled by devicePixelRatio: 2 in chart options
Source deduplication via JSON.stringify keyHandles identical sources referenced from multiple fields
Deterministic narrative templatesNo AI-generated prose — fixed descriptions per section
Stage-level error wrappingEach pipeline stage throws descriptive Error with { cause } for actionable messages
FilePurpose
reporting/types.tsReportData, ReportContext, ChartImages, DeduplicatedSource, etc.
reporting/collectReportData.tsPure function: builds assumptions, population, lines, incidence tables + sources appendix
reporting/captureChartPngs.tsSafe per-chart capture with fallback metadata
reporting/buildDocxReport.tsBuilds Document with cover, config, incidence, model, sales, MC, tornado, sources sections
reporting/exportReport.tsOrchestrates pipeline and triggers browser download
components/ExportReportPopover.tsxSection selection UI with nested checkbox tree for export
hooks/useExportSections.tsManages section selection state (parent/child, indeterminate)

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 context
type TornadoHandle = ChartSectionHandle & {
getExportMeta: () => { tornadoYear: number };
};

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.

  • 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

SectionDescriptionKey Atoms
ConfigurationTherapy line setup, market assumptions, patient treeassumptions, population, lines, biomarkerFactor
ModelPatient flow calculations, sales by therapy lineComputed from config
SalesChartStacked bar revenue visualization (Chart.js)Sales data per line
MonteCarloProbabilistic simulation (1K–100K iterations, configurable)simulationVariables, numSimulationsAtom, mcIsRunningAtom, results
TornadoSensitivity analysis (variable impact ranking)Uses MonteCarlo variables
IncidenceEvolutionPopulation growth projections, erosion ratesincidenceEvolution, erosionRates
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"
│ └── IncidenceEvolution

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) with Dna icon, toggle, and editable sub-fields (percentage, testing rate) using border-primary/20 indent guide

Collapsible wrapper for each section with optional ID for scroll targeting.

Props:

  • titleTooltip?: ReactNode — renders an InfoButton beside the title
  • headerMeta?: 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>

Fixed left-side navigation with scroll tracking:

  • Uses useActiveSection hook (IntersectionObserver)
  • Hidden on xs/sm, visible on md+ (hidden md:block)
  • Smooth scroll to section on click

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.

YearIncidence = BaseIncidence × (Pop[Year] / Pop[BaseYear]) × RegionalFactor
EarlyAddressable = Incidence × EarlyStage% × (1 + Early→EarlyRelapse%) × HealthcareAccess%
MetAddressable = (Incidence × Met% + Incidence × Early% × Early→MetRelapse%) × HealthcareAccess%

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 - DelayPenalty
DelayPenalty = 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.
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%
LineSales ($M) = Σ(CohortPatients[yearOffset] × NetPrice × Compliance% × MonthsThisYear[yearOffset]) / 1,000,000
ConstantValuePurpose
PROJECTION_DATA_LENGTH20Years of projection (base + 19)
REVENUE_SCALE_DIVISOR1,000,000Convert to millions
COMPETITION_THRESHOLD_QUARTERS3When delay penalty kicks in
COMPETITION_FACTOR_MULTIPLIER0.5Delay penalty per quarter
COHORT_YEARS5Patient cohorts to aggregate
MAX_CUSTOM_VARIABLES5Custom variables per line
FieldDefaultNotes
Treatment Rate100%Applied to 1L and 2L only
Transition Rate100%Between therapy lines
Healthcare Access95%Varies by geography
Compliance100%Patient adherence
Months of Therapy12Distributed across cohort years (≤12 per year)
Launch Price$6,000/monthPer indication (from constants.ts)
Annual Price Change2%Year-over-year
Market Exclusivity12 yearsBefore LoE

// Geography - known values plus extensibility
type Geography = "USA" | "EU5" | "Japan" | "China" | (string & {});
// Indication = disease name
type Indication = string;
// Line ID (e.g., "1L", "2L", "adjuvant")
type LineId = string;
// Disease configuration from API
interface 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 configuration
interface LineConfig {
id: string;
displayName: string;
category: string;
transitionRate: number;
treatmentRate: number;
healthcareAccess?: number;
}
// Market data per therapy line
interface 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 variable
interface 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 lines
interface MarketEvent {
id?: string; // Unique identifier (generated on creation)
name?: string;
impactPercent: number;
startDate: string; // YYYY-MM format
endDate?: string;
}
  1. NEVER use any - use unknown if truly unknown
  2. Explicit return types for all functions
  3. Use ReactElement for component returns (not JSX.Element)
  4. Path aliases: @/./src/
// CORRECT
import { type ReactElement } from 'react';
function MyComponent(): ReactElement {
return <div />;
}
// FORBIDDEN (JSX.Element doesn't exist in React 19)
function MyComponent(): JSX.Element { ... }

Authentication uses JWT cookies shared with the BioLoupe platform:

contexts/auth-context.tsx
import { useAuth } from "@/contexts/auth-context";
const { isLoggedIn, isLoading, user, logout, redirectToLogin } = useAuth();
interface User {
id: number;
email: string;
firstName: string;
lastName: string;
role?: string;
}
  1. App mounts → check session via /api/check_session
  2. If authenticated → show app
  3. If not authenticated → redirect to BioLoupe login (VITE_BIOLOUPE_URL)
  4. After login → redirect back with JWT cookie set

All API calls include credentials for cookie auth:

// lib/api.ts - Namespace export pattern
async 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 are read from .env files via Vite:

VariablePurposeExample
VITE_API_URLData-gov API endpointhttp://localhost:3010 (dev)
VITE_BIOLOUPE_URLBioLoupe client for auth redirectshttp://localhost:8080 (dev)
VITE_ENABLE_PRICINGFeature flag for pricing sectionfalse

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";

RuleDetails
No any typeUse unknown if truly unknown
Explicit return typesAll functions must have return types
Max file size500 lines/file, 200 lines/component, 50 lines/function
Section atom factories use DIReceive atom deps via parameters, not direct imports
Peak share is computedDerive from inputs; user can override effective value via customEffectivePeakShare
Use proper Jotai hooksuseAtomValue for read, useSetAtom for write
Path aliases@/./src/, @/lib./src/lib
React 19 typesUse ReactElement (not JSX.Element)
PracticeAlternative
any typeunknown or proper typing
@ts-ignoreFix the type issue
JSX.ElementReactElement
Files > 500 linesSplit into modules
Components > 200 linesExtract sub-components
Business logic in componentsPure functions in computations.ts
Section atom factories importing atoms from atoms.tsReceive atoms via factory parameters
Nested state objectsNormalized flat maps with composite keys
Direct fetch callsUse Api namespace
Hardcoded API URLsUse VITE_API_URL environment variable
useAtom when only readinguseAtomValue (prevents re-renders)
useAtom when only writinguseSetAtom (prevents re-renders)
Async atoms for data fetchingReact Query + setter atoms

Before committing, ensure:

  • pnpm lint passes
  • pnpm build succeeds
  • All states handled (loading, error, empty, success)
  • No any types or @ts-ignore
  • Pure computations are testable outside React
  • Section atom factories use dependency injection (no direct atom imports from atoms.ts)
Terminal window
pnpm dev # Start dev server (port 3001)
pnpm build # TypeScript check + Vite build
pnpm lint # Check with Biome
pnpm lint:fix # Auto-fix with Biome
pnpm format # Format code with Biome
pnpm preview # Preview production build

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 }
  • SDK: @sentry/react — initialized in src/instrument.ts (must be first import in main.tsx)
  • Features: Error Monitoring + Tracing (no Session Replay, no Profiling)
  • Disabled when: VITE_SENTRY_DSN is empty (local dev default)
  • Source maps: Hidden sourcemaps uploaded via @sentry/vite-plugin (CI only, guarded by SENTRY_AUTH_TOKEN)
  • PII: sendDefaultPii: false — pharmaceutical app, conservative default
VariableRuntimePurpose
VITE_SENTRY_DSNBrowserSentry DSN (empty = disabled)
VITE_APP_VERSIONBrowserRelease version for Sentry
SENTRY_ORGCI buildSentry org slug (source map upload)
SENTRY_PROJECTCI buildSentry project slug
SENTRY_AUTH_TOKENCI buildSentry auth token (source map upload)
DecisionRationale
No Sentry.captureException in ErrorBoundaryReact 19 onCaughtError already captures — would duplicate
No Sentry bridge in LoggerLogger called from componentDidCatch (already captured) and mutation callbacks (toast errors)
Smart retry skips auth errors by message regexAPI throws Error with user-friendly messages; status codes not preserved on the Error object
Export pipeline stage-level errorsActionable messages surface to user via existing toast in useReportExport