Skip to content

Architecture

Jotai atom patterns, section factory architecture, data fetching strategy, and component structure for the pharmaceutical forecasting application.

  1. Overview
  2. Technology Stack
  3. Project Structure
  4. State Management
  5. Data Fetching
  6. Runtime Validation
  7. Model Management
  8. Report Export
  9. Component Architecture
  10. Comparison Page
  11. Monte Carlo Architecture
  12. Authentication
  13. Error Handling & Monitoring

For domain terminology and patient flow concepts, see FORECASTING_MODEL.md.

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 under src/features/forecasting/math/ (forecasting, distributions, monte-carlo, tornado, transplant, incidence-evolution, constants), 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.


See package.json for current versions. Key technologies:

TechnologyPurpose
ReactUI framework
TypeScriptType safety (strict mode)
ViteBuild tool, dev server
JotaiAtomic state management
jotai-tanstack-queryBridges React Query cache to Jotai atoms
TanStack QueryServer state, caching
Tailwind CSS + Radix UIStyling + accessible component primitives
Chart.jsData visualization
Web WorkersOff-thread Monte Carlo simulation (>1K)
docxClient-side DOCX report generation
SentryError monitoring and performance tracing
BiomeLinting + formatting (2-space, double quotes, semicolons)

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

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) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘

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 store

Key behaviors:

  • ScenarioProvider manages an array of ScenarioTabData (each with its own store and meta)
  • Adding a scenario creates a new store; closing one removes it (with undo via removedTabsRef)
  • collectSnapshot() iterates all stores to build a FullSnapshot for saving
  • loadScenarios() 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, simulationVariablesFamily enumerated via getParams() so every (geo, indication) the source has touched survives, Tornado year override, config tree highlight, chart palettes), and the parent model metadata. mcIsRunningAtom is intentionally skipped because it tracks a per-store Web Worker — copying true would strand the duplicate at “running” with no worker. When adding a new primitive scenario atom, extend cloneStoreState.ts so duplication stays complete.

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.

TypeSourceEditableUsage
ReferenceAPI (/api/forecasting/statistics)NoDefault values, disease configs
InstanceUser inputYesUser’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.

VariableCompanion row nameScope
Healthcare AccesshealthcareAccessGrowthscenario ({line: null})
Drug Treatment RatetreatmentRateGrowthper-line
Biomarker Testing RatebiomarkerTestingRateGrowthline: "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.

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

FieldComputation
peakSharecalculatePeakShare(launchOrder, bestInClass, delay, numCompetitors)

All fields — stored or computed — use metadata-based getIsDefault():

