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