@retiregolden/planner-ui 0.1.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 +177 -6
- package/package.json +4 -1
- package/src/App.tsx +24 -76
- package/src/data/PlanStoreProvider.tsx +13 -0
- package/src/data/planFormat.ts +89 -0
- package/src/data/planStore.ts +65 -16
- package/src/data/planStoreContext.ts +138 -0
- package/src/data/v2Backup.ts +21 -70
- package/src/import/ImportPage.tsx +3 -2
- package/src/index.ts +23 -0
- package/src/planner/ComparePlansPage.tsx +6 -5
- package/src/planner/PlanContext.tsx +7 -6
- package/src/planner/PlanWorkspace.tsx +3 -2
- package/src/planner/ResultsPage.tsx +25 -31
- package/src/planner/SurvivalPercentileModal.tsx +1 -1
- package/src/planner/chartTooltip.tsx +18 -0
- package/src/planner/examples/ExampleLibrary.tsx +5 -1
- package/src/planner/examples/ExamplePreviewBanner.tsx +3 -1
- package/src/planner/examples/loadExample.ts +35 -3
- package/src/planner/home/DataAndPrivacyCard.tsx +1 -1
- package/src/planner/home/YourPlans.tsx +1 -1
- package/src/planner/home/useHomeData.ts +29 -18
- package/src/planner/home/useHomeMode.ts +1 -1
- 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/ReportBrandingProvider.tsx +14 -0
- package/src/report/reportHtml.ts +174 -226
- package/src/report/reportModel.ts +715 -0
- package/src/routes/groups.tsx +75 -0
- package/src/routes/lazyPages.tsx +18 -0
package/src/report/reportHtml.ts
CHANGED
|
@@ -1,46 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone HTML rendering of the edition-neutral report model.
|
|
3
|
+
*
|
|
4
|
+
* `buildStandaloneReportHtml` assembles a `ReportModel` (see ./reportModel)
|
|
5
|
+
* and renders it as the self-contained, no-script HTML report the web app
|
|
6
|
+
* downloads. All report data comes from the model; this module only formats
|
|
7
|
+
* and lays it out. Optional host branding is a render-time concern and never
|
|
8
|
+
* enters the model.
|
|
9
|
+
*/
|
|
10
|
+
|
|
1
11
|
import { objectivePolicies } from '@retiregolden/engine/decisions'
|
|
2
|
-
import type {
|
|
3
|
-
import {
|
|
4
|
-
LATEST_PACK_YEAR,
|
|
5
|
-
PARAMETER_DATA_AS_OF,
|
|
6
|
-
PARAMETER_DATA_BASIS,
|
|
7
|
-
PARAMETER_PROVENANCE,
|
|
8
|
-
} from '@retiregolden/engine/params'
|
|
9
|
-
import { LATEST_STATE_PACK_YEAR } from '@retiregolden/engine/params/state'
|
|
12
|
+
import type { Plan } from '@retiregolden/engine/model/plan'
|
|
10
13
|
import type { ProjectionSummary } from '@retiregolden/engine/projection/compare'
|
|
11
|
-
import type { ProjectionResult
|
|
14
|
+
import type { ProjectionResult } from '@retiregolden/engine/projection/types'
|
|
12
15
|
import type { ExactLedgerTournament, ExactLedgerValidation } from '@retiregolden/engine/projection/optimizePlan'
|
|
13
16
|
import type { OptimizeResult } from '../optimize/messages'
|
|
14
17
|
import { fmtMoney } from '../planner/format'
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
currentClaimExactEstate: number
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface ReportRecommendationEvidence {
|
|
34
|
-
objectiveId: string
|
|
35
|
-
objectiveLabel: string
|
|
36
|
-
recommendationState: string
|
|
37
|
-
winnerLabel: string
|
|
38
|
-
winnerSource: string
|
|
39
|
-
validation: ExactLedgerValidation | null
|
|
40
|
-
candidates: ReportDecisionCandidateRow[]
|
|
41
|
-
/** Non-null only when claim-age co-optimization ran (Step 5). */
|
|
42
|
-
claimAge: ReportClaimAgeEvidence | null
|
|
43
|
-
}
|
|
18
|
+
import {
|
|
19
|
+
buildReportModel,
|
|
20
|
+
chartDataCsv,
|
|
21
|
+
type ReportAdvisorRecommendationsBlock,
|
|
22
|
+
type ReportModel,
|
|
23
|
+
type ReportRecommendationEvidence,
|
|
24
|
+
type ReportValidationEvidence,
|
|
25
|
+
} from './reportModel'
|
|
26
|
+
|
|
27
|
+
export type {
|
|
28
|
+
ReportClaimAgeEvidence,
|
|
29
|
+
ReportDecisionCandidateRow,
|
|
30
|
+
ReportRecommendationEvidence,
|
|
31
|
+
ReportValidationEvidence,
|
|
32
|
+
} from './reportModel'
|
|
44
33
|
|
|
45
34
|
/**
|
|
46
35
|
* Host-supplied identity for downloaded reports — a generic composition hook
|
|
@@ -130,21 +119,6 @@ function safeLogoDataUri(value: string | undefined): string | null {
|
|
|
130
119
|
return /^data:image\/(png|jpeg|gif|webp|svg\+xml|avif);base64,[a-zA-Z0-9+/=]+$/.test(trimmed) ? trimmed : null
|
|
131
120
|
}
|
|
132
121
|
|
|
133
|
-
const CATEGORIES = ['cash', 'taxable', 'equityComp', 'traditional', 'roth', 'hsa'] as const
|
|
134
|
-
|
|
135
|
-
const ACCOUNT_LABEL: Record<Account['type'], string> = {
|
|
136
|
-
annuity: 'Annuity',
|
|
137
|
-
cash: 'Cash',
|
|
138
|
-
debt: 'Debt',
|
|
139
|
-
equityComp: 'Equity comp',
|
|
140
|
-
hsa: 'HSA',
|
|
141
|
-
pension: 'Pension',
|
|
142
|
-
property: 'Property',
|
|
143
|
-
roth: 'Roth',
|
|
144
|
-
taxable: 'Brokerage',
|
|
145
|
-
traditional: 'Traditional',
|
|
146
|
-
}
|
|
147
|
-
|
|
148
122
|
function escapeHtml(value: unknown): string {
|
|
149
123
|
return String(value)
|
|
150
124
|
.replace(/&/g, '&')
|
|
@@ -165,83 +139,6 @@ function dateLabel(iso: string): string {
|
|
|
165
139
|
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
|
|
166
140
|
}
|
|
167
141
|
|
|
168
|
-
function ownerName(plan: Plan, ownerPersonId: string | null): string {
|
|
169
|
-
if (ownerPersonId === null) return 'Joint'
|
|
170
|
-
return plan.household.people.find((p) => p.id === ownerPersonId)?.name ?? '-'
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function accountBalance(a: Account): number {
|
|
174
|
-
if ('balance' in a) return a.balance
|
|
175
|
-
if (a.type === 'property') return a.value
|
|
176
|
-
return 0
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function incomeLabel(plan: Plan, s: IncomeStream): string {
|
|
180
|
-
if (s.type === 'wages') return `Wages - ${ownerName(plan, s.personId)}`
|
|
181
|
-
if (s.type === 'socialSecurity') return `Social Security - ${ownerName(plan, s.personId)}`
|
|
182
|
-
return s.label
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function incomeDetail(s: IncomeStream): string {
|
|
186
|
-
switch (s.type) {
|
|
187
|
-
case 'wages':
|
|
188
|
-
return `${fmtMoney(s.annualGross)}/yr until ${s.endAge ?? 'retirement'}`
|
|
189
|
-
case 'socialSecurity':
|
|
190
|
-
return `${s.piaMonthly !== null ? `${fmtMoney(s.piaMonthly)}/mo PIA` : 'from earnings record'}, claim ${s.claimAge.years}y${s.claimAge.months ? ` ${s.claimAge.months}m` : ''}`
|
|
191
|
-
case 'recurring':
|
|
192
|
-
return `${fmtMoney(s.annualAmount)}/yr${s.inflationAdjusted ? ', inflation-adjusted' : ''}`
|
|
193
|
-
case 'oneTime':
|
|
194
|
-
return `${fmtMoney(s.amount)} in ${s.year}`
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function conversionSummary(plan: Plan): string {
|
|
199
|
-
const rc = plan.strategies.rothConversion
|
|
200
|
-
if (rc.mode === 'none') return 'None'
|
|
201
|
-
if (rc.mode === 'manual') return `Manual - ${rc.conversions.length} year(s)`
|
|
202
|
-
if (rc.mode === 'optimized') return `Optimized - ${rc.conversions.length} year(s)`
|
|
203
|
-
return `Fill to ${rc.target}${rc.targetValue !== null ? ` (${rc.targetValue})` : ''}, ${rc.startYear}-${rc.endYear}`
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function withdrawalSummary(plan: Plan): string {
|
|
207
|
-
const w = plan.strategies.withdrawalOrder
|
|
208
|
-
if (w.mode === 'sequential') return 'Sequential: cash, taxable, equity comp, traditional, Roth, HSA'
|
|
209
|
-
if (w.mode === 'proportional') return 'Proportional across accounts'
|
|
210
|
-
return `Bracket-targeted to ${w.bracketPct}%`
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function spendingPolicySummary(plan: Plan): string {
|
|
214
|
-
const policy = plan.expenses.spendingPolicy
|
|
215
|
-
if (!policy || policy.mode === 'fixedTarget') return 'Fixed target (no guardrails)'
|
|
216
|
-
if (policy.mode === 'withdrawalRateGuardrails') {
|
|
217
|
-
return `Withdrawal-rate guardrails: cut above ${policy.upperGuardrailPct ?? 120}% / restore below ${policy.lowerGuardrailPct ?? 80}% of the starting rate, ${policy.adjustmentPct ?? 10}% steps`
|
|
218
|
-
}
|
|
219
|
-
if (policy.mode === 'abw') {
|
|
220
|
-
const source =
|
|
221
|
-
policy.abw?.returnSource === 'cape'
|
|
222
|
-
? `CAPE ${policy.abw?.startingCape ?? 25} earnings yield`
|
|
223
|
-
: policy.abw?.returnSource === 'tips'
|
|
224
|
-
? `TIPS real yield ${policy.abw?.bondRealYieldPct ?? 2}%`
|
|
225
|
-
: `fixed ${policy.abw?.fixedRealReturnPct ?? 3.8}% real`
|
|
226
|
-
const horizon =
|
|
227
|
-
policy.abw?.horizon === 'survival25'
|
|
228
|
-
? '25% survival age'
|
|
229
|
-
: policy.abw?.horizon === 'survival10'
|
|
230
|
-
? '10% survival age'
|
|
231
|
-
: 'planning age'
|
|
232
|
-
return `Amortized spending (ABW): ${source}, to ${horizon}, tilt ${policy.abw?.tiltPct ?? 0}%/yr`
|
|
233
|
-
}
|
|
234
|
-
const lower =
|
|
235
|
-
policy.lowerBalanceThresholdPct !== undefined
|
|
236
|
-
? `cut below ${policy.lowerBalanceThresholdPct.toFixed(0)}%`
|
|
237
|
-
: 'no cut threshold'
|
|
238
|
-
const upper =
|
|
239
|
-
policy.upperBalanceThresholdPct !== undefined
|
|
240
|
-
? `raise above ${policy.upperBalanceThresholdPct.toFixed(0)}%`
|
|
241
|
-
: 'no raise threshold'
|
|
242
|
-
return `Risk-based guardrails (${policy.targetSuccessLowerPct ?? 70}-${policy.targetSuccessUpperPct ?? 95}% success band): ${lower} / ${upper} of the starting portfolio, ${policy.adjustmentPct ?? 10}% steps`
|
|
243
|
-
}
|
|
244
|
-
|
|
245
142
|
function table(headers: string[], rows: string[][]): string {
|
|
246
143
|
return [
|
|
247
144
|
'<table>',
|
|
@@ -251,78 +148,92 @@ function table(headers: string[], rows: string[][]): string {
|
|
|
251
148
|
].join('')
|
|
252
149
|
}
|
|
253
150
|
|
|
254
|
-
function headlineSection(
|
|
255
|
-
const
|
|
151
|
+
function headlineSection(model: ReportModel): string {
|
|
152
|
+
const headline = model.blocks['headline-results']
|
|
153
|
+
// An unfunded plan's depletion year is a fact about missing data, not a
|
|
154
|
+
// funded plan's failure — qualify it so the headline can't read as doom.
|
|
155
|
+
const lasts =
|
|
156
|
+
headline.depletionYear === null
|
|
157
|
+
? `Full plan through ${model.endYear}`
|
|
158
|
+
: model.blocks['household'].incompleteData
|
|
159
|
+
? `Depletes in ${headline.depletionYear} (plan setup incomplete - see missing-data note)`
|
|
160
|
+
: `Depletes in ${headline.depletionYear}`
|
|
256
161
|
const rows = [
|
|
257
|
-
['Ending net worth', fmtMoney(
|
|
258
|
-
['Ending after-tax estate', fmtMoney(
|
|
162
|
+
['Ending net worth', fmtMoney(headline.endingNetWorth), `in ${model.endYear}`],
|
|
163
|
+
['Ending after-tax estate', fmtMoney(headline.endingAfterTaxEstate), 'net of heir tax on pre-tax balances'],
|
|
259
164
|
['Money lasts', escapeHtml(lasts), 'deterministic exact-ledger run'],
|
|
260
|
-
['Lifetime tax + penalties', fmtMoney(
|
|
261
|
-
['Lifetime Roth conversions', fmtMoney(
|
|
262
|
-
['FI target', fmtMoney(
|
|
165
|
+
['Lifetime tax + penalties', fmtMoney(headline.lifetimeTaxesAndPenalties), 'federal + state + penalties'],
|
|
166
|
+
['Lifetime Roth conversions', fmtMoney(headline.lifetimeRothConversions), 'executed by the ledger'],
|
|
167
|
+
['FI target', fmtMoney(headline.fiNumber), 'today-dollar portfolio target'],
|
|
263
168
|
]
|
|
264
169
|
return `<section><h2>Headline results</h2>${table(['Metric', 'Value', 'Notes'], rows)}</section>`
|
|
265
170
|
}
|
|
266
171
|
|
|
267
|
-
function householdSection(
|
|
268
|
-
|
|
172
|
+
function householdSection(model: ReportModel): string {
|
|
173
|
+
const incompleteNote = model.blocks['household'].incompleteData
|
|
174
|
+
? '<p class="muted">No income sources or funded accounts yet - the household setup is incomplete.</p>'
|
|
175
|
+
: ''
|
|
176
|
+
return `<section><h2>Household</h2>${incompleteNote}${table(
|
|
269
177
|
['Person', 'Date of birth', 'Retirement age', 'Planning age'],
|
|
270
|
-
|
|
178
|
+
model.blocks['household'].people.map((p) => [
|
|
271
179
|
escapeHtml(p.name),
|
|
272
180
|
escapeHtml(p.dob),
|
|
273
181
|
escapeHtml(p.retirementAge ?? '-'),
|
|
274
|
-
escapeHtml(p.
|
|
182
|
+
escapeHtml(p.planningAge),
|
|
275
183
|
]),
|
|
276
184
|
)}</section>`
|
|
277
185
|
}
|
|
278
186
|
|
|
279
|
-
function accountsSection(
|
|
280
|
-
const rows =
|
|
187
|
+
function accountsSection(model: ReportModel): string {
|
|
188
|
+
const rows = model.blocks['accounts'].rows.map((a) => [
|
|
281
189
|
escapeHtml(a.name),
|
|
282
|
-
escapeHtml(
|
|
283
|
-
escapeHtml(
|
|
284
|
-
fmtMoney(
|
|
285
|
-
escapeHtml(
|
|
190
|
+
escapeHtml(a.typeLabel),
|
|
191
|
+
escapeHtml(a.owner),
|
|
192
|
+
fmtMoney(a.balance),
|
|
193
|
+
escapeHtml(a.annualReturnPct !== null ? `${a.annualReturnPct}%` : 'default'),
|
|
286
194
|
])
|
|
287
195
|
return `<section><h2>Accounts</h2>${table(['Account', 'Type', 'Owner', 'Balance', 'Return'], rows)}</section>`
|
|
288
196
|
}
|
|
289
197
|
|
|
290
|
-
function incomeSection(
|
|
198
|
+
function incomeSection(model: ReportModel): string {
|
|
291
199
|
return `<section><h2>Income</h2>${table(
|
|
292
200
|
['Source', 'Detail'],
|
|
293
|
-
|
|
201
|
+
model.blocks['income-sources'].rows.map((stream) => [escapeHtml(stream.label), escapeHtml(stream.detail)]),
|
|
294
202
|
)}</section>`
|
|
295
203
|
}
|
|
296
204
|
|
|
297
|
-
function assumptionsSection(
|
|
205
|
+
function assumptionsSection(model: ReportModel): string {
|
|
206
|
+
const assumptions = model.blocks['assumptions']
|
|
207
|
+
const provenance = model.provenance
|
|
298
208
|
const rows = [
|
|
299
|
-
['General inflation', fmtPct(
|
|
300
|
-
['Healthcare extra inflation', fmtPct(
|
|
301
|
-
['Default return', fmtPct(
|
|
302
|
-
['Safe withdrawal rate', fmtPct(
|
|
303
|
-
['State effective tax override', fmtPct(
|
|
304
|
-
['Local income tax', fmtPct(
|
|
305
|
-
['Heir tax rate', fmtPct(
|
|
306
|
-
['Roth conversions', escapeHtml(
|
|
307
|
-
['Withdrawal order', escapeHtml(
|
|
308
|
-
['Spending policy', escapeHtml(spendingPolicySummary
|
|
309
|
-
['Federal parameter pack', `${
|
|
310
|
-
['State parameter pack', `${
|
|
311
|
-
['Parameter data as of', escapeHtml(
|
|
312
|
-
['Parameter data basis', escapeHtml(
|
|
209
|
+
['General inflation', fmtPct(assumptions.inflationPct)],
|
|
210
|
+
['Healthcare extra inflation', fmtPct(assumptions.healthcareExtraInflationPct)],
|
|
211
|
+
['Default return', fmtPct(assumptions.defaultReturnPct)],
|
|
212
|
+
['Safe withdrawal rate', fmtPct(assumptions.safeWithdrawalRatePct)],
|
|
213
|
+
['State effective tax override', fmtPct(assumptions.stateEffectiveTaxPct)],
|
|
214
|
+
['Local income tax', fmtPct(assumptions.localIncomeTaxPct)],
|
|
215
|
+
['Heir tax rate', fmtPct(assumptions.heirTaxRatePct, 0)],
|
|
216
|
+
['Roth conversions', escapeHtml(assumptions.rothConversionSummary)],
|
|
217
|
+
['Withdrawal order', escapeHtml(assumptions.withdrawalOrderSummary)],
|
|
218
|
+
['Spending policy', escapeHtml(assumptions.spendingPolicySummary)],
|
|
219
|
+
['Federal parameter pack', `${provenance.federalParameterPackYear}`],
|
|
220
|
+
['State parameter pack', `${provenance.stateParameterPackYear}`],
|
|
221
|
+
['Parameter data as of', escapeHtml(provenance.parameterDataAsOf)],
|
|
222
|
+
['Parameter data basis', escapeHtml(provenance.parameterDataBasis)],
|
|
313
223
|
]
|
|
314
224
|
return `<section><h2>Assumptions and provenance</h2>${table(['Assumption', 'Value'], rows)}</section>`
|
|
315
225
|
}
|
|
316
226
|
|
|
317
|
-
function warningsSection(
|
|
318
|
-
|
|
319
|
-
|
|
227
|
+
function warningsSection(model: ReportModel): string {
|
|
228
|
+
const warnings = model.blocks['modeling-notes'].warnings
|
|
229
|
+
if (warnings.length === 0) return ''
|
|
230
|
+
return `<section><h2>Modeling notes</h2><ul>${warnings.map((w) => `<li>${escapeHtml(w)}</li>`).join('')}</ul></section>`
|
|
320
231
|
}
|
|
321
232
|
|
|
322
|
-
function provenanceSection(): string {
|
|
233
|
+
function provenanceSection(model: ReportModel): string {
|
|
323
234
|
return `<section><h2>Parameter source appendix</h2>${table(
|
|
324
235
|
['Group', 'Figures', 'Publisher', 'Source'],
|
|
325
|
-
|
|
236
|
+
model.blocks['parameter-sources'].sources.map((source) => [
|
|
326
237
|
escapeHtml(source.label),
|
|
327
238
|
escapeHtml(source.figures),
|
|
328
239
|
escapeHtml(source.publisher),
|
|
@@ -331,7 +242,7 @@ function provenanceSection(): string {
|
|
|
331
242
|
)}</section>`
|
|
332
243
|
}
|
|
333
244
|
|
|
334
|
-
function recommendationSection(evidence: ReportRecommendationEvidence | null
|
|
245
|
+
function recommendationSection(evidence: ReportRecommendationEvidence | null): string {
|
|
335
246
|
if (!evidence) return ''
|
|
336
247
|
const validation = evidence.validation
|
|
337
248
|
const summaryRows = [
|
|
@@ -342,8 +253,8 @@ function recommendationSection(evidence: ReportRecommendationEvidence | null | u
|
|
|
342
253
|
]
|
|
343
254
|
if (validation) {
|
|
344
255
|
summaryRows.push(
|
|
345
|
-
['Baseline after-tax estate', fmtMoney(validation.
|
|
346
|
-
['Candidate after-tax estate', fmtMoney(validation.
|
|
256
|
+
['Baseline after-tax estate', fmtMoney(validation.baselineAfterTaxEstate)],
|
|
257
|
+
['Candidate after-tax estate', fmtMoney(validation.candidateAfterTaxEstate)],
|
|
347
258
|
['After-tax estate delta', fmtSignedMoney(validation.afterTaxEstateDelta)],
|
|
348
259
|
['Lifetime tax delta', fmtSignedMoney(validation.lifetimeTaxDelta)],
|
|
349
260
|
['Money-lasts delta', `${validation.moneyLastsYearsDelta > 0 ? '+' : ''}${validation.moneyLastsYearsDelta} year(s)`],
|
|
@@ -385,51 +296,49 @@ function recommendationSection(evidence: ReportRecommendationEvidence | null | u
|
|
|
385
296
|
}</section>`
|
|
386
297
|
}
|
|
387
298
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
299
|
+
/**
|
|
300
|
+
* Advisor-authored content, rendered as a separately attributed block. The
|
|
301
|
+
* model carries this only when the host supplied it (the web app never does);
|
|
302
|
+
* rendering it here keeps host-authored content from silently disappearing.
|
|
303
|
+
*/
|
|
304
|
+
function advisorSection(block: ReportAdvisorRecommendationsBlock | null): string {
|
|
305
|
+
if (!block || block.entries.length === 0) return ''
|
|
306
|
+
const entries = block.entries
|
|
307
|
+
.map((entry) => {
|
|
308
|
+
const adopted = entry.adoptedAtIso ? ` - adopted ${escapeHtml(dateLabel(entry.adoptedAtIso))}` : ''
|
|
309
|
+
return `<h3>${escapeHtml(entry.heading)}</h3><p>${escapeHtml(entry.body)}</p><p class="muted">Authored by ${escapeHtml(entry.authoredBy)}${adopted}</p>`
|
|
310
|
+
})
|
|
311
|
+
.join('')
|
|
312
|
+
return `<section><h2>Advisor recommendations</h2><p class="muted">Professional judgment authored by the advisor named below - not RetireGolden output.</p>${entries}</section>`
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function appendixSection(model: ReportModel): string {
|
|
402
316
|
return `<section><h2>Year-by-year ledger appendix</h2>${table(
|
|
403
317
|
['Year', 'Income', 'Expenses', 'Contrib.', 'RMD', 'Roth conv.', 'Tax + penalties', 'MAGI', 'Withdrawals', 'Investable', 'Net worth'],
|
|
404
|
-
|
|
318
|
+
model.blocks['year-ledger'].rows.map((y) => [
|
|
319
|
+
`${y.year}`,
|
|
320
|
+
fmtMoney(y.income),
|
|
321
|
+
fmtMoney(y.expenses),
|
|
322
|
+
fmtMoney(y.contributions),
|
|
323
|
+
fmtMoney(y.rmd),
|
|
324
|
+
fmtMoney(y.rothConversion),
|
|
325
|
+
fmtMoney(y.taxAndPenalties),
|
|
326
|
+
fmtMoney(y.magi),
|
|
327
|
+
fmtMoney(y.withdrawals),
|
|
328
|
+
fmtMoney(y.investable),
|
|
329
|
+
fmtMoney(y.netWorth),
|
|
330
|
+
]),
|
|
405
331
|
)}</section>`
|
|
406
332
|
}
|
|
407
333
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
return [
|
|
417
|
-
year.year,
|
|
418
|
-
...CATEGORIES.map((category) => Math.round(categories[category])),
|
|
419
|
-
Math.round(year.incomes.total),
|
|
420
|
-
Math.round(year.expenses.total + year.tax + year.penalties),
|
|
421
|
-
].join(',')
|
|
422
|
-
})
|
|
423
|
-
return [
|
|
424
|
-
['year', ...CATEGORIES, 'income', 'spendingPlusTax'].join(','),
|
|
425
|
-
...rows,
|
|
426
|
-
].join('\n')
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
export function buildStandaloneReportHtml(input: StandaloneReportInput): string {
|
|
430
|
-
const preparedAtIso = input.preparedAtIso ?? new Date().toISOString()
|
|
431
|
-
const csvData = escapeHtml(balanceChartData(input.plan, input.result))
|
|
432
|
-
const branding = input.branding ?? {}
|
|
334
|
+
/**
|
|
335
|
+
* Render the standalone, self-contained HTML report from a report model.
|
|
336
|
+
* `buildStandaloneReportHtml` is the assembling wrapper; hosts that already
|
|
337
|
+
* hold a `ReportModel` (e.g. one they persisted) can render it directly.
|
|
338
|
+
*/
|
|
339
|
+
export function renderStandaloneReportHtml(model: ReportModel, brandingInput?: ReportBranding | null): string {
|
|
340
|
+
const csvData = escapeHtml(chartDataCsv(model.blocks['chart-data']))
|
|
341
|
+
const branding = brandingInput ?? {}
|
|
433
342
|
const productName = branding.productName?.trim() || DEFAULT_PRODUCT_NAME
|
|
434
343
|
const accentColor = safeCssColor(branding.accentColor)
|
|
435
344
|
const logoDataUri = safeLogoDataUri(branding.logoDataUri)
|
|
@@ -439,12 +348,20 @@ export function buildStandaloneReportHtml(input: StandaloneReportInput): string
|
|
|
439
348
|
const logoCss = logoDataUri ? '.report-logo { display: block; max-height: 56px; max-width: 280px; margin-bottom: 12px; }\n' : ''
|
|
440
349
|
const footerNote = branding.footerNote?.trim()
|
|
441
350
|
const footerNoteHtml = footerNote ? `\n<p class="muted">${escapeHtml(footerNote)}</p>` : ''
|
|
351
|
+
const disclosures = model.blocks['disclosures'].statements
|
|
352
|
+
.map((statement) => `<p class="disclaimer">${escapeHtml(statement)}</p>`)
|
|
353
|
+
.join('\n')
|
|
354
|
+
// Missing-data caveat (non-removable, per the model contract): an
|
|
355
|
+
// incomplete plan must not present its projection as a funded outcome.
|
|
356
|
+
const incompleteNote = model.blocks['household'].incompleteData
|
|
357
|
+
? '\n<p class="disclaimer">Missing data: this plan has no income sources and no funded accounts, so the projection reflects an incomplete setup rather than a funded plan\'s outlook.</p>'
|
|
358
|
+
: ''
|
|
442
359
|
return `<!doctype html>
|
|
443
360
|
<html lang="en">
|
|
444
361
|
<head>
|
|
445
362
|
<meta charset="utf-8">
|
|
446
363
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
447
|
-
<title>${escapeHtml(
|
|
364
|
+
<title>${escapeHtml(model.planName)} - ${escapeHtml(productName)} report</title>
|
|
448
365
|
<style>
|
|
449
366
|
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #12342b; background: #f7fbf8; }
|
|
450
367
|
body { margin: 0; }
|
|
@@ -468,19 +385,19 @@ pre { background: #f0f5f2; border: 1px solid #dceae3; border-radius: 8px; max-he
|
|
|
468
385
|
<body>
|
|
469
386
|
<main>
|
|
470
387
|
<header>
|
|
471
|
-
${logoHtml}<h1>${escapeHtml(
|
|
472
|
-
<p class="muted">${escapeHtml(productName)} self-contained HTML report prepared ${escapeHtml(dateLabel(
|
|
388
|
+
${logoHtml}<h1>${escapeHtml(model.planName)}</h1>
|
|
389
|
+
<p class="muted">${escapeHtml(productName)} self-contained HTML report prepared ${escapeHtml(dateLabel(model.generatedAtIso))}. Projection start year: ${model.startYear}.</p>
|
|
473
390
|
</header>
|
|
474
|
-
|
|
475
|
-
${headlineSection(
|
|
476
|
-
${recommendationSection(
|
|
477
|
-
${householdSection(
|
|
478
|
-
${accountsSection(
|
|
479
|
-
${incomeSection(
|
|
480
|
-
${assumptionsSection(
|
|
481
|
-
${warningsSection(
|
|
482
|
-
${appendixSection(
|
|
483
|
-
${provenanceSection()}
|
|
391
|
+
${disclosures}${incompleteNote}
|
|
392
|
+
${headlineSection(model)}
|
|
393
|
+
${recommendationSection(model.blocks['modeled-findings'])}${advisorSection(model.blocks['advisor-recommendations'])}
|
|
394
|
+
${householdSection(model)}
|
|
395
|
+
${accountsSection(model)}
|
|
396
|
+
${incomeSection(model)}
|
|
397
|
+
${assumptionsSection(model)}
|
|
398
|
+
${warningsSection(model)}
|
|
399
|
+
${appendixSection(model)}
|
|
400
|
+
${provenanceSection(model)}
|
|
484
401
|
<details>
|
|
485
402
|
<summary>Embedded chart/automation data CSV</summary>
|
|
486
403
|
<pre>${csvData}</pre>
|
|
@@ -490,6 +407,18 @@ ${provenanceSection()}
|
|
|
490
407
|
</html>`
|
|
491
408
|
}
|
|
492
409
|
|
|
410
|
+
export function buildStandaloneReportHtml(input: StandaloneReportInput): string {
|
|
411
|
+
const model = buildReportModel({
|
|
412
|
+
plan: input.plan,
|
|
413
|
+
result: input.result,
|
|
414
|
+
summary: input.summary,
|
|
415
|
+
startYear: input.startYear,
|
|
416
|
+
generatedAtIso: input.preparedAtIso,
|
|
417
|
+
modeledFindings: input.recommendationEvidence,
|
|
418
|
+
})
|
|
419
|
+
return renderStandaloneReportHtml(model, input.branding)
|
|
420
|
+
}
|
|
421
|
+
|
|
493
422
|
function lossReasonForCandidate(
|
|
494
423
|
tournament: ExactLedgerTournament,
|
|
495
424
|
validation: ExactLedgerValidation | null,
|
|
@@ -504,6 +433,25 @@ function lossReasonForCandidate(
|
|
|
504
433
|
return 'Not selected under the active objective and guardrails.'
|
|
505
434
|
}
|
|
506
435
|
|
|
436
|
+
/** Restate the engine's validation as the flat figures the report model carries. */
|
|
437
|
+
function validationEvidence(validation: ExactLedgerValidation | null): ReportValidationEvidence | null {
|
|
438
|
+
if (!validation) return null
|
|
439
|
+
return {
|
|
440
|
+
baselineAfterTaxEstate: validation.baseline.endingAfterTaxEstate,
|
|
441
|
+
candidateAfterTaxEstate: validation.candidate.endingAfterTaxEstate,
|
|
442
|
+
afterTaxEstateDelta: validation.afterTaxEstateDelta,
|
|
443
|
+
endingNetWorthDelta: validation.endingNetWorthDelta,
|
|
444
|
+
lifetimeTaxDelta: validation.lifetimeTaxDelta,
|
|
445
|
+
moneyLastsYearsDelta: validation.moneyLastsYearsDelta,
|
|
446
|
+
requestedConversionTotal: validation.requestedConversionTotal,
|
|
447
|
+
executedConversionTotal: validation.executedConversionTotal,
|
|
448
|
+
executedConversionRatio: validation.executedConversionRatio,
|
|
449
|
+
firstMateriallyUnexecutedYear: validation.firstMateriallyUnexecutedYear,
|
|
450
|
+
traditionalDepletionYear: validation.traditionalDepletionYear,
|
|
451
|
+
recommendationState: validation.recommendationState,
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
507
455
|
export function reportEvidenceFromOptimizeResult(result: OptimizeResult): ReportRecommendationEvidence {
|
|
508
456
|
const tournament = result.tournament
|
|
509
457
|
// Only the winning source's validation belongs on the report. winnerValidation is
|
|
@@ -526,7 +474,7 @@ export function reportEvidenceFromOptimizeResult(result: OptimizeResult): Report
|
|
|
526
474
|
recommendationState,
|
|
527
475
|
winnerLabel,
|
|
528
476
|
winnerSource: tournament.winnerSource,
|
|
529
|
-
validation,
|
|
477
|
+
validation: validationEvidence(validation),
|
|
530
478
|
candidates: tournament.candidates.map((candidate) => ({
|
|
531
479
|
afterTaxEstateDelta: candidate.afterTaxEstateDelta,
|
|
532
480
|
candidateId: candidate.id,
|