@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 +54 -0
- package/package.json +3 -1
- package/src/planner/ResultsPage.tsx +25 -31
- package/src/planner/SurvivalPercentileModal.tsx +1 -1
- package/src/planner/chartTooltip.tsx +18 -0
- package/src/planner/insights/InsightCardView.tsx +5 -5
- package/src/planner/insights/InsightsPage.tsx +6 -5
- package/src/planner/marketModelPicker.ts +1 -1
- package/src/report/reportHtml.ts +174 -226
- package/src/report/reportModel.ts +715 -0
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.
|
|
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
|
-
|
|
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:
|
|
263
|
-
socialSecurity:
|
|
264
|
-
pension:
|
|
265
|
-
annuity:
|
|
266
|
-
tipsLadder:
|
|
267
|
-
recurring:
|
|
268
|
-
oneTime:
|
|
269
|
-
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:
|
|
279
|
-
healthcare:
|
|
280
|
-
property:
|
|
281
|
-
debt:
|
|
282
|
-
insurance:
|
|
283
|
-
care:
|
|
284
|
-
goals:
|
|
285
|
-
taxes:
|
|
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 {...
|
|
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 {...
|
|
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 {...
|
|
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 —
|
|
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
|
|
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
|
|
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
|
|
202
|
-
title="Dismiss this
|
|
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">
|
|
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
|
|
105
|
+
<h2>Insights</h2>
|
|
106
106
|
<p className="card-hint">
|
|
107
|
-
RetireGolden scans your plan
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
},
|