Skip to content

2026 04 08 Pricing Benchmark Design

Add a Pricing Benchmark tab to the forecasting application that displays reference drug pricing data (WAC prices) for oncology drugs, filterable by indication, with an optional WAC-to-Net discount calculator. The pricing tab is a view within the existing layout — not a separate page — following the same pattern as the Comparison view.

This feature has two workstreams:

  1. API/Database changes — handled in a separate Claude Code session in bioloupe-data-gov
  2. Frontend implementation — handled in the current repo (bioloupe-forecasting)

Refactor: compareActiveactiveView Union Type

Section titled “Refactor: compareActive → activeView Union Type”

Replace the boolean-based view switching in ScenarioProvider with a discriminated union.

Current state:

const [compareActive, setCompareActive] = useState(false);

New state:

type ActiveView = "editor" | "comparison" | "pricing";
const [activeView, setActiveView] = useState<ActiveView>("editor");

Affected files:

  • src/features/reports/forecasting/scenarios/ScenarioProvider.tsx — state declaration, context value, all compareActive references
  • src/features/reports/forecasting/Forecasting.tsxForecastingContent branching logic
  • src/features/reports/forecasting/scenarios/ScenarioTabBar.tsx — CompareTab onClick, scenario tab onClick
  • src/features/reports/forecasting/scenarios/CompareTab.tsxisActive prop derivation
  • src/features/reports/forecasting/sections/Comparison/ComparisonFilterRow.tsx — back button handler
  • Any other consumer of useScenario() that reads compareActive

Branching in ForecastingContent:

function ForecastingContent() {
const { activeTabId, activeStore, activeView } = useScenario();
const [showSelector, setShowSelector] = useState(true);
if (activeView === "pricing") {
return (
<Provider store={activeStore}>
<PricingLayout />
</Provider>
);
}
if (activeView === "comparison") {
return (
<Provider store={activeStore}>
<ComparisonLayout />
</Provider>
);
}
return (
<Provider store={activeStore}>
<JotaiDevTools />
<div role="tabpanel" ...>
<ForecastingInner showSelector={showSelector} setShowSelector={setShowSelector} />
</div>
</Provider>
);
}

View transition rules:

  • Clicking any scenario tab → setActiveView("editor")
  • Clicking Compare tab → setActiveView("comparison")
  • Clicking Pricing tab → setActiveView("pricing")
  • Closing scenarios below 2 while in comparison → setActiveView("editor") (existing guard logic)

New component in src/features/reports/forecasting/scenarios/PricingTab.tsx, peer of CompareTab.

  • Icon: DollarSign from lucide-react
  • Label: “Pricing”
  • No disable guard — pricing is always available once a model is loaded (unlike Compare which requires 2+ scenarios)
  • Styling: Identical to CompareTabborder-b-2, active/inactive states, icon with text-primary/60 when inactive

After the Compare tab, separated by a vertical divider:

[Base Case] [Optimistic] [+] | [Compare] | [Pricing]

When PricingTab is clicked:

  1. Read indicationAtom from the active Jotai store (one-time read)
  2. Pass it as the initial diseaseFilter to PricingLayout
  3. Store in PricingLayout as React state — subsequent filter changes are local

Implementation: ScenarioProvider captures the current indication at transition time and exposes it as pricingInitialIndication in context, or passes it directly via a callback.


GET /api/forecasting_pricings
GET /api/forecasting_pricings?disease=Colorectal+Cancer

Response envelope (matching existing render_json pattern):

{
"data": [
{
"id": 1,
"brand_name": "KEYTRUDA",
"indication": "Colorectal Cancer (MSI-H/dMMR)",
"monthly_wac_price": "21843.00",
"target": "{PD-1}",
"technology": "Antibody",
"date_approved": "2020-06-30",
"disease": "Colorectal Cancer",
"disease_id": 42,
"disease_name": "Colorectal Cancer",
"source": [{"name": "Texas DSHS", "url": "https://..."}],
"created_at": "...",
"updated_at": "..."
}
],
"meta": { "total_count": 8 }
}

useQuery in PricingLayout — no Jotai atoms needed (self-contained reference data):

const { data, isLoading } = useQuery({
queryKey: ["forecasting-pricings", diseaseFilter, showAll],
queryFn: () => api.get("/api/forecasting_pricings", {
params: showAll ? {} : { disease: diseaseFilter },
}),
});

