2026 04 08 Pricing Benchmark Design
Pricing Benchmark Page — Design Spec
Section titled “Pricing Benchmark Page — Design Spec”Overview
Section titled “Overview”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:
- API/Database changes — handled in a separate Claude Code session in
bioloupe-data-gov - Frontend implementation — handled in the current repo (
bioloupe-forecasting)
1. View Switching Architecture
Section titled “1. View Switching Architecture”Refactor: compareActive → activeView 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, allcompareActivereferencessrc/features/reports/forecasting/Forecasting.tsx—ForecastingContentbranching logicsrc/features/reports/forecasting/scenarios/ScenarioTabBar.tsx— CompareTab onClick, scenario tab onClicksrc/features/reports/forecasting/scenarios/CompareTab.tsx—isActiveprop derivationsrc/features/reports/forecasting/sections/Comparison/ComparisonFilterRow.tsx— back button handler- Any other consumer of
useScenario()that readscompareActive
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)
2. Navigation — Pricing Tab
Section titled “2. Navigation — Pricing Tab”PricingTab Component
Section titled “PricingTab Component”New component in src/features/reports/forecasting/scenarios/PricingTab.tsx, peer of CompareTab.
- Icon:
DollarSignfrom 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
CompareTab—border-b-2, active/inactive states, icon withtext-primary/60when inactive
Placement in ScenarioTabBar
Section titled “Placement in ScenarioTabBar”After the Compare tab, separated by a vertical divider:
[Base Case] [Optimistic] [+] | [Compare] | [Pricing]Pre-selecting Indication
Section titled “Pre-selecting Indication”When PricingTab is clicked:
- Read
indicationAtomfrom the active Jotai store (one-time read) - Pass it as the initial
diseaseFiltertoPricingLayout - Store in
PricingLayoutas 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.
3. Data Layer
Section titled “3. Data Layer”API Endpoint (consumed by frontend)
Section titled “API Endpoint (consumed by frontend)”GET /api/forecasting_pricingsGET /api/forecasting_pricings?disease=Colorectal+CancerResponse 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 }}Frontend Data Fetching
Section titled “Frontend Data Fetching”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 indicationshowAll(boolean) — expands to all indicationsbrandFilter,indicationFilter,targetFilter,technologyFilter(strings) — text searchdiscountToggle(boolean) — shows/hides discount columnsdiscounts(Record<string, string>) — per-drug discount percentages, keyed bybrandName/indication
4. Frontend Components
Section titled “4. Frontend Components”File Structure
Section titled “File Structure”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/sortingPricingLayout
Section titled “PricingLayout”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" },];PricingFilterRow
Section titled “PricingFilterRow”Mirrors ComparisonFilterRow:
| Element | Behavior |
|---|---|
| ”Back to editor” button | setActiveView("editor") |
| Vertical divider | Visual separator |
| ”Indication:” label + chip | Shows current disease filter, styled like comparison scenario chips |
| WAC-to-Net Discount toggle | Switch 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)
PricingTable
Section titled “PricingTable”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):
| Column | Align | Rendering |
|---|---|---|
| Brand Name | left | Bold text |
| Indication | left | Plain text |
| Monthly WAC ($) | right | Monospace, formatted with thousand separators |
| Target | left | Violet tags (split {A,B} into individual tags) |
| Technology | left | Emerald tag |
| Date Approved | center | MM/YYYY format, muted color |
Table columns (discount toggle ON) — two additional columns:
| Column | Align | Rendering |
|---|---|---|
| Discount % | center | Amber-bordered input (56px wide), bg-amber-50 header |
| Monthly Net ($) | right | Emerald 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.
pricing-utils.ts
Section titled “pricing-utils.ts”// Split "{PD-1,VEGFR}" → ["PD-1", "VEGFR"]export function targetNormalize(value: string | undefined): string[]
// Format number with locale thousand separatorsexport function toThousandSeparator(value: number | string | undefined): string
// Client-side filter pipelineexport function filterPricingData( data: PricingEntry[], filters: { brand: string; indication: string; target: string; technology: string }): PricingEntry[]5. API/Database Prompt (Separate Session)
Section titled “5. API/Database Prompt (Separate Session)”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.
Prompt for API Session
Section titled “Prompt for API Session”# 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 data2. **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`
```rubycreate_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.timestampsend
add_index :forecasting_pricings, :brand_nameadd_index :forecasting_pricings, :diseaseadd_index :forecasting_pricings, :disease_idadd_index :forecasting_pricings, :deleted_atadd_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) }endController — 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!+ customauthenticate_admin_or_api_user! indexaction: filter bydisease,disease_id,technology,brand_name- Order by
date_approved DESC NULLS LAST - Inline
serialize_forecasting_pricingmethod - Use
render_jsonenvelope:{ data: [...], meta: { total_count: N } }
Routes
Section titled “Routes”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_allthen create from CSV - CSV columns:
Brand Name,Indication,Monthly WAC Price ($),Target,Technology,Date approved,Disease extract_attributesmaps CSV headers to model attributes:Brand Name→brand_nameIndication→indicationMonthly WAC Price ($)→monthly_wac_price(parse as decimal)Target→targetTechnology→technologyDate approved→date_approved(parse MM/DD/YYYY to Date)Disease→disease
- Duplicate check on
(brand_name, indication, disease) - Support
--dry_run/-dflag - Print summary: created/skipped/errors
CSV data file
Section titled “CSV data file”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:
diseasemust be one of the known disease names (not a date or “technology1/3”)date_approvedmust parse as a valid datemonthly_wac_pricemust be numeric
Admin panel (optional)
Section titled “Admin panel (optional)”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 endendThis 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 configurationsForecastingModelmodel +ForecastingController— active code- CORS origins for
forecasting.bioloupe.com— production config - Deploy config references — production config
Documentation cleanup
Section titled “Documentation cleanup”After dropping the schema, update these docs to remove references to the old forecasting schema:
docs/data-model.mddocs/index.mddocs/architecture.md
Task 4: Run and verify
Section titled “Task 4: Run and verify”After all changes:
bundle exec rails db:migratebundle exec thor forecasting_statistics:import— verify existing statistics still import correctlybundle exec thor forecasting_pricing:import— import the pricing data, note any skipped rows- Start the Rails server and test:
GET /api/forecasting_statisticsandGET /api/forecasting_pricings bundle exec rails test— all tests passbundle exec rubocop— no new violations
Important notes
Section titled “Important notes”- The existing
statisticsimport command isbundle exec thor statistics:import— after rename it becomesbundle exec thor forecasting_statistics:import - The
Thorfileat project root loads the Rails environment — Thor task class names must match the file namespace - The
render_jsonhelper is inApi::BaseController— it wraps responses in{ data:, meta: }envelope - The
Lockableconcern is for tracking manually edited attributes — include it on both models acts_as_paranoidprovides soft delete viadeleted_at— both tables need this columnhas_paper_trailprovides 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)