@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.
@@ -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)) {
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useState } from 'react'
2
2
 
3
3
  import { readLocal, STORAGE_KEYS, writeLocal } from '../../data/localStore'
4
- import type { PlanSummary } from '../../data/planStore'
4
+ import type { PlanSummary } from '../../data/planStoreContext'
5
5
 
6
6
  export const WELCOME_DISMISSED_KEY = STORAGE_KEYS.homeWelcomeDismissed
7
7
 
@@ -119,7 +119,7 @@ export function InsightCardView({ card, onDismiss }: { card: InsightCard; onDism
119
119
  }
120
120
  }
121
121
  } else {
122
- setPreviewError(`This suggestion can't be applied to your plan: ${applied.issues.join('; ')}`)
122
+ setPreviewError(`This insight can't be applied to your plan: ${applied.issues.join('; ')}`)
123
123
  }
124
124
  }
125
125
  } catch (err) {
@@ -136,7 +136,7 @@ export function InsightCardView({ card, onDismiss }: { card: InsightCard; onDism
136
136
  if (action.kind !== 'preview-scenario') return
137
137
  const applied = applyScenarioPatch(plan, action.patch)
138
138
  if (!applied.ok) {
139
- setPreviewError(`This suggestion can't be applied to your plan: ${applied.issues.join('; ')}`)
139
+ setPreviewError(`This insight can't be applied to your plan: ${applied.issues.join('; ')}`)
140
140
  return
141
141
  }
142
142
  const name = uniqueScenarioName(action.scenarioName, plan)
@@ -198,8 +198,8 @@ export function InsightCardView({ card, onDismiss }: { card: InsightCard; onDism
198
198
  type="button"
199
199
  className="btn-ghost insight-dismiss"
200
200
  onClick={onDismiss}
201
- aria-label="Dismiss this recommendation"
202
- title="Dismiss this recommendation"
201
+ aria-label="Dismiss this insight"
202
+ title="Dismiss this insight"
203
203
  >
204
204
  <svg
205
205
  viewBox="0 0 24 24"
@@ -319,7 +319,7 @@ export function InsightCardView({ card, onDismiss }: { card: InsightCard; onDism
319
319
  </>
320
320
  )}
321
321
  {card.action.kind === 'advisory' && (
322
- <span className="insight-advisory-note">Advisory only</span>
322
+ <span className="insight-advisory-note">Informational only</span>
323
323
  )}
324
324
  {card.action.kind === 'apply-toggle' && (
325
325
  <>
@@ -102,12 +102,13 @@ export function InsightsPage() {
102
102
  <section>
103
103
  <LiveStatus message={liveMessage} />
104
104
  <div className="card">
105
- <h2>Insights & Recommendations</h2>
105
+ <h2>Insights</h2>
106
106
  <p className="card-hint">
107
- RetireGolden scans your plan to identify personalized planning opportunities. These recommendations update live as you edit.
107
+ RetireGolden scans your plan for modeled opportunities worth comparing. These findings update live as you
108
+ edit, and each one can be previewed as a scenario before anything in your plan changes.
108
109
  </p>
109
110
  <div className="callout callout--info">
110
- <strong>Educational only.</strong> These suggestions are for educational purposes based on the rules and assumptions modeled. They do not constitute financial, tax, or legal advice.
111
+ <strong>Educational only.</strong> These findings are for educational purposes based on the rules and assumptions modeled. They do not constitute financial, tax, or legal advice.
111
112
  </div>
112
113
  </div>
113
114
 
@@ -115,7 +116,7 @@ export function InsightsPage() {
115
116
  <div className="card">
116
117
  <h2>Roth & Tax Optimizer</h2>
117
118
  <p className="card-hint">
118
- Compare candidate Roth-conversion schedules on your full year-by-year projection and see the best result found for your plan.
119
+ Compare candidate Roth-conversion schedules on your full year-by-year projection, ranked by the objective you select.
119
120
  </p>
120
121
  <Link to={`/plan/${plan.id}/optimize`} className="btn btn-primary btn-small">
121
122
  Run optimizer →
@@ -126,7 +127,7 @@ export function InsightsPage() {
126
127
  <div className="card">
127
128
  <h2>Social Security Optimizer</h2>
128
129
  <p className="card-hint">
129
- Analyze every claiming age combination side-by-side to find the schedule that maximizes your ending estate.
130
+ Run every claiming-age combination through your full plan and rank them by the objective you choose.
130
131
  </p>
131
132
  <Link to={`/plan/${plan.id}/social-security-analysis`} className="btn btn-secondary btn-small">
132
133
  Run SS sweep →
@@ -41,7 +41,7 @@ export interface ModelPreset {
41
41
  export const MODEL_PRESETS: ReadonlyArray<ModelPreset> = [
42
42
  {
43
43
  id: 'smooth',
44
- label: 'Smooth randomness (recommended)',
44
+ label: 'Smooth randomness (default)',
45
45
  description: 'Each year varies around your expected return, like a bell curve.',
46
46
  kind: 'lognormal',
47
47
  },
@@ -0,0 +1,14 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import { ReportBrandingContext } from './brandingContext'
4
+ import type { ReportBranding } from './reportHtml'
5
+
6
+ /**
7
+ * Supplies report branding to hosts that mount the exported route groups
8
+ * directly instead of `<PlannerApp/>` (whose `reportBranding` prop does the
9
+ * same thing). Omit `branding` (or the provider) and downloaded reports keep
10
+ * the RetireGolden defaults.
11
+ */
12
+ export function ReportBrandingProvider({ branding, children }: { branding?: ReportBranding; children: ReactNode }) {
13
+ return <ReportBrandingContext.Provider value={branding ?? null}>{children}</ReportBrandingContext.Provider>
14
+ }