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/core/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)”core/state/orchestrator.ts │ │ 1. Imports primitive atoms (instanceRows) and selectors (geo, indication, selectedYear, diseaseConfigFamily) │ from core/state/primitives/* and core/state/selectors/* │ │ 2. Passes them as parameters to section factories (re-exported via core/state/sections/index.ts) │ ├── 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` (lazy + read-only Proxy; │ see the file's header doc) │ └── Components import `forecastingAtoms` from core/state/orchestrator and consume via useAtomValue/useSetAtomFactories receive primitive atoms as parameters (dependency injection) to avoid circular module imports — core/state/orchestrator.ts imports from the section factories (via the core/state/sections/index.ts barrel), 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”The codebase is a four-layer layout: shared → core → features → app (arrows = “may be imported by”).
src/├── main.tsx · instrument.ts · globals.css # Vite entry set (instrument.ts must load first)├── components/ # SHARED: ui/ (Radix + custom wrappers, see DESIGN_SYSTEM.md), dev/ (jotai-devtools loader)├── contexts/auth-context.tsx # SHARED: JWT cookie authentication├── hooks/ # SHARED: useActiveSection, useStickyScroll├── lib/ # SHARED: api.ts (API namespace), config.ts (env vars), format.ts, utils.ts, export-csv.ts, docx-helpers.ts, schemas/ (session, feedback)├── core/ # Forecasting domain layer (imports shared only)│ ├── math/ # Pure math: forecasting.ts, distributions.ts, monte-carlo.ts, tornado.ts, transplant.ts, incidence-evolution.ts, constants.ts — no React/Jotai/DOM│ ├── types/ # types.ts, constants.ts (UI/data defaults, limits, SCENARIO_COLORS, UPTAKE_CURVE), handles.ts, scenarios/types.ts│ ├── schemas/ # Zod domain schemas: forecasting, statistics, pricing, common│ ├── state/ # Central Jotai state (reference + instance): orchestrator.ts (forecastingAtoms bundle), lineSalesContext.ts (per-line sales-params assembly), primitives/ (instance-rows, reference-query, context-keys, line-metadata, placeholder-templates, upstream-custom-bypass-row), selectors/ (geo-indication, lines, biomarker, incidence, families, upstream-custom-bypass), actions/ (init-flows, erosion-toggles, geo-indication-selection, growth-config), sections/ (the section atom factories + index.ts barrel), utils/selectGuidance.ts│ ├── persistence/ # model-atoms.ts (snapshot create/load, dirty tracking), cloneStoreState.ts│ ├── scenarios/ # Multi-scenario tab system (ScenarioProvider, per-store management, useBeforeUnloadGuard)│ ├── simulation/ # Monte Carlo worker bundle: monteCarlo.worker.ts, useMonteCarloWorker, deriveDefaultSimulationVariables│ ├── queries/ # Query definitions, one per server resource: statistics, forecasting, pricing│ ├── charts/ # Pure Chart.js config builders: salesChartConfig, monteCarloChartConfig, tornadoChartConfig│ └── ui/ # Domain widgets used by 2+ features: Annotation*, ColorPaletteSelector, ExportButton, ExportSectionPopover, SourceInfoButton, useExportSections, useReferenceSource, guidance-legend, rollback-button├── features/ # Per-section features (import shared + core + own files only — never each other, never app)│ ├── configuration/ # Therapy lines, market assumptions (tree/, panels, utils/)│ ├── model/ · sales-chart/ · monte-carlo/ · tornado/ · incidence-evolution/│ ├── comparison/ # Cross-scenario comparison page (+ its own reporting/ DOCX pipeline)│ ├── pricing/ · standard-of-care/│ ├── model-management/ # Save/load/rename/delete UI, useForecasting (model CRUD hooks), useModelManagement│ └── exports/ # Per-scenario DOCX report + PPTX slideshow pipelines (reporting/, reporting/slideshow/)├── app/ # Composition: App, Forecasting shell, scenario tab chrome, PricingLayout, EditorHeaderActions, section-config.ts└── test/ # Test helpers, fixtures, integration testsLayering Contract (LD1–LD4)
Section titled “Layering Contract (LD1–LD4)”The layer rules and the locked decisions below bind every structural change (extracted from CONTINUITY_GUARDS.md §3 — this section is their canonical home):
- shared (
src/lib,src/components,src/hooks,src/contexts): domain-free; imports nothing above it. - core (
src/core): the forecasting domain layer; imports shared only. Headless belowcore/ui—core/mathbans React/Jotai/DOM entirely;core/state/persistence/scenariosuse Jotai but no feature/app code;core/uimay use React. - features (
src/features/<kebab-slug>): import shared + core + own files only. Never each other, never app. - app (
src/app): composition — shell, providers, view routing, chrome. May import everything. - Placement rule: state that is persisted, cloned, or read across features lives in core; used by one feature → that feature. A component enters
core/uionly when a second feature needs it.
Locked decisions:
- LD1 — One-way layering; the compute core is UI-free. Dependencies flow one direction only (shared → core → features → app) and never reverse; cross-feature imports are banned. The math/compute core imports no React, Jotai, or DOM, so it stays Web-Worker-portable and directly callable headlessly.
- LD2 — One query definition per server resource. A single
queryOptions()-style definition per resource (insrc/core/queries/) is the source of truth for its query key + fetcher;atomWithQueryand any imperative fetch subscribe that same definition/cache (no duplicated key/fetcher pairs). - LD3 — The core stays headlessly drivable. A caller must be able to create a store, load a snapshot, set values, and read outputs with no UI, using
store.get/store.set(collectSnapshot/loadStoreSnapshotwrite;collectComparisonDataalready reads viastore.get). Corollary: no async Jotai atoms (keeps the store synchronously drivable) and do not dropatomWithQuery. - LD4 — Client SPA; Rails owns server logic. The auth-walled forecasting app is a client-rendered SPA. No server-side rendering of it, no application server layer, and no backend/API-contract changes as part of any restructure. A headless adapter drives client state — it does not add a server.
Enforcement: .dependency-cruiser.cjs is the source of truth — layer direction, cross-feature ban, and import cycles — run whole-repo as pnpm lint:boundaries (a CI verify step, not part of the staged pre-commit hook). biome.json overrides (style/noRestrictedImports) mirror the layer-direction bans as an editor-time signal. Note on LD1’s headless claim: the math-stays-headless rule machine-checks only react/react-dom/jotai imports out of core/math; the broader no-DOM part of the claim (e.g. no chart.js or other DOM-touching packages) is enforced by review, not by CI.
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
core/persistence/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,customReferenceRowsAtomdeep-cloned so a duplicated tab whose active indication is a placeholder keeps its synthetic reference rows), 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.
Common gotcha — keep key={activeTabId} on <Provider> (the three <Provider store={activeStore}> mounts in src/app/Forecasting.tsx). The key forces React to remount the section subtree on tab switch, so each tab’s component state and atoms initialize fresh. Removing it (the pre-fix state — see commit 6227bff5 “close useRef-hydration-guard bug class via key={activeTabId}”) re-introduces the multi-store hydration bug class: with no key, React reuses the same component instances across tabs and only useStore() repoints, so component-scoped useState / useRef / empty-deps useEffect leak across stores. Several inline comments still describe the pre-fix “no key” world (e.g., in src/features/monte-carlo/MonteCarlo.tsx, the src/core/state/sections/tornado/atoms.ts header doc, and src/features/tornado/Tornado.tsx); the patterns those comments document (init derived from store state, e.g. atoms instead of useState/useRef) remain good defense-in-depth in case the key is removed in the future.
Section Factory Pattern
Section titled “Section Factory Pattern”Why? Direct atom imports in factory files create circular dependencies:
// ❌ BAD: core/state/sections/configuration/atoms.ts imports atoms from ../../orchestratorimport { instanceRowsAtom } from "../../orchestrator"; // orchestrator 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 core/state/orchestrator.ts):
core/state/orchestrator.ts │ ├── Imports: instanceRowsAtom (primitives/instance-rows), geoAtom/indicationAtom (selectors/geo-indication), selectedYearAtom (selectors/lines), diseaseConfigAtomFamily (selectors/families) │ ├── Calls: createConfigAtoms({ instanceRows, geo, indication, diseaseConfigFamily }) │ → returns config.assumptions, config.lines, etc. │ └── Calls: createModelAtoms({ selectedYear, indication, diseaseConfigFamily }) → 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 from core/state/orchestrator.ts and context atoms (geoAtom, indicationAtom, etc.) from core/state/selectors/geo-indication.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 |
| Progression Rate | transitionRateGrowth | per-line |
| Compliance | complianceGrowth | per-line |
| Months of Therapy | monthsOfTherapyGrowth | per-line |
| Biomarker Testing Rate | biomarkerTestingRateGrowth | line: "bm:{biomarkerId}" (one per active biomarker) |
| Biomarker Prevalence | biomarkerPercentageGrowth | line: "bm:{biomarkerId}" (one per active biomarker) |
Atom families: treatmentRateGrowthFamily(lineId), transitionRateGrowthFamily(lineId), complianceGrowthFamily(lineId), and monthsOfTherapyGrowthFamily(lineId), plus the singletons healthcareAccessGrowthAtom, biomarkerTestingRateGrowthAtom, and biomarkerPercentageGrowthAtom. Setters (setHealthcareAccessGrowthAtom, setTreatmentRateGrowthAtom, setTransitionRateGrowthAtom, setComplianceGrowthAtom, setMonthsOfTherapyGrowthAtom, setBiomarkerTestingRateGrowthAtom, setBiomarkerPercentageGrowthAtom) 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 (module-level selector, src/core/state/selectors/lines.ts; exposed as config.firstSelectedLaunchDate — 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 src/features/tornado/Tornado.tsx and src/features/comparison/collectComparisonData.ts apply this split. Mirror the pattern if you add another sensitivity analysis section.
Calendar-year anchor (anchoredIncidenceEvolutionAtom)
Section titled “Calendar-year anchor (anchoredIncidenceEvolutionAtom)”The stored incidenceEvolution curve is anchored at selectedYear (launch − 1), not at today. Incidence consumers read the anchored selector anchoredIncidenceEvolutionAtom (core/state/selectors/incidence.ts) rather than the raw evolution slot, so the series is re-leveled at read time to the current calendar year — the entry for today equals the stored base; past years shrink and future years grow (geometric inverse). The scaling is a read-time derivation, never written back.
currentYearAtom(core/state/primitives/context-keys.ts): session-only anchor, seeded fromnew Date().getFullYear(); tests override it for deterministic anchoring.anchorIncidenceEvolution(core/math/incidence-evolution.ts): pure helper that does the re-leveling; no-op whenselectedYear === currentYear.
Consumers reading the anchored series: Model, SalesChart, Monte Carlo, Tornado, IncidenceEvolution, Comparison, slideshow.
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/core/state/primitives/instance-rows.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 vialineAnnotationCountincore/state/sections/configuration/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 name “Custom Line N”, where N is the highest existing suffix + 1) 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 (Metastatic for Solid Tumors, or Therapy for Hematology). Early stage is single-reference-line in solid tumors; custom lines are not permitted on early. See docs/FORECASTING_MODEL.md §2.5 for the domain rationale and cross-stage customVariable copy semantics. Custom lines appear after the reference lines and inherit their transitionRate default from LINE_DEFAULTS.
Per-line filter bypass — definer-always-applies pattern
Section titled “Per-line filter bypass — definer-always-applies pattern”Two per-line filters (transplant, upstream custom variables) share a single semantic: the line that defines a filter always applies it. Non-definer lines surface a per-line apply Switch (default on) that toggles whether the filter contributes to that line’s display. The biomarker filter no longer follows this rule (changed 2026-06-21): every line — including the stage-first line — honors its own per-line biomarkerEnabled flag via the Apply switch in the Biomarker card header. The stage-first line still defines the biomarker type/prevalence/testing-rate for its stage, but no longer force-applies the filter; a stored biomarkerEnabled === false on a stage-first line is honored.
| Filter | Definer | Toggle visibility |
|---|---|---|
| Biomarker (per-line — not definer-always-applies) | Stage-first selected line of each stage defines type/prevalence/testing-rate; it does not force-apply | Every line (incl. stage-first) via the on/off Switch in the Biomarker card header |
| Transplant | Stage-first selected line of each stage (via inheritance in core/state/selectors/lines.ts) | All non-stage-first lines via the InheritedFiltersPanel |
| Custom variable | The line that owns it (in customVariables[]) | All downstream lines via the InheritedFiltersPanel |
Stage-first is selection-aware. “First selected line of stage” is dynamic — if the disease-config-first line is deselected, the next selected line becomes the definer. The check is a single canonical at src/core/math/forecasting.ts:isStageFirstSelectedLine, exported and reused by core/state/selectors/lines.ts (transplant inheritance) and LinePanel.tsx (UI gating) — no per-call-site re-implementations.
Storage (all three use sparse instance rows — absent row = applied, explicit row = whatever the user set):
- Biomarker:
{geo, indication, line: lineId, name: "biomarkerEnabled"}(boolean). Honored on every line including stage-first (per-line enable — see FORECASTING_MODEL.md §2.1 Behavior for the 2026-06-21 change superseding the prior force-apply). - Transplant:
{geo, indication, line: lineId, name: "transplantApplies"}(boolean, with stage-first inheritance inlines.ts). - Upstream-custom-bypass:
{geo, indication, line: downstreamLineId, name: "appliesUpstreamCustom:{ownerLineId}:{varId}"}(boolean). Row name helpers incore/state/primitives/upstream-custom-bypass-row.ts; selector atcore/state/selectors/upstream-custom-bypass.ts.
Math composition. Filters compose as per-line scalars in applyPerLineFilters (src/core/math/forecasting.ts). The cascade carry holds only structural numbers (transitionRate, neoAdjFactor, DTR). Custom variables left the cascade carry on 2026-05-24 and are now composed at the display boundary — see FORECASTING_MODEL.md §2.3 for the proof that default-all-on preserves prior numbers.
Deselected lines. Customs on deselected upstream lines are excluded from inheritance enumeration (Stance B, manager-confirmed 2026-05-26). Bypass rows for deselected lines are preserved in storage and re-engage on re-selection. transitionRate / treatmentRate growth, by contrast, does propagate through deselected upstream lines (Stance A, disease-intrinsic) — see FORECASTING_MODEL.md §2.4.
Cascade-delete. Deleting a custom variable from its owner line fires cascadeDeleteCustomVariableBypassAtom, which removes all matching appliesUpstreamCustom:{ownerLineId}:{varId} rows across the scenario so stale bypass rows do not survive their target variable.
DOCX surfacing. Bypassed filters per line are collected in collectReportData.ts and rendered as a standalone “Filter Bypasses” H1 chapter — sibling to the “Assumptions Rationale” appendix, not nested under it (the two are semantically independent; see buildRationaleAppendix.ts). The chapter emits only when at least one line in the scenario has a non-empty bypass list.
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.
The presentational pieces (TrapezoidRow, FlowTail, PhasePlatform,
PhaseDivider, FunnelDisplay) and the pure stage-model builder
(buildFunnelStages) live in core/ui/funnel/; PatientFlowTimeline
(features/configuration/tree/) is the interactive wrapper that re-injects
row selection, the include Switch, and source citations.
Composition (solid tumor): PopulationSummaryCard →
PhasePlatform("Early stage") → Early TrapezoidRow → PhaseDivider →
PhasePlatform("Metastatic") → 1L–4L TrapezoidRows with FlowTail strips
between them → optional custom TrapezoidRows (rectangles) → AddLineButton.
Composition (hematology): PopulationSummaryCard →
PhasePlatform("Therapy lines") → 1L–nL TrapezoidRows with FlowTail strips
between → optional custom TrapezoidRows → AddLineButton. No Early row, no
divider.
Source citations: the presentational FlowTail carries no hook — the
wrapper’s TailInfo component (in PatientFlowTimeline.tsx) calls
useReferenceSource("transitionRate", lineId) (the codebase’s canonical
reference-data hook, also used by LinePanel, SummaryPanel, and
IncidenceEvolution), feeds sources / year / alternatives into the shared
SourceInfoButton, and injects it via FlowTail’s infoSlot. 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 CSS-only via the
pointer-coarse: Tailwind variant (see DESIGN_SYSTEM.md §Responsive &
Touch); <InfoButton> is now a single controlled Popover whose hover
handlers are inert on touch (no hover events fire), so tap routes through
the click-to-pin path with no JS device detection.
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/features/model-management/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:
// core/state/primitives/reference-query.ts - Reference data → derived atomsimport { atomWithQuery } from "jotai-tanstack-query";
export const statisticsQueryAtom = atomWithQuery(() => ({ queryKey: STATISTICS_QUERY_KEY, queryFn: fetchStatistics,}));
// Derived atom reads from the query atom, then appends any client-side custom// rows so synthetic placeholder-indication data flows through the same pipeline.export const referenceRowsAtom = atom<ReferenceRow[]>((get) => { const base = get(statisticsQueryAtom).data ?? []; const custom = get(customReferenceRowsAtom); return custom.length > 0 ? [...base, ...custom] : base;});customReferenceRowsAtom holds synthetic ReferenceRow[] for client-created placeholder indications (diseases with no curated data). It is seeded from the model snapshot, never queried or written to the DB, so a placeholder indication resolves a valid DiseaseConfig through the normal diseaseConfigAtomFamily → linesAtom path.
Each custom indication’s name is stored with a leading (Custom) prefix, baked into its identity at creation by createPlaceholderIndicationAtom (via toCustomIndicationName in core/state/primitives/placeholder-templates.ts). Because the indication name is also the merge/lookup key, this namespacing is what keeps the plain, no-dedup concat above collision-safe: a (Custom) Foo identity can never equal a server Foo, so a custom indication can never blend with a same-named server indication, and isPlaceholderIndicationAtom (exact-match) never mis-fires on real server data. The creation-time validator (validatePlaceholderIndicationName) reserves the prefix and dedupes prefix-agnostically so a user can’t type (Custom) … or create a custom duplicate of an existing name.
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:
// features/model-management/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/core/queries/ (query definitions — one per server resource: statistics, forecasting, pricing; see LD2 in §3), src/features/model-management/useForecasting.ts (model CRUD hooks), src/core/queries/statistics.ts (reference data)
6. Runtime Validation
Section titled “6. Runtime Validation”Zod schemas in src/core/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 incore/state/sections/configuration/atoms.tsaccepts an optional Zod schema. On failure, returnsnull(callers fall through to?? default). Gradual opt-in — simple scalars use bare casts. - Write-side:
setValueAtomincore/state/primitives/instance-rows.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), ReferenceRowSchema (with its ReferenceValueSchema value union — validates the customReferenceRows array on snapshot load, .default([]) backfilling pre-feature models), plus the API-boundary schemas (FullSnapshotSchema, ApiStatisticsResponseSchema, etc.).
Key files: src/core/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/core/types/scenarios/types.ts for FullSnapshot/ScenarioSnapshot and src/core/types/types.ts for ModelContext.
Each ScenarioSnapshot also carries customReferenceRows — the synthetic reference rows for a client-created placeholder indication (empty for normal models). loadStoreSnapshot(store, context, rows, customReferenceRows) seeds customReferenceRowsAtom before instanceRowsAtom/geo/indication, so a placeholder indication’s DiseaseConfig re-derives from its reference rows before context is applied.
Biomarker Atoms
Section titled “Biomarker Atoms”Biomarker selectors (availableBiomarkersAtom, activeBiomarkerAtom, biomarkerFactorAtom, biomarkerOverridesAtom, biomarkerRawInputsAtom) live in src/core/state/selectors/biomarker.ts. Action atoms live in src/core/state/actions/growth-config.ts: the per-stage definition writers (setBiomarkerFieldAtom / resetBiomarkerFieldAtom, keyed by stage, with biomarker{Percentage,TestingRate}ByStageFamily reads), the per-line override writers (setLineBiomarkerFieldAtom / resetLineBiomarkerFieldAtom, with biomarker{Percentage,TestingRate}ByLineFamily reads), the per-line on/off setLineBiomarkerEnabledAtom, toggleBiomarkerAtom, the growth writers (set{,Line}Biomarker{Percentage,TestingRate}GrowthAtom), and the cascade aggregator biomarkerByLineAtom (resolves each line to perLine override ?? perStage definition ?? raw default).
Data flow: User toggles a biomarker → activeBiomarkerAtom updates → each line’s prevalence/testing-rate resolve through the per-line cascade biomarkerByLineAtom (perLine override ?? perStage definition ?? raw default) → computation derives a 0-1 multiplier per therapy line via resolveBiomarkerFactorForLine(lineId, year, biomarkerByLine, …). The definition is keyed per stage (bm:{id}:{stage} rows, written by setBiomarkerFieldAtom({…, stage})), so each stage’s early/metastatic definitions stay independent; non-stage-first lines may additionally override the values per line (setLineBiomarkerFieldAtom). Every line — including the stage-first selected line — carries an independent on/off flag UnifiedLineData.biomarkerEnabled (default true, written by setLineBiomarkerEnabledAtom); a line toggled OFF forecasts the full population. The stage-first selected line still defines the biomarker type/prevalence/testing-rate for its stage, but no longer force-applies it — every line honors its own stored biomarkerEnabled flag via biomarkerScalarForLine(line, biomarkerFactor) (a stored false on a stage-first line is honored). (biomarkerFactorAtom remains only as a scalar fallback; the per-line cascade is the live path.) Biomarker does not modify incidence; it is a funnel multiplier applied after addressable calculation.
Transplant per-line backward compatibility: the transplant-eligibility split is likewise per-line. The legacy UI stored transplant rows only on each stage’s first line, so the linesAtom selector (core/state/selectors/lines.ts) inherits the stage-first selected line’s transplantSplitEnabled / transplantSplitPercent / transplantSplitSetting onto downstream lines that have no stored transplant row — saved models open with identical numbers. Inheritance is gated on donor.isSelected: a deselected stage-first line is skipped (downstream lines stay at defaults), since otherwise its stored settings would propagate through applyPerLineFilters and scale siblings’ output.
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/core/persistence/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 (the
isDirtyAtomsubscriptionuseEffectinsrc/core/scenarios/ScenarioProvider.tsx): 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)(src/core/scenarios/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/model-management/validation.ts - UI components:
src/features/model-management/ - API endpoints:
src/features/model-management/useForecasting.ts
8. Report Export
Section titled “8. Report Export”The application generates a DOCX report and a PowerPoint slideshow for the currently selected geography + indication. Both pipelines are split into pure, testable functions; the DOCX pipeline is described first.
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/exports/reporting/ — shared DOCX helpers (paragraph, table, image factories) live in src/lib/docx-helpers.ts (domain-free, shared layer) 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
Slideshow Export (PowerPoint)
Section titled “Slideshow Export (PowerPoint)”A second export pipeline generates a branded PowerPoint deck. The “Export Slideshow” button (SlideshowVariablePicker) sits beside “Export Report” in EditorHeaderActions. Unlike the DOCX export’s section tree, the slideshow has a fixed 5-slide structure; the selection is per-line × per-variable (parent nodes are therapy lines, child nodes are each line’s variable universe — built-ins pre-checked for model-selected lines only (line.isSelected), customs/inherited available but unchecked). The picker emits non-blocking soft warnings (e.g. “this line is over-selecting customs” or “table will shrink past the readability floor”) above the export button via buildPickerWarnings, but never blocks export.
useSlideshowExport (hook — reads context atoms, builds the per-line variable tree) │ ▼exportSlideshow (orchestrator) │ ├── 1. collectSlideshowData(store, selection) — pure (node-safe) — plain SlideshowData snapshot (incl. per-line variable selection) ├── 2. captureChartImages(data) + captureFunnelImage(data.funnel) — browser-only — headless Chart.js → PNG for sales, MC, tornado; off-screen FunnelDisplay → PNG for the funnel (each: ok|failed|absent) ├── 3. buildDeck(data, selection) — pure (node-safe) — DeckContent (5 slide objects) ├── 4. renderDeck(deck) — pptxgenjs — DeckContent → .pptx Blob └── 5. downloadBlob() — browser downloadcaptureChartImages runs once for the whole export (sales is a multi-line stacked chart; MC + tornado are whole-model metrics).
The slides (fixed 5-slide structure): dark model-wide summary (disease as title, model name as subtitle, indication + geography + the selected line labels rendered highlighted in yellow) → light patient-flow funnel slide (image-backed: the shared FunnelDisplay is rendered off-screen and rasterized via html-to-image at 1200 CSS px wide; natural height, fitted into the slide box by the captured aspect ratio) → light sales + assumptions slide (image-backed stacked multi-series column chart with one series per selected line, sliced to the 20-year launch-anchored export window; per-line peak-year assumptions table on the right — columns are the selected lines, rows are the union of each line’s selected variables, every value evaluated at that line’s peak year; empty cells render as em-dash; auto-fit font shrinks to keep the table on one slide, with a readability-floor warning surfaced through the picker) → standalone Monte Carlo slide (image-backed; series also sliced to the 20-year window) → standalone Sensitivity (tornado) slide (image-backed; evaluated at the model-wide peak year within the export window — all-zero sales resolve to the launch year via the window argmax (index 0); the explicit first-launch-year fallback guards only an empty/malformed label axis). The funnel, sales, MC, and tornado slides each carry a 3-state result — ready / unavailable / capture-failed (results / not-run / capture-failed for MC) — so the placeholder text is layer-honest (e.g. “Monte Carlo chart could not be rendered” when the simulation DID run but the headless render threw). The funnel capture additionally fails fast with an actionable on-slide message when the tab is hidden at export time, and races all of its waits (fonts, double-rAF, html-to-image’s internal rAF) against a 10-second timeout — a backgrounded tab suspends rAF indefinitely — with a console diagnostic on every failure path.
| Decision | Rationale |
|---|---|
Single combined deck (was: per-line decks bundled into a .zip) | One file matches how users actually present — the per-line zip required manual concatenation and lost a coherent narrative across lines |
20-year launch-anchored export window (EXPORT_WINDOW_START = 1, EXPORT_WINDOW_LEN = 20 in format.ts) | Drops the launch-minus-1 zero year (selectedYear) and caps the horizon at 20 years so the sales chart, MC chart, and peak-year argmax all share the same axis |
| Tornado evaluated at model-wide peak year (was: first launch year) | The sensitivity is most meaningful at peak revenue, not launch. Argmax taken over total net sales across the selected lines within the export window; all-zero sales naturally resolve to the launch year (window index 0), so the explicit firstLaunchYear fallback guards only an empty/non-numeric label axis |
Chart.js charts pre-rendered to PNG, embedded via addImage (not addChart) | Google Slides flattens native pptxgenjs vector charts to ~600×264 bitmaps on upload — irreversible quality loss. Pre-rendering ourselves at 2400×1080 CSS px × DPR 2 (= 4800×2160 internal) gives pixel parity with the in-app charts in PowerPoint, Keynote, AND Google Slides |
Single source of truth for Chart.js styling (salesChartConfig.ts + monteCarloChartConfig.ts + tornadoChartConfig.ts) | Both in-app components AND the headless capture step consume the same pure config builder, so deck charts can never visually diverge from what the user sees in the app. Sales was migrated from a native pptxgenjs addChart path because Google Slides falls back to numeric axis indices ("1,2,3…") for pptx multiLvlStrRef category axes — pre-rendering closes that gap and unifies all three slides on the same image-backed contract |
| Per-line peak-year assumptions table (was: interim model-wide assumptions) | Each selected line is evaluated at its own peak year inside the 20-year window, so a late-launch line is not penalized by being read at the model’s peak. Auto-fit font (pickAutoFitFont in peakYear.ts) shrinks the font column-aware (more lines = narrower columns = more wrapping) and keeps shrinking past the 5pt readability floor so a select-everything deck still fits one slide; a tiny table is the intended “you’ve packed in too much” signal. The picker’s soft warning (breachesReadabilityFloor) fires iff the real render would breach the floor |
Renderer isolated in renderDeck.ts | The only slideshow file importing pptxgenjs and the ?inline brand PNGs — keeps types.ts / buildSlides.ts / collectSlideshowData.ts import-safe under the node test environment. captureChartImages.ts imports raw Chart.js, not pptxgenjs, but is browser-only (needs a canvas 2D context) |
3-state CapturedChart (ok / failed / absent) | Lets the builder distinguish “no source data” from “headless render threw” — without it, a render-time failure presented as a data-state placeholder (“simulation not run”) and misled the reader about which layer broke |
Render failures reported via { exported, total } | exported is 1 on success and 0 on failure; the caller uses the pair to build a success/failure toast |
Key files: src/features/exports/reporting/slideshow/ — types.ts (contracts + brand tokens + CapturedChart / SalesChartImage / TornadoImage unions), collectSlideshowData.ts, captureChartImages.ts (headless Chart.js → PNG for sales/MC/tornado), captureFunnelImage.tsx (off-screen FunnelDisplay → PNG via html-to-image; hidden-tab fast-fail + 10-second timeout), buildSlides.ts, renderDeck.ts, exportSlideshow.ts, format.ts (number formatting + sliceToExportWindow / EXPORT_WINDOW_START / EXPORT_WINDOW_LEN), variables.ts (per-line variable universe — built-ins, customs, inherited), peakYear.ts (per-line peak-year resolver + column-aware auto-fit font / readability-floor helpers), pickerWarnings.ts (soft picker warnings). Pure chart-config builders (shared with the in-app components): src/core/charts/salesChartConfig.ts + src/core/charts/monteCarloChartConfig.ts + src/core/charts/tornadoChartConfig.ts. UI: src/features/exports/SlideshowVariablePicker.tsx (thin wrapper over the generalized ExportSectionPopover). Hook: src/features/exports/useSlideshowExport.ts. Dependency: pptxgenjs.
Output: a single forecast-<modelName>-<YYYY-MM-DD>.pptx.
9. Component Architecture
Section titled “9. Component Architecture”Component Hierarchy
Section titled “Component Hierarchy”main.tsx: ErrorBoundary → QueryClientProvider → Provider (default store) → HydrateAtoms → AuthProvider → TooltipProvider → AppApp → 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 per-section feature directories under src/features/ (configuration, incidence-evolution, model, sales-chart, monte-carlo, tornado). Section IDs and labels are defined in src/app/section-config.ts.
LinePanel Sub-Components
Section titled “LinePanel Sub-Components”FirstLineExtras (LinePanel.tsx) is an isolated sub-component that renders the Biomarker card in one of two modes (branched on isStageFirstLine): definition mode on each stage’s stage-first selected line, and override mode on every non-stage-first line while a biomarker is active (gated internally on showBiomarker && (isStageFirstLine || isBiomarkerActive)). It is extracted so that biomarker atom subscriptions stay scoped to this sub-component rather than re-rendering the whole panel.
When rendered: Always mounted on every line panel; the biomarker card inside renders when showBiomarker && (isStageFirstLine || isBiomarkerActive) — definition mode on stage-first lines, override mode on non-stage-first lines. The per-line on/off Apply Switch lives in the card header on every line including stage-first (not in InheritedFiltersPanel).
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 and editable sub-fields (percentage, testing rate, growth) with indent guide. Rendered in two modes: definition mode on each stage’s stage-first selected line (which defines the per-stage type/values but no longer force-applies them — every line honors its ownbiomarkerEnabled), and override mode on non-stage-first lines, which seed the same controls from the per-line resolved cascade (biomarkerByLineAtom) and write per-line overrides. The per-line on/off Apply Switch lives in the card header and renders on every line including stage-first; toggling it OFF collapses a non-stage-first card body to just that header, while the stage-first card body stays visible (it is the stage-definition card). Stage-first lines also show an italic hint paragraph beneath the testing rate field.
InheritedFiltersPanel (InheritedFiltersPanel.tsx) is an isolated sub-component that consolidates two per-line filter bypass surfaces (transplant + upstream lines’ custom variables) into a single panel on non-stage-first lines (see “Per-line filter bypass — definer-always-applies pattern” above and FORECASTING_MODEL.md “Per-Line Upstream Bypass”). The per-line biomarker on/off toggle is not here — it moved to the Biomarker override card header in T12. Reads upstreamCustomBypassAtomFamily(lineId) and the stageFirstLine prop (for transplant settings); writes via setLineTransplantAppliesAtom, setUpstreamCustomBypassAtom + markDirtyAtom.
When rendered: Always mounted; returns null when no rows are visible (transplant row requires isTransplantAllowlisted(indication) + non-stage-first + stage-first line has transplantSplitEnabled=true; custom-bypass rows require at least one selected upstream line with customs).
Position: Inside the per-line config, directly above the customVariables rows.
Contains:
- Collapsible header (
Layersicon, “Inherited filters”, “{applied} of {total} applied”Badge), default-expanded on first render - Flat list of rows (no per-owner grouping); each row carries a
[Biomarker]/[Transplant]/[Owner-line]Badgechip, the variable name + read-only value (or biomarker prevalence/testing rate, or transplant setting + percent), and an applySwitch
Model Driver Breakdown
Section titled “Model Driver Breakdown”Each line row in the Model table can expand an in-place driver breakdown — a per-year chain showing how the displayed “Drug Treated Patients” number was built up factor by factor. The data-flow plumbing has three layers (row semantics live in FORECASTING_MODEL.md; the chip + glyph-free-row affordance lives in DESIGN_SYSTEM.md):
Accessor (getLineBreakdownSeries in Model.tsx). A useCallback that mirrors the live getEligibleAndNewPatients accessor’s param bundle and lineGroupMap translation exactly, swapping calculateLinePatients → explainLinePatients and iterating the year columns. It produces one LineDriverBreakdown per column over the same selectedYear + [0..PROJECTION_DATA_LENGTH) window the Model table uses (see Year Anchors). Because it threads the identical inputs — including diseaseFilters — as the number path, the breakdown’s terminal step reconciles to the displayed Drug Treated Patients value. (One label-only param, the resolved active-biomarker name, is passed to explainLinePatients but ignored by the number path; it never affects the count.) Gating is at the call site: collapsed lines never invoke it (see the open-state hook below).
Math pair (computeLinePatients / explainLinePatients in math/forecasting.ts). computeLinePatients is the extracted engine body; it folds the funnel statefully — seeding counts, multiplying percents, subtracting count-subtractions in engine order — and records the funnel factors it emits as DriverSteps as it applies them. So the emitted breakdown reconciles by construction (a stateful fold, not a flat product recomputed after the fact). calculateLinePatients (the number path) and explainLinePatients (the breakdown path) are both thin wrappers over computeLinePatients: the former returns [eligible, newPatients], the latter returns LineDriverBreakdown ({ year, drugTreatedPatients, steps: DriverStep[] }). The two can never drift because they share one engine — a captured-oracle test pins calculateLinePatients byte-identical across the extraction.
Open state (useDriverChainOpenState). Local React useState (not Jotai — mirrors useAccordionOpenState), keyed by line ID, default collapsed. Exposes isChainOpen, toggleChain, and collapseLine; because the chain is nested inside the line accordion, Model calls collapseLine(lineId) when a line’s accordion closes, giving reset-to-collapsed on reopen.
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. A combined total — the per-year sum of net sales across the compared scenarios — overlays the Net Sales chart and appears as a row in the Model Totals table (see Combined Total below).
Key files:
src/features/comparison/ComparisonPage.tsx— orchestrator, exposesComparisonPageHandlefor DOCX exportsrc/features/comparison/useComparisonData.ts— reactive subscription viauseSyncExternalStoresrc/features/comparison/collectComparisonData.ts— pure data extractorsrc/features/comparison/chart-utils.ts— year alignment, color tokens,computeCombinedTotalByYearhelper +COMBINED_TOTAL_COLORemphasis tokensrc/features/comparison/types.ts—ComparisonScenarioData,ComparisonExportHandlesrc/features/comparison/export-utils.ts— CSV row builders (sales, MC, model totals, tornado)src/features/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.
Combined Total
Section titled “Combined Total”computeCombinedTotalByYear(data, unionYears) (in chart-utils.ts) sums each scenario’s totalSalesByYear per year across the compared scenarios — a presentation-layer aggregate one level above a single scenario’s own line-summed total. Values are already in $M (no /1e6); a scenario out of its display window contributes 0 that year (never null). It is the single source consumed by four surfaces: the Net Sales chart line, the always-on Model Totals row, and the two CSV builders in export-utils.ts (the DOCX report auto-propagates from those builders).
The chart line is gated by a showCombinedTotal toggle — ephemeral useState(true) lifted to ComparisonPage, not a Jotai atom and not persisted. The table row and CSV/DOCX columns are always on (toggle-independent). The emphasis color is COMBINED_TOTAL_COLOR (= CHART_COLORS.text.primary, #1f2937), intentionally outside the SCENARIO_COLORS palette.
Scenario Colors
Section titled “Scenario Colors”Colors assigned by tab index from SCENARIO_COLORS in src/core/types/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). The Monte Carlo section’s export button is additionally disabled when any compared scenario holds stale MC results (monteCarlo.mcStale), so outdated forecasts can’t be exported.
Comparison DOCX Report
Section titled “Comparison DOCX Report”A separate DOCX pipeline under src/features/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 src/lib/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. collectComparisonDatasums per-year sales through the sharedsumLineSalesForYearkernel (which scores each group-scoped line viacalculateLineSales, not all lines at once), 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/core/simulation/useMonteCarloWorker.ts— store-pinned worker lifecyclesrc/core/state/sections/monte-carlo/atoms.ts— results, stale, running atomssrc/features/monte-carlo/MonteCarlo.tsx— UI-only presentation layersrc/core/simulation/deriveDefaultSimulationVariables.ts— shared pure function for default simulation variable derivation (used by comparison fallback)src/core/simulation/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). - Failure toast: Both execution paths surface a failed simulation via
toast.error. On the worker path this covers a worker error message, aworker.onerrorcrash, and the 5-minuteMAX_SIMULATION_TIMEOUT— the timeout uses a distinct"{label}: simulation timed out"message so a hung job reads differently from a crash.
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/core/state/sections/monte-carlo/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()).
Production injection: VITE_* values are inlined at build time in the GitHub Actions Build step (.github/workflows/deploy.yml) from repo Actions variables/secrets — the Cloudflare Pages dashboard does not supply build-time env (it’s a Direct Upload via wrangler pages deploy dist, not a Cloudflare-native build).
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/core/types/types.ts. Use IDE navigation to inspect them.
Development rules and constraints are defined in CLAUDE.md.