@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.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The plan-persistence seam: a host supplies plan storage by implementing
3
+ * `PlanStore` and wrapping the planner in `<PlanStoreProvider>`; the default
4
+ * is the browser IndexedDB implementation, so web hosts change nothing.
5
+ *
6
+ * The seam is deliberately plan-scoped and storage-dumb:
7
+ *
8
+ * - **Documents in, documents out.** `loadPlan` returns the stored plan JSON
9
+ * verbatim (any schema version) and the seam layer runs schema migration +
10
+ * validation on read — the single code path persisted reads have always
11
+ * gone through. `savePlan` receives an already-validated, already-stamped
12
+ * `Plan`; a store never re-implements validation.
13
+ * - **No client/household concepts.** A host that groups plans (per-client
14
+ * libraries) binds that identity outside this interface — its adapter maps
15
+ * plan ids into whatever structure it keeps.
16
+ * - **No change feed.** Every list view refetches after its own mutations
17
+ * (the pattern the web app has always used), so a subscription mechanism
18
+ * is deliberately omitted rather than speculatively bolted on.
19
+ * - **Library demo records (`example:*` ids) never cross the seam.** They
20
+ * are per-device preview UX, not plan data, and stay in the browser store
21
+ * regardless of provider; "Save to my plans" is the crossing point, and it
22
+ * writes the converted user plan through the seam. The `*Via` operations
23
+ * below route by id so callers don't care.
24
+ */
25
+
26
+ import { createContext, useContext } from 'react'
27
+
28
+ import { migratePlanToCurrent, type MigrateResult } from '@retiregolden/engine/model/migrations'
29
+ import type { Plan } from '@retiregolden/engine/model/plan'
30
+ import { isExamplePlanId } from './planOrigin'
31
+ import {
32
+ checkPlanForSave,
33
+ cloneAsUserPlan,
34
+ deletePlan,
35
+ getPlanRecord,
36
+ listExampleSummaries,
37
+ listUserPlanSummaries,
38
+ loadPlan,
39
+ putPlanRecord,
40
+ savePlan,
41
+ type DuplicatePlanOptions,
42
+ type SavePlanResult,
43
+ } from './planStore'
44
+
45
+ /** What a plan list needs to render a row. The seam type carries no origin — every plan in a store is the user's. */
46
+ export interface PlanSummary {
47
+ id: string
48
+ name: string
49
+ updatedAtIso: string
50
+ }
51
+
52
+ /**
53
+ * Host-implementable plan storage. Implementations are plain objects; keep
54
+ * the instance stable (module constant or memoized) — the planner reloads
55
+ * when the provided store changes identity.
56
+ */
57
+ export interface PlanStore {
58
+ /** Summaries of every plan in the store. Order is not significant — the seam sorts by `updatedAtIso`, newest first. */
59
+ listPlans(): Promise<PlanSummary[]>
60
+ /** The stored plan document verbatim (any schema version), or `null`/`undefined` when absent. */
61
+ loadPlan(id: string): Promise<unknown>
62
+ /** Persist a validated plan. This is the autosave path — it runs on a debounce while the user edits. */
63
+ savePlan(plan: Plan): Promise<void>
64
+ deletePlan(id: string): Promise<void>
65
+ }
66
+
67
+ /**
68
+ * The browser IndexedDB implementation (data/planStore.ts) — the default
69
+ * store, and exported so hosts can wrap it. `listPlans` excludes library
70
+ * demo records: they live in the same database but belong to the example
71
+ * slots, not the user's plan list.
72
+ */
73
+ export const indexedDbPlanStore: PlanStore = {
74
+ listPlans: listUserPlanSummaries,
75
+ loadPlan: getPlanRecord,
76
+ savePlan: putPlanRecord,
77
+ deletePlan,
78
+ }
79
+
80
+ /** Prefer `<PlanStoreProvider>` (data/PlanStoreProvider.tsx); the raw context is exported for it, not for general use. */
81
+ export const PlanStoreContext = createContext<PlanStore>(indexedDbPlanStore)
82
+
83
+ export function usePlanStore(): PlanStore {
84
+ return useContext(PlanStoreContext)
85
+ }
86
+
87
+ /*
88
+ * Store-generic operations. Components read the store from context and call
89
+ * these; each routes `example:*` ids to the browser store (demo records are
90
+ * device-local by design) and everything else through the given store, with
91
+ * migration/validation applied here so hosts stay storage-dumb.
92
+ */
93
+
94
+ /** The store's plan summaries, newest first. */
95
+ export async function listPlansVia(store: PlanStore): Promise<PlanSummary[]> {
96
+ const summaries = await store.listPlans()
97
+ return summaries.slice().sort((a, b) => b.updatedAtIso.localeCompare(a.updatedAtIso))
98
+ }
99
+
100
+ /** Loads, migrates, and validates a plan. Missing records surface as `{ ok: false, reason: 'not_object' }`, exactly like the browser store. */
101
+ export async function loadPlanVia(store: PlanStore, id: string): Promise<MigrateResult> {
102
+ if (isExamplePlanId(id)) return loadPlan(id)
103
+ const raw = await store.loadPlan(id)
104
+ if (raw === undefined || raw === null) return { ok: false, reason: 'not_object' }
105
+ return migratePlanToCurrent(raw)
106
+ }
107
+
108
+ /** Validates and writes a plan, bumping `updatedAtIso`. */
109
+ export async function savePlanVia(store: PlanStore, plan: Plan, now: () => Date = () => new Date()): Promise<SavePlanResult> {
110
+ if (isExamplePlanId(plan.id)) return savePlan(plan, now)
111
+ const checked = checkPlanForSave(plan, now)
112
+ if (!checked.ok) return checked
113
+ await store.savePlan(checked.plan)
114
+ return checked
115
+ }
116
+
117
+ export async function deletePlanVia(store: PlanStore, id: string): Promise<void> {
118
+ if (isExamplePlanId(id)) return deletePlan(id)
119
+ return store.deletePlan(id)
120
+ }
121
+
122
+ export async function duplicatePlanVia(store: PlanStore, id: string, opts: DuplicatePlanOptions = {}): Promise<SavePlanResult> {
123
+ let source = opts.source
124
+ if (!source) {
125
+ const loaded = await loadPlanVia(store, id)
126
+ if (!loaded.ok) return { ok: false, issues: [`Could not load source plan (${loaded.reason}).`] }
127
+ source = loaded.plan
128
+ }
129
+ const { clone, nowIso } = cloneAsUserPlan(source, opts)
130
+ // Duplicates are always user plans (fresh uuid), so this writes through the store.
131
+ return savePlanVia(store, clone, () => new Date(nowIso))
132
+ }
133
+
134
+ /** Every id the import path must not collide with: the store's plans plus the browser's demo slots. */
135
+ export async function listKnownPlanIdsVia(store: PlanStore): Promise<Set<string>> {
136
+ const [stored, demos] = await Promise.all([store.listPlans(), listExampleSummaries()])
137
+ return new Set([...stored.map((s) => s.id), ...demos.map((s) => s.id)])
138
+ }
@@ -1,86 +1,37 @@
1
1
  /**
2
2
  * v2 JSON backup envelope — ports the retired v1 pattern (versioned envelope,
3
3
  * size cap, validate-before-replace) to multi-plan v2 data.
4
- * Plans inside the envelope carry their own schemaVersion and are individually
5
- * migrated on import, so old backups stay restorable forever.
4
+ *
5
+ * The pure envelope logic lives in `planFormat.ts` (published as the stable
6
+ * `@retiregolden/planner-ui/plan-format` subpath) and is re-exported here so
7
+ * existing import sites keep working; this module keeps the storage-aware
8
+ * import normalization.
6
9
  */
