@robinking/retcalc-us
v0.2.0
Published
US retirement calculator engine and React components for Sitecore XM. Chart-library agnostic. Ported from the Sitecore 9.3 fiRc modules and validated against the reference workbook.
Maintainers
Readme
@robinking/retcalc-us
US retirement calculator for Sitecore XM. Give it the funnel's answers, get back every number the results page needs — the headline figures, the chart series, and the state classes the stylesheet keys off.
It draws nothing itself. No charting library is a dependency — not ECharts, not Chart.js. The package hands you the series and, if you want it, a ready-made option object for the library you already use. Adapters for ECharts and Chart.js ship in the box; anything else is a few lines.
The package is split four ways, and you can enter at whichever level suits the page:
| Layer | What it is | Runs on |
| --- | --- | --- |
| Model | Reads sessionStorage, normalises it, resolves a complete input set | Server or browser |
| Engine | Pure functions. Inputs in, projection out. No React, no DOM | Server or browser |
| Chart | Renderer-agnostic series, plus ECharts / Chart.js option builders | Server or browser |
| View | React 19 components and a controller hook | Browser ('use client') |
Two entry points:
import { RetirementCalculator } from '@robinking/retcalc-us'; // everything
import { calculateFromFields } from '@robinking/retcalc-us/engine'; // model + engine onlyThe engine has no dependency on React, so a Next.js server component or a route handler can call it directly. Only the view layer needs the browser.
Install
npm install @robinking/retcalc-usOnly React is required, and only if you use the components:
npm install react@^19.1.0 react-dom@^19.1.0Then whichever charting library you intend to draw with — or none:
# Apache ECharts, via the React wrapper
npm install echarts@^5.4.3 echarts-for-react@^3.0.6
# or Chart.js
npm install chart.js@^4.4.0
# or nothing at all — the built-in SVG renderer needs no dependencyEvery charting peer is declared optional, and nothing in src imports one. Install the ones you
use; npm will not warn about the rest.
| Peer | Required? | Needed for |
| --- | --- | --- |
| react, react-dom | For the components | RetirementCalculator, RetirementChart, the hook |
| echarts, echarts-for-react | Optional | An ECharts renderer you write from buildEChartsOption |
| chart.js | Optional | A Chart.js renderer you write from buildChartJsConfig |
For numbers only, import from @robinking/retcalc-us/engine — it has no runtime dependencies at
all, React included.
The shape of the thing
sessionStorage.formData ─┐
sessionStorage._pagesettings ─┼──▶ resolveFields() ──▶ RcFields ──▶ calculateFromFields() ──▶ RcResults
results-page control edits ─┘ (model) 30 numbers (engine) everything
the page needsRcFields is the input state: thirty flat numbers. RcResults is the output: headline figures,
two chart series, and the full year-by-year tables behind them.
Tax brackets and Social Security bend points are not hand-written: they're generated from
data/TaxTable.csv and data/BendPoints.csv by npm run tax:import. The package ships one tax year
at a time (currently 2026). See Updating the tax year.
Quick start
1. Drop-in component
The fastest path. Renders inputs, summary, and chart, and wires up session storage for you.
'use client';
import { RetirementCalculator } from '@robinking/retcalc-us';
export default function ResultsPage() {
return (
<RetirementCalculator
country="US"
locale="en-US"
currencySymbol="$"
chart={{ needLabel: 'Amount Needed', actualLabel: 'Amount Saved', axisLabel: 'Age' }}
/>
);
}It renders .rc__main (summary + chart) and .rc__sidebar (details + asset mix) as siblings inside
a .rc root, which is the same two-panel shape as the live thank-you page. Pass className to set
the wrapper's class.
Props are UseRetirementCalculatorOptions plus locale, currencySymbol, chart,
chartRenderer, className, showAssetMix, and applyBodyClasses. chart carries the
ChartOptions; chartRenderer picks the charting library. Omit chartRenderer and it uses the
built-in SVG renderer, so the example above runs with nothing but React installed.
2. Hook plus your own markup
More likely what you want, since the Sitecore layout dictates the markup. The hook owns the state; you own every element.
'use client';
import {
useRetirementCalculator,
buildChartSeries,
outlookBand,
OUTLOOK_LABELS,
} from '@robinking/retcalc-us';
export function Results() {
const { fields, results, socialSecurity, setField, isReady } = useRetirementCalculator({
country: 'US',
});
if (!isReady) return null;
// Plain arrays — feed them to whatever chart the page already has.
const series = buildChartSeries(results);
const outlook = OUTLOOK_LABELS[outlookBand(results.runningOutAge, fields.lifeExpectancy)];
return (
<section>
<h2>{outlook}</h2>
<p>You are {results.totalPercent}% of the way to your goal.</p>
<label>
Retirement age
<input
type="range"
min={50}
max={80}
value={fields.retirementAge}
onChange={(event) => setField('retirementAge', Number(event.target.value))}
/>
</label>
<YourChart ages={series.ages} needs={series.needs} actual={series.actual} />
</section>
);
}Every setField call recomputes the projection synchronously — results is a useMemo over
fields, so there is no loading state after the first mount.
3. Headless, just the numbers
No React at all. Useful in a route handler, a server component, a test, or a CLI script.
import { resolveFields, calculateFromFields } from '@robinking/retcalc-us/engine';
const fields = resolveFields({
country: 'US',
formData: { currentAge: 50, retirementAge: 65, personAssetValue: 1_000_000 },
});
const results = calculateFromFields(fields, 'US');
console.log(results.totalPercent, results.runningOutAge);Note the import path. The main entry re-exports the components, so it pulls react and
react/jsx-runtime in at the top level. A bundler tree-shakes those away; bare Node does not, and
the import throws before your code runs if React isn't installed. The /engine entry has zero
imports — model, engine, chart series and both option builders:
import { resolveFields, calculateFromFields } from '@robinking/retcalc-us/engine';Use it anywhere React isn't already in play. It also exports buildChartSeries, buildChartModel,
buildEChartsOption and buildChartJsConfig, so you can produce chart data — or a complete option
object — server-side and hand plain JSON to the client.
Where the inputs come from
resolveFields() merges four sources:
| Source | Holds |
| --- | --- |
| overrides | Results-page controls the user has touched |
| sessionStorage._pagesettings | Rates authored in Sitecore — inflation, cost, tax |
| sessionStorage.formData | The funnel's answers — age, assets, contributions, expenses |
| Country defaults | US_DEFAULT_FIELDS, derived from the workbook |
Precedence is per field, not a flat cascade. Each field declares which store owns it, and that store is checked first; the other acts as a fallback:
overrides— always wins_pagesettings, for the fields the CSV marks as page settings (the rates)formData_pagesettingsagain, as a late fallback for anything still missing- Country defaults
So a rate in formData will not override the same rate in _pagesettings, and a user answer in
_pagesettings will not override formData. Sitecore owns the rates; the funnel owns the answers.
Each field is looked up under every alias it has ever had — canonical name, pre-rename results-page
data-name, 9.3 data-name, old cookie key, fiRc.fields property — so a funnel that hasn't been
migrated still feeds the calculator. Lookups are key-based, never positional. FIELD_MAP in
src/model/fieldMap.ts is the single source of truth, transcribed from RetCalc_US_Names-mapper.csv.
Called with no arguments it reads window.sessionStorage itself. Pass formData or pageSettings
explicitly to bypass storage:
resolveFields({ country: 'US' }); // reads sessionStorage
resolveFields({ country: 'US', formData: { ... } }); // uses what you pass_pagesettings comes in two shapes
Sitecore writes it as an array of { name, value, scope } entries, not a flat object. readStore()
normalises that on the way out of storage, so the default path handles it. Passing the raw array
straight to resolveFields does not normalise it — pick() sees array indices as keys, matches
nothing, and silently falls back to country defaults. No error, just quietly wrong rates.
import { normaliseStore, resolveFields } from '@robinking/retcalc-us';
const pageSettings = normaliseStore([
{ name: 'inflationRate', value: 0.0223, scope: 'retcalc' },
{ name: 'costRate', value: 0.0228, scope: 'retcalc' },
]);
resolveFields({ country: 'US', pageSettings });Writing back
persistFields(fields) writes every resolvable field to sessionStorage.formData, merging rather
than replacing so other funnel steps' keys survive. The hook does this automatically after the first
user edit; pass persist: false to turn it off.
The input set — RcFields
Thirty flat numbers. Every one has a default, so you can supply as few or as many as you have and
resolveFields() fills the rest. Rates are decimals, not percentages: 0.0478, not 4.78.
Money is in whole currency units. Booleans are 0 | 1, matching the legacy markup.
From the funnel — sessionStorage.formData
| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| currentAge | number | 50 | Age today |
| isRetired | 0 \| 1 | 0 | Already retired |
| yearsToRetirement | number | 15 | Years until retirement |
| yearsRetired | number | 0 | Years already retired, when isRetired is 1 |
| retirementAge | number | 65 | Target retirement age |
| lifeExpectancy | number | 95 | Age the projection runs to |
| personAssetValue | number | 1000000 | Investable assets today |
| currentMonthlyRetirementSavings | number | 5000 | Monthly contribution |
| monthlyCashFlowNeededInRetirement | number | 6250 | Monthly spend in retirement |
| includeSpouse | 0 \| 1 | 0 | Joint filing — selects the bracket table |
Asset mix, from the results-page controls
| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| stocksMix | number | 0.1 | Stock weight, decimal |
| bondsMix | number | 0.0367 | Bond weight, decimal |
| otherMix | number | 0.01875 | Other weight, decimal |
| customRoR | number | 0.06 | Directly-entered return. Used unless the three weights sum to 1 |
The weights are expected returns per asset class, not an allocation. expectedRateOfReturn()
blends them against the user's allocation and subtracts costRate. When an allocation doesn't total
100%, the engine falls back to customRoR.
Income and Social Security
| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| ssiLastYrWage | number | 0 | Last year's wage — drives the SS bend-point estimate |
| monthlySocialSecurityBenefits | number | 0 | Known monthly benefit, if the user has one |
| ssBenefitsAve | number | 1711 | Average monthly benefit, used when nothing else is known |
| additionalMthlyIncome | number | 0 | Pensions, rent, anything outside the portfolio |
| ageStartSocialSecurity | number | 68 | Age benefits start. Excel B18 |
| bendPointPercent1/2/3 | number | 0.9 / 0.32 / 0.15 | SS bend-point rates |
| bendPointMax1/2 | number | 1115 / 6721 | SS bend-point monthly ceilings |
| benefitMax | number | 3627 | Max monthly benefit at full retirement age |
Authored in Sitecore — sessionStorage._pagesettings
| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| inflationRate | number | 0.0478 | Annual inflation, decimal. Excel B11 |
| costRate | number | 0.02 | Annual cost drag on the blended return. Excel F11 |
| taxRate | number | 0 | Carried through but not applied — see below |
| stateTaxRate | number | 0 | Flat regional rate on top of the federal effective rate. Excel C8 |
| targetAtLifeExpectancy | number | 0 | Portfolio value to leave at lifeExpectancy. Excel B9 |
Field names are matched by key against every alias each field has ever had — canonical name,
pre-rename results-page data-name, 9.3 data-name, old cookie key, fiRc.fields property. Never
by position. aliasesFor('inflationRate') lists them.
What you get back
Headline figures — RcResults
These are the ones a results page actually prints.
| Field | Type | Meaning |
| --- | --- | --- |
| totalPercent | number | Progress toward the goal, as a whole percent |
| totalPortfolioAtRetirement | number | Projected balance at the retirement year |
| needAtRetirement | number | What the balance needs to be |
| surplusAtRetirement | number | Positive = surplus, negative = shortfall |
| runningOutAge | number \| null | Age the portfolio is exhausted; null means it lasts |
| monthlyPortfolioWithdrawal | number | Monthly draw the plan requires |
| effectiveTaxRates | { preSS, withSS } | Blended rates before and after SS starts |
| fundBreakdown | object | Balance / contributions / growth split, with each as a rate |
| params | RcParams | Every derived input, if you need to show assumptions |
Series and tables
| Field | Shape | Use |
| --- | --- | --- |
| graph | { ages, needs, actual } | Chart-ready, all three aligned. 46 points for a 50→95 span |
| contributions | ContributionRow[] | Year-by-year pre-retirement growth |
| retirementValues | RetirementValueRow[] | Year-by-year drawdown |
| needsPostRetirement | number[] | Annual requirement from retirement onward |
contributions and retirementValues are fixed-length projection tables (PROJECTION_ROWS) that
run past life expectancy, with null in the trailing rows. graph is the trimmed view — use that
for anything user-facing.
Social Security
The hook also returns socialSecurity, a SocialSecurityEstimate derived from ssiLastYrWage:
totalMonthlyBenefitToday, totalAnnualBenefitToday, and the three bend-point components.
The chart
The package draws nothing and imports no charting library. It hands you the data at three levels of readiness — pick the one that matches how much control you want.
| You want | Call | Returns |
| --- | --- | --- |
| Just the numbers | buildChartSeries(results, options) | { ages, needs, actual, needLabel, actualLabel, isSurplus } |
| Numbers + theme + paint order | buildChartModel(results, options) | ChartModel — the above plus bands, theme, formatValue |
| A finished config | buildEChartsOption(...) / buildChartJsConfig(...) | A complete option object for that library |
| A rendered component | <RetirementChart results={r} renderer={...} /> | A sized, labelled container with your renderer inside |
All four are pure and none of them touch the DOM, so they run on the server as happily as in the browser.
ChartOptions
Every builder and RetirementChart accept the same options object. All fields are optional.
| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| locale | string | 'en-US' | Passed to Intl.NumberFormat for ticks and tooltips |
| currency | string | 'USD' | ISO 4217 code |
| needLabel | string | 'Amount Needed' | Legend label for the requirement band |
| actualLabel | string | 'Amount Saved' | Legend label for the projection band |
| axisLabel | string | 'Age' | X-axis title, also the tooltip header |
| showLegend | boolean | true | Set false when the page supplies its own |
| theme | Partial<ChartTheme> | see below | Colours and fill opacity |
ChartTheme is { needColor, actualColor, gridColor, textColor, fillAlpha }. Colours default to
hsl(...) strings and fillAlpha to 1, matching the legacy opaque bands. DEFAULT_CHART_THEME
is exported if you want to spread and tweak it.
ChartModel — the renderer-agnostic view
This is what every adapter in the package is built on, and what your renderer receives.
| Field | Type | Meaning |
| --- | --- | --- |
| ages | number[] | X values. 46 points for a 50 → 95 span |
| needs | number[] | Required balance at each age. Excel column AB |
| actual | number[] | Projected balance at each age. Excel column Q |
| needLabel | string | Label with the figure appended, e.g. Amount Needed: $7.0M |
| actualLabel | string | Same for the projection |
| isSurplus | boolean | true when the projection clears the requirement |
| bands | ChartBand[] | Both series, already ordered back-to-front |
| theme | ChartTheme | Resolved theme |
| formatValue | (n: number) => string | Compact currency formatter — $1.23M |
| axisLabel, locale, currency, showLegend | | Resolved options |
Each ChartBand is { key, label, data, color, fill, z }. fill already has theme.fillAlpha
applied. z is normalised so higher paints on top, and bands is sorted so index 0 paints
first.
Why paint order matters. The bands use opaque fills, so whichever is smaller has to be drawn
last or it disappears underneath. That flips depending on isSurplus, and the libraries disagree on
which end of the array is on top: ECharts paints the last entry on top, Chart.js paints the
lowest order on top. bands is back-to-front, which is ECharts' convention directly and an
index flip for Chart.js. If you write your own renderer against bands, iterate in order and you
will be right.
Connecting echarts-for-react
Install the peers:
npm install echarts@^5.4.3 echarts-for-react@^3.0.6Then write the adapter once. This is the whole thing — buildEChartsOption does the work:
'use client';
import { LineChart } from 'echarts/charts';
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import ReactEChartsCoreEntry from 'echarts-for-react/lib/core';
import { buildEChartsOption, type ChartRenderer } from '@robinking/retcalc-us';
// Register only what a line chart needs. The registry is global, so calling
// this more than once is a no-op.
echarts.use([LineChart, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer]);
// `echarts-for-react` ships `lib/` as CommonJS with no `exports` map, so a
// subpath import under an ESM loader yields `{ __esModule: true, default }`
// rather than the component. Bundlers paper over this; SSR and browser
// importmaps do not.
const ReactEChartsCore =
(ReactEChartsCoreEntry as { default?: unknown }).default ?? ReactEChartsCoreEntry;
export const echartsRenderer: ChartRenderer = ({ results, options }) => (
<ReactEChartsCore
echarts={echarts}
option={buildEChartsOption(results, options)}
notMerge // a shrinking series array would otherwise leave a stale band
lazyUpdate
opts={{ renderer: 'canvas' }}
style={{ height: '100%', width: '100%' }}
/>
);Pass it in, either to the chart or to the whole calculator:
<RetirementChart results={results} renderer={echartsRenderer} height={380} />
<RetirementCalculator country="US" chartRenderer={echartsRenderer} />Two things worth knowing. Use the lib/core entry, not the default echarts-for-react export — the
default entry imports the whole ECharts bundle and undoes the registration above. And the container
needs a height; RetirementChart writes one inline, so if the page should own sizing instead, omit
height and style the [name="lineChart"] element.
A working copy of this adapter, written in plain ESM so it runs from Live Server with no build step,
is in test-pages/renderers/echartsRenderer.js.
Connecting Chart.js
Same shape, with buildChartJsConfig and the instance lifecycle in your hands:
'use client';
import Chart from 'chart.js/auto';
import { useEffect, useRef } from 'react';
import { buildChartJsConfig, type ChartRenderer } from '@robinking/retcalc-us';
const ChartJsChart = ({ results, options }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const chartRef = useRef<Chart | null>(null);
const configRef = useRef(buildChartJsConfig(results, options));
configRef.current = buildChartJsConfig(results, options);
useEffect(() => {
if (!canvasRef.current) return;
if (chartRef.current) {
chartRef.current.data = configRef.current.data;
chartRef.current.options = configRef.current.options;
chartRef.current.update();
return;
}
chartRef.current = new Chart(canvasRef.current, configRef.current);
}, [results]);
useEffect(() => () => { chartRef.current?.destroy(); chartRef.current = null; }, []);
return <canvas ref={canvasRef} />;
};
export const chartjsRenderer: ChartRenderer = (props) => <ChartJsChart {...props} />;chart.js/auto is used here for brevity. In production register just LineController,
LineElement, PointElement, LinearScale, CategoryScale, Filler, Legend and Tooltip so
the other chart types stay out of the bundle. Nothing is registered inside the package, so
tree-shaking is yours to control. Working copy: test-pages/renderers/chartjsRenderer.js.
Connecting anything else
A renderer is a function from { model, results, options } to a React node. Against a declarative
library like Recharts you only need model:
const rechartsRenderer: ChartRenderer = ({ model }) => (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={model.ages.map((age, i) => ({
age,
need: model.needs[i],
actual: model.actual[i],
}))}>
<XAxis dataKey="age" />
<YAxis tickFormatter={model.formatValue} />
{/* `bands` is back-to-front, which is also SVG paint order */}
{model.bands.map((band) => (
<Area key={band.key} dataKey={band.key === 'need' ? 'need' : 'actual'}
stroke={band.color} fill={band.fill} name={band.label} />
))}
</AreaChart>
</ResponsiveContainer>
);Or skip RetirementChart entirely and feed a non-React chart from buildChartSeries. The engine
entry exports it, so the series can be computed server-side and shipped as JSON:
import { buildChartSeries } from '@robinking/retcalc-us/engine';
const { ages, needs, actual } = buildChartSeries(results);The built-in renderer
With no renderer prop, RetirementChart falls back to SvgProjectionChart — a plain SVG area
chart with no dependencies, drawing the same two bands with the same colours, formatter and paint
order. It exists so the package works before a charting library is chosen, and so a page that only
needs a small static chart doesn't have to ship one. It is deliberately plain: gridlines, axis
labels, a hover crosshair, nothing more. For production, pass a renderer backed by whatever the rest
of the site already loads.
Results-page state
The legacy stylesheet shows and hides blocks based on classes on <body>. bodyClasses(results)
returns them:
bodyClasses(results);
// → ['not-retired', 'qual', 'prog-low', 'outlook-focus-on-your-future']| Class | Derived from |
| --- | --- |
| retired / not-retired | params.isRetired |
| show-spouse | params.includeSpouse |
| qual / unqual | params.personAssetValue >= 500_000 |
| prog-low\|medium\|high\|highest | progressBand(totalPercent) — cuts at 40 / 75 / 100 |
| outlook-* | outlookBand(runningOutAge, lifeExpectancy) |
RetirementCalculator applies these to document.body automatically. Pass
applyBodyClasses={false} if you'd rather apply them yourself, which you'll want in Next.js if the
classes need to survive a route change.
OUTLOOK_LABELS maps the outlook band to its display string.
Markup hooks
The components carry name attributes holding the canonical name from RetCalc_US_Names-mapper.csv, so
one string identifies a value in the markup, in formData and in FIELD_MAP:
| name | Rendered by | Was (9.3) |
| --- | --- | --- |
| pillStatus | ResultsSummary outlook headline | smhstatus |
| statusAmt | ResultsSummary surplus / shortfall line | amtstatus |
| totalPortfolioAtRetirement | ResultsSummary projected balance | totamt |
| totalPercent | ResultsSummary progress tooltip | totpct |
| lifeExpectancy | ResultsSummary estimate sentence | totlif |
| monthlyPortfolioWithdrawal | ResultsSummary detail list | monemw |
| calculatedLifeExpectancy | ResultsSummary detail list | lifexpdis |
| runningOutAge | ResultsSummary detail list | runoutage |
| stocksMix / bondsMix / otherMix | AssetMix rows (bars take <name>-width) | amixst / amixbo / amixot |
| expectedReturns | AssetMix blended return | amixer |
| retirementAge, lifeExpectancy, personAssetValue, currentMonthlyRetirementSavings, monthlyCashFlowNeededInRetirement, ssiLastYrWage, MonthlySocialSecurityBenefits, additionalMthlyIncome, inflationRate | PersonalDetails text boxes (sliders take <name>-slider) | retage, lifexp, invass, moncon, monexp, ssilyw, ssmobe, addinc, infrat |
Four hooks are containers or controls rather than values, have no CSV row, and keep their original
token: lineChart, perdet-recalc, amixbtnadd, portfolio_error_handling.
The short forms are not dead — FIELD_MAP still accepts every one of them as a storage alias, so
an unmigrated funnel step that writes totpct or retage resolves exactly as before. They are just
no longer DOM selectors.
Note that name is only conforming HTML on form-associated elements. On the output <span>, <p>
and <dd> hooks it is a non-standard attribute: every browser sets it and [name="…"] matches it,
but the markup will not pass HTML validation. That is the trade-off this naming scheme accepts.
Breaking: FieldControl takes name where it used to take dataName, and renders it as name
rather than data-name. Anything selecting on [data-name="…"] — Sitecore selectors, analytics,
CSS, tests — has to move to [name="…"] with the canonical name from the table above.
Adding a country
One US federal tax year ships in the box (currently 2026 — see Updating the tax year). Register another country without touching the engine:
import { taxTables } from '@robinking/retcalc-us/engine';
taxTables.register({
country: 'CA',
source: 'CRA federal brackets',
year: 2024,
topBracketCeiling: 1_000_000_000,
brackets: {
single: [{ rate: 0.15, taxableIncomeOver: 0 } /* … */],
married: [/* … */],
},
});taxableIncomeOver is the inclusive lower bound; the upper bound is derived as next - 1.
Countries without joint filing can point married at the same array. registerDefaults() does the
same job for the country's default field values.
Updating the tax year
The package ships one tax year at a time. Brackets, bend points, and the default Social Security
benefit are generated from two CSVs in data/, so a yearly refresh is a data change and a review,
not a code edit.
# 1. drop the new sheets into data/, replacing the old ones
# 2. regenerate
npm run tax:import
# 3. review the diff, then confirm nothing else moved
npm run validatenpm run tax:check parses and reports without writing, and exits non-zero when the generated files
are stale. It's the one to run in CI if you want a guard against someone hand-editing the output.
The two files
data/TaxTable.csv — one row per bracket. Required columns are Tax year, Tax rate,
Single filers, and Married couples filing jointly.
| Tax year | Tax rate | Single filers | Married couples filing jointly | |---|---|---|---| | 2026 | 10% | $12,400 or less | $24,800 or less | | 2026 | 12% | $12,401 to $50,400 | $24,801 to $100,800 | | … | … | … | … | | 2026 | 37% | Over $640,600 | Over $768,700 |
data/BendPoints.csv — one row per value, as Field and Amt. The tax year can be a Tax year
column or a Tax year row; either works.
| Tax year | Field | Amt | |---|---|---| | 2026 | bendPointPercent1 | 90% | | 2026 | bendPointMax1 | 1,286 | | 2026 | Maximum benefit | 4,152 | | 2026 | Default benefit (…) | 2,071 |
Both sheets must carry the same tax year; the import fails if they disagree.
What the parser tolerates
Columns and rows are located by name, never by position, matched case- and
punctuation-insensitively — so Tax year, tax_year, and TAXYEAR are the same key, and adding or
reordering an unrelated column changes nothing. Beyond that:
- Amounts may carry
$, thousands separators, or neither. - Rates may be
10%or0.1. - Ranges are read in all three published forms:
$12,400 or less(lower bound 0),$12,401 to $50,400(lower bound 12,401), andOver $640,600(lower bound 640,601 — the sheet's bound is exclusive, the stored one is inclusive). - Encoding may be UTF-8 or the Windows-1252 that Excel writes by default. A curly apostrophe in a label won't break the import.
- Extra columns are ignored with a warning. Married-filing-separately and head-of-household are
named explicitly, since
FilingStatusis'single' | 'married'andbuildParamsderives it fromincludeSpouse— no input can select them today.
What it rejects
The import fails rather than emitting a table that would produce quietly wrong rates:
| Condition | Why it matters |
|---|---|
| First bracket doesn't start at 0 | Income below the first bound would go untaxed |
| Bounds or rates out of order | expandBrackets derives each ceiling as next - 1 |
| A published upper bound isn't one below the next lower bound | The sheet has a gap or an overlap |
| bendPointMax1 >= bendPointMax2, or a non-descending percentage | Tiers would overlap or invert |
| The two sheets carry different tax years | The package ships one year at a time |
| A required field is missing or renamed | Better a hard failure than a silent default |
Generated files — don't edit these
| File | Holds |
|---|---|
| src/engine/taxes/us.ts | US_TAX_TABLE, US_TAX_YEAR |
| src/model/bendPoints.ts | US_BEND_POINTS, US_DEFAULT_MONTHLY_BENEFIT, US_BEND_POINT_YEAR |
Both carry a GENERATED FILE — DO NOT EDIT banner and are overwritten on every import. Everything
downstream reads from them, so US_DEFAULT_FIELDS picks up new bend points automatically — there is
no second place to update. Each bracket row is emitted with its published range as a trailing
comment, so a transcription error is visible in the diff without opening the CSV.
data/ is repo-only. The files whitelist in package.json packs dist and README.md and
nothing else, so neither the CSVs nor the importer ever reach the tarball.
After an import
npm run validate reports in three tiers, and it's worth knowing what each one proves:
| Tier | What it checks | Needs updating? |
|---|---|---|
| WORKBOOK | Workbook cells independent of the tax year — parameter derivation, the accumulation phase, the asset-mix blend. The workbook is the authority; these are frozen. | Never |
| PIN/yyyy | Post-retirement figures, which move with the tax table and bend points. Captured from the engine, not read off the workbook. | Once per tax year |
| LIVE | Structural invariants over the shipped table — bracket ordering, monotonicity, convergence on the top marginal rate, bend-point arithmetic recomputed from config. | Never |
Because the package ships one tax year at a time, the workbook's own 2023 brackets are no longer
carried anywhere, so the tax-dependent half of the workbook can't be asserted against it. Those
figures are PIN/yyyy instead: they catch a refactor changing behaviour, they don't prove
correctness.
After an import lands a new year, npm run validate prints a paste-ready TAX_YEAR_PINS block and
skips those checks rather than failing — a data refresh is never blocked by a stale pin. Review the
printed values against the new tax table before pasting them in. Once pasted they become the
baseline, so a wrong number accepted here is pinned as correct indefinitely. Spot-checking the two
effective rates by hand against the bracket ranges is enough to catch a bad transcription; the
existing 2026 entry shows the arithmetic in a comment.
Two fields that don't behave as their names suggest
costRate is the annual cost drag subtracted from the blended asset-mix return, and it replaces
what the 9.3 code called expenseRatio. It resolves from _pagesettings.costRate, falls back to the
results-page expratio control, and defaults to 0.02. It applies only when the user sets a
custom asset mix — a directly-entered customRoR is already net of costs.
taxRate is carried but not applied. It resolves from _pagesettings and appears on
RcResults.params, but nothing consumes it yet. The rate that is applied is stateTaxRate, a flat
regional addition on top of the federal effective rate. Don't wire a UI control to taxRate
expecting it to move the numbers.
Next.js notes
Components are client components. They carry 'use client', which the build restores after
bundling. Import them from a client component or a client boundary; importing from a server
component gives you the usual "cannot use hooks on the server" error.
No hydration mismatch. The hook reads session storage in an effect, not during render, so the
first paint is identical on server and client. isReady is false until that read completes — gate
on it if the markup would otherwise flash defaults.
The engine is server-safe. resolveFields and calculateFromFields have no DOM dependency.
Passing formData explicitly makes them work anywhere:
// app/api/projection/route.ts
import { resolveFields, calculateFromFields } from '@robinking/retcalc-us/engine';
export async function POST(request: Request) {
const formData = await request.json();
const results = calculateFromFields(resolveFields({ country: 'US', formData }), 'US');
return Response.json({
totalPercent: results.totalPercent,
graph: results.graph,
});
}Charts need a sized container, and most need a browser. ECharts and Chart.js both measure their
container and reach for a canvas, so keep the chart under a client boundary or wrap it in
dynamic(..., { ssr: false }). The built-in SVG renderer is safe to server-render — it draws
nothing until it has measured, so the first paint matches.
App Router and body classes. applyBodyClasses mutates document.body directly. That's fine on
a single results page; on a page that can be navigated away from, apply them yourself in a layout so
they clear correctly.
Verifying a change
npm run validate # 56 checks across three tiers: WORKBOOK, PIN/yyyy, LIVE
npm run tax:check # generated tax data still matches data/*.csv
npm run build # dual ESM/CJS with type declarations
npm run typecheck # tsc --noEmit
npm test # typecheck + validate
npm run testpages:verify # test-pages markup contract — run after buildtestpages:verify runs test-pages/retcalcHarness.js for real inside jsdom and asserts on the DOM
it produces: no data-name survives, every label[for] resolves to an element whose name matches,
every body [name] is a canonical name from data/RetCalc_US_Names-mapper.csv (parsed by column
name, never by position) or one of the two allow-listed controls, every output is written, and every
text box is filled. It also pins the input/output selector split — lifeExpectancy is both a text box
and a span, and a single [name="…"] selector would write the span's text into the input.
It needs npm run build first, because the harness imports ../dist/index.js.
Where the legacy JS and the workbook disagree, the workbook wins; the divergences are recorded in
LEGACY_RCDATA_DEFAULTS so the mapping stays traceable. That authority is enforced by the
WORKBOOK tier only — see After an import for what the other two tiers do and
don't prove.
Troubleshooting
Numbers look like defaults. Session storage wasn't read, or _pagesettings came through in the
array form without normaliseStore. Log resolveFields({ country: 'US' }) and compare
inflationRate against what the page authored.
Element type is invalid… but got: object. echarts-for-react ships lib/ as CommonJS with no
exports map, so under an ESM loader a subpath import yields { __esModule: true, default } rather
than the component. Unwrap it — entry.default ?? entry — as the adapter above does. A bundler
usually hides this; SSR and browser importmaps do not.
Chart is blank but the page renders. Nearly always the container has no height. RetirementChart
writes one inline, so check you haven't overridden it; if you removed height deliberately, style
[name="lineChart"] instead. ECharts logs Can't get DOM width or height in this case.
A band disappears when the numbers change. Paint order. The fills are opaque and the smaller band
has to be drawn last, which flips with isSurplus. Iterate model.bands in order rather than
reading needs and actual directly. With ECharts, also set notMerge — a shrinking series array
otherwise leaves the previous band behind.
Two ECharts or two React instances. Symptoms range from a dead chart to hook errors. In a
bundler, check npm ls echarts react. In a browser importmap, pin versions and use esm.sh's deps
param so the wrapper resolves against the same copies:
https://esm.sh/[email protected]/lib/[email protected],[email protected].
Cannot find module 'react' in Node. You imported the main entry, which re-exports the
components. Import from @robinking/retcalc-us/engine instead — it has no runtime imports at all.
A charting library is in your bundle that you didn't want. Not from this package — it imports
none. Check for a stray chart.js/auto or the default echarts-for-react entry, which pulls the
whole ECharts library and undoes selective registration.
Reference
Internals — repository layout, the build pipeline, the workbook validation harness, the test pages
and the publish procedure — are in BUILD.md. Every field carries its legacy name and Excel cell
in the doc comments on src/model/types.ts.