// Stored field
healthcareAccess: 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.” setAnnotationAtom creates 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 check metadata.isDefault via getIsDefault().
  • 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 checks.
  • 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 preserves existingRow.metadata.annotation and forces isDefault: false (canonical “user wrote a value” semantics) — mirrors setValueAtom’s contract for the bulk upsertRowsAtom path. Spreading the full metadata would carry forward isDefault: true from seeded rows and make user edits read as defaults.

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.

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` changes

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 is the only forecast section that needs two distinct year anchors:

AnchorValuePurpose
analysisStartYearselectedYear (= launch − 1)Passed to runTornadoAnalysis as startYear. MUST match the netPriceEvolution / incidenceEvolution anchor so yearIdx = targetYear - startYear agrees with array indices.
firstLaunchYearfirst selected line’s launch yearUX 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):

WriterTriggering field(s)Rows cleared
updateAssumptionsmarketExclusivityYearsloeDate (defensive — Option A doesn’t write loeDate but old models may have stored values)
updateLinelaunch, isSelectedloeDate, 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.

The general rule across the forecasting state:

  • Direct user overrides on editable fields persist across unrelated edits. Editing transitionRate does not touch the user’s override on peakShare, compliance, monthsOfTherapy, customEffectivePeakShare, events, customVariables, etc. Each row is keyed by {geo, indication, line, name} and only the matching writer touches it.
  • yearOfFirstLaunch is 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. loeDate is 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: false does 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: loeDate has no rollback button because Option A doesn’t store an override (editing LoE shifts all line launches instead). (2) Implicit auto-resets (clearDerivedOverrides for yearOfFirstLaunch on source-field changes, and defensively for legacy loeDate overrides) also wipe the override value but preserve any attached annotation by rewriting the row with value: null instead 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.

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.

TargetStorageRead/write path
Scalar rows (e.g., healthcareAccess, transitionRate, peakShare)InstanceRow.metadata.annotationsetAnnotationAtom 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 customVariablesPer-element Variable.annotation inside the array valuesetAnnotationAtom uses the subkey (= Variable.id) to target one element
Array-stored eventsPer-element MarketEvent.annotationSame 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.

  • AnnotationButton — speech-bubble icon next to each annotatable input; opens a Popover containing AnnotationPopover. 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 and clearAnnotationAtom. If no annotation is attached, the reset runs immediately. The implicit auto-resets in clearDerivedOverrides (for loeDate / yearOfFirstLaunch) bypass this dialog because they preserve the annotation directly — no user prompt needed. The dialog also accepts optional title / description / confirmLabel props for multi-row destructive actions (e.g., custom-line deletion via lineAnnotationCount in Configuration/atoms.ts) where the default single-field rationale-preview layout doesn’t fit.

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.

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). The X button always opens a single AnnotationResetDialog for confirmation; the dialog’s title, description, and confirm label adapt based on lineAnnotationCount (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 (PatientFlowTimelinePatientFunnel), 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.

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): PopulationSummaryCardPhasePlatform("Early stage") → Early LineCardPhaseDividerPhasePlatform("Metastatic") → 1L–4L LineCards with FlowTail strips between them → optional custom LineCards (rectangles) → AddLineButton.

Composition (hematology): PopulationSummaryCardPhasePlatform("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).


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

Key files: src/lib/api.ts (API namespace), src/hooks/useForecasting.ts (model CRUD), src/hooks/useStatistics.ts (reference data)


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. Schemas like FullSnapshotSchema and ApiStatisticsResponseSchema throw on failure, blocking load.

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 in Configuration/atoms.ts accepts an optional Zod schema. On failure, returns null (callers fall through to ?? default). Gradual opt-in — simple scalars use bare casts.
  • Write-side: setValueAtom in atoms.ts validates via the WRITE_SCHEMAS map. On failure, the write is silently rejected and logged.
  • Bypass: Internal bulk writes via upsertRowsAtom skip 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)


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

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)

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 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 atoms and helpers are in src/features/reports/forecasting/model-atoms.ts.

Dirty state uses a two-layer approach:

  1. Per-store isDirtyAtom: Boolean in each scenario’s Jotai store, set by markDirtyAtom when any input changes
  2. Store-derived baselines: ScenarioProvider captures JSON snapshots from stores (not raw server data) after load/save, excluding cosmetic metadata (label, isAutoLabeled) from comparison
  3. Aggregation: isAnyDirty() iterates all scenario stores to check if any tab is dirty — called at click time in handlers to avoid stale closures
  4. Reactive subscription (ScenarioProvider.tsx:619-642): A provider-level useEffect subscribes to every tab’s isDirtyAtom via Jotai’s imperative store.sub and mirrors isAnyDirty() into a React anyDirty: boolean. The effect’s deps are scoped to tabsState only — isAnyDirty is read via a ref so active-tab switches don’t re-subscribe every store. Used by useBeforeUnloadGuard(anyDirty) (hooks/useBeforeUnloadGuard.ts) to install a beforeunload listener only while work is unsaved — so the page stays eligible for the browser back/forward cache (BFCache) when clean, per MDN best practice.

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

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
DOCX reference-row lookup applies the guidance cascadefindReferenceRow in collectReportData.ts routes through selectGuidance so DOCX and UI cite the same guidance source when multiple rows exist for a key
Deterministic narrative templatesNo AI-generated prose — fixed descriptions per section
Stage-level error wrappingEach 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.

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

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 → Assumptions Rationale → Sources
  • Charts: Embedded PNG images (600×340px) with italic placeholder text when unavailable

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 → Tornado

Section components live in src/features/reports/forecasting/sections/. Section IDs and labels are defined in src/features/reports/forecasting/section-config.ts.

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.tsx for exact Tailwind classes) with Dna icon, toggle, and editable sub-fields (percentage, testing rate) with indent guide

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.


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, exposes ComparisonPageHandle for DOCX export
  • src/features/reports/forecasting/sections/Comparison/useComparisonData.ts — reactive subscription via useSyncExternalStore
  • src/features/reports/forecasting/sections/Comparison/collectComparisonData.ts — pure data extractor
  • src/features/reports/forecasting/sections/Comparison/chart-utils.ts — year alignment, color tokens
  • src/features/reports/forecasting/sections/Comparison/types.tsComparisonScenarioData, ComparisonExportHandle
  • src/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)

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.

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.

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.

Colors assigned by tab index from SCENARIO_COLORS in src/features/reports/forecasting/constants.ts. Colors wrap via index % SCENARIO_COLORS.length.

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

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.
  • collectComparisonData calls calculateLineSales with group-scoped lines (not all lines), matching the per-stage-group logic in the Model section.
  • Export buttons use disabled prop, not conditional rendering — keeps layout stable during MC runs.

Key files:

  • src/features/reports/forecasting/sections/MonteCarlo/useMonteCarloWorker.ts — store-pinned worker lifecycle
  • src/features/reports/forecasting/sections/MonteCarlo/atoms.ts — results, stale, running atoms
  • src/features/reports/forecasting/sections/MonteCarlo/MonteCarlo.tsx — UI-only presentation layer
  • src/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

Manages the full simulation lifecycle with multi-scenario safety.

  • Store pinning: Captures the Jotai store from the nearest <Provider> via useStore(). 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 setTimeout for a paint yield). Counts > 1K spawn a Web Worker. A yearIncidenceMap dictionary is pre-computed since functions cannot be sent to Workers.
  • One worker per store: activeWorkers is a module-level Map<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).

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:

  • numSimulations round-trips through ModelContextSchema (Zod). On load, falls back to 1000 if the field is missing (.optional() in schema).
  • MonteCarlo.tsx is now UI-only — all simulation logic lives in useMonteCarloWorker.
  • The factory function createMonteCarloAtoms() handles only simulation variables (per geo/indication via atomFamily), not results/running/stale state.
  • Simulation variables are initialized by MonteCarlo.tsx’s useEffect (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).

Authentication uses JWT cookies shared with the BioLoupe platform. All API calls use credentials: 'include' for cookie auth.

  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

Key files: src/contexts/auth-context.tsx (User type, useAuth hook), src/lib/api.ts (API namespace)

See .env.example for all variables. Consumed via src/lib/config.ts (getApiUrl(), getBioloupeUrl()).


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

Sentry environment variables are in .env.example. See src/instrument.ts for initialization.

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

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.