7
10
 
8
- import { migratePlanToCurrent } from '@retiregolden/engine/model/migrations'
9
11
  import type { Plan } from '@retiregolden/engine/model/plan'
10
12
  import { isExamplePlanId } from './planOrigin'
11
13
  import { listPlanSummaries } from './planStore'
12
14
 
13
- export const V2_BACKUP_KIND = 'retiregolden.v2.backup'
14
- /** Legacy envelope kinds from before the RetireGolden rebrand — still accepted on import. */
15
- export const LEGACY_V2_BACKUP_KIND = 'retirecalc.v2.backup'
16
- export const RETIREMINT_V2_BACKUP_KIND = 'retiremint.v2.backup'
17
- export const V2_BACKUP_VERSION = 1
18
- /** Generous cap; primarily guards against importing the wrong (huge) file. */
19
- export const MAX_BACKUP_JSON_CHARS = 10_000_000
20
-
21
- export interface V2BackupEnvelope {
22
- kind: typeof V2_BACKUP_KIND
23
- backupVersion: typeof V2_BACKUP_VERSION
24
- exportedAtIso: string
25
- plans: unknown[]
26
- }
27
-
28
- export function serializeV2Backup(plans: Plan[], now: () => Date = () => new Date()): string {
29
- const envelope: V2BackupEnvelope = {
30
- kind: V2_BACKUP_KIND,
31
- backupVersion: V2_BACKUP_VERSION,
32
- exportedAtIso: now().toISOString(),
33
- plans,
34
- }
35
- return JSON.stringify(envelope, null, 2)
36
- }
37
-
38
- export type ParseV2BackupResult =
39
- | { ok: true; plans: Plan[]; warnings: string[] }
40
- | { ok: false; reason: 'too_large' | 'not_json' | 'wrong_kind' | 'unsupported_version' | 'no_valid_plans' }
41
-
42
- export function parseV2Backup(json: string): ParseV2BackupResult {
43
- if (json.length > MAX_BACKUP_JSON_CHARS) return { ok: false, reason: 'too_large' }
44
-
45
- let raw: unknown
46
- try {
47
- raw = JSON.parse(json)
48
- } catch {
49
- return { ok: false, reason: 'not_json' }
50
- }
51
- if (typeof raw !== 'object' || raw === null) return { ok: false, reason: 'wrong_kind' }
52
- const env = raw as { kind?: string; backupVersion?: number; plans?: unknown }
53
- if (
54
- (env.kind !== V2_BACKUP_KIND &&
55
- env.kind !== RETIREMINT_V2_BACKUP_KIND &&
56
- env.kind !== LEGACY_V2_BACKUP_KIND) ||
57
- !Array.isArray(env.plans)
58
- ) {
59
- return { ok: false, reason: 'wrong_kind' }
60
- }
61
- if (env.backupVersion !== V2_BACKUP_VERSION) return { ok: false, reason: 'unsupported_version' }
62
-
63
- const plans: Plan[] = []
64
- const warnings: string[] = []
65
- env.plans.forEach((rawPlan, i) => {
66
- const result = migratePlanToCurrent(rawPlan)
67
- if (result.ok) {
68
- plans.push(result.plan)
69
- } else {
70
- warnings.push(`plan ${i + 1}: skipped (${result.reason})`)
71
- }
72
- })
73
- if (plans.length === 0) return { ok: false, reason: 'no_valid_plans' }
74
- return { ok: true, plans, warnings }
75
- }
15
+ export {
16
+ LEGACY_V2_BACKUP_KIND,
17
+ MAX_BACKUP_JSON_CHARS,
18
+ RETIREMINT_V2_BACKUP_KIND,
19
+ V2_BACKUP_KIND,
20
+ V2_BACKUP_VERSION,
21
+ parseV2Backup,
22
+ serializeV2Backup,
23
+ type ParseV2BackupResult,
24
+ type V2BackupEnvelope,
25
+ } from './planFormat'
76
26
 
