@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/README.md
CHANGED
|
@@ -115,13 +115,183 @@ resolve against the app bundle, not the filesystem root.
|
|
|
115
115
|
|
|
116
116
|
### Published API surface
|
|
117
117
|
|
|
118
|
-
The supported product API is
|
|
119
|
-
|
|
118
|
+
The supported product API is:
|
|
119
|
+
|
|
120
|
+
- the **root export** — `PlannerApp`, the plan-persistence seam
|
|
121
|
+
(`PlanStore`, `PlanSummary`, `PlanStoreProvider`, `indexedDbPlanStore`),
|
|
122
|
+
the route groups (`plannerWorkspaceRoutes`, `plannerContentRoutes`,
|
|
123
|
+
`plannerHomeRoutes`), and `ReportBrandingProvider` — see "Hosting the
|
|
124
|
+
workspace" below;
|
|
125
|
+
- the **`./plan-format` subpath** — `serializeV2Backup`, `parseV2Backup`,
|
|
126
|
+
the envelope types, and the kind/version constants. This is the plan
|
|
127
|
+
interchange format (the same file the web app's backup download produces);
|
|
128
|
+
its exported names, signatures, and envelope contract only change with a
|
|
129
|
+
semver-major release. The parser ignores unknown envelope fields, so hosts
|
|
130
|
+
may extend the envelope with their own top-level keys and the file still
|
|
131
|
+
imports everywhere. The module is browser-free (no IndexedDB/DOM) and safe
|
|
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;
|
|
141
|
+
- `./index.css`.
|
|
142
|
+
|
|
143
|
+
The exports map also exposes wildcard `./*.ts` subpaths
|
|
120
144
|
(e.g. `./report/reportHtml`) — these exist for the upstream repo's own test
|
|
121
145
|
and case-runner harnesses, are not covered by any stability promise, and may
|
|
122
146
|
move or change in any release. If a host needs one of them long-term, open an
|
|
123
147
|
upstream issue so it can be promoted to a real export instead.
|
|
124
148
|
|
|
149
|
+
## Hosting the workspace
|
|
150
|
+
|
|
151
|
+
`<PlannerApp/>` is the batteries-included web composition: chrome, all
|
|
152
|
+
routes, browser storage. A host with its own plans-management surface (its
|
|
153
|
+
own library UI, its own chrome) instead mounts *parts* of the planner and
|
|
154
|
+
supplies storage. Three hooks make that possible; none of them involve any
|
|
155
|
+
capability detection — they are plain props, context, and route arrays.
|
|
156
|
+
|
|
157
|
+
### Plan storage: the `PlanStore` seam
|
|
158
|
+
|
|
159
|
+
The workspace, Compare, the optimizers, and the import wizard read and write
|
|
160
|
+
plans through a provider interface:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
import type { PlanStore, PlanSummary } from '@retiregolden/planner-ui'
|
|
164
|
+
|
|
165
|
+
interface PlanStore {
|
|
166
|
+
listPlans(): Promise<PlanSummary[]> // { id, name, updatedAtIso }
|
|
167
|
+
loadPlan(id: string): Promise<unknown> // stored plan JSON verbatim; null/undefined when absent
|
|
168
|
+
savePlan(plan: Plan): Promise<void> // already validated + stamped; the autosave path
|
|
169
|
+
deletePlan(id: string): Promise<void>
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Implementations are storage-dumb by design: `loadPlan` returns the stored
|
|
174
|
+
document as-is (any schema version) and planner-ui runs schema migration and
|
|
175
|
+
Zod validation on it — the same single code path the web app has always used
|
|
176
|
+
— while `savePlan` receives a plan that already passed validation and got its
|
|
177
|
+
`updatedAtIso` stamp. A store never re-implements plan semantics.
|
|
178
|
+
|
|
179
|
+
Supply a store with the provider or the `planStore` prop on `<PlannerApp/>`
|
|
180
|
+
(the prop wins when both are present); keep the instance stable — the
|
|
181
|
+
planner reloads when the store's identity changes:
|
|
182
|
+
|
|
183
|
+
```tsx
|
|
184
|
+
import { PlanStoreProvider } from '@retiregolden/planner-ui'
|
|
185
|
+
|
|
186
|
+
<PlanStoreProvider store={myStore}>{/* planner routes */}</PlanStoreProvider>
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Omit the provider and the browser IndexedDB implementation applies — it is
|
|
190
|
+
also exported as `indexedDbPlanStore` for hosts that want to wrap it.
|
|
191
|
+
|
|
192
|
+
Deliberate boundaries of the seam:
|
|
193
|
+
|
|
194
|
+
- **Plan-scoped.** No client/household-grouping concepts; a host that keeps
|
|
195
|
+
per-client libraries maps plan ids to its own structure in its adapter.
|
|
196
|
+
- **No change feed.** Planner list views refetch after their own mutations,
|
|
197
|
+
so the interface carries no subscription mechanism.
|
|
198
|
+
- **Example demo records never cross it.** The example library's editable
|
|
199
|
+
demo slots (`example:*` ids) are per-device preview UX and stay in the
|
|
200
|
+
browser store regardless of provider; "Save to my plans" converts a demo
|
|
201
|
+
into a user plan and writes *that* through the seam. Small conveniences
|
|
202
|
+
(theme, dismissed banners) likewise stay in `localStorage`.
|
|
203
|
+
|
|
204
|
+
### Route groups
|
|
205
|
+
|
|
206
|
+
The route table is exported as three react-router v7 `RouteObject[]` arrays
|
|
207
|
+
that spread into `useRoutes` or feed `createBrowserRouter`. Mount them at
|
|
208
|
+
the host router's **root**; to serve the planner under a URL prefix, put the
|
|
209
|
+
prefix in the router's `basename`
|
|
210
|
+
(e.g. `<BrowserRouter basename="/planner">`) — planner pages navigate with
|
|
211
|
+
root-absolute paths, which react-router resolves against the basename. Do
|
|
212
|
+
not nest the groups under a parent route path (`path: 'planner/*'`): the
|
|
213
|
+
initial deep link would render, but the first in-app navigation would escape
|
|
214
|
+
the prefix. Deep links (e.g. `/plan/<id>/results`) work with only the
|
|
215
|
+
workspace group mounted:
|
|
216
|
+
|
|
217
|
+
| Export | Routes | Notes |
|
|
218
|
+
|--------|--------|-------|
|
|
219
|
+
| `plannerWorkspaceRoutes` | `plan/:planId/*` (sections, results, Monte Carlo, scenarios, survivor, relocation, optimizers, report) + `compare` | The plan workspace a host wraps in its own chrome |
|
|
220
|
+
| `plannerContentRoutes` | `examples`, `learn/*`, `disclaimer`, `how-tested` | Storage-independent content; workspace pages link into it, so mount it (or redirect those paths) |
|
|
221
|
+
| `plannerHomeRoutes` | `` (index), `import`, retired-route redirects | The web plans-management home — omit it if the host owns plan management |
|
|
222
|
+
|
|
223
|
+
```tsx
|
|
224
|
+
import { useRoutes } from 'react-router-dom'
|
|
225
|
+
import { plannerWorkspaceRoutes, plannerContentRoutes } from '@retiregolden/planner-ui'
|
|
226
|
+
|
|
227
|
+
function PlannerRoutes() {
|
|
228
|
+
return useRoutes([...plannerWorkspaceRoutes, ...plannerContentRoutes])
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The groups are chrome-free: no header/nav/footer, no theme toggle, and no
|
|
233
|
+
`document.title` management for non-plan routes (plan routes retitle
|
|
234
|
+
themselves). Workspace pages render links to `/` ("Your plans") — point that
|
|
235
|
+
path at your own library surface. Hosts mounting groups directly brand
|
|
236
|
+
downloaded reports with `ReportBrandingProvider` (the component form of the
|
|
237
|
+
`reportBranding` prop below); `<PlannerApp/>` remains exactly the composition
|
|
238
|
+
of all three groups plus the web chrome.
|
|
239
|
+
|
|
240
|
+
### Plan interchange
|
|
241
|
+
|
|
242
|
+
Use the `./plan-format` subpath (see "Published API surface") for import/
|
|
243
|
+
export that speaks the same envelope as the web app's backup files:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
import { serializeV2Backup, parseV2Backup } from '@retiregolden/planner-ui/plan-format'
|
|
247
|
+
```
|
|
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
|
+
|
|
125
295
|
### Report branding
|
|
126
296
|
|
|
127
297
|
Downloaded HTML reports (Results, Report, and Optimizer pages) can carry the
|
|
@@ -153,10 +323,11 @@ tokens (above).
|
|
|
153
323
|
|
|
154
324
|
### Storage
|
|
155
325
|
|
|
156
|
-
|
|
157
|
-
for small preferences, exactly as on retiregolden.app. Hosts
|
|
158
|
-
different persistence story
|
|
159
|
-
|
|
326
|
+
By default, plans persist in the browser profile via IndexedDB (`idb`) with
|
|
327
|
+
localStorage for small preferences, exactly as on retiregolden.app. Hosts
|
|
328
|
+
that need a different persistence story implement the `PlanStore` seam (see
|
|
329
|
+
"Hosting the workspace") — anything the seam doesn't cover should be an
|
|
330
|
+
upstream conversation, not a fork point.
|
|
160
331
|
|
|
161
332
|
## Relationship to the web app
|
|
162
333
|
|
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,12 +25,15 @@
|
|
|
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
|
],
|
|
31
32
|
"types": "./src/index.ts",
|
|
32
33
|
"exports": {
|
|
33
34
|
".": "./src/index.ts",
|
|
35
|
+
"./plan-format": "./src/data/planFormat.ts",
|
|
36
|
+
"./report-model": "./src/report/reportModel.ts",
|
|
34
37
|
"./index.css": "./src/index.css",
|
|
35
38
|
"./*": "./src/*.ts",
|
|
36
39
|
"./package.json": "./package.json"
|
package/src/App.tsx
CHANGED
|
@@ -1,26 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { PlanPickerPage } from './planner/PlanPickerPage'
|
|
4
|
-
import { DisclaimerPage } from './planner/DisclaimerPage'
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { NavLink, useLocation, useRoutes } from 'react-router-dom'
|
|
5
3
|
import { RouteErrorBoundary } from './RouteErrorBoundary.tsx'
|
|
6
|
-
import {
|
|
4
|
+
import { plannerContentRoutes, plannerHomeRoutes, plannerWorkspaceRoutes } from './routes/groups'
|
|
7
5
|
import { readLocal, STORAGE_KEYS, writeLocal } from './data/localStore'
|
|
6
|
+
import { usePlanStore, type PlanStore } from './data/planStoreContext'
|
|
7
|
+
import { PlanStoreProvider } from './data/PlanStoreProvider'
|
|
8
8
|
import { ReportBrandingContext } from './report/brandingContext'
|
|
9
9
|
import type { ReportBranding } from './report/reportHtml'
|
|
10
10
|
import './planner/planner.css'
|
|
11
11
|
|
|
12
|
-
const PlanRoutes = lazy(() => import('./routes/PlanRoutes'))
|
|
13
|
-
const LearnRoutes = lazy(() => import('./routes/LearnRoutes'))
|
|
14
|
-
// Lazy like /plan and /learn: Examples pulls in all example builders + the Zod
|
|
15
|
-
// schema, Compare pulls in the whole engine via projectPlan — eager imports
|
|
16
|
-
// here would drag both into the landing entry chunk.
|
|
17
|
-
const ExamplesPage = lazy(() => import('./planner/examples/ExamplesPage').then((m) => ({ default: m.ExamplesPage })))
|
|
18
|
-
const ComparePlansPage = lazy(() => import('./planner/ComparePlansPage').then((m) => ({ default: m.ComparePlansPage })))
|
|
19
|
-
// Lazy so the glob-derived test-suite manifests it embeds stay out of the landing chunk.
|
|
20
|
-
const HowTestedPage = lazy(() => import('./planner/HowTestedPage').then((m) => ({ default: m.HowTestedPage })))
|
|
21
|
-
// Lazy: the import wizard pulls in the per-source mappers and the Zod schema.
|
|
22
|
-
const ImportPage = lazy(() => import('./import/ImportPage').then((m) => ({ default: m.ImportPage })))
|
|
23
|
-
|
|
24
12
|
const navClass = ({ isActive }: { isActive: boolean }) =>
|
|
25
13
|
isActive ? 'nav-link nav-link--active' : 'nav-link'
|
|
26
14
|
|
|
@@ -67,10 +55,25 @@ export interface PlannerAppProps {
|
|
|
67
55
|
* (override the custom properties from index.css).
|
|
68
56
|
*/
|
|
69
57
|
reportBranding?: ReportBranding
|
|
58
|
+
/**
|
|
59
|
+
* Plan storage for the planner (see `PlanStore` in the package exports).
|
|
60
|
+
* Precedence: this prop, else a `<PlanStoreProvider>` wrapping
|
|
61
|
+
* `<PlannerApp/>`, else the browser IndexedDB store — exactly as on
|
|
62
|
+
* retiregolden.app. Pass a stable instance — the planner reloads when the
|
|
63
|
+
* store's identity changes.
|
|
64
|
+
*/
|
|
65
|
+
planStore?: PlanStore
|
|
70
66
|
}
|
|
71
67
|
|
|
72
|
-
export function App({ reportBranding }: PlannerAppProps = {}) {
|
|
68
|
+
export function App({ reportBranding, planStore }: PlannerAppProps = {}) {
|
|
69
|
+
// An ambient <PlanStoreProvider> above the app must win over the built-in
|
|
70
|
+
// default; with neither prop nor provider this resolves to the browser
|
|
71
|
+
// store (the context's default value).
|
|
72
|
+
const ambientStore = usePlanStore()
|
|
73
73
|
const location = useLocation()
|
|
74
|
+
// The full route table — <Routes> is exactly useRoutes over its children,
|
|
75
|
+
// so composing the exported groups this way renders identically.
|
|
76
|
+
const routeTree = useRoutes([...plannerHomeRoutes, ...plannerWorkspaceRoutes, ...plannerContentRoutes])
|
|
74
77
|
const isLanding = location.pathname === '/' || location.pathname === '/examples'
|
|
75
78
|
const [themeMode, setThemeMode] = useState<ThemeMode>(getInitialThemeMode)
|
|
76
79
|
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => getResolvedTheme(getInitialThemeMode()))
|
|
@@ -122,6 +125,7 @@ export function App({ reportBranding }: PlannerAppProps = {}) {
|
|
|
122
125
|
: '/brand/retiregolden-logo-lockup-light.png'
|
|
123
126
|
|
|
124
127
|
return (
|
|
128
|
+
<PlanStoreProvider store={planStore ?? ambientStore}>
|
|
125
129
|
<ReportBrandingContext.Provider value={reportBranding ?? null}>
|
|
126
130
|
<div className={`app-shell planner-shell${isLanding ? ' app-shell--landing' : ''}`}>
|
|
127
131
|
<a className="skip-link" href="#main-content">
|
|
@@ -175,64 +179,7 @@ export function App({ reportBranding }: PlannerAppProps = {}) {
|
|
|
175
179
|
</div>
|
|
176
180
|
</header>
|
|
177
181
|
<main className="app-main" id="main-content" tabIndex={-1}>
|
|
178
|
-
<RouteErrorBoundary>
|
|
179
|
-
<Routes>
|
|
180
|
-
<Route path="/" element={<PlanPickerPage />} />
|
|
181
|
-
<Route
|
|
182
|
-
path="/examples"
|
|
183
|
-
element={
|
|
184
|
-
<Suspense fallback={<RouteFallback />}>
|
|
185
|
-
<ExamplesPage />
|
|
186
|
-
</Suspense>
|
|
187
|
-
}
|
|
188
|
-
/>
|
|
189
|
-
<Route
|
|
190
|
-
path="/compare"
|
|
191
|
-
element={
|
|
192
|
-
<Suspense fallback={<RouteFallback />}>
|
|
193
|
-
<ComparePlansPage />
|
|
194
|
-
</Suspense>
|
|
195
|
-
}
|
|
196
|
-
/>
|
|
197
|
-
<Route
|
|
198
|
-
path="/plan/*"
|
|
199
|
-
element={
|
|
200
|
-
<Suspense fallback={<RouteFallback />}>
|
|
201
|
-
<PlanRoutes />
|
|
202
|
-
</Suspense>
|
|
203
|
-
}
|
|
204
|
-
/>
|
|
205
|
-
<Route
|
|
206
|
-
path="/learn/*"
|
|
207
|
-
element={
|
|
208
|
-
<Suspense fallback={<RouteFallback />}>
|
|
209
|
-
<LearnRoutes />
|
|
210
|
-
</Suspense>
|
|
211
|
-
}
|
|
212
|
-
/>
|
|
213
|
-
<Route
|
|
214
|
-
path="/import"
|
|
215
|
-
element={
|
|
216
|
-
<Suspense fallback={<RouteFallback />}>
|
|
217
|
-
<ImportPage />
|
|
218
|
-
</Suspense>
|
|
219
|
-
}
|
|
220
|
-
/>
|
|
221
|
-
<Route path="/disclaimer" element={<DisclaimerPage />} />
|
|
222
|
-
<Route
|
|
223
|
-
path="/how-tested"
|
|
224
|
-
element={
|
|
225
|
-
<Suspense fallback={<RouteFallback />}>
|
|
226
|
-
<HowTestedPage />
|
|
227
|
-
</Suspense>
|
|
228
|
-
}
|
|
229
|
-
/>
|
|
230
|
-
{/* Retired v1 routes now redirect into the planner. */}
|
|
231
|
-
<Route path="/legacy" element={<Navigate to="/" replace />} />
|
|
232
|
-
<Route path="/longevity" element={<Navigate to="/" replace />} />
|
|
233
|
-
<Route path="/social-security" element={<Navigate to="/" replace />} />
|
|
234
|
-
</Routes>
|
|
235
|
-
</RouteErrorBoundary>
|
|
182
|
+
<RouteErrorBoundary>{routeTree}</RouteErrorBoundary>
|
|
236
183
|
</main>
|
|
237
184
|
<footer className="app-footer">
|
|
238
185
|
<span className="muted small">
|
|
@@ -242,5 +189,6 @@ export function App({ reportBranding }: PlannerAppProps = {}) {
|
|
|
242
189
|
</footer>
|
|
243
190
|
</div>
|
|
244
191
|
</ReportBrandingContext.Provider>
|
|
192
|
+
</PlanStoreProvider>
|
|
245
193
|
)
|
|
246
194
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { PlanStoreContext, type PlanStore } from './planStoreContext'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Supplies a host `PlanStore` (see data/planStoreContext.ts) to the planner
|
|
7
|
+
* tree. Without it — or via `<PlannerApp/>` with no `planStore` prop — the
|
|
8
|
+
* browser IndexedDB store applies. Keep the instance stable (module constant
|
|
9
|
+
* or memoized): the planner reloads when the store changes identity.
|
|
10
|
+
*/
|
|
11
|
+
export function PlanStoreProvider({ store, children }: { store: PlanStore; children: ReactNode }) {
|
|
12
|
+
return <PlanStoreContext.Provider value={store}>{children}</PlanStoreContext.Provider>
|
|
13
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The v2 backup envelope — serialization and parsing of the plan-interchange
|
|
3
|
+
* format documented in DOCS/features/plan-file-format.md.
|
|
4
|
+
*
|
|
5
|
+
* **Stability promise:** this module is published as the
|
|
6
|
+
* `@retiregolden/planner-ui/plan-format` subpath and, unlike the wildcard
|
|
7
|
+
* deep paths, is a supported API: the envelope `kind`/`backupVersion`
|
|
8
|
+
* contract, the exported names, and their signatures only change with a
|
|
9
|
+
* semver-major release of the package. It is deliberately browser-free
|
|
10
|
+
* (no IndexedDB, no DOM) so hosts can run it anywhere — e.g. an Electron
|
|
11
|
+
* main process assembling library backups. (A standalone
|
|
12
|
+
* `@retiregolden/plan-format` package may eventually take this over; this
|
|
13
|
+
* subpath is the first step.)
|
|
14
|
+
*
|
|
15
|
+
* Format invariants callers may rely on:
|
|
16
|
+
* - Plans inside the envelope carry their own `schemaVersion` and are
|
|
17
|
+
* individually migrated on parse, so old backups stay restorable forever.
|
|
18
|
+
* - `parseV2Backup` ignores unknown envelope fields — a host may extend the
|
|
19
|
+
* envelope (extra top-level keys) and the file still round-trips through
|
|
20
|
+
* this parser and the web app's import.
|
|
21
|
+
* - Envelope `kind`s from before the RetireGolden rebrand are still accepted.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { migratePlanToCurrent } from '@retiregolden/engine/model/migrations'
|
|
25
|
+
import type { Plan } from '@retiregolden/engine/model/plan'
|
|
26
|
+
|
|
27
|
+
export const V2_BACKUP_KIND = 'retiregolden.v2.backup'
|
|
28
|
+
/** Legacy envelope kinds from before the RetireGolden rebrand — still accepted on import. */
|
|
29
|
+
export const LEGACY_V2_BACKUP_KIND = 'retirecalc.v2.backup'
|
|
30
|
+
export const RETIREMINT_V2_BACKUP_KIND = 'retiremint.v2.backup'
|
|
31
|
+
export const V2_BACKUP_VERSION = 1
|
|
32
|
+
/** Generous cap; primarily guards against importing the wrong (huge) file. */
|
|
33
|
+
export const MAX_BACKUP_JSON_CHARS = 10_000_000
|
|
34
|
+
|
|
35
|
+
export interface V2BackupEnvelope {
|
|
36
|
+
kind: typeof V2_BACKUP_KIND
|
|
37
|
+
backupVersion: typeof V2_BACKUP_VERSION
|
|
38
|
+
exportedAtIso: string
|
|
39
|
+
plans: unknown[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function serializeV2Backup(plans: Plan[], now: () => Date = () => new Date()): string {
|
|
43
|
+
const envelope: V2BackupEnvelope = {
|
|
44
|
+
kind: V2_BACKUP_KIND,
|
|
45
|
+
backupVersion: V2_BACKUP_VERSION,
|
|
46
|
+
exportedAtIso: now().toISOString(),
|
|
47
|
+
plans,
|
|
48
|
+
}
|
|
49
|
+
return JSON.stringify(envelope, null, 2)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type ParseV2BackupResult =
|
|
53
|
+
| { ok: true; plans: Plan[]; warnings: string[] }
|
|
54
|
+
| { ok: false; reason: 'too_large' | 'not_json' | 'wrong_kind' | 'unsupported_version' | 'no_valid_plans' }
|
|
55
|
+
|
|
56
|
+
export function parseV2Backup(json: string): ParseV2BackupResult {
|
|
57
|
+
if (json.length > MAX_BACKUP_JSON_CHARS) return { ok: false, reason: 'too_large' }
|
|
58
|
+
|
|
59
|
+
let raw: unknown
|
|
60
|
+
try {
|
|
61
|
+
raw = JSON.parse(json)
|
|
62
|
+
} catch {
|
|
63
|
+
return { ok: false, reason: 'not_json' }
|
|
64
|
+
}
|
|
65
|
+
if (typeof raw !== 'object' || raw === null) return { ok: false, reason: 'wrong_kind' }
|
|
66
|
+
const env = raw as { kind?: string; backupVersion?: number; plans?: unknown }
|
|
67
|
+
if (
|
|
68
|
+
(env.kind !== V2_BACKUP_KIND &&
|
|
69
|
+
env.kind !== RETIREMINT_V2_BACKUP_KIND &&
|
|
70
|
+
env.kind !== LEGACY_V2_BACKUP_KIND) ||
|
|
71
|
+
!Array.isArray(env.plans)
|
|
72
|
+
) {
|
|
73
|
+
return { ok: false, reason: 'wrong_kind' }
|
|
74
|
+
}
|
|
75
|
+
if (env.backupVersion !== V2_BACKUP_VERSION) return { ok: false, reason: 'unsupported_version' }
|
|
76
|
+
|
|
77
|
+
const plans: Plan[] = []
|
|
78
|
+
const warnings: string[] = []
|
|
79
|
+
env.plans.forEach((rawPlan, i) => {
|
|
80
|
+
const result = migratePlanToCurrent(rawPlan)
|
|
81
|
+
if (result.ok) {
|
|
82
|
+
plans.push(result.plan)
|
|
83
|
+
} else {
|
|
84
|
+
warnings.push(`plan ${i + 1}: skipped (${result.reason})`)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
if (plans.length === 0) return { ok: false, reason: 'no_valid_plans' }
|
|
88
|
+
return { ok: true, plans, warnings }
|
|
89
|
+
}
|
package/src/data/planStore.ts
CHANGED
|
@@ -82,15 +82,26 @@ export async function loadPlan(id: string): Promise<MigrateResult> {
|
|
|
82
82
|
|
|
83
83
|
export type SavePlanResult = { ok: true; plan: Plan } | { ok: false; issues: string[] }
|
|
84
84
|
|
|
85
|
-
/**
|
|
86
|
-
|
|
85
|
+
/**
|
|
86
|
+
* The pure half of a save: bump `updatedAtIso` and re-validate. Every store
|
|
87
|
+
* that persists plans (this one and any host-provided `PlanStore`) writes
|
|
88
|
+
* through this so validation cannot drift between implementations.
|
|
89
|
+
*/
|
|
90
|
+
export function checkPlanForSave(plan: Plan, now: () => Date = () => new Date()): SavePlanResult {
|
|
87
91
|
const stamped: Plan = { ...plan, updatedAtIso: now().toISOString() }
|
|
88
92
|
const checked = parsePlan(stamped)
|
|
89
93
|
if (!checked.ok) return { ok: false, issues: checked.issues }
|
|
90
|
-
await (await db()).put(PLANS_STORE, checked.plan)
|
|
91
94
|
return { ok: true, plan: checked.plan }
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
/** Validates and writes a plan, bumping `updatedAtIso`. */
|
|
98
|
+
export async function savePlan(plan: Plan, now: () => Date = () => new Date()): Promise<SavePlanResult> {
|
|
99
|
+
const checked = checkPlanForSave(plan, now)
|
|
100
|
+
if (!checked.ok) return checked
|
|
101
|
+
await (await db()).put(PLANS_STORE, checked.plan)
|
|
102
|
+
return checked
|
|
103
|
+
}
|
|
104
|
+
|
|
94
105
|
export interface DuplicatePlanOptions {
|
|
95
106
|
name?: string
|
|
96
107
|
newId?: () => string
|
|
@@ -103,13 +114,12 @@ export interface DuplicatePlanOptions {
|
|
|
103
114
|
source?: Plan
|
|
104
115
|
}
|
|
105
116
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
117
|
+
/**
|
|
118
|
+
* The pure half of duplication: fresh id, user origin, stamped timestamps.
|
|
119
|
+
* Shared by the browser store and the store-generic seam operations so the
|
|
120
|
+
* clone semantics live in exactly one place.
|
|
121
|
+
*/
|
|
122
|
+
export function cloneAsUserPlan(source: Plan, opts: DuplicatePlanOptions = {}): { clone: Plan; nowIso: string } {
|
|
113
123
|
const now = opts.now ?? (() => new Date())
|
|
114
124
|
const nowIso = now().toISOString()
|
|
115
125
|
const clone: Plan = structuredClone(source)
|
|
@@ -119,17 +129,29 @@ export async function duplicatePlan(id: string, opts: DuplicatePlanOptions = {})
|
|
|
119
129
|
clone.exampleSourceId = source.exampleSourceId
|
|
120
130
|
clone.createdAtIso = nowIso
|
|
121
131
|
clone.updatedAtIso = nowIso
|
|
132
|
+
return { clone, nowIso }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function duplicatePlan(id: string, opts: DuplicatePlanOptions = {}): Promise<SavePlanResult> {
|
|
136
|
+
let source = opts.source
|
|
137
|
+
if (!source) {
|
|
138
|
+
const loaded = await loadPlan(id)
|
|
139
|
+
if (!loaded.ok) return { ok: false, issues: [`Could not load source plan (${loaded.reason}).`] }
|
|
140
|
+
source = loaded.plan
|
|
141
|
+
}
|
|
142
|
+
const { clone, nowIso } = cloneAsUserPlan(source, opts)
|
|
122
143
|
return savePlan(clone, () => new Date(nowIso))
|
|
123
144
|
}
|
|
124
145
|
|
|
125
146
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
147
|
+
* The pure half of demo conversion: the `example:*` record re-keyed under a
|
|
148
|
+
* fresh id as a user plan, re-validated. The delete/write choreography is the
|
|
149
|
+
* caller's business (atomic here; save-then-delete across the seam).
|
|
128
150
|
*/
|
|
129
|
-
export
|
|
151
|
+
export function convertedFromExample(
|
|
130
152
|
plan: Plan,
|
|
131
153
|
opts: { newId?: () => string; now?: () => Date } = {},
|
|
132
|
-
):
|
|
154
|
+
): SavePlanResult {
|
|
133
155
|
const newId = (opts.newId ?? (() => crypto.randomUUID()))()
|
|
134
156
|
const now = opts.now ?? (() => new Date())
|
|
135
157
|
const nowIso = now().toISOString()
|
|
@@ -142,13 +164,26 @@ export async function convertExampleToUserPlan(
|
|
|
142
164
|
}
|
|
143
165
|
const checked = parsePlan(converted)
|
|
144
166
|
if (!checked.ok) return { ok: false, issues: checked.issues }
|
|
167
|
+
return { ok: true, plan: checked.plan }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Atomically converts a library demo to a user plan: delete the reserved
|
|
172
|
+
* `example:*` record and put the converted plan under a fresh id.
|
|
173
|
+
*/
|
|
174
|
+
export async function convertExampleToUserPlan(
|
|
175
|
+
plan: Plan,
|
|
176
|
+
opts: { newId?: () => string; now?: () => Date } = {},
|
|
177
|
+
): Promise<SavePlanResult> {
|
|
178
|
+
const converted = convertedFromExample(plan, opts)
|
|
179
|
+
if (!converted.ok) return converted
|
|
145
180
|
|
|
146
181
|
const database = await db()
|
|
147
182
|
const tx = database.transaction(PLANS_STORE, 'readwrite')
|
|
148
183
|
await tx.store.delete(plan.id)
|
|
149
|
-
await tx.store.put(
|
|
184
|
+
await tx.store.put(converted.plan)
|
|
150
185
|
await tx.done
|
|
151
|
-
return
|
|
186
|
+
return converted
|
|
152
187
|
}
|
|
153
188
|
|
|
154
189
|
export async function deletePlan(id: string): Promise<void> {
|
|
@@ -163,3 +198,17 @@ export async function clearAllPlans(): Promise<void> {
|
|
|
163
198
|
export async function countStoredPlans(): Promise<number> {
|
|
164
199
|
return (await (await db()).count(PLANS_STORE))
|
|
165
200
|
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Raw record accessors backing the browser implementation of the `PlanStore`
|
|
204
|
+
* seam (data/planStoreContext.tsx): documents in/out, no migration or
|
|
205
|
+
* validation — those stay in the seam layer so they run identically over any
|
|
206
|
+
* store. Prefer `loadPlan`/`savePlan` everywhere else.
|
|
207
|
+
*/
|
|
208
|
+
export async function getPlanRecord(id: string): Promise<unknown> {
|
|
209
|
+
return (await (await db()).get(PLANS_STORE, id)) as unknown
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function putPlanRecord(plan: Plan): Promise<void> {
|
|
213
|
+
await (await db()).put(PLANS_STORE, plan)
|
|
214
|
+
}
|