Local state only for:

  • diseaseFilter (string) — initialized from current indication
  • showAll (boolean) — expands to all indications
  • brandFilter, indicationFilter, targetFilter, technologyFilter (strings) — text search
  • discountToggle (boolean) — shows/hides discount columns
  • discounts (Record<string, string>) — per-drug discount percentages, keyed by brandName/indication

src/features/reports/forecasting/sections/Pricing/
├── index.ts # Barrel exports: PricingLayout, PricingFilterRow
├── PricingLayout.tsx # Top-level view (header, sticky bar, content, footer)
├── PricingFilterRow.tsx # Back button + indication chip + discount toggle w/ InfoButton
├── PricingTable.tsx # Filters + table + show-all checkbox
└── pricing-utils.ts # targetNormalize, toThousandSeparator, filtering/sorting

Mirrors ComparisonLayout structure:

<Header />
<SectionNav sections={PRICING_SECTIONS} />
<main>
Sticky bar: ScenarioTabBar + PricingFilterRow
SectionCard: "Drug Pricing Benchmark"
titleTooltip: InfoButton with data derivation explanation
content: PricingTable
</main>
<Footer />

Section header InfoButton content:

Pricing data derived from the Texas DSHS Prescription Drug Price Disclosure Program (WAC + Price Increase Reports). Per-milligram costs were calculated using FDA-listed active ingredients, then converted to approximate monthly prices based on standard-of-care dosing regimens.

section-config.ts addition:

export const PRICING_SECTIONS: SectionConfig[] = [
{ id: "section-pricing", label: "Pricing Benchmark" },
];

Mirrors ComparisonFilterRow:

ElementBehavior
”Back to editor” buttonsetActiveView("editor")
Vertical dividerVisual separator
”Indication:” label + chipShows current disease filter, styled like comparison scenario chips
WAC-to-Net Discount toggleSwitch from Radix, right-aligned in bg-muted container
InfoButton (next to toggle)Gray tooltip with discount guidance text + 4 source cards

Discount guidance InfoButton content:

For recently approved oncology drugs, the typical discount off WAC ranges from 20% to 35%, depending on payer mix, administration setting, and competition.

Use ~20% for novel, first-in-class drugs with limited exposure to 340B, Medicaid, and PBM rebates.

Use ~35% if the drug is administered in hospital/clinic settings, covered under Medicare Part D, or faces competitive pressure.

Sources:

  • Medicaid 23.1% Minimum Rebate (medicaid.gov)
  • IQVIA Global Oncology Trends 2023 (iqvia.com)
  • FTC PBMs & Drug Pricing 2023 (ftc.gov)
  • DrugChannels.net (drugchannels.net)

Search filters: 4-column grid of text inputs (brand, indication, target, technology) — filters applied client-side on the fetched data.

Table columns (discount toggle OFF):

ColumnAlignRendering
Brand NameleftBold text
IndicationleftPlain text
Monthly WAC ($)rightMonospace, formatted with thousand separators
TargetleftViolet tags (split {A,B} into individual tags)
TechnologyleftEmerald tag
Date ApprovedcenterMM/YYYY format, muted color

Table columns (discount toggle ON) — two additional columns:

ColumnAlignRendering
Discount %centerAmber-bordered input (56px wide), bg-amber-50 header
Monthly Net ($)rightEmerald bold monospace, computed as WAC × (1 - discount/100)

Sorting: By date_approved descending (newest first).

“Show all drugs” checkbox: Below the table. When checked, re-fetches without disease filter.

// Split "{PD-1,VEGFR}" → ["PD-1", "VEGFR"]
export function targetNormalize(value: string | undefined): string[]
// Format number with locale thousand separators
export function toThousandSeparator(value: number | string | undefined): string
// Client-side filter pipeline
export function filterPricingData(
data: PricingEntry[],
filters: { brand: string; indication: string; target: string; technology: string }
): PricingEntry[]

The following prompt should be given to a new Claude Code session opened in ../bioloupe-data-gov. It contains full context for the API agent to work independently.


