@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.
@@ -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 { Account, IncomeStream, Plan } from '@retiregolden/engine/model/plan'
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, YearResult } from '@retiregolden/engine/projection/types'
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
- export interface ReportDecisionCandidateRow {
17
- afterTaxEstateDelta: number
18
- candidateId: string
19
- label: string
20
- lifetimeTaxDelta: number
21
- lossReason: string
22
- moneyLastsYearsDelta: number
23
- }
24
-
25
- export interface ReportClaimAgeEvidence {
26
- combinationsEvaluated: number
27
- /** Null when the current claim ages won the joint grid. */
28
- winningClaimLabel: string | null
29
- jointExactEstate: number
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, '&amp;')
@@ -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(summary: ProjectionSummary, result: ProjectionResult): string {
255
- const lasts = summary.depletionYear === null ? `Full plan through ${result.endYear}` : `Depletes in ${summary.depletionYear}`
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(summary.endingNetWorth), `in ${result.endYear}`],
258
- ['Ending after-tax estate', fmtMoney(summary.endingAfterTaxEstate), 'net of heir tax on pre-tax balances'],
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(summary.lifetimeTaxesAndPenalties), 'federal + state + penalties'],
261
- ['Lifetime Roth conversions', fmtMoney(summary.lifetimeRothConversions), 'executed by the ledger'],
262
- ['FI target', fmtMoney(summary.fiNumber), 'today-dollar portfolio target'],
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(plan: Plan): string {
268
- return `<section><h2>Household</h2>${table(
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
- plan.household.people.map((p) => [
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.longevity.planningAge),
182
+ escapeHtml(p.planningAge),
275
183
  ]),
276
184
  )}</section>`
277
185
  }
278
186
 
279
- function accountsSection(plan: Plan): string {
280
- const rows = plan.accounts.map((a) => [
187
+ function accountsSection(model: ReportModel): string {
188
+ const rows = model.blocks['accounts'].rows.map((a) => [
281
189
  escapeHtml(a.name),
282
- escapeHtml(ACCOUNT_LABEL[a.type]),
283
- escapeHtml(ownerName(plan, a.ownerPersonId)),
284
- fmtMoney(accountBalance(a)),
285
- escapeHtml('annualReturnPct' in a && a.annualReturnPct !== null ? `${a.annualReturnPct}%` : 'default'),
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(plan: Plan): string {
198
+ function incomeSection(model: ReportModel): string {
291
199
  return `<section><h2>Income</h2>${table(
292
200
  ['Source', 'Detail'],
293
- plan.incomes.map((stream) => [escapeHtml(incomeLabel(plan, stream)), escapeHtml(incomeDetail(stream))]),
201
+ model.blocks['income-sources'].rows.map((stream) => [escapeHtml(stream.label), escapeHtml(stream.detail)]),
294
202
  )}</section>`
295
203
  }
296
204
 
297
- function assumptionsSection(plan: Plan): string {
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(plan.assumptions.inflationPct)],
300
- ['Healthcare extra inflation', fmtPct(plan.assumptions.healthcareExtraInflationPct)],
301
- ['Default return', fmtPct(plan.assumptions.defaultReturnPct)],
302
- ['Safe withdrawal rate', fmtPct(plan.assumptions.safeWithdrawalRatePct ?? 4)],
303
- ['State effective tax override', fmtPct(plan.assumptions.stateEffectiveTaxPct)],
304
- ['Local income tax', fmtPct(plan.assumptions.localIncomeTaxPct)],
305
- ['Heir tax rate', fmtPct(plan.assumptions.heirTaxRatePct, 0)],
306
- ['Roth conversions', escapeHtml(conversionSummary(plan))],
307
- ['Withdrawal order', escapeHtml(withdrawalSummary(plan))],
308
- ['Spending policy', escapeHtml(spendingPolicySummary(plan))],
309
- ['Federal parameter pack', `${LATEST_PACK_YEAR}`],
310
- ['State parameter pack', `${LATEST_STATE_PACK_YEAR}`],
311
- ['Parameter data as of', escapeHtml(PARAMETER_DATA_AS_OF)],
312
- ['Parameter data basis', escapeHtml(PARAMETER_DATA_BASIS)],
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(result: ProjectionResult): string {
318
- if (result.warnings.length === 0) return ''
319
- return `<section><h2>Modeling notes</h2><ul>${result.warnings.map((w) => `<li>${escapeHtml(w)}</li>`).join('')}</ul></section>`
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
- PARAMETER_PROVENANCE.map((source) => [
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 | undefined): string {
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.baseline.endingAfterTaxEstate)],
346
- ['Candidate after-tax estate', fmtMoney(validation.candidate.endingAfterTaxEstate)],
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
- function appendixSection(result: ProjectionResult): string {
389
- const rowFor = (y: YearResult) => [
390
- `${y.year}`,
391
- fmtMoney(y.incomes.total),
392
- fmtMoney(y.expenses.total),
393
- fmtMoney(y.contributions),
394
- fmtMoney(y.rmd),
395
- fmtMoney(y.rothConversion),
396
- fmtMoney(y.tax + y.penalties),
397
- fmtMoney(y.magi),
398
- fmtMoney(y.withdrawals.total),
399
- fmtMoney(y.investableTotal),
400
- fmtMoney(y.netWorth),
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
- result.years.map(rowFor),
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
- function balanceChartData(plan: Plan, result: ProjectionResult): string {
409
- const rows = result.years.map((year) => {
410
- const categories = Object.fromEntries(CATEGORIES.map((category) => [category, 0])) as Record<(typeof CATEGORIES)[number], number>
411
- for (const account of plan.accounts) {
412
- if ((CATEGORIES as readonly string[]).includes(account.type)) {
413
- categories[account.type as (typeof CATEGORIES)[number]] += year.balances[account.id] ?? 0
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(input.plan.name)} - ${escapeHtml(productName)} report</title>
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(input.plan.name)}</h1>
472
- <p class="muted">${escapeHtml(productName)} self-contained HTML report prepared ${escapeHtml(dateLabel(preparedAtIso))}. Projection start year: ${input.startYear}.</p>
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
- <p class="disclaimer">Educational illustration only - not tax, legal, financial, or medical advice. Figures are projections based on the assumptions below and will differ from actual results.</p>
475
- ${headlineSection(input.summary, input.result)}
476
- ${recommendationSection(input.recommendationEvidence)}
477
- ${householdSection(input.plan)}
478
- ${accountsSection(input.plan)}
479
- ${incomeSection(input.plan)}
480
- ${assumptionsSection(input.plan)}
481
- ${warningsSection(input.result)}
482
- ${appendixSection(input.result)}
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,