@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.
@@ -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
  }
@@ -36,6 +36,7 @@ import { useProjection } from './useProjection'
36
36
  import { BucketLensCard } from './BucketLensCard'
37
37
  import { FundedRatioCard } from './sections/IncomeFloorSection'
38
38
  import { chartTooltipStyle } from './chartStyle'
39
+ import { NonZeroTooltipContent } from './chartTooltip'
39
40
  import { frameH } from './chartFrame'
40
41
  import { useMcSuccessRate } from './useMcSuccessRate'
41
42
 
@@ -116,6 +117,10 @@ const tooltipProps = {
116
117
  wrapperStyle: { zIndex: 2 },
117
118
  } as const
118
119
 
120
+ // Stacked charts (6–8 series) hide ~$0 rows in the tooltip via content, never
121
+ // by nulling zeros in the data — see NonZeroTooltipContent.
122
+ const stackTooltipProps = { ...tooltipProps, content: NonZeroTooltipContent } as const
123
+
119
124
  /** "1,000" — keeps verdict copy in sync if the default path count changes. */
120
125
  const PATH_COUNT_LABEL = DEFAULT_PATH_COUNT.toLocaleString()
121
126
 
@@ -231,14 +236,7 @@ export function ResultsPage() {
231
236
  const nominalFiTarget = view.summary.fiNumber * Math.pow(1 + plan.assumptions.inflationPct / 100, y.year - view.startYear)
232
237
  return {
233
238
  year: y.year,
234
- // Zero balances render as null so empty categories stay out of the
235
- // tooltip (an "HSA: $0" row is noise in a six-series stack).
236
- ...Object.fromEntries(
237
- CATEGORIES.map((c) => {
238
- const v = adj(y.year, cats[c])
239
- return [c, v > 0.5 ? v : null]
240
- }),
241
- ),
239
+ ...Object.fromEntries(CATEGORIES.map((c) => [c, adj(y.year, cats[c])])),
242
240
  income: adj(y.year, y.incomes.total),
243
241
  spending: adj(y.year, y.expenses.total + y.tax + y.penalties),
244
242
  tax: adj(y.year, y.tax),
@@ -251,22 +249,18 @@ export function ResultsPage() {
251
249
  [view, plan, adj],
252
250
  )
253
251
 
254
- // Zero series values render as null so the tooltip (filterNull) skips them —
255
- // a stack of 6–8 series otherwise lists every "$0" row as noise.
256
- const orNull = (v: number) => (v > 0.5 ? v : null)
257
-
258
252
  const incomeRows = useMemo(
259
253
  () =>
260
254
  view.result.years.map((y) => ({
261
255
  year: y.year,
262
- wages: orNull(adj(y.year, y.incomes.wages)),
263
- socialSecurity: orNull(adj(y.year, y.incomes.socialSecurity)),
264
- pension: orNull(adj(y.year, y.incomes.pension)),
265
- annuity: orNull(adj(y.year, y.incomes.annuity)),
266
- tipsLadder: orNull(adj(y.year, y.incomes.tipsLadder)),
267
- recurring: orNull(adj(y.year, y.incomes.recurring)),
268
- oneTime: orNull(adj(y.year, y.incomes.oneTime)),
269
- taxableYield: orNull(adj(y.year, y.incomes.taxableYield)),
256
+ wages: adj(y.year, y.incomes.wages),
257
+ socialSecurity: adj(y.year, y.incomes.socialSecurity),
258
+ pension: adj(y.year, y.incomes.pension),
259
+ annuity: adj(y.year, y.incomes.annuity),
260
+ tipsLadder: adj(y.year, y.incomes.tipsLadder),
261
+ recurring: adj(y.year, y.incomes.recurring),
262
+ oneTime: adj(y.year, y.incomes.oneTime),
263
+ taxableYield: adj(y.year, y.incomes.taxableYield),
270
264
  })),
271
265
  [view, adj],
272
266
  )
@@ -275,14 +269,14 @@ export function ResultsPage() {
275
269
  () =>
276
270
  view.result.years.map((y) => ({
277
271
  year: y.year,
278
- base: orNull(adj(y.year, y.expenses.baseSpending)),
279
- healthcare: orNull(adj(y.year, y.expenses.healthcare)),
280
- property: orNull(adj(y.year, y.expenses.propertyCosts)),
281
- debt: orNull(adj(y.year, y.expenses.debtService)),
282
- insurance: orNull(adj(y.year, y.expenses.insurancePremiums)),
283
- care: orNull(adj(y.year, Math.max(0, y.expenses.careCost - y.expenses.ltcBenefit))),
284
- goals: orNull(adj(y.year, y.expenses.oneTimeGoals)),
285
- taxes: orNull(adj(y.year, y.tax + y.penalties)),
272
+ base: adj(y.year, y.expenses.baseSpending),
273
+ healthcare: adj(y.year, y.expenses.healthcare),
274
+ property: adj(y.year, y.expenses.propertyCosts),
275
+ debt: adj(y.year, y.expenses.debtService),
276
+ insurance: adj(y.year, y.expenses.insurancePremiums),
277
+ care: adj(y.year, Math.max(0, y.expenses.careCost - y.expenses.ltcBenefit)),
278
+ goals: adj(y.year, y.expenses.oneTimeGoals),
279
+ taxes: adj(y.year, y.tax + y.penalties),
286
280
  })),
287
281
  [view, adj],
288
282
  )
@@ -535,7 +529,7 @@ export function ResultsPage() {
535
529
  {view.summary.depletionYear !== null ? (
536
530
  <ReferenceLine x={view.summary.depletionYear} stroke="var(--bad)" strokeDasharray="4 4" label={{ value: 'depleted', fill: 'var(--bad)', fontSize: 12 }} />
537
531
  ) : null}
538
- <Tooltip {...tooltipProps} />
532
+ <Tooltip {...stackTooltipProps} />
539
533
  </AreaChart>
540
534
  </ResponsiveContainer>
541
535
  </div>
@@ -591,7 +585,7 @@ export function ResultsPage() {
591
585
  {INCOME_SOURCES.map((s) => (
592
586
  <Bar key={s.key} dataKey={s.key} stackId="inc" name={s.label} fill={s.color} />
593
587
  ))}
594
- <Tooltip {...tooltipProps} />
588
+ <Tooltip {...stackTooltipProps} />
595
589
  </BarChart>
596
590
  </ResponsiveContainer>
597
591
  </div>
@@ -620,7 +614,7 @@ export function ResultsPage() {
620
614
  {EXPENSE_CATEGORIES.map((c) => (
621
615
  <Bar key={c.key} dataKey={c.key} stackId="exp" name={c.label} fill={c.color} />
622
616
  ))}
623
- <Tooltip {...tooltipProps} />
617
+ <Tooltip {...stackTooltipProps} />
624
618
  </BarChart>
625
619
  </ResponsiveContainer>
626
620
  </div>
@@ -113,7 +113,7 @@ export function SurvivalPercentileModal({ person, personIndex, partner, onApply,
113
113
  <div className="form-grid">
114
114
  <SelectField
115
115
  label="Chance of reaching that age"
116
- help="The survival probability the planning age is anchored to. Planning to the 25th percentile means only a 1-in-4 chance of outliving the plan horizon — the usual prudent recommendation. The median (50%) is a coin flip; 10% is conservative."
116
+ help="The survival probability the planning age is anchored to. Planning to the 25th percentile means only a 1-in-4 chance of outliving the plan horizon — a common prudent convention. The median (50%) is a coin flip; 10% is conservative."
117
117
  value={pct}
118
118
  options={PCT_OPTIONS}
119
119
  onCommit={(v) => setPct(v as '50' | '25' | '10')}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Default tooltip content, minus ~$0 rows. For stacked charts with many series
3
+ * this keeps "HSA: $0" noise out of the tooltip. Filter here, not by nulling
4
+ * zeros in the data: a null in a stacked Area breaks that series' polygon while
5
+ * the series above still ramp their baseline across the gap, punching
6
+ * background-colored wedges into the stack.
7
+ */
8
+
9
+ import { DefaultTooltipContent, type TooltipContentProps } from 'recharts'
10
+
11
+ export function NonZeroTooltipContent(props: TooltipContentProps) {
12
+ const payload = props.payload?.filter((entry) => typeof entry.value === 'number' && entry.value > 0.5)
13
+ // Recharts gates tooltip visibility on the unfiltered payload, so when every
14
+ // series is ~$0 (e.g. post-depletion years) render nothing rather than a
15
+ // floating card holding only the year label.
16
+ if (!payload || payload.length === 0) return null
17
+ return <DefaultTooltipContent {...props} payload={payload} />
18
+ }
@@ -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 {