@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
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { exampleStorageId } from '../../data/planOrigin'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
convertExampleToUserPlan,
|
|
8
|
+
convertedFromExample,
|
|
9
|
+
deletePlan,
|
|
10
|
+
loadPlan,
|
|
11
|
+
savePlan,
|
|
12
|
+
type SavePlanResult,
|
|
13
|
+
} from '../../data/planStore'
|
|
14
|
+
import { indexedDbPlanStore, type PlanStore } from '../../data/planStoreContext'
|
|
7
15
|
import type { Plan } from '@retiregolden/engine/model/plan'
|
|
8
16
|
import { exampleFixedNow } from './buildContext'
|
|
9
17
|
import { getExampleById, type ExamplePlan } from './registry'
|
|
@@ -81,10 +89,34 @@ export async function openExampleFresh(exampleId: string): Promise<{ ok: true; p
|
|
|
81
89
|
|
|
82
90
|
export async function saveExampleToMyPlans(
|
|
83
91
|
plan: Plan,
|
|
84
|
-
opts: { newId?: () => string } = {},
|
|
92
|
+
opts: { newId?: () => string; store?: PlanStore } = {},
|
|
85
93
|
): Promise<SavePlanResult> {
|
|
86
94
|
if (plan.origin !== 'example') {
|
|
87
95
|
return { ok: false, issues: ['Only library examples can be converted.'] }
|
|
88
96
|
}
|
|
89
|
-
|
|
97
|
+
const { store } = opts
|
|
98
|
+
if (store === undefined || store === indexedDbPlanStore) {
|
|
99
|
+
// Demo records live in the browser store, so when the user-plan store is
|
|
100
|
+
// that same database the swap is a single atomic transaction.
|
|
101
|
+
return convertExampleToUserPlan(plan, opts)
|
|
102
|
+
}
|
|
103
|
+
// Host-provided store: land the converted user plan there first, then drop
|
|
104
|
+
// the browser-local demo record — an interrupted convert can leave both
|
|
105
|
+
// copies (recoverable), never neither.
|
|
106
|
+
const converted = convertedFromExample(plan, opts)
|
|
107
|
+
if (!converted.ok) return converted
|
|
108
|
+
try {
|
|
109
|
+
await store.savePlan(converted.plan)
|
|
110
|
+
} catch {
|
|
111
|
+
// A store rejection must surface through the same result channel as a
|
|
112
|
+
// validation failure — callers show issues in a dialog, not a crash.
|
|
113
|
+
return { ok: false, issues: ['The plan store could not save the converted plan.'] }
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
await deletePlan(plan.id)
|
|
117
|
+
} catch {
|
|
118
|
+
// Non-fatal: the user plan landed; a surviving demo record is the
|
|
119
|
+
// recoverable half of the both-copies-never-neither invariant.
|
|
120
|
+
}
|
|
121
|
+
return converted
|
|
90
122
|
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { useNavigate } from 'react-router-dom'
|
|
3
3
|
|
|
4
|
+
import { clearAllPlans } from '../../data/planStore'
|
|
4
5
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
deletePlanVia,
|
|
7
|
+
duplicatePlanVia,
|
|
8
|
+
indexedDbPlanStore,
|
|
9
|
+
listKnownPlanIdsVia,
|
|
10
|
+
listPlansVia,
|
|
11
|
+
loadPlanVia,
|
|
12
|
+
savePlanVia,
|
|
13
|
+
usePlanStore,
|
|
11
14
|
type PlanSummary,
|
|
12
|
-
} from '../../data/
|
|
15
|
+
} from '../../data/planStoreContext'
|
|
13
16
|
import { normalizePlansForImport, parseV2Backup, serializeV2Backup } from '../../data/v2Backup'
|
|
14
17
|
import { type Plan } from '@retiregolden/engine/model/plan'
|
|
15
18
|
import { useDialogs } from '../dialogs'
|
|
@@ -17,6 +20,7 @@ import { importErrorMessage } from './importErrorMessage'
|
|
|
17
20
|
|
|
18
21
|
export function useHomeData() {
|
|
19
22
|
const navigate = useNavigate()
|
|
23
|
+
const store = usePlanStore()
|
|
20
24
|
const [plans, setPlans] = useState<PlanSummary[] | null>(null)
|
|
21
25
|
const [notice, setNotice] = useState<string | null>(null)
|
|
22
26
|
// A just-deleted plan, held in memory for a brief undo window. The delete is
|
|
@@ -28,8 +32,8 @@ export function useHomeData() {
|
|
|
28
32
|
const { confirm, prompt, dialogs } = useDialogs()
|
|
29
33
|
|
|
30
34
|
const refresh = useCallback(() => {
|
|
31
|
-
void
|
|
32
|
-
}, [])
|
|
35
|
+
void listPlansVia(store).then(setPlans)
|
|
36
|
+
}, [store])
|
|
33
37
|
|
|
34
38
|
const clearUndoTimer = () => {
|
|
35
39
|
if (undoTimer.current !== null) {
|
|
@@ -47,16 +51,16 @@ export function useHomeData() {
|
|
|
47
51
|
const openPlan = (id: string) => navigate(`/plan/${id}`)
|
|
48
52
|
|
|
49
53
|
const createAndOpen = async (plan: Plan) => {
|
|
50
|
-
const r = await
|
|
54
|
+
const r = await savePlanVia(store, plan)
|
|
51
55
|
if (r.ok) openPlan(r.plan.id)
|
|
52
56
|
else setNotice(`Could not save the new plan: ${r.issues.join('; ')}`)
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
const handleExportAll = async () => {
|
|
56
|
-
const summaries = await
|
|
60
|
+
const summaries = await listPlansVia(store)
|
|
57
61
|
const loaded: Plan[] = []
|
|
58
62
|
for (const s of summaries) {
|
|
59
|
-
const r = await
|
|
63
|
+
const r = await loadPlanVia(store, s.id)
|
|
60
64
|
if (r.ok) loaded.push(r.plan)
|
|
61
65
|
}
|
|
62
66
|
const blob = new Blob([serializeV2Backup(loaded)], { type: 'application/json' })
|
|
@@ -73,8 +77,8 @@ export function useHomeData() {
|
|
|
73
77
|
setNotice(importErrorMessage(r.reason))
|
|
74
78
|
return
|
|
75
79
|
}
|
|
76
|
-
const normalized = await normalizePlansForImport(r.plans)
|
|
77
|
-
for (const p of normalized) await
|
|
80
|
+
const normalized = await normalizePlansForImport(r.plans, await listKnownPlanIdsVia(store))
|
|
81
|
+
for (const p of normalized) await savePlanVia(store, p)
|
|
78
82
|
setNotice(
|
|
79
83
|
`Imported ${normalized.length} plan${normalized.length === 1 ? '' : 's'}.` +
|
|
80
84
|
(r.warnings.length > 0 ? ` Skipped: ${r.warnings.join('; ')}` : ''),
|
|
@@ -90,7 +94,7 @@ export function useHomeData() {
|
|
|
90
94
|
confirmLabel: 'Duplicate',
|
|
91
95
|
})
|
|
92
96
|
if (name === null) return
|
|
93
|
-
const r = await
|
|
97
|
+
const r = await duplicatePlanVia(store, s.id, { name })
|
|
94
98
|
if (r.ok) {
|
|
95
99
|
setNotice(`Duplicated "${s.name}" as "${r.plan.name}".`)
|
|
96
100
|
refresh()
|
|
@@ -107,8 +111,8 @@ export function useHomeData() {
|
|
|
107
111
|
danger: true,
|
|
108
112
|
})
|
|
109
113
|
if (!ok) return
|
|
110
|
-
const loaded = await
|
|
111
|
-
await
|
|
114
|
+
const loaded = await loadPlanVia(store, s.id)
|
|
115
|
+
await deletePlanVia(store, s.id)
|
|
112
116
|
refresh()
|
|
113
117
|
if (loaded.ok) {
|
|
114
118
|
clearUndoTimer()
|
|
@@ -125,7 +129,7 @@ export function useHomeData() {
|
|
|
125
129
|
// lands — if the save fails, the user must not lose both the plan and
|
|
126
130
|
// the affordance at once.
|
|
127
131
|
try {
|
|
128
|
-
const r = await
|
|
132
|
+
const r = await savePlanVia(store, restored)
|
|
129
133
|
if (r.ok) {
|
|
130
134
|
setUndoPlan(null)
|
|
131
135
|
refresh()
|
|
@@ -157,6 +161,13 @@ export function useHomeData() {
|
|
|
157
161
|
// A pending delete-undo would let "Undo" resurrect a plan after the
|
|
158
162
|
// erasure — drop it before clearing so "erases ALL data" stays true.
|
|
159
163
|
dismissUndo()
|
|
164
|
+
// With a host-provided store, this surface's plan list lives there —
|
|
165
|
+
// honor the dialog's "every plan" promise through the seam before the
|
|
166
|
+
// device-local wipe. (The default web path is untouched: clearAllPlans
|
|
167
|
+
// already clears the browser database in one call.)
|
|
168
|
+
if (store !== indexedDbPlanStore) {
|
|
169
|
+
for (const s of await store.listPlans()) await deletePlanVia(store, s.id)
|
|
170
|
+
}
|
|
160
171
|
await clearAllPlans()
|
|
161
172
|
try {
|
|
162
173
|
for (const key of Object.keys(localStorage)) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback, useState } from 'react'
|
|
2
2
|
|
|
3
3
|
import { readLocal, STORAGE_KEYS, writeLocal } from '../../data/localStore'
|
|
4
|
-
import type { PlanSummary } from '../../data/
|
|
4
|
+
import type { PlanSummary } from '../../data/planStoreContext'
|
|
5
5
|
|
|
6
6
|
export const WELCOME_DISMISSED_KEY = STORAGE_KEYS.homeWelcomeDismissed
|
|
7
7
|
|
|
@@ -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
|
},
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { ReportBrandingContext } from './brandingContext'
|
|
4
|
+
import type { ReportBranding } from './reportHtml'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Supplies report branding to hosts that mount the exported route groups
|
|
8
|
+
* directly instead of `<PlannerApp/>` (whose `reportBranding` prop does the
|
|
9
|
+
* same thing). Omit `branding` (or the provider) and downloaded reports keep
|
|
10
|
+
* the RetireGolden defaults.
|
|
11
|
+
*/
|
|
12
|
+
export function ReportBrandingProvider({ branding, children }: { branding?: ReportBranding; children: ReactNode }) {
|
|
13
|
+
return <ReportBrandingContext.Provider value={branding ?? null}>{children}</ReportBrandingContext.Provider>
|
|
14
|
+
}
|