@retiregolden/planner-ui 0.1.0 → 0.2.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,129 @@ 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
+ - `./index.css`.
134
+
135
+ The exports map also exposes wildcard `./*.ts` subpaths
120
136
  (e.g. `./report/reportHtml`) — these exist for the upstream repo's own test
121
137
  and case-runner harnesses, are not covered by any stability promise, and may
122
138
  move or change in any release. If a host needs one of them long-term, open an
123
139
  upstream issue so it can be promoted to a real export instead.
124
140
 
141
+ ## Hosting the workspace
142
+
143
+ `<PlannerApp/>` is the batteries-included web composition: chrome, all
144
+ routes, browser storage. A host with its own plans-management surface (its
145
+ own library UI, its own chrome) instead mounts *parts* of the planner and
146
+ supplies storage. Three hooks make that possible; none of them involve any
147
+ capability detection — they are plain props, context, and route arrays.
148
+
149
+ ### Plan storage: the `PlanStore` seam
150
+
151
+ The workspace, Compare, the optimizers, and the import wizard read and write
152
+ plans through a provider interface:
153
+
154
+ ```ts
155
+ import type { PlanStore, PlanSummary } from '@retiregolden/planner-ui'
156
+
157
+ interface PlanStore {
158
+ listPlans(): Promise<PlanSummary[]> // { id, name, updatedAtIso }
159
+ loadPlan(id: string): Promise<unknown> // stored plan JSON verbatim; null/undefined when absent
160
+ savePlan(plan: Plan): Promise<void> // already validated + stamped; the autosave path
161
+ deletePlan(id: string): Promise<void>
162
+ }
163
+ ```
164
+
165
+ Implementations are storage-dumb by design: `loadPlan` returns the stored
166
+ document as-is (any schema version) and planner-ui runs schema migration and
167
+ Zod validation on it — the same single code path the web app has always used
168
+ — while `savePlan` receives a plan that already passed validation and got its
169
+ `updatedAtIso` stamp. A store never re-implements plan semantics.
170
+
171
+ Supply a store with the provider or the `planStore` prop on `<PlannerApp/>`
172
+ (the prop wins when both are present); keep the instance stable — the
173
+ planner reloads when the store's identity changes:
174
+
175
+ ```tsx
176
+ import { PlanStoreProvider } from '@retiregolden/planner-ui'
177
+
178
+ <PlanStoreProvider store={myStore}>{/* planner routes */}</PlanStoreProvider>
179
+ ```
180
+
181
+ Omit the provider and the browser IndexedDB implementation applies — it is
182
+ also exported as `indexedDbPlanStore` for hosts that want to wrap it.
183
+
184
+ Deliberate boundaries of the seam:
185
+
186
+ - **Plan-scoped.** No client/household-grouping concepts; a host that keeps
187
+ per-client libraries maps plan ids to its own structure in its adapter.
188
+ - **No change feed.** Planner list views refetch after their own mutations,
189
+ so the interface carries no subscription mechanism.
190
+ - **Example demo records never cross it.** The example library's editable
191
+ demo slots (`example:*` ids) are per-device preview UX and stay in the
192
+ browser store regardless of provider; "Save to my plans" converts a demo
193
+ into a user plan and writes *that* through the seam. Small conveniences
194
+ (theme, dismissed banners) likewise stay in `localStorage`.
195
+
196
+ ### Route groups
197
+
198
+ The route table is exported as three react-router v7 `RouteObject[]` arrays
199
+ that spread into `useRoutes` or feed `createBrowserRouter`. Mount them at
200
+ the host router's **root**; to serve the planner under a URL prefix, put the
201
+ prefix in the router's `basename`
202
+ (e.g. `<BrowserRouter basename="/planner">`) — planner pages navigate with
203
+ root-absolute paths, which react-router resolves against the basename. Do
204
+ not nest the groups under a parent route path (`path: 'planner/*'`): the
205
+ initial deep link would render, but the first in-app navigation would escape
206
+ the prefix. Deep links (e.g. `/plan/<id>/results`) work with only the
207
+ workspace group mounted:
208
+
209
+ | Export | Routes | Notes |
210
+ |--------|--------|-------|
211
+ | `plannerWorkspaceRoutes` | `plan/:planId/*` (sections, results, Monte Carlo, scenarios, survivor, relocation, optimizers, report) + `compare` | The plan workspace a host wraps in its own chrome |
212
+ | `plannerContentRoutes` | `examples`, `learn/*`, `disclaimer`, `how-tested` | Storage-independent content; workspace pages link into it, so mount it (or redirect those paths) |
213
+ | `plannerHomeRoutes` | `` (index), `import`, retired-route redirects | The web plans-management home — omit it if the host owns plan management |
214
+
215
+ ```tsx
216
+ import { useRoutes } from 'react-router-dom'
217
+ import { plannerWorkspaceRoutes, plannerContentRoutes } from '@retiregolden/planner-ui'
218
+
219
+ function PlannerRoutes() {
220
+ return useRoutes([...plannerWorkspaceRoutes, ...plannerContentRoutes])
221
+ }
222
+ ```
223
+
224
+ The groups are chrome-free: no header/nav/footer, no theme toggle, and no
225
+ `document.title` management for non-plan routes (plan routes retitle
226
+ themselves). Workspace pages render links to `/` ("Your plans") — point that
227
+ path at your own library surface. Hosts mounting groups directly brand
228
+ downloaded reports with `ReportBrandingProvider` (the component form of the
229
+ `reportBranding` prop below); `<PlannerApp/>` remains exactly the composition
230
+ of all three groups plus the web chrome.
231
+
232
+ ### Plan interchange
233
+
234
+ Use the `./plan-format` subpath (see "Published API surface") for import/
235
+ export that speaks the same envelope as the web app's backup files:
236
+
237
+ ```ts
238
+ import { serializeV2Backup, parseV2Backup } from '@retiregolden/planner-ui/plan-format'
239
+ ```
240
+
125
241
  ### Report branding
126
242
 
127
243
  Downloaded HTML reports (Results, Report, and Optimizer pages) can carry the
@@ -153,10 +269,11 @@ tokens (above).
153
269
 
154
270
  ### Storage
155
271
 
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.
272
+ By default, plans persist in the browser profile via IndexedDB (`idb`) with
273
+ localStorage for small preferences, exactly as on retiregolden.app. Hosts
274
+ that need a different persistence story implement the `PlanStore` seam (see
275
+ "Hosting the workspace") — anything the seam doesn't cover should be an
276
+ upstream conversation, not a fork point.
160
277
 
161
278
  ## Relationship to the web app
162
279
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retiregolden/planner-ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -31,6 +31,7 @@
31
31
  "types": "./src/index.ts",
32
32
  "exports": {
33
33
  ".": "./src/index.ts",
34
+ "./plan-format": "./src/data/planFormat.ts",
34
35
  "./index.css": "./src/index.css",
35
36
  "./*": "./src/*.ts",
36
37
  "./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
+ }