77
27
  /**
78
28
  * On import, demos become user plans and reserved `example:*` ids are rekeyed
79
- * so they cannot collide with library demo slots.
29
+ * so they cannot collide with library demo slots. `existingIds` is every id
30
+ * already taken (host store plans + browser demo slots — see
31
+ * `listKnownPlanIdsVia`); when omitted, the browser store supplies them.
80
32
  */
81
- export async function normalizePlansForImport(plans: Plan[]): Promise<Plan[]> {
82
- const existing = new Set((await listPlanSummaries()).map((s) => s.id))
83
- const used = new Set(existing)
33
+ export async function normalizePlansForImport(plans: Plan[], existingIds?: Iterable<string>): Promise<Plan[]> {
34
+ const used = new Set(existingIds ?? (await listPlanSummaries()).map((s) => s.id))
84
35
  const normalized: Plan[] = []
85
36
 
86
37
  for (const plan of plans) {
@@ -11,7 +11,7 @@
11
11
  import { useRef, useState } from 'react'
12
12
  import { Link, useNavigate } from 'react-router-dom'
13
13
 
14
- import { savePlan } from '../data/planStore'
14
+ import { savePlanVia, usePlanStore } from '../data/planStoreContext'
15
15
  import type { Plan } from '@retiregolden/engine/model/plan'
16
16
  import { DateField, MoneyField, SelectField } from '../planner/fields'
17
17
  import { US_STATES } from '../planner/usStates'
@@ -82,6 +82,7 @@ const EMPTY_1040: TenFortyInputs = {
82
82
 
83
83
  export function ImportPage() {
84
84
  const navigate = useNavigate()
85
+ const store = usePlanStore()
85
86
  const [source, setSource] = useState<SourceId | null>(null)
86
87
  const [error, setError] = useState<string | null>(null)
87
88
  const [draft, setDraft] = useState<Draft | null>(null)
@@ -151,7 +152,7 @@ export function ImportPage() {
151
152
 
152
153
  const saveAndOpen = async () => {
153
154
  if (!draft) return
154
- const r = await savePlan(draft.plan)
155
+ const r = await savePlanVia(store, draft.plan)
155
156
  if (r.ok) navigate(`/plan/${r.plan.id}`)
156
157
  else setError(`Could not save the draft plan: ${r.issues.join('; ')}`)
157
158
  }
package/src/index.ts CHANGED
@@ -14,3 +14,26 @@
14
14
  */
15
15
  export { App as PlannerApp, type PlannerAppProps } from './App.tsx'
16
16
  export type { ReportBranding } from './report/reportHtml'
17
+
18
+ // The plan-persistence seam: implement `PlanStore` and wrap the planner in
19
+ // `<PlanStoreProvider>` (or pass `planStore` to `<PlannerApp/>`) to supply
20
+ // host storage; omit both and plans persist in the browser via IndexedDB.
21
+ export { PlanStoreProvider } from './data/PlanStoreProvider.tsx'
22
+ export {
23
+ indexedDbPlanStore,
24
+ type PlanStore,
25
+ type PlanSummary,
26
+ } from './data/planStoreContext.ts'
27
+
28
+ // Route-level exports: mount a subset of the planner under the host's own
29
+ // router (react-router v7 route-object arrays). `<PlannerApp/>` remains the
30
+ // batteries-included composition of all three groups plus the web chrome.
31
+ export {
32
+ plannerContentRoutes,
33
+ plannerHomeRoutes,
34
+ plannerWorkspaceRoutes,
35
+ } from './routes/groups.tsx'
36
+
37
+ // Report branding for hosts that mount route groups directly; `<PlannerApp/>`
38
+ // hosts use the `reportBranding` prop instead.
39
+ export { ReportBrandingProvider } from './report/ReportBrandingProvider.tsx'
@@ -7,7 +7,7 @@
7
7
  import { useEffect, useMemo, useState } from 'react'
8
8
  import { Link } from 'react-router-dom'
9
9
 
10
- import { listUserPlanSummaries, loadPlan, type PlanSummary } from '../data/planStore'
10
+ import { listPlansVia, loadPlanVia, usePlanStore, type PlanSummary } from '../data/planStoreContext'
11
11
  import type { Plan } from '@retiregolden/engine/model/plan'
12
12
  import type { ProjectionSummary } from '@retiregolden/engine/projection/compare'
13
13
  import { fmtMoneyCompact } from './format'
@@ -65,6 +65,7 @@ function MetricRow({
65
65
  }
66
66
 
67
67
  export function ComparePlansPage() {
68
+ const store = usePlanStore()
68
69
  const [summaries, setSummaries] = useState<PlanSummary[] | null>(null)
69
70
  const [leftId, setLeftId] = useState('')
70
71
  const [rightId, setRightId] = useState('')
@@ -73,12 +74,12 @@ export function ComparePlansPage() {
73
74
  const [notice, setNotice] = useState<string | null>(null)
74
75
 
75
76
  useEffect(() => {
76
- void listUserPlanSummaries().then((items) => {
77
+ void listPlansVia(store).then((items) => {
77
78
  setSummaries(items)
78
79
  setLeftId(items[0]?.id ?? '')
79
80
  setRightId(items.find((p) => p.id !== items[0]?.id)?.id ?? '')
80
81
  })
81
- }, [])
82
+ }, [store])
82
83
 
83
84
  useEffect(() => {
84
85
  let cancelled = false
@@ -87,7 +88,7 @@ export function ComparePlansPage() {
87
88
  setter(null)
88
89
  return
89
90
  }
90
- const r = await loadPlan(id)
91
+ const r = await loadPlanVia(store, id)
91
92
  if (cancelled) return
92
93
  if (r.ok) setter({ plan: r.plan, view: projectPlan(r.plan) })
93
94
  else {
@@ -100,7 +101,7 @@ export function ComparePlansPage() {
100
101
  return () => {
101
102
  cancelled = true
102
103
  }
103
- }, [leftId, rightId])
104
+ }, [leftId, rightId, store])
104
105
 
105
106
  const options = summaries ?? []
106
107
  const canCompare = left !== null && right !== null && left.plan.id !== right.plan.id
@@ -10,7 +10,7 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
10
10
  import { Link } from 'react-router-dom'
11
11
 
12
12
  import { parsePlan, type Plan } from '@retiregolden/engine/model/plan'
13
- import { loadPlan, savePlan } from '../data/planStore'
13
+ import { loadPlanVia, savePlanVia, usePlanStore } from '../data/planStoreContext'
14
14
  import { EXAMPLE_PLAN_ID_PREFIX, isExamplePlanId } from '../data/planOrigin'
15
15
  import { getExampleById } from './examples/registry'
16
16
  import { saveFreshDemo } from './examples/loadExample'
@@ -24,6 +24,7 @@ const AUTOSAVE_MS = 600
24
24
  * asynchronously.
25
25
  */
26
26
  export function PlanProvider({ planId, children }: { planId: string; children: ReactNode }) {
27
+ const store = usePlanStore()
27
28
  const [plan, setPlan] = useState<Plan | null>(null)
28
29
  const [loadError, setLoadError] = useState<string | null>(null)
29
30
  const [saveState, setSaveState] = useState<SaveState>('loading')
@@ -39,7 +40,7 @@ export function PlanProvider({ planId, children }: { planId: string; children: R
39
40
  setSaveState('saved')
40
41
  }
41
42
  void (async () => {
42
- const r = await loadPlan(planId)
43
+ const r = await loadPlanVia(store, planId)
43
44
  if (cancelled) return
44
45
  if (r.ok) {
45
46
  adopt(r.plan)
@@ -68,21 +69,21 @@ export function PlanProvider({ planId, children }: { planId: string; children: R
68
69
  return () => {
69
70
  cancelled = true
70
71
  }
71
- }, [planId])
72
+ }, [planId, store])
72
73
 
73
- // savePlan resolves { ok: false } on validation failure, but the IndexedDB
74
+ // savePlanVia resolves { ok: false } on validation failure, but the store
74
75
  // write itself can still reject (quota, private mode) — degrade to 'error'
75
76
  // instead of leaving 'saving' stuck plus an unhandled rejection.
76
77
  const runSave = useCallback((toSave: Plan) => {
77
78
  setSaveState('saving')
78
- void savePlan(toSave)
79
+ void savePlanVia(store, toSave)
79
80
  .then((r) => {
80
81
  setSaveState(r.ok ? 'saved' : 'error')
81
82
  })
82
83
  .catch(() => {
83
84
  setSaveState('error')
84
85
  })
85
- }, [])
86
+ }, [store])
86
87
 
87
88
  const scheduleSave = useCallback(() => {
88
89
  if (timer.current !== null) window.clearTimeout(timer.current)
@@ -7,7 +7,7 @@
7
7
  import { useEffect } from 'react'
8
8
  import { Link, NavLink, Outlet, useLocation, useNavigate, useParams } from 'react-router-dom'
9
9
 
10
- import { duplicatePlan } from '../data/planStore'
10
+ import { duplicatePlanVia, usePlanStore } from '../data/planStoreContext'
11
11
  import { DEFAULT_PATH_COUNT } from '../mc/pool'
12
12
  import { useDialogs } from './dialogs'
13
13
  import { isPlanIncomplete } from './planCompleteness'
@@ -177,6 +177,7 @@ function PlanName() {
177
177
 
178
178
  function WorkspaceInner() {
179
179
  const { plan, discardPendingSave } = usePlan()
180
+ const store = usePlanStore()
180
181
  const navigate = useNavigate()
181
182
  const location = useLocation()
182
183
  const { prompt, alert, dialogs } = useDialogs()
@@ -205,7 +206,7 @@ function WorkspaceInner() {
205
206
  })
206
207
  if (name === null) return
207
208
  if (plan.origin === 'example') discardPendingSave()
208
- const r = await duplicatePlan(plan.id, { name, source: plan })
209
+ const r = await duplicatePlanVia(store, plan.id, { name, source: plan })
209
210
  if (r.ok) navigate(`/plan/${r.plan.id}/results`)
210
211
  else await alert({ title: 'Duplicate plan', body: `Could not duplicate this plan: ${r.issues.join('; ')}` })
211
212
  }
@@ -10,7 +10,10 @@ import { useDialogs } from '../dialogs'
10
10
  import { EXAMPLE_PLANS, type ExamplePlan } from './registry'
11
11
  import { openExampleExisting, openExampleFresh, prepareExampleOpen, saveExampleToMyPlans } from './loadExample'
12
12
  import { EXAMPLE_LOAD_FRESH_DESC, EXAMPLE_OPEN_EXISTING_DESC } from './exampleCopy'
13
+ // Demo records are browser-local by design, so this loadPlan stays on the
14
+ // browser store; only the "Save to my plans" conversion crosses the seam.
13
15
  import { loadPlan } from '../../data/planStore'
16
+ import { usePlanStore } from '../../data/planStoreContext'
14
17
  import { readLocal, STORAGE_KEYS, writeLocal } from '../../data/localStore'
15
18
 
16
19
  /**
@@ -30,6 +33,7 @@ function householdFacts(example: ExamplePlan): string {
30
33
 
31
34
  function ExampleCard({ example, onNotice }: { example: ExamplePlan; onNotice: (msg: string) => void }) {
32
35
  const navigate = useNavigate()
36
+ const store = usePlanStore()
33
37
  const [busy, setBusy] = useState(false)
34
38
  const { choice, dialogs } = useDialogs()
35
39
 
@@ -94,7 +98,7 @@ function ExampleCard({ example, onNotice }: { example: ExamplePlan; onNotice: (m
94
98
  onNotice('Could not load the example to save.')
95
99
  return
96
100
  }
97
- const converted = await saveExampleToMyPlans(loaded.plan)
101
+ const converted = await saveExampleToMyPlans(loaded.plan, { store })
98
102
  if (converted.ok) {
99
103
  onNotice(`"${example.title}" saved to Your plans.`)
100
104
  navigate(`/plan/${converted.plan.id}/results`)
@@ -6,6 +6,7 @@ import { useState } from 'react'
6
6
  import { useNavigate } from 'react-router-dom'
7
7
 
8
8
  import { LearnLink } from '../../learn/LearnLink'
9
+ import { usePlanStore } from '../../data/planStoreContext'
9
10
  import { useDialogs } from '../dialogs'
10
11
  import { usePlan } from '../planContextCore'
11
12
  import { getExampleById } from './registry'
@@ -14,6 +15,7 @@ import { EXAMPLE_BANNER_PERSISTENCE } from './exampleCopy'
14
15
 
15
16
  export function ExamplePreviewBanner() {
16
17
  const { plan, discardPendingSave } = usePlan()
18
+ const store = usePlanStore()
17
19
  const navigate = useNavigate()
18
20
  const [busy, setBusy] = useState(false)
19
21
  const { alert, dialogs } = useDialogs()
@@ -29,7 +31,7 @@ export function ExamplePreviewBanner() {
29
31
  setBusy(true)
30
32
  try {
31
33
  discardPendingSave()
32
- const r = await saveExampleToMyPlans(plan)
34
+ const r = await saveExampleToMyPlans(plan, { store })
33
35
  if (r.ok) navigate(`/plan/${r.plan.id}/results`)
34
36
  else await alert({ title: 'Save example', body: `Could not save: ${r.issues.join('; ')}` })
35
37
  } finally {
@@ -3,7 +3,15 @@
3
3
  */
4
4
 
5
5
  import { exampleStorageId } from '../../data/planOrigin'
6
- import { convertExampleToUserPlan, loadPlan, savePlan, type SavePlanResult } from '../../data/planStore'
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
- return convertExampleToUserPlan(plan, opts)
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,6 +1,6 @@
1
1
  import type { RefObject } from 'react'
2
2
 
3
- import type { PlanSummary } from '../../data/planStore'
3
+ import type { PlanSummary } from '../../data/planStoreContext'
4
4
 
5
5
  type DataAndPrivacyCardProps = {
6
6
  plans: PlanSummary[] | null
@@ -1,6 +1,6 @@
1
1
  import type { ReactNode } from 'react'
2
2
 
3
- import type { PlanSummary } from '../../data/planStore'
3
+ import type { PlanSummary } from '../../data/planStoreContext'
4
4
 
5
5
  function fmtUpdated(iso: string): string {
6
6
  const d = new Date(iso)
@@ -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
- clearAllPlans,
6
- duplicatePlan,
7
- listUserPlanSummaries,
8
- loadPlan,
9
- savePlan,
10
- deletePlan,
6
+ deletePlanVia,
7
+ duplicatePlanVia,
8
+ indexedDbPlanStore,
9
+ listKnownPlanIdsVia,
10
+ listPlansVia,
11
+ loadPlanVia,
12
+ savePlanVia,
13
+ usePlanStore,
11
14
  type PlanSummary,
12
- } from '../../data/planStore'
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 listUserPlanSummaries().then(setPlans)
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 savePlan(plan)
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 listUserPlanSummaries()
60
+ const summaries = await listPlansVia(store)
57
61
  const loaded: Plan[] = []
58
62
  for (const s of summaries) {
59
- const r = await loadPlan(s.id)
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 savePlan(p)
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 duplicatePlan(s.id, { name })
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 loadPlan(s.id)
111
- await deletePlan(s.id)
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 savePlan(restored)
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)) {