Architecture
Bioloupe Forecasting Architecture
Section titled “Bioloupe Forecasting Architecture”Jotai atom patterns, section factory architecture, data fetching strategy, and component structure for the pharmaceutical forecasting application.
Table of Contents
Section titled “Table of Contents”- Overview
- Technology Stack
- Project Structure
- State Management
- Data Fetching
- Runtime Validation
- Model Management
- Report Export
- Component Architecture
- Comparison Page
- Monte Carlo Architecture
- Authentication
- Error Handling & Monitoring
1. Overview
Section titled “1. Overview”For domain terminology and patient flow concepts, see FORECASTING_MODEL.md.
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 under src/features/forecasting/math/ (forecasting, distributions, monte-carlo, tornado, transplant, incidence-evolution, constants), 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. Technology Stack
Section titled “2. Technology Stack”See package.json for current versions. Key technologies:
| Technology | Purpose |
|---|---|
| React | UI framework |
| TypeScript | Type safety (strict mode) |
| Vite | Build tool, dev server |
| Jotai | Atomic state management |
| jotai-tanstack-query | Bridges React Query cache to Jotai atoms |
| TanStack Query | Server state, caching |
| Tailwind CSS + Radix UI | Styling + accessible component primitives |
| Chart.js | Data visualization |
| Web Workers | Off-thread Monte Carlo simulation (>1K) |
| docx | Client-side DOCX report generation |
| Sentry | Error monitoring and performance tracing |
| Biome | Linting + formatting (2-space, double quotes, semicolons) |
3. Project Structure
Section titled “3. Project Structure”src/├── components/ui/ # Radix UI + custom wrappers (see DESIGN_SYSTEM.md)├── contexts/auth-context.tsx # JWT cookie authentication├── hooks/ # useForecasting (model CRUD), useStatistics (API data), useActiveSection├── lib/ # api.ts (API namespace), config.ts (env vars), schemas/ (Zod)└── features/ ├── forecasting/math/ # Pure math (extracted Phase 1): forecasting.ts, distributions.ts, monte-carlo.ts, tornado.ts, transplant.ts, incidence-evolution.ts, constants.ts └── reports/forecasting/ ├── atoms.ts # Central Jotai state (reference + instance, section factory orchestrator) ├── model-atoms.ts # Model persistence (snapshot create/load, dirty tracking) ├── constants.ts # UI/data defaults, limits, SCENARIO_COLORS, UPTAKE_CURVE ├── scenarios/ # Multi-scenario tab system (ScenarioProvider, per-store management) ├── reporting/ # DOCX export pipeline (collect → capture → build → download) └── sections/ # Feature sections (Configuration, Model, MonteCarlo, etc.)4. State Management
Section titled “4. State Management”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) │ ││ └──────────────────────────────────────────────────────────────┘ ││ │└─────────────────────────────────────────────────────────────────────┘Multi-Store Architecture (Scenarios)
Section titled “Multi-Store Architecture (Scenarios)”Business framing — what a scenario is. One scenario = one indication + one geography + one set of LoE / price / launch / market-share assumptions. Different indications, different geographies, or different LoE / price / molecule assumptions live in separate scenario tabs — never combined within a single tab. The Comparison page is the only surface that aggregates across scenarios; within a tab, the math is single-indication, single-geography. This keeps each scenario’s per-store state self-consistent and avoids cross-contaminating regional erosion curves, regional pricing, or per-indication patient flows.
Each scenario tab owns a separate Jotai createStore(). Switching tabs changes the active store wrapped in a <Provider>.
main.tsx <Provider> → DEFAULT STORE (queryClientAtom only) └── Forecasting.tsx └── ScenarioProvider └── <Provider store={activeStore}> → SCENARIO STORE (all forecasting atoms) └── Section components read/write this storeKey behaviors:
ScenarioProvidermanages an array ofScenarioTabData(each with its ownstoreandmeta)- Adding a scenario creates a new store; closing one removes it (with undo via
removedTabsRef) collectSnapshot()iterates all stores to build aFullSnapshotfor savingloadScenarios()creates stores from saved snapshot data, captures store-derived baselines- Dirty tracking compares current store state against baselines, excluding cosmetic metadata
- Duplicate scenario clones the source tab’s full per-store state via
scenarios/cloneStoreState.ts:instanceRowsAtom(deep-cloned — carries selected lines, annotations, biomarkers, growth configs, geo/indication selections, etc.), all standalone primitive atoms (MC sim count/results,simulationVariablesFamilyenumerated viagetParams()so every(geo, indication)the source has touched survives, Tornado year override, config tree highlight, chart palettes), and the parent model metadata.mcIsRunningAtomis intentionally skipped because it tracks a per-store Web Worker — copyingtruewould strand the duplicate at “running” with no worker. When adding a new primitive scenario atom, extendcloneStoreState.tsso duplication stays complete.
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.
Instance vs Reference Data
Section titled “Instance vs Reference Data”| Type | Source | Editable | Usage |
|---|---|---|---|
| Reference | API (/api/forecasting/statistics) | No | Default values, disease configs |
| Instance | User input | Yes | User’s custom values |
Reference data provides defaults; instance data stores user modifications.
For UI component patterns (InfoButton, default highlighting, reference sources), see DESIGN_SYSTEM.md.
*Growth Companion Rows (Year-over-Year Projection)
Section titled “*Growth Companion Rows (Year-over-Year Projection)”Variables that support year-over-year growth projection store a JSON-encoded ChangeableConfig ({changeable, min, max, startYear}) in a companion instance row named {fieldName}Growth. Absent row = growth OFF; reading a missing row returns null and the math falls through to the static base value.
| Variable | Companion row name | Scope |
|---|---|---|
| Healthcare Access | healthcareAccessGrowth | scenario ({line: null}) |
| Drug Treatment Rate | treatmentRateGrowth | per-line |
| Biomarker Testing Rate | biomarkerTestingRateGrowth | line: "bm:{biomarkerId}" (one per active biomarker) |
Atom families: treatmentRateGrowthFamily(lineId), plus the singletons healthcareAccessGrowthAtom and biomarkerTestingRateGrowthAtom. Setters (setHealthcareAccessGrowthAtom, setTreatmentRateGrowthAtom, setBiomarkerTestingRateGrowthAtom) accept null to delete the companion row.
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) |
All fields — stored or computed — use metadata-based getIsDefault():
// Stored fieldhealthcareAccess: isDefault("healthcareAccess"),
// Computed field — same pattern, because annotated-default rows carry// `metadata.isDefault: true` and must still read as "at default."peakShare: isDefault("peakShare"),The pattern:
- No row exists → IS at default → Show yellow
- Row exists with
metadata.isDefault: true→ IS at default → Show yellow (e.g., user attached a rationale to a still-default value) - Row exists with
metadata.isDefault: false→ NOT at default → No yellow
Common gotchas:
- Do not use existence-based checks (e.g.,
getRow(...) !== undefined) to detect “user overrode this.”setAnnotationAtomcreates a{ value: null, metadata: { isDefault: true, annotation } }row when the user annotates a still-default field, so a row can exist while the value is still at default. Always checkmetadata.isDefaultviagetIsDefault(). NumberInputonly firesonChangewhen the value actually differs from the prop (not on every blur). This prevents spurious row creation that would breakisDefaultchecks.- The
write()helper in Configuration atoms skips no-op upserts — if the new value equals the existing row’s value, no write occurs. On real edits it preservesexistingRow.metadata.annotationand forcesisDefault: false(canonical “user wrote a value” semantics) — mirrorssetValueAtom’s contract for the bulkupsertRowsAtompath. Spreading the fullmetadatawould carry forwardisDefault: truefrom seeded rows and make user edits read as defaults.
Year Anchors
Section titled “Year Anchors”Time-based computations across the app anchor on the first selected line’s launch date, not on today’s date. This keeps the forecast centered on the product lifecycle regardless of when the user opens the scenario.
Single source of truth
Section titled “Single source of truth”firstSelectedLaunchDateAtom (Configuration factory, ISO YYYY-MM-DD) ├── Fallback chain: selected lines → all lines → ${today}-01-01 ├── Feeds: selectedYearAtom, assumptions.loeDate, assumptions.yearOfFirstLaunch, │ Tornado dropdown, Comparison Tornado analysis └── Reactive: recomputes whenever any line's `launch` or `isSelected` changesDisplay-window anchor (selectedYearAtom)
Section titled “Display-window anchor (selectedYearAtom)”selectedYearAtom = firstSelectedLaunchYear − 1. The − 1 opens every forecast with a “run-up to launch” row of zeros so users see the transition into sales. Used by:
- Model, SalesChart, IncidenceEvolution, Monte Carlo: all derive their year columns as
selectedYear + [0..PROJECTION_DATA_LENGTH). - Tornado + Comparison Tornado: analysis anchor only, not the UI dropdown (see next).
netPriceEvolution and incidenceEvolution arrays are indexed relative to selectedYear — element [0] represents the pre-launch row. Any consumer that passes a different startYear to downstream computation helpers (e.g., runTornadoAnalysis) will silently off-by-one the custom-evolution compounding. This is a subtle gotcha; see Tornado’s two-anchor split below.
Tornado’s two anchors
Section titled “Tornado’s two anchors”Tornado is the only forecast section that needs two distinct year anchors:
| Anchor | Value | Purpose |
|---|---|---|
analysisStartYear | selectedYear (= launch − 1) | Passed to runTornadoAnalysis as startYear. MUST match the netPriceEvolution / incidenceEvolution anchor so yearIdx = targetYear - startYear agrees with array indices. |
firstLaunchYear | first selected line’s launch year | UX anchor for the year-picker dropdown. Pre-launch years are not meaningful for sensitivity, so the dropdown starts at launch. |
Both sections/Tornado/Tornado.tsx and sections/Comparison/collectComparisonData.ts apply this split. Mirror the pattern if you add another sensitivity analysis section.
Override-with-auto-reset (yearOfFirstLaunch) and bidirectional sync (loeDate)
Section titled “Override-with-auto-reset (yearOfFirstLaunch) and bidirectional sync (loeDate)”assumptions.loeDate and assumptions.yearOfFirstLaunch are both derived from the line launch dates, but use different update strategies:
loeDate — bidirectional sync (no override state). loeDate is purely derived from firstSelectedLaunchDate + marketExclusivityYears. Editing the LoE MonthPicker does not store an override — instead, updateAssumptions intercepts the loeDate write, computes the month delta, and shifts ALL therapy lines’ launch dates by that delta (via shiftLaunchByMonths). This preserves relative launch timing and guarantees the invariant firstLaunch + marketExclusivityYears = loeDate always holds. There is no LoE rollback button — nothing to roll back.
yearOfFirstLaunch — computed default with storage override. This still uses the override-with-auto-reset pattern: derived from firstSelectedLaunchYear by default, user can override via NumberInput, and the override clears when a source field changes.
Write-time side-effects that clear yearOfFirstLaunch overrides (and any legacy loeDate overrides from pre-bidirectional-sync models):
| Writer | Triggering field(s) | Rows cleared |
|---|---|---|
updateAssumptions | marketExclusivityYears | loeDate (defensive — Option A doesn’t write loeDate but old models may have stored values) |
updateLine | launch, isSelected | loeDate, yearOfFirstLaunch |
addCustomLine | (adding an isSelected: true line) | loeDate, yearOfFirstLaunch |
deleteCustomLine | (shrinking the selected pool) | loeDate, yearOfFirstLaunch |
Clearing uses deleteRowsAtom when no annotation is attached. When the row has metadata.annotation (user attached a rationale), the row is rewritten with value: null + metadata: { isDefault: true, annotation } instead — readers fall through to the recomputed default identically (because getValue returns null for both no-row and value: null, and getIsDefault returns true for both no-row and metadata.isDefault: true), and the rationale survives.
Override Preservation
Section titled “Override Preservation”The general rule across the forecasting state:
- Direct user overrides on editable fields persist across unrelated edits. Editing
transitionRatedoes not touch the user’s override onpeakShare,compliance,monthsOfTherapy,customEffectivePeakShare,events,customVariables, etc. Each row is keyed by{geo, indication, line, name}and only the matching writer touches it. yearOfFirstLaunchis the explicit override-with-auto-reset exception — its override clears when source fields (launch,isSelected) change so the override can’t drift out of sync with its inputs.loeDateis purely derived (no override state at all — see bidirectional sync above). See the “Override-with-auto-reset / bidirectional sync” section above for the exact triggers.- Deselecting a therapy line preserves its data. Toggling
isSelected: falsedoes not delete the line’s overrides — peak share, market share, events, custom variables, and price assumptions all stay intact. Re-selecting the line restores it unchanged. (Deselect is treated as a “hide from current run”, not a destructive reset.) - Two paths clear an override: (1) the rollback button is the explicit user-driven path — deletes the row so the field falls back to the computed default. Note:
loeDatehas no rollback button because Option A doesn’t store an override (editing LoE shifts all line launches instead). (2) Implicit auto-resets (clearDerivedOverridesforyearOfFirstLaunchon source-field changes, and defensively for legacyloeDateoverrides) also wipe the override value but preserve any attached annotation by rewriting the row withvalue: nullinstead of deleting. A value-equality write that happens to match the default does not touch the row.
This rule applies symmetrically across all editable fields except where called out as a derived default. When in doubt, look for a clearDerivedOverrides (or equivalent) call in the writing atom — its presence is the signal that an override might be cleared.
Annotations (Rationale + Source URL)
Section titled “Annotations (Rationale + Source URL)”Users can attach a VariableAnnotation ({ rationale, sourceUrl?, createdAt, updatedAt }) to any editable variable, market event, or custom variable to record “why this number.” Annotations persist with the scenario, round-trip through snapshots, and surface in the DOCX report as an Assumptions Rationale appendix.
Storage (dispatched by row shape)
Section titled “Storage (dispatched by row shape)”| Target | Storage | Read/write path |
|---|---|---|
Scalar rows (e.g., healthcareAccess, transitionRate, peakShare) | InstanceRow.metadata.annotation | setAnnotationAtom preserves metadata.isDefault so attaching a rationale to a still-default value doesn’t flip it to non-default. Conversely, value writers (setValueAtom, Configuration write()) preserve metadata.annotation and force isDefault: false — editing the value drops the default state but keeps the rationale. |
Array-stored customVariables | Per-element Variable.annotation inside the array value | setAnnotationAtom uses the subkey (= Variable.id) to target one element |
Array-stored events | Per-element MarketEvent.annotation | Same subkey dispatch (= MarketEvent.id); MarketEvent.id is now required — backfilled on snapshot load for pre-feature data |
AnnotationKey is the write address ({ geo, indication, line, year, name, subkey? }). Atoms: setAnnotationAtom, clearAnnotationAtom (write); getAnnotation(rows, key) (single-key read helper) and countLineAnnotations(rows, geo, indication, lineId) (multi-row count helper used by destructive-action guards) — both mirror the same three-branch dispatch so counts can’t desync from reads. All live in src/features/reports/forecasting/atoms.ts.
Components
Section titled “Components”AnnotationButton— speech-bubble icon next to each annotatable input; opens aPopovercontainingAnnotationPopover. Dot indicator + primary-colored icon when an annotation is attached.AnnotationPopover— rationale textarea (required) + optional source URL. ⌘/Ctrl+Enter saves; Esc cancels.AnnotationResetDialog+useResetWithAnnotationGuard— wraps reset callbacks so a reset that would clobber a saved rationale prompts for confirmation first. If confirmed, the hook runs both the value reset andclearAnnotationAtom. If no annotation is attached, the reset runs immediately. The implicit auto-resets inclearDerivedOverrides(forloeDate/yearOfFirstLaunch) bypass this dialog because they preserve the annotation directly — no user prompt needed. The dialog also accepts optionaltitle/description/confirmLabelprops for multi-row destructive actions (e.g., custom-line deletion vialineAnnotationCountinConfiguration/atoms.ts) where the default single-field rationale-preview layout doesn’t fit.
Report appendix
Section titled “Report appendix”When the user includes the rationale-appendix section in the export (on by default via SECTION_TREE in useExportSections), buildRationaleAppendix emits an “Assumptions Rationale” chapter grouped by category (Indication → Biomarkers → Market Assumptions → Therapy Lines → Monte Carlo) with optional line/biomarker subgroups. Entries are collected during collectReportData by walking instanceRows.
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). The X button always opens a single
AnnotationResetDialogfor confirmation; the dialog’s title, description, and confirm label adapt based onlineAnnotationCount(annotation-aware “discard N rationales” copy when > 0, generic “Are you sure?” copy when 0). On confirm, deletion wipes every row under(geo, indication, lineId)— symmetric with the single-field reset guard.
In the Patient Flow funnel (PatientFlowTimeline → PatientFunnel), custom lines append to the appropriate stage’s funnel slice (Early Stage/Metastatic for Solid Tumors, or Therapy for Hematology), appearing after the reference lines and inheriting their transitionRate default from LINE_DEFAULTS.
Patient flow funnel
Section titled “Patient flow funnel”The Configuration page renders a fixed-proportion funnel above the line panel.
The funnel is built from per-row 2-column CSS grids (44px switch rail | 1fr
trapezoid area), where each trapezoid is shaped via clip-path: polygon()
driven by two CSS custom properties (--top-w, --bot-w). Width math uses
fixed visual proportions per ref-line position (1L=100/75, 2L=75/56,
3L=56/39, 4L=39/32, 5L+ rectangle 32/32; Early row forced to inverted
70/100). This is intentionally decoupled from real transition rates —
patient counts are surfaced in the row labels, while trapezoid widths are
decorative, ensuring downstream lines remain readable even with aggressive
drop-off (rationale captured in commit 46bb9f7). Custom rows render as
rectangles at the previous row’s bot-w, continuing the visual flow.
Composition (solid tumor): PopulationSummaryCard →
PhasePlatform("Early stage") → Early LineCard → PhaseDivider →
PhasePlatform("Metastatic") → 1L–4L LineCards with FlowTail strips
between them → optional custom LineCards (rectangles) → AddLineButton.
Composition (hematology): PopulationSummaryCard →
PhasePlatform("Therapy lines") → 1L–nL LineCards with FlowTail strips
between → optional custom LineCards → AddLineButton. No Early row, no
divider.
Source citations: each FlowTail calls
useReferenceSource("transitionRate", lineId) (the codebase’s canonical
reference-data hook, also used by LinePanel, SummaryPanel, and
IncidenceEvolution) and feeds sources / year / alternatives into the
shared <InfoButton> component. lineId is the receiving (downstream)
line, since transitionRate is stored as the rate INTO that line; the strip
between row[i] and row[i+1] therefore displays row[i+1].transitionRate and
keys its citations to row[i+1].lineId. Funnel and panel cannot drift.
Palette: fixed CHART_PALETTES["indigo-flow"] (7-shade cascade; ID
retained for back-compat with persisted selections, values shifted to
Tailwind blue, display name Blue Flow). The funnel is not
user-recolorable; sales / Monte Carlo / tornado palettes remain
user-configurable via their respective atoms (defaults: sales and
Monte Carlo "monochrome", tornado "vibrant").
Animations: the patient-flow pane entrance and the per-row funnel
cascade both use the pf-flow-in keyframe with staggered nth-child delays
— .pf-pane.animating > * for the two top-level children (summary card +
funnel section), .pf-funnel > * for individual rows/platforms/dividers.
The keyframe animates opacity only — animating transform here would pin
an identity matrix on .pf-row via animation-fill-mode: both and override
the active row’s translateY(-1px). Count changes scrub via the
useCountScrub hook (RAF + cubic ease-out, 700ms). Both motion sources are
gated on prefers-reduced-motion: no-preference, and the scrub hook also
bails to instant transitions when matchMedia("(prefers-reduced-motion: reduce)")
matches.
Tablet support: a single md: Tailwind breakpoint (~768px) gates the
forecasting UI — below it the user sees a “switch to a larger screen” notice
(Forecasting.tsx). There are no XS/SM mobile breakpoints and no
funnel-specific responsive rules. Touch handling is centralized in
useIsTouchDevice (used by <InfoButton> to swap Tooltip → Popover).
5. Data Fetching
Section titled “5. Data Fetching”Rate Limiting & Retry
Section titled “Rate Limiting & Retry”Queries retry 3x with exponential backoff. Mutations retry once with fixed 1s delay. Configuration is in src/main.tsx (QueryClient setup). The four model-CRUD mutations in src/hooks/useForecasting.ts (useCreateModel, useUpdateModel, useRenameModel, useDeleteModel) override this to retry: 0 to reduce duplicate-write blast radius — PR previews share the production API (see CLAUDE.md “Deployment topology”).
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 useAtomValueKey files: src/lib/api.ts (API namespace), src/hooks/useForecasting.ts (model CRUD), src/hooks/useStatistics.ts (reference data)
6. Runtime Validation
Section titled “6. 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. Schemas like FullSnapshotSchema and ApiStatisticsResponseSchema throw on failure, blocking load.
Layer 2 — Atom Read/Write Boundaries
Section titled “Layer 2 — Atom Read/Write Boundaries”Validates high-risk complex fields (events, customVariables) on read and write. Catches corrupted data that would otherwise propagate silently via bare as T casts.
- Read-side: The
read()helper inConfiguration/atoms.tsaccepts an optional Zod schema. On failure, returnsnull(callers fall through to?? default). Gradual opt-in — simple scalars use bare casts. - Write-side:
setValueAtominatoms.tsvalidates via theWRITE_SCHEMASmap. On failure, the write is silently rejected and logged. - Bypass: Internal bulk writes via
upsertRowsAtomskip validation (they construct known-good data from constants/templates).
Schema inventory: EventArraySchema, CustomVariableArraySchema, ChangeableConfigSchema (validates {changeable, min, max, startYear} for *Growth companion rows), NumberArraySchema (validates incidenceEvolution, incidenceEditedYears, netPriceEvolution, moleculeErosion, biologicsErosion), plus the API-boundary schemas (FullSnapshotSchema, ApiStatisticsResponseSchema, etc.).
Key files: src/lib/schemas/forecasting.ts (all Zod schemas)
7. Model Management
Section titled “7. 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: ScenarioProvider.collectSnapshot() → API POST/PUT │ └── Load: queryClient.fetchQuery() → ScenarioProvider.loadScenarios() │ ▼ For each scenario tab: loadStoreSnapshot(store, context, rows) │ ┌───────────────────┼───────────────────┐ │ │ │ ▼ ▼ ▼ instanceRowsAtom Context Atoms isDirtyAtom = false (user data) (UI state) (per-store) │ ▼ Store-derived baseline captured (for dirty comparison)Snapshot Structure
Section titled “Snapshot Structure”Models are persisted as FullSnapshot with per-scenario data. See src/features/reports/forecasting/scenarios/types.ts for FullSnapshot/ScenarioSnapshot and src/features/reports/forecasting/types.ts for ModelContext.
Biomarker Atoms
Section titled “Biomarker Atoms”Biomarker atoms are defined in src/features/reports/forecasting/sections/Configuration/atoms.ts (search Biomarker).
Data flow: User toggles biomarker → activeBiomarkerAtom updates → biomarkerFactorAtom derives a 0-1 multiplier → computation functions apply factor to first-line addressable population. 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.
Model Persistence
Section titled “Model Persistence”Model persistence atoms and helpers are in src/features/reports/forecasting/model-atoms.ts.
Dirty Tracking
Section titled “Dirty Tracking”Dirty state uses a two-layer approach:
- Per-store
isDirtyAtom: Boolean in each scenario’s Jotai store, set bymarkDirtyAtomwhen any input changes - Store-derived baselines:
ScenarioProvidercaptures JSON snapshots from stores (not raw server data) after load/save, excluding cosmetic metadata (label,isAutoLabeled) from comparison - Aggregation:
isAnyDirty()iterates all scenario stores to check if any tab is dirty — called at click time in handlers to avoid stale closures - Reactive subscription (
ScenarioProvider.tsx:619-642): A provider-leveluseEffectsubscribes to every tab’sisDirtyAtomvia Jotai’s imperativestore.suband mirrorsisAnyDirty()into a ReactanyDirty: boolean. The effect’s deps are scoped totabsStateonly —isAnyDirtyis read via a ref so active-tab switches don’t re-subscribe every store. Used byuseBeforeUnloadGuard(anyDirty)(hooks/useBeforeUnloadGuard.ts) to install abeforeunloadlistener only while work is unsaved — so the page stays eligible for the browser back/forward cache (BFCache) when clean, per MDN best practice.
useModelManagement Hook
Section titled “useModelManagement Hook”Orchestrates all model operations. Uses a discriminated PendingAction union (create | back | load) to track what should happen after an unsaved-changes dialog resolves. Dirty-gated handlers call isAnyDirty() at click time (not at render time) to avoid stale closures.
Key files:
- Validation rules:
src/features/reports/forecasting/components/model-management/validation.ts - UI components:
src/features/reports/forecasting/components/model-management/ - API endpoints:
src/hooks/useForecasting.ts
8. Report Export
Section titled “8. 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 |
| DOCX reference-row lookup applies the guidance cascade | findReferenceRow in collectReportData.ts routes through selectGuidance so DOCX and UI cite the same guidance source when multiple rows exist for a key |
| 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 |
Key files: src/features/reports/forecasting/reporting/ — shared DOCX helpers (paragraph, table, image factories) live in docx-helpers.ts and are used by both the per-scenario and comparison report builders. downloadBlob() lives in src/lib/export-csv.ts.
Section Handle Pattern
Section titled “Section Handle Pattern”Each section component exposes data via useImperativeHandle. Handle types are defined in the respective section component files. The hierarchy: ModelHandle (table export) → ChartSectionHandle (+ image export) → MonteCarloHandle (+ run/cancel) and TornadoHandle (+ year context).
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 → Assumptions Rationale → Sources
- Charts: Embedded PNG images (600×340px) with italic placeholder text when unavailable
9. Component Architecture
Section titled “9. Component Architecture”Component Hierarchy
Section titled “Component Hierarchy”App → AuthProvider → Forecasting → ScenarioProvider → ForecastingContent ├── [activeView="comparison"] Provider store={activeStore} → ComparisonLayout │ ├── SectionNav (COMPARISON_SECTIONS from section-config.ts) │ ├── ScenarioTabBar + ComparisonFilterRow (+ Export Report button) │ └── ComparisonPage (cross-scenario visualizations, per-section ExportButton) ├── [activeView="pricing"] Provider store={activeStore} → PricingLayout │ ├── SectionNav (PRICING_SECTIONS from section-config.ts) │ ├── ScenarioTabBar + PricingFilterRow │ └── PricingTable (discount columns, filters) └── [activeView="editor"] Provider store={activeStore} → ForecastingInner ├── [Selector View] ModelSelector + dialogs └── [Editor View] ├── ScenarioTabBar (tabs, CompareTab, PricingTab, AddScenarioButton) ├── Geo/Indication selectors └── SectionCards: Configuration → IncidenceEvolution → Model → SalesChart → MonteCarlo → TornadoSection components live in src/features/reports/forecasting/sections/. Section IDs and labels are defined in src/features/reports/forecasting/section-config.ts.
LinePanel 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 Drug Treatment Rate and other built-in line variables. (Healthcare Access lives in the SummaryPanel — see the *Growth Companion Rows table above.)
Contains:
- Biomarker card (light background, see
LinePanel.tsxfor exact Tailwind classes) withDnaicon, toggle, and editable sub-fields (percentage, testing rate) with indent guide
SectionCard and SectionNav
Section titled “SectionCard and SectionNav”SectionCard is a collapsible wrapper with optional titleTooltip and headerMeta slots — see src/components/ui/section-card.tsx.
SectionNav is a fixed left-side navigation using useActiveSection (scroll position tracking, not IntersectionObserver). Hidden below lg breakpoint.
10. Comparison Page
Section titled “10. Comparison Page”Cross-scenario analysis activated from the tab bar. Renders side-by-side visualizations for selected scenarios with per-section export (Image + CSV) and a DOCX comparison report.
Key files:
src/features/reports/forecasting/sections/Comparison/ComparisonPage.tsx— orchestrator, exposesComparisonPageHandlefor DOCX exportsrc/features/reports/forecasting/sections/Comparison/useComparisonData.ts— reactive subscription viauseSyncExternalStoresrc/features/reports/forecasting/sections/Comparison/collectComparisonData.ts— pure data extractorsrc/features/reports/forecasting/sections/Comparison/chart-utils.ts— year alignment, color tokenssrc/features/reports/forecasting/sections/Comparison/types.ts—ComparisonScenarioData,ComparisonExportHandlesrc/features/reports/forecasting/sections/Comparison/export-utils.ts— CSV row builders (sales, MC, model totals, tornado)src/features/reports/forecasting/sections/Comparison/reporting/— DOCX comparison report pipeline (types → build → export)
Activation
Section titled “Activation”Compare mode is state-driven, not routed. activeView (union type: "editor" | "comparison" | "pricing") and selectedForComparison (string array of tab IDs) live as React state in ScenarioProvider. When activeView is "comparison", ForecastingContent renders <ComparisonLayout /> (which wraps <ComparisonPage /> with the tab bar and filter row) instead of the normal editor. When activeView is "pricing", it renders <PricingLayout />. All branches wrap content in <Provider store={activeStore}>.
CompareTab in the tab bar sets activeView to "comparison". It is disabled when fewer than 2 scenario tabs exist. PricingTab sets activeView to "pricing". Clicking any scenario tab returns to "editor" mode.
Data Flow
Section titled “Data Flow”collectComparisonData(store, meta) reads atoms synchronously from any scenario’s Jotai store via store.get(). It computes patient funnel, drug-treated patient counts, per-year sales, reads cached MC results, and computes tornado inline via runTornadoAnalysis. If the store’s simulationVariables atom is empty (MonteCarlo component never mounted for that store), it falls back to deriveDefaultSimulationVariables() to derive defaults from the store’s assumptions and lines. Returns a ComparisonScenarioData object.
ComparisonPage uses the useComparisonData hook, which subscribes to key atoms across all selected scenario stores via useSyncExternalStore. When any atom changes (MC completes, user edits assumptions, etc.), the hook re-runs collectComparisonData for each selected tab and returns the resulting array. All child visualization components receive this data as props — no child reads atoms directly.
Year Alignment
Section titled “Year Alignment”Scenarios can have different year ranges. computeYearLabelUnion() produces the sorted union of all scenario year labels. alignToUnion() maps each scenario’s per-year data to the union, inserting null for missing years. This is critical for correct bar chart grouping and table columns.
Scenario Colors
Section titled “Scenario Colors”Colors assigned by tab index from SCENARIO_COLORS in src/features/reports/forecasting/constants.ts. Colors wrap via index % SCENARIO_COLORS.length.
Per-Section Export
Section titled “Per-Section Export”Each comparison section component (ComparisonSalesChart, ComparisonMonteCarloChart, ComparisonModelTotals, ComparisonTornadoGrid) exposes a ComparisonExportHandle via React 19 ref-as-prop + useImperativeHandle. The handle provides exportCSV(), exportImage(), getExportRows(), and a getter for DOCX capture (getImageDataUrl for Chart.js components, getCaptureElement for DOM-based components). Export buttons are disabled while MC simulations are running (runningScenarios.length > 0).
Comparison DOCX Report
Section titled “Comparison DOCX Report”A separate DOCX pipeline under Comparison/reporting/ generates a cross-scenario comparison report. The orchestrator (exportComparisonReport) captures chart images (Chart.js via toBase64Image, DOM via html-to-image), then builds the document using shared helpers from reporting/docx-helpers.ts. The report uses landscape orientation with wider chart dimensions to accommodate multi-scenario tables. A section selector popover (reusing ExportSectionPopover) lets users choose which sections to include. Triggered by the “Export Report” button in ComparisonFilterRow, wired through ComparisonLayout in Forecasting.tsx via dynamic import for bundle splitting.
Common gotchas:
- MC data is nullable per scenario — each visualization checks for null and shows a placeholder. Tornado data falls back to derived defaults when the store’s simulation variables are uninitialized, so it is typically available even for never-visited scenarios.
- Compare state is plain React
useState, not Jotai atoms — avoids entangling comparison state with per-scenario stores. collectComparisonDatacallscalculateLineSaleswith group-scoped lines (not all lines), matching the per-stage-group logic in the Model section.- Export buttons use
disabledprop, not conditional rendering — keeps layout stable during MC runs.
11. Monte Carlo Architecture
Section titled “11. Monte Carlo Architecture”Key files:
src/features/reports/forecasting/sections/MonteCarlo/useMonteCarloWorker.ts— store-pinned worker lifecyclesrc/features/reports/forecasting/sections/MonteCarlo/atoms.ts— results, stale, running atomssrc/features/reports/forecasting/sections/MonteCarlo/MonteCarlo.tsx— UI-only presentation layersrc/features/reports/forecasting/sections/MonteCarlo/deriveDefaultSimulationVariables.ts— shared pure function for default simulation variable derivation (used by comparison fallback)src/features/reports/forecasting/sections/MonteCarlo/monteCarlo.worker.ts— Web Worker entry
useMonteCarloWorker hook
Section titled “useMonteCarloWorker hook”Manages the full simulation lifecycle with multi-scenario safety.
- Store pinning: Captures the Jotai store from the nearest
<Provider>viauseStore(). All worker callbacks write results back to the originating store, not whichever store is active when the worker finishes. - Dual-path execution: Simulations with count <= 1K run synchronously on the main thread (via
setTimeoutfor a paint yield). Counts > 1K spawn a Web Worker. AyearIncidenceMapdictionary is pre-computed since functions cannot be sent to Workers. - One worker per store:
activeWorkersis a module-levelMap<JotaiStore, Worker>(not a ref — survives component unmount/remount). Background workers on inactive tabs survive while new workers can spawn for the active tab. Starting a new run for a given store terminates any existing worker for that store. - Auto-run with stale logic: On input changes within the same tab: if no results exist or count <= 1K, auto-run with 800ms debounce. If count > 1K and results exist, mark stale via
mcResultsStaleAtom(user must manually re-run). On tab switch: auto-run only if the new store has no results. - Completion toast: Worker-path simulations always toast
"{label}: simulation complete"on completion. Main-thread simulations toast only when the originating store differs from the currently active store (i.e., the user switched tabs while it was running).
Atoms (standalone, not in factory)
Section titled “Atoms (standalone, not in factory)”Results/running/stale atoms are standalone (not in createMonteCarloAtoms factory). Each scenario’s Jotai store holds its own copy via multi-store isolation. See src/features/reports/forecasting/sections/MonteCarlo/atoms.ts.
Common gotchas:
numSimulationsround-trips throughModelContextSchema(Zod). On load, falls back to 1000 if the field is missing (.optional()in schema).MonteCarlo.tsxis now UI-only — all simulation logic lives inuseMonteCarloWorker.- The factory function
createMonteCarloAtoms()handles only simulation variables (per geo/indication viaatomFamily), not results/running/stale state. - Simulation variables are initialized by
MonteCarlo.tsx’suseEffect(merge-on-reinitialize pattern). If MonteCarlo never mounts for a store, variables stay empty.deriveDefaultSimulationVariables()provides a fallback for consumers that need variables without the component (e.g., comparison page).
12. Authentication
Section titled “12. Authentication”JWT Cookie Auth
Section titled “JWT Cookie Auth”Authentication uses JWT cookies shared with the BioLoupe platform. All API calls use credentials: 'include' for cookie auth.
- 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
Key files: src/contexts/auth-context.tsx (User type, useAuth hook), src/lib/api.ts (API namespace)
Environment Variables
Section titled “Environment Variables”See .env.example for all variables. Consumed via src/lib/config.ts (getApiUrl(), getBioloupeUrl()).
13. Error Handling & Monitoring
Section titled “13. 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
Sentry environment variables are in .env.example. See src/instrument.ts for initialization.
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 |
Type definitions live in
src/features/reports/forecasting/types.ts. Use IDE navigation to inspect them.
Development rules and constraints are defined in CLAUDE.md.