# Context: Forecasting App — Pricing Benchmark Feature
## What is this about
We're building a **Pricing Benchmark** feature for the Bioloupe forecasting application (a React SPA at bioloupe-forecasting). This feature shows a reference table of oncology drug WAC (Wholesale Acquisition Cost) prices so analysts can benchmark pricing when building forecasting models.
The forecasting frontend (`bioloupe-forecasting`) consumes data from this Rails API (`bioloupe-data-gov`). Currently, the frontend's reference data (population stats, incidence rates, disease parameters) is served through the `statistics` table and `GET /api/statistics` endpoint. The new pricing data needs the same treatment — a proper Rails table, model, controller, and import task.
We're also taking this opportunity to:
1. **Rename the `statistics` table to `forecasting_statistics`** — to clearly namespace forecasting-specific data
2. **Drop the legacy `forecasting` schema** — 15 Django-era tables that are completely unused by Rails
## Task 1: Rename `statistics` → `forecasting_statistics`
Rename everything consistently:
### Migration
- `rename_table :statistics, :forecasting_statistics`
- Rename all indexes to use the `forecasting_statistics` prefix
- Update the unique composite index name
### Model
- Rename `app/models/statistic.rb` → `app/models/forecasting_statistic.rb`
- Class name: `Statistic` → `ForecastingStatistic`
- All constants, validations, scopes, associations stay the same
### Controller
- Rename `app/controllers/api/statistics_controller.rb` → `app/controllers/api/forecasting_statistics_controller.rb`
- Class name: `Api::StatisticsController` → `Api::ForecastingStatisticsController`
- Update `serialize_statistic` → `serialize_forecasting_statistic`
### Routes
- In `config/routes.rb`: `resources :statistics` → `resources :forecasting_statistics`
- This changes the endpoint from `/api/statistics` to `/api/forecasting_statistics`
### Thor task
- Rename `lib/tasks/statistics.thor` → `lib/tasks/forecasting_statistics.thor`
- Class name: `Statistics` → `ForecastingStatistics`
- Update default CSV path to `lib/tasks/data/forecasting_statistics/statistics.csv`
- Move the CSV: `lib/tasks/data/statistics/` → `lib/tasks/data/forecasting_statistics/`
- Update all references from `Statistic` to `ForecastingStatistic`
### Admin panel
- Rename `app/admin/statistics.rb` → `app/admin/forecasting_statistics.rb`
- Update `ActiveAdmin.register` to use `ForecastingStatistic`
- Update the admin service if it references the old model name
### Other references
- `User` model: `has_many :statistics` → does NOT exist (statistics aren't user-scoped, unlike forecasting_models). Verify no user association exists.
- Update any specs/tests referencing `Statistic`
- Search for ALL references to the old names: `grep -r "Statistic\b" --include="*.rb"` and `grep -r "statistics" --include="*.rb"` to catch everything
## Task 2: Create `forecasting_pricing` table + full stack
Follow the exact same pattern as `forecasting_statistics`. The existing implementation at `app/models/forecasting_statistic.rb`, `app/controllers/api/forecasting_statistics_controller.rb`, and `lib/tasks/forecasting_statistics.thor` is your reference — replicate the same structure.
### Migration — `create_forecasting_pricings`
```ruby
create_table :forecasting_pricings do |t|
t.string :brand_name, null: false
t.string :indication, null: false
t.decimal :monthly_wac_price, precision: 12, scale: 2, null: false
t.string :target # e.g., "{PD-1}", "{VEGFR,BRAF}"
t.string :technology # e.g., "Antibody", "Small Molecule", "ADC"
t.date :date_approved
t.string :disease # maps to forecasting indication name
t.references :disease, foreign_key: true, null: true
t.jsonb :source # [{name, url, sampleSize, comments}]
t.datetime :last_review_at
t.datetime :deleted_at
t.timestamps
end
add_index :forecasting_pricings, :brand_name
add_index :forecasting_pricings, :disease
add_index :forecasting_pricings, :disease_id
add_index :forecasting_pricings, :deleted_at
add_index :forecasting_pricings, [:brand_name, :indication, :disease],
unique: true, name: "index_forecasting_pricings_unique"

Model — app/models/forecasting_pricing.rb

Section titled “Model — app/models/forecasting_pricing.rb”
class ForecastingPricing < ApplicationRecord
include Lockable
has_paper_trail
acts_as_paranoid
belongs_to :disease, optional: true
validates :brand_name, presence: true
validates :indication, presence: true
validates :monthly_wac_price, presence: true, numericality: true
scope :for_disease, ->(disease) { where(disease: disease) }
scope :for_technology, ->(tech) { where(technology: tech) }
end

Controller — app/controllers/api/forecasting_pricings_controller.rb

Section titled “Controller — app/controllers/api/forecasting_pricings_controller.rb”
  • Extend Api::BaseController
  • Use same auth pattern as forecasting_statistics: skip_before_action :authenticate_api_user! + custom authenticate_admin_or_api_user!
  • index action: filter by disease, disease_id, technology, brand_name
  • Order by date_approved DESC NULLS LAST
  • Inline serialize_forecasting_pricing method
  • Use render_json envelope: { data: [...], meta: { total_count: N } }
resources :forecasting_pricings, only: %i[index show create update destroy]

Thor task — lib/tasks/forecasting_pricing.thor

Section titled “Thor task — lib/tasks/forecasting_pricing.thor”
  • Class: ForecastingPricing < Thor
  • Command: forecasting_pricing:import [FILE]
  • Default file: lib/tasks/data/forecasting_pricing/pricing.csv
  • Strategy: ForecastingPricing.unscoped.delete_all then create from CSV
  • CSV columns: Brand Name,Indication,Monthly WAC Price ($),Target,Technology,Date approved,Disease
  • extract_attributes maps CSV headers to model attributes:
    • Brand Namebrand_name
    • Indicationindication
    • Monthly WAC Price ($)monthly_wac_price (parse as decimal)
    • Targettarget
    • Technologytechnology
    • Date approveddate_approved (parse MM/DD/YYYY to Date)
    • Diseasedisease
  • Duplicate check on (brand_name, indication, disease)
  • Support --dry_run / -d flag
  • Print summary: created/skipped/errors

Copy pricing.csv from ../bioloupe-forecasting-development/public/data/pricing.csv to lib/tasks/data/forecasting_pricing/pricing.csv.

Data quality note: Some rows in the CSV have shifted columns — e.g., “technology3” appearing in the Date Approved column and dates in the Disease column. The import task should validate each row and skip malformed entries (log them as errors). Specifically:

  • disease must be one of the known disease names (not a date or “technology1/3”)
  • date_approved must parse as a valid date
  • monthly_wac_price must be numeric

app/admin/forecasting_pricings.rb — register in ActiveAdmin with CRUD, SoftDelete and History modules. Match the forecasting_statistics admin pattern.

Task 3: Drop the legacy forecasting schema

Section titled “Task 3: Drop the legacy forecasting schema”

Create a migration:

class DropForecastingSchema < ActiveRecord::Migration[7.2]
def up
execute "DROP SCHEMA IF EXISTS forecasting CASCADE"
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

This drops all 15 Django-era tables (5 api_* data tables + 10 auth_*/django_* boilerplate tables). None are referenced by any Rails code.

DO NOT drop or modify:

  • public.forecasting_models — active Rails table for user-saved model configurations
  • ForecastingModel model + ForecastingController — active code
  • CORS origins for forecasting.bioloupe.com — production config
  • Deploy config references — production config

After dropping the schema, update these docs to remove references to the old forecasting schema:

  • docs/data-model.md
  • docs/index.md
  • docs/architecture.md

After all changes:

  1. bundle exec rails db:migrate
  2. bundle exec thor forecasting_statistics:import — verify existing statistics still import correctly
  3. bundle exec thor forecasting_pricing:import — import the pricing data, note any skipped rows
  4. Start the Rails server and test: GET /api/forecasting_statistics and GET /api/forecasting_pricings
  5. bundle exec rails test — all tests pass
  6. bundle exec rubocop — no new violations
  • The existing statistics import command is bundle exec thor statistics:import — after rename it becomes bundle exec thor forecasting_statistics:import
  • The Thorfile at project root loads the Rails environment — Thor task class names must match the file namespace
  • The render_json helper is in Api::BaseController — it wraps responses in { data:, meta: } envelope
  • The Lockable concern is for tracking manually edited attributes — include it on both models
  • acts_as_paranoid provides soft delete via deleted_at — both tables need this column
  • has_paper_trail provides version tracking — include on both models
---
## 6. Scope Summary
| Workstream | Scope |
|------------|-------|
| **API (separate session)** | Rename statistics → forecasting_statistics, create forecasting_pricings, drop legacy schema, Thor import, admin |
| **Frontend: View refactor** | `compareActive` boolean → `activeView` union type in ScenarioProvider, update all consumers |
| **Frontend: Navigation** | `PricingTab` component, placement in ScenarioTabBar, indication pre-selection |
| **Frontend: Pricing page** | PricingLayout, PricingFilterRow, PricingTable, pricing-utils |
| **Frontend: Data** | `useQuery` for pricing API, client-side filtering, local discount state |
### Frontend API path update (post-API rename)
After the API renames `statistics` → `forecasting_statistics`, the frontend must update its API calls from `/api/statistics` to `/api/forecasting_statistics`. Search for all references to `/api/statistics` or `statistics` endpoint paths in `src/` and update them.
### Out of scope
- Persisting discount values (ephemeral, local state only)
- Exporting pricing data to DOCX reports
- Admin panel UI for managing pricing data in the frontend (managed via ActiveAdmin in the API)