> ⚠️ Stacked PR — do not merge to main directly.
> This PR is stacked on feat/school-pl-unit-economics (#341), _not_ on main. The diff shown here is only AERIE-355's delta on top of #341. Merge order: land #341 first, then rebase this branch onto main before merging. Until then the comparison base is the #341 branch.
Linear: AERIE-355 — https://linear.app/builder-team/issue/AERIE-355
## Demo
<img width="2624" height="1644" alt="image" src="https://github.com/user-attachments/assets/3f85bb1a-830f-4862-b3f1-0e05b4b9cd20" />
## Overview
Finishes the Unit Economics Comparison section of the School P&L dashboard by making the "Unit Economics per Student" and "Unit Economics Annualised" sibling cards live and data-driven for every school with a unit_economics_model mapping (~34), instead of only Alpha Miami / Austin K-8. Both cards render from a single deriveUnitEconomicsTable(...) call whose model side now comes from the school's own tier+scale CSV and whose run-rate Headcount line comes from live QB Contracted-Labor actuals.
## Specs in this PR
| Spec | Status | Description |
|------|--------|-------------|
| [03-full-tier-model](features/dashboards/school-pl-unit-economics/specs/03-full-tier-model/spec.md) | Completed | Adds getModelPerStudent(unitEconomicsModel, students): ModelPerStudent \| null to unit-economics-model.ts, reading tier+scale economics (Tuition, Net Facility/student, Programs/student, Apps, CapEx, Timeback, + headcount totals) from the model tables (mirroring data/models/{tier}.csv). Replaces the $50K hardcodes & MODEL_50K_250. Facilities/Apps/CapEx are number \| null (null = n/a for the reduced alpha-anywhere model); unknown/malformed model string → null. |
| [04-sibling-cards-live-and-gate](features/dashboards/school-pl-unit-economics/specs/04-sibling-cards-live-and-gate/spec.md) | Completed | Wires the live run-rate roster (foldRunRateRoles over QB getHeadcountByRoleForCell) + the spec-03 tier model into deriveUnitEconomicsTable; adds modelHeadcountCostByRole; flips showModelComparisonCards from hasHeadcountData to hasUnitEconomicsModel; makes the $50K/$200K copy & formula strings tier-aware; handles graceful $0/0 + divide-by-zero; renders alpha-anywhere n/a lines as "—". Also threads unitEconomicsModel through financials-kpi-cards.tsx to keep the shared deriveUnitEconomicsTable signature compiling. |
## What changed
- Full tier-aware model — getModelPerStudent reads every per-student line from the school's own tier+scale CSV (alpha-40k … alpha-anywhere), replacing the $50K-hardcoded constants and MODEL_50K_250 as the model data source. An alpha-40k school now shows $40K tuition / $150K Lead salary / its own facilities/programs/apps/capex.
- Live run-rate roster fold — the run-rate Headcount line, per-role breakdown, and roster-scaling inputs to deriveUnitEconomicsTable (headcountAnnualisedTotal, headcountRoleAnnualised, headcountRoleCounts) are now folded out of the live getHeadcountByRoleForCell query via AERIE-354's foldRunRateRoles helper (60211→leadGuides / 60212→guides / 60220→coord), replacing getManualHeadcount(school). All other run-rate lines and the "variable scaled, fixed flat" SY26/27 + Capacity projection are preserved on the school's own live base.
- Gate flip — showModelComparisonCards in financials-view.tsx changes from hasHeadcountData to hasUnitEconomicsModel, so the two sibling cards render for every model-mapped school.
- Tier-aware copy — the shared MODEL_SOURCE.tier subtitle and the formula strings in unit-economics-comparison-derive.ts that hardcoded "$50K"/"$200K"/etc. are now tier-aware.
- Graceful degradation — a model-mapped school with no Contracted-Labor activity renders the run-rate Headcount line at $0 / 0; scaleRosterToStudents / ceilWithGrace never divide by zero or produce NaN/Infinity when live counts are 0 but cost is > 0; alpha-anywhere n/a lines render as "—" (no fabricated $0).
## Schools enabled
The gate is purely data-driven — unit_economics_model != null on the school's campusQbEntityMappings row (sourced from Redshift finance_dw.core_education.map_campus_qb_entities). No hardcoded list, so the card set tracks the mapping table automatically. As currently mapped that is 34 schools: 2 already live, 32 newly enabled by the gate flip.
Already live (unchanged): Alpha Miami (alpha-50k · 250), Austin K-8 (alpha-40k · 250).
<details>
<summary><b>32 newly-enabled schools</b> (school → tier · scale)</summary>
| School | Model (tier · scale) |
|--------|----------------------|
| Alpha Anywhere Center | alpha-65k · 250 |
| Alpha Boston | alpha-65k · 25 |
| Alpha Chantilly | alpha-65k · 25 |
| Alpha Charlotte | alpha-50k · 25 |
| Alpha Chicago | alpha-65k · 1000 |
| Alpha Dorado | alpha-50k · 25 |
| Alpha Fort Lauderdale | alpha-50k · 250 |
| Alpha Fort Worth | alpha-50k · 25 |
| Alpha Kirkland | alpha-50k |
| Alpha Lake Forest | alpha-50k |
| Alpha Los Angeles | alpha-65k |
| Alpha Orange County | alpha-50k · 25 |
| Alpha Palm Beach | alpha-50k · 25 |
| Alpha Palo Alto | alpha-75k · 25 |
| Alpha Park City | alpha-40k · 250 |
| Alpha Piedmont | alpha-65k · 1000 |
| Alpha Plano | alpha-50k · 25 |
| Alpha Raleigh | alpha-50k · 25 |
| Alpha Roswell | alpha-40k · 250 |
| Alpha San Francisco | alpha-75k · 25 |
| Alpha Santa Barbara | alpha-50k · 25 |
| Alpha Santa Monica | alpha-65k · 25 |
| Alpha Scottsdale | alpha-40k · 250 |
| Alpha Tampa | alpha-40k |
| Alpha The Woodlands | alpha-40k · 250 |
| Brownsville K-8 | alpha-50k · 25 |
| GT Anywhere | alpha-anywhere · 2000 (reduced model — Facilities/CapEx/Apps render as "—") |
| Montessorium | alpha-40k · 25 |
| Nova Academy Austin | alpha-40k · 25 |
| Nova Academy Bastrop | alpha-40k · 25 |
| Texas Sports Academy | alpha-40k · 250 |
| Waypoint Academy | alpha-40k · 25 |
</details>
## Test coverage
- Spec 03 — 11 unit tests for getModelPerStudent in unit-economics-model.test.ts (non-$50K tier economics, alpha-anywhere n/a Facilities/CapEx/Apps, unknown-model null degradation, per-line sourcing).
- Spec 04 — 5 new derive tests in unit-economics-comparison-derive.test.ts covering the three required cases (non-$50K tier, cost-without-count, pre-operational $0/0) plus extras (alpha-anywhere null lines, unknown-model).
- pnpm typecheck clean; biome clean; full suite green (72/72 in the two affected test files; repo-wide 4696 tests pass).
## ⚠️ Needs sign-off
Two deliberate Miami/Austin numeric shifts (the ticket already anticipates these needing sign-off):
1. Run-rate Headcount line → live QB Contracted-Labor. The run-rate Headcount line moves from the manual Miami/Austin roster (getManualHeadcount) to live QB Contracted-Labor actuals. This is a deliberate ~$4K/quarter divergence (the manual roster and QB CL differ by ~$4K/quarter), mirroring the change already shipped for the Headcount Detail Cost card in #341.
2. Model Headcount role counts → fixed scale-token counts. The model Headcount role counts now come from the fixed-per-scale-token model table (e.g. alpha-50k · 250 → 25 staff) instead of the legacy dynamic students/62.5 (~17 for Miami). This is consistent with #341's already-live Headcount Detail Cost card, but it also shifts the out-of-scope KPI cards' model EBITDA for Miami/Austin (those cards consume the same deriveUnitEconomicsTable).
## Deferred (follow-up cleanup ticket)
Per the ticket, the dead Miami/$50K scaffolding is left in place in this PR and retired in a separate follow-up cleanup ticket:
- getManualHeadcount / HEADCOUNT_RATES_BY_SCHOOL
- MODEL_50K_250
- headcount-miami-data.ts
- the now-redundant hasHeadcountData gate
(computeModelPerStudent is also retained because the out-of-scope HC-Count-by-Role consumer still calls it.)
## CI
CI does not run on this PR while it is stacked — the repo's ci.yml only triggers on PRs targeting main/production, and this PR targets feat/school-pl-unit-economics. CI activates once the stack rebases onto main (after #341 merges). Verified locally instead: pnpm typecheck, biome, and the full test suite are green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)