{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "metrics-analysis",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "Scenarios side by side, all measured against the base.",
  "files": [
    {
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/ledger/cn\";\nimport { periodChange } from \"@/lib/ledger/period-change\";\nimport { InfoTip } from \"@/components/ledger/info-tip\";\nimport { Money } from \"@/components/ledger/money\";\nimport { Percent } from \"@/components/ledger/percent\";\nimport type { PeriodColumn, PeriodRow } from \"@/components/ledger/period-grid\";\nimport { PeriodGrid } from \"@/components/ledger/period-grid\";\n\nexport interface Scenario {\n  id: string;\n  /** \"Base case\", \"Downside\", \"Board plan\". Named, never \"Scenario 2\". */\n  label: string;\n  /** What has to be true for this scenario to be the one that happens. */\n  premise: string;\n  /** One entry per measure, in the order `measures` gives them. */\n  values: (number | null)[];\n}\n\nexport interface ScenarioMeasure {\n  id: string;\n  label: string;\n  /** A floor or ceiling the business cannot cross. Breaches are named. */\n  limit?: { kind: \"min\" | \"max\"; value: number; label: string };\n  /** Up is not always good — runway up is good, burn up is not. */\n  polarity?: \"standard\" | \"inverted\" | \"neutral\";\n  unit?: \"money\" | \"percent\" | \"count\" | \"days\" | \"ratio\";\n}\n\nexport interface ScenarioCompareProps {\n  /** The scenario every other one is measured against. */\n  baseId: string;\n  className?: string;\n  currency: string;\n  measures: ScenarioMeasure[];\n  /** The period the comparison resolves at — \"FY26\", \"Dec 2026\". */\n  periodLabel: string;\n  scenarios: Scenario[];\n  storageKey?: string;\n}\n\n/**\n * Scenarios side by side, every one of them measured against the base.\n *\n * The grid is transposed from the rest of the family — measures down,\n * scenarios across — because the question is never \"what does the downside do\n * over time\", it is \"what is different about the downside\". Putting the\n * scenarios in columns is what lets the eye run across one row and see it.\n *\n * Two things this refuses to do:\n *\n * - **It will not rank the scenarios.** There is no \"best case\" styling and\n *   nothing is sorted by outcome. A scenario is a set of assumptions, and\n *   presenting the one with the largest number as the good one is how a\n *   forecast review becomes a negotiation about which column to show the\n *   board.\n * - **It will not hide a breach.** A measure carrying a limit — a covenant\n *   floor, a minimum cash balance — says which scenarios cross it, in words,\n *   under the grid. A downside that breaches a covenant in month seven is the\n *   only thing on the page that matters, and it is not going to be left for\n *   someone to notice in a cell.\n */\nexport function ScenarioCompare({\n  baseId,\n  scenarios,\n  measures,\n  currency,\n  periodLabel,\n  storageKey,\n  className,\n}: ScenarioCompareProps) {\n  const base = scenarios.find((s) => s.id === baseId) ?? scenarios[0];\n\n  const columns: PeriodColumn[] = React.useMemo(\n    () =>\n      scenarios.map((s) => ({\n        /* Everything is measured against the base, whatever order the reader\n           drags the columns into and whether or not the base is beside it. */\n        compare: s.id === base?.id ? undefined : base?.id,\n        hint: s.id === base?.id ? \"base\" : undefined,\n        id: s.id,\n        kind: s.id === base?.id ? \"actual\" : \"forecast\",\n        label: s.label,\n        locked: s.id === base?.id,\n        width: 132,\n      })),\n    [scenarios, base],\n  );\n\n  const rows: PeriodRow[] = React.useMemo(\n    () =>\n      measures.map((m, i) => ({\n        id: m.id,\n        label: m.label,\n        note: m.limit ? `${m.limit.kind === \"min\" ? \"floor\" : \"ceiling\"} ${m.limit.label}` : undefined,\n        polarity: m.polarity ?? \"standard\",\n        unit: m.unit ?? \"money\",\n        values: scenarios.map((s) => s.values[i] ?? null),\n      })),\n    [measures, scenarios],\n  );\n\n  /* Every breach, named. Computed from the same arrays the cells draw from. */\n  const breaches = React.useMemo(() => {\n    const out: { scenario: string; measure: string; limit: string }[] = [];\n    measures.forEach((m, i) => {\n      if (!m.limit) return;\n      for (const s of scenarios) {\n        const value = s.values[i];\n        if (value == null) continue;\n        const crossed = m.limit.kind === \"min\" ? value < m.limit.value : value > m.limit.value;\n        if (crossed) out.push({ limit: m.limit.label, measure: m.label, scenario: s.label });\n      }\n    });\n    return out;\n  }, [measures, scenarios]);\n\n  return (\n    <div className={cn(\"space-y-4\", className)}>\n      <PeriodGrid\n        caption={`Scenario comparison at ${periodLabel}`}\n        columns={columns}\n        currency={currency}\n        footnote={\n          <>\n            {periodLabel} · every column is measured against{\" \"}\n            <span className=\"font-medium\">{base?.label}</span>, including in the Δ and Δ% views. Scenarios are\n            not ranked and none of them is the plan until someone says so.\n          </>\n        }\n        labelHeader=\"Measure\"\n        labelWidth={220}\n        maxHeight={420}\n        rows={rows}\n        storageKey={storageKey}\n      />\n\n      <div className=\"grid gap-3 md:grid-cols-2 xl:grid-cols-3\">\n        {scenarios.map((s) => {\n          const isBase = s.id === base?.id;\n          /* The headline delta: the first measure, against the base. It is\n             the one the reader is about to ask for, and it is computed from\n             the same values the grid holds. */\n          const headline = periodChange(\n            s.values[0] ?? null,\n            base?.values[0] ?? null,\n            measures[0]?.polarity ?? \"standard\",\n          );\n\n          return (\n            <div\n              className={cn(\n                \"rounded-xl border bg-surface p-4\",\n                isBase ? \"border-accent-border\" : \"border-border\",\n              )}\n              key={s.id}\n            >\n              <div className=\"flex items-baseline justify-between gap-2\">\n                <h3 className=\"text-title-sm font-medium\">{s.label}</h3>\n                {isBase ? (\n                  <span className=\"rounded-xs bg-accent-subtle px-1.5 py-0.5 text-caption text-[var(--accent)]\">\n                    base\n                  </span>\n                ) : (\n                  <span className=\"flex items-baseline gap-2\">\n                    <Percent\n                      size=\"sm\"\n                      value={\n                        headline.relative == null ? null : { kind: \"relative\", value: headline.relative }\n                      }\n                    />\n                    {headline.absolute != null && (measures[0]?.unit ?? \"money\") === \"money\" ? (\n                      <Money\n                        accounting\n                        size=\"xs\"\n                        tone=\"secondary\"\n                        value={{ amount: headline.absolute, currency }}\n                      />\n                    ) : null}\n                  </span>\n                )}\n              </div>\n              <p className=\"mt-2 text-caption text-text-secondary\">{s.premise}</p>\n              {!isBase && measures[0] ? (\n                <p className=\"mt-2 text-caption text-text-tertiary\">\n                  {measures[0].label}, against {base?.label}.\n                </p>\n              ) : null}\n            </div>\n          );\n        })}\n      </div>\n\n      {breaches.length > 0 ? (\n        <div className=\"rounded-xl border border-negative bg-surface p-4\">\n          <h3 className=\"text-label font-medium\">\n            Limits crossed\n            <InfoTip about=\"Limits crossed\">\n              A floor or ceiling a measure is not allowed to cross — a covenant, a minimum cash balance, a\n              board-mandated ceiling. Named here rather than left as a cell someone has to notice.\n            </InfoTip>\n          </h3>\n          <ul className=\"mt-2 space-y-1 text-body\">\n            {breaches.map((b) => (\n              <li key={`${b.scenario}-${b.measure}`}>\n                <span className=\"font-medium\">{b.scenario}</span> crosses {b.limit} on {b.measure}.\n              </li>\n            ))}\n          </ul>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "path": "components/ledger/scenario-compare.tsx",
      "target": "components/ledger/scenario-compare.tsx",
      "type": "registry:component"
    }
  ],
  "name": "scenario-compare",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/cn",
    "@lopifi/info-tip",
    "@lopifi/money",
    "@lopifi/percent",
    "@lopifi/period-change",
    "@lopifi/period-grid"
  ],
  "title": "ScenarioCompare",
  "type": "registry:component"
}
