@retiregolden/planner-ui 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -130,6 +130,14 @@ The supported product API is:
130
130
  may extend the envelope with their own top-level keys and the file still
131
131
  imports everywhere. The module is browser-free (no IndexedDB/DOM) and safe
132
132
  to run in Node — e.g. an Electron main process assembling backups;
133
+ - the **`./report-model` subpath** — the edition-neutral report data model:
134
+ `ReportModel` and its block types, `buildReportModel`, the stable
135
+ `REPORT_BLOCK_IDS`, `serializeReportModel` (deterministic JSON), and the
136
+ CSV table helpers (`chartDataCsv`, `yearLedgerCsv`, `accountsCsv`). See
137
+ "Report model" under "Hosting the workspace". Like `./plan-format`, its
138
+ exported names, signatures, and block ids only change with a semver-major
139
+ release (new blocks/fields may be added in minors), and the module is
140
+ browser-free and safe to run in Node;
133
141
  - `./index.css`.
134
142
 
135
143
  The exports map also exposes wildcard `./*.ts` subpaths
@@ -238,6 +246,52 @@ export that speaks the same envelope as the web app's backup files:
238
246
  import { serializeV2Backup, parseV2Backup } from '@retiregolden/planner-ui/plan-format'
239
247
  ```
240
248
 
249
+ ### Report model
250
+
251
+ Reports are data before they are documents. The stable `./report-model`
252
+ subpath exposes the edition-neutral `ReportModel`: everything a report needs
253
+ — headline metrics, household snapshot, accounts, income sources,
254
+ assumptions, modeled findings with their evidence, the year-by-year ledger,
255
+ chart series, parameter sources, disclosures, and provenance (parameter pack
256
+ years, data vintage, generation timestamp) — assembled from an
257
+ already-computed projection, independent of any DOM, theme, or layout:
258
+
259
+ ```ts
260
+ import { buildReportModel, serializeReportModel } from '@retiregolden/planner-ui/report-model'
261
+
262
+ const model = buildReportModel({ plan, result, summary, startYear })
263
+ const json = serializeReportModel(model) // deterministic: same input, same bytes
264
+ ```
265
+
266
+ Engine-computed dollar figures are carried as **whole nominal dollars** — the
267
+ precision every report presents, and the same whole-dollar discipline as the
268
+ repo's case-runner manifests. (Raw engine floats can differ in the last digit
269
+ across platforms' math libraries, which would make serialized models and
270
+ golden fixtures machine-dependent.)
271
+
272
+ Every block carries a stable id from `REPORT_BLOCK_IDS`; hosts building their
273
+ own renderers or packet templates should key layout off those ids and warn on
274
+ ids they don't recognize rather than dropping content. The web app's own
275
+ downloaded report is rendered from this same model
276
+ (`buildStandaloneReportHtml` assembles it internally), so a host renderer and
277
+ the standard report can never disagree about the underlying numbers. Golden
278
+ JSON fixtures for the reference cases are committed under
279
+ `src/report/goldens/` and gate changes to the serialized contract.
280
+
281
+ Boundary notes for hosts (see the decision-support posture in the upstream
282
+ repo): the `modeled-findings` block is calculation evidence attributed to the
283
+ user's selected objective — render it as modeled results, not as advice
284
+ authored by the software. The `advisor-recommendations` block is
285
+ host-authored professional content: `buildReportModel` copies it verbatim
286
+ from its input and never populates it on its own, and renderers must keep it
287
+ visibly attributed to the professional. The `disclosures` block and the
288
+ household `incompleteData` flag are caveats a rendering must keep visible:
289
+ `incompleteData` marks a plan that cannot fund spending yet (no income
290
+ sources, nothing funded), and renderers should surface it as a missing-data
291
+ warning instead of presenting depletion as a funded plan's failure — the
292
+ standard report renders this caveat, and the in-app Results page suppresses
293
+ verdict framing for such plans.
294
+
241
295
  ### Report branding
242
296
 
243
297
  Downloaded HTML reports (Results, Report, and Optimizer pages) can carry the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retiregolden/planner-ui",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "The RetireGolden planner React UI: plan picker, planner workspace, results/Monte Carlo/optimizer pages, import wizard, Learning Center, and report export — the full planner tree a host app composes inside its own router. Ships TypeScript source and requires a Vite-class bundler.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -25,6 +25,7 @@
25
25
  "src",
26
26
  "!src/**/*.test.ts",
27
27
  "!src/**/*.test.tsx",
28
+ "!src/report/goldens",
28
29
  "LICENSE",
29
30
  "README.md"
30
31
  ],
@@ -32,6 +33,7 @@
32
33
  "exports": {
33
34
  ".": "./src/index.ts",
34
35
  "./plan-format": "./src/data/planFormat.ts",
36
+ "./report-model": "./src/report/reportModel.ts",
35
37
  "./index.css": "./src/index.css",
36
38
  "./*": "./src/*.ts",
37
39
  "./package.json": "./package.json"
@@ -36,6 +36,7 @@ import { useProjection } from './useProjection'
36
36
  import { BucketLensCard } from './BucketLensCard'
37
37
  import { FundedRatioCard } from './sections/IncomeFloorSection'
38
38
  import { chartTooltipStyle } from './chartStyle'
39
+ import { NonZeroTooltipContent } from './chartTooltip'
39
40
  import { frameH } from './chartFrame'
40
41
  import { useMcSuccessRate } from './useMcSuccessRate'
41
42
 
@@ -116,6 +117,10 @@ const tooltipProps = {
116
117
  wrapperStyle: { zIndex: 2 },
117
118
  } as const
118
119
 
120
+ // Stacked charts (6–8 series) hide ~$0 rows in the tooltip via content, never
121
+ // by nulling zeros in the data — see NonZeroTooltipContent.
122
+ const stackTooltipProps = { ...tooltipProps, content: NonZeroTooltipContent } as const
123
+
119
124
  /** "1,000" — keeps verdict copy in sync if the default path count changes. */
120
125
  const PATH_COUNT_LABEL = DEFAULT_PATH_COUNT.toLocaleString()
121
126
 
@@ -231,14 +236,7 @@ export function ResultsPage() {
231
236
  const nominalFiTarget = view.summary.fiNumber * Math.pow(1 + plan.assumptions.inflationPct / 100, y.year - view.startYear)
232
237
  return {
233
238
  year: y.year,
234
- // Zero balances render as null so empty categories stay out of the
235
- // tooltip (an "HSA: $0" row is noise in a six-series stack).
236
- ...Object.fromEntries(
237
- CATEGORIES.map((c) => {
238
- const v = adj(y.year, cats[c])
239
- return [c, v > 0.5 ? v : null]
240
- }),
241
- ),
239
+ ...Object.fromEntries(CATEGORIES.map((c) => [c, adj(y.year, cats[c])])),
242
240
  income: adj(y.year, y.incomes.total),
243
241
  spending: adj(y.year, y.expenses.total + y.tax + y.penalties),
244
242
  tax: adj(y.year, y.tax),
@@ -251,22 +249,18 @@ export function ResultsPage() {
251
249
  [view, plan, adj],
252
250
  )
253
251
 
254
- // Zero series values render as null so the tooltip (filterNull) skips them —
255
- // a stack of 6–8 series otherwise lists every "$0" row as noise.
256
- const orNull = (v: number) => (v > 0.5 ? v : null)
257
-
258
252
  const incomeRows = useMemo(
259
253
  () =>
260
254
  view.result.years.map((y) => ({
261
255
  year: y.year,
262
- wages: orNull(adj(y.year, y.incomes.wages)),
263
- socialSecurity: orNull(adj(y.year, y.incomes.socialSecurity)),
264
- pension: orNull(adj(y.year, y.incomes.pension)),
265
- annuity: orNull(adj(y.year, y.incomes.annuity)),
266
- tipsLadder: orNull(adj(y.year, y.incomes.tipsLadder)),
267
- recurring: orNull(adj(y.year, y.incomes.recurring)),
268
- oneTime: orNull(adj(y.year, y.incomes.oneTime)),
269
- taxableYield: orNull(adj(y.year, y.incomes.taxableYield)),
256
+ wages: adj(y.year, y.incomes.wages),
257
+ socialSecurity: adj(y.year, y.incomes.socialSecurity),
258
+ pension: adj(y.year, y.incomes.pension),
259
+ annuity: adj(y.year, y.incomes.annuity),
260
+ tipsLadder: adj(y.year, y.incomes.tipsLadder),
261
+ recurring: adj(y.year, y.incomes.recurring),
262
+ oneTime: adj(y.year, y.incomes.oneTime),
263
+ taxableYield: adj(y.year, y.incomes.taxableYield),
270
264
  })),
271
265
  [view, adj],
272
266
  )
@@ -275,14 +269,14 @@ export function ResultsPage() {
275
269
  () =>
276
270
  view.result.years.map((y) => ({
277
271
  year: y.year,
278
- base: orNull(adj(y.year, y.expenses.baseSpending)),
279
- healthcare: orNull(adj(y.year, y.expenses.healthcare)),
280
- property: orNull(adj(y.year, y.expenses.propertyCosts)),
281
- debt: orNull(adj(y.year, y.expenses.debtService)),
282
- insurance: orNull(adj(y.year, y.expenses.insurancePremiums)),
283
- care: orNull(adj(y.year, Math.max(0, y.expenses.careCost - y.expenses.ltcBenefit))),
284
- goals: orNull(adj(y.year, y.expenses.oneTimeGoals)),
285
- taxes: orNull(adj(y.year, y.tax + y.penalties)),
272
+ base: adj(y.year, y.expenses.baseSpending),
273
+ healthcare: adj(y.year, y.expenses.healthcare),
274
+ property: adj(y.year, y.expenses.propertyCosts),
275
+ debt: adj(y.year, y.expenses.debtService),
276
+ insurance: adj(y.year, y.expenses.insurancePremiums),
277
+ care: adj(y.year, Math.max(0, y.expenses.careCost - y.expenses.ltcBenefit)),
278
+ goals: adj(y.year, y.expenses.oneTimeGoals),
279
+ taxes: adj(y.year, y.tax + y.penalties),
286
280
  })),
287
281
  [view, adj],
288
282
  )
@@ -535,7 +529,7 @@ export function ResultsPage() {
535
529
  {view.summary.depletionYear !== null ? (
536
530
  <ReferenceLine x={view.summary.depletionYear} stroke="var(--bad)" strokeDasharray="4 4" label={{ value: 'depleted', fill: 'var(--bad)', fontSize: 12 }} />
537
531
  ) : null}
538
- <Tooltip {...tooltipProps} />
532
+ <Tooltip {...stackTooltipProps} />
539
533
  </AreaChart>
540
534
  </ResponsiveContainer>
541
535
  </div>
@@ -591,7 +585,7 @@ export function ResultsPage() {
591
585
  {INCOME_SOURCES.map((s) => (
592
586
  <Bar key={s.key} dataKey={s.key} stackId="inc" name={s.label} fill={s.color} />
593
587
  ))}
594
- <Tooltip {...tooltipProps} />
588
+ <Tooltip {...stackTooltipProps} />
595
589
  </BarChart>
596
590
  </ResponsiveContainer>
597
591
  </div>
@@ -620,7 +614,7 @@ export function ResultsPage() {
620
614
  {EXPENSE_CATEGORIES.map((c) => (
621
615
  <Bar key={c.key} dataKey={c.key} stackId="exp" name={c.label} fill={c.color} />
622
616
  ))}
623
- <Tooltip {...tooltipProps} />
617
+ <Tooltip {...stackTooltipProps} />
624
618
  </BarChart>
625
619
  </ResponsiveContainer>
626
620
  </div>
@@ -113,7 +113,7 @@ export function SurvivalPercentileModal({ person, personIndex, partner, onApply,
113
113
  <div className="form-grid">
114
114
  <SelectField
115
115
  label="Chance of reaching that age"
116
- help="The survival probability the planning age is anchored to. Planning to the 25th percentile means only a 1-in-4 chance of outliving the plan horizon — the usual prudent recommendation. The median (50%) is a coin flip; 10% is conservative."
116
+ help="The survival probability the planning age is anchored to. Planning to the 25th percentile means only a 1-in-4 chance of outliving the plan horizon — a common prudent convention. The median (50%) is a coin flip; 10% is conservative."
117
117
  value={pct}
118
118
  options={PCT_OPTIONS}
119
119
  onCommit={(v) => setPct(v as '50' | '25' | '10')}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Default tooltip content, minus ~$0 rows. For stacked charts with many series
3
+ * this keeps "HSA: $0" noise out of the tooltip. Filter here, not by nulling
4
+ * zeros in the data: a null in a stacked Area breaks that series' polygon while
5
+ * the series above still ramp their baseline across the gap, punching
6
+ * background-colored wedges into the stack.
7
+ */
8
+
9
+ import { DefaultTooltipContent, type TooltipContentProps } from 'recharts'
10
+
11
+ export function NonZeroTooltipContent(props: TooltipContentProps) {
12
+ const payload = props.payload?.filter((entry) => typeof entry.value === 'number' && entry.value > 0.5)
13
+ // Recharts gates tooltip visibility on the unfiltered payload, so when every
14
+ // series is ~$0 (e.g. post-depletion years) render nothing rather than a
15
+ // floating card holding only the year label.
16
+ if (!payload || payload.length === 0) return null
17
+ return <DefaultTooltipContent {...props} payload={payload} />
18
+ }
@@ -119,7 +119,7 @@ export function InsightCardView({ card, onDismiss }: { card: InsightCard; onDism
119
119
  }
120
120
  }
121
121
  } else {
122
- setPreviewError(`This suggestion can't be applied to your plan: ${applied.issues.join('; ')}`)
122
+ setPreviewError(`This insight can't be applied to your plan: ${applied.issues.join('; ')}`)
123
123
  }
