@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 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 the root export (`PlannerApp`) plus
119
- `./index.css`. The exports map also exposes wildcard `./*.ts` subpaths
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
- Plans persist in the browser profile via IndexedDB (`idb`) with localStorage
157
- for small preferences, exactly as on retiregolden.app. Hosts that need a
158
- different persistence story should treat that as an upstream conversation,
159
- not a fork point.
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.1.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 { lazy, Suspense, useEffect, useRef, useState } from 'react'
2
- import { Navigate, NavLink, Route, Routes, useLocation } from 'react-router-dom'
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 { RouteFallback } from './routes/RouteFallback'
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
+ }
@@ -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
- /** Validates and writes a plan, bumping `updatedAtIso`. */
86
- export async function savePlan(plan: Plan, now: () => Date = () => new Date()): Promise<SavePlanResult> {
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
- export async function duplicatePlan(id: string, opts: DuplicatePlanOptions = {}): Promise<SavePlanResult> {
107
- let source = opts.source
108
- if (!source) {
109
- const loaded = await loadPlan(id)
110
- if (!loaded.ok) return { ok: false, issues: [`Could not load source plan (${loaded.reason}).`] }
111
- source = loaded.plan
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
- * Atomically converts a library demo to a user plan: delete the reserved
127
- * `example:*` record and put the converted plan under a fresh id.
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 async function convertExampleToUserPlan(
151
+ export function convertedFromExample(
130
152
  plan: Plan,
131
153
  opts: { newId?: () => string; now?: () => Date } = {},
132
- ): Promise<SavePlanResult> {
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(checked.plan)
184
+ await tx.store.put(converted.plan)
150
185
  await tx.done
151
- return { ok: true, plan: checked.plan }
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
+ }