124
124
  }
125
125
  } catch (err) {
@@ -136,7 +136,7 @@ export function InsightCardView({ card, onDismiss }: { card: InsightCard; onDism
136
136
  if (action.kind !== 'preview-scenario') return
137
137
  const applied = applyScenarioPatch(plan, action.patch)
138
138
  if (!applied.ok) {
139
- setPreviewError(`This suggestion can't be applied to your plan: ${applied.issues.join('; ')}`)
139
+ setPreviewError(`This insight can't be applied to your plan: ${applied.issues.join('; ')}`)
140
140
  return
141
141
  }
142
142
  const name = uniqueScenarioName(action.scenarioName, plan)
@@ -198,8 +198,8 @@ export function InsightCardView({ card, onDismiss }: { card: InsightCard; onDism
198
198
  type="button"
199
199
  className="btn-ghost insight-dismiss"
200
200
  onClick={onDismiss}
201
- aria-label="Dismiss this recommendation"
202
- title="Dismiss this recommendation"
201
+ aria-label="Dismiss this insight"
202
+ title="Dismiss this insight"
203
203
  >
204
204
  <svg
205
205
  viewBox="0 0 24 24"
@@ -319,7 +319,7 @@ export function InsightCardView({ card, onDismiss }: { card: InsightCard; onDism
319
319
  </>
320
320
  )}
321
321
  {card.action.kind === 'advisory' && (
322
- <span className="insight-advisory-note">Advisory only</span>
322
+ <span className="insight-advisory-note">Informational only</span>
323
323
  )}
324
324
  {card.action.kind === 'apply-toggle' && (
325
325
  <>
@@ -102,12 +102,13 @@ export function InsightsPage() {
102
102
  <section>
103
103
  <LiveStatus message={liveMessage} />
104
104
  <div className="card">
105
- <h2>Insights & Recommendations</h2>
105
+ <h2>Insights</h2>
106
106
  <p className="card-hint">
107
- RetireGolden scans your plan to identify personalized planning opportunities. These recommendations update live as you edit.
107
+ RetireGolden scans your plan for modeled opportunities worth comparing. These findings update live as you
108
+ edit, and each one can be previewed as a scenario before anything in your plan changes.
108
109
  </p>
109
110
  <div className="callout callout--info">
110
- <strong>Educational only.</strong> These suggestions are for educational purposes based on the rules and assumptions modeled. They do not constitute financial, tax, or legal advice.
111
+ <strong>Educational only.</strong> These findings are for educational purposes based on the rules and assumptions modeled. They do not constitute financial, tax, or legal advice.
111
112
  </div>
112
113
  </div>
113
114
 
@@ -115,7 +116,7 @@ export function InsightsPage() {
115
116
  <div className="card">
116
117
  <h2>Roth & Tax Optimizer</h2>
117
118
  <p className="card-hint">
118
- Compare candidate Roth-conversion schedules on your full year-by-year projection and see the best result found for your plan.
119
+ Compare candidate Roth-conversion schedules on your full year-by-year projection, ranked by the objective you select.
119
120
  </p>
120
121
  <Link to={`/plan/${plan.id}/optimize`} className="btn btn-primary btn-small">
121
122
  Run optimizer →
@@ -126,7 +127,7 @@ export function InsightsPage() {
126
127
  <div className="card">
127
128
  <h2>Social Security Optimizer</h2>
128
129
  <p className="card-hint">
129
- Analyze every claiming age combination side-by-side to find the schedule that maximizes your ending estate.
130
+ Run every claiming-age combination through your full plan and rank them by the objective you choose.
130
131
  </p>
131
132
  <Link to={`/plan/${plan.id}/social-security-analysis`} className="btn btn-secondary btn-small">
132
133
  Run SS sweep →
@@ -41,7 +41,7 @@ export interface ModelPreset {
41
41
  export const MODEL_PRESETS: ReadonlyArray<ModelPreset> = [
42
42
  {
43
43
  id: 'smooth',
44
- label: 'Smooth randomness (recommended)',
44
+ label: 'Smooth randomness (default)',
45
45
  description: 'Each year varies around your expected return, like a bell curve.',
46
46
  kind: 'lognormal',
47
47
  },