npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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 only

The 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-us

Only React is required, and only if you use the components:

npm install react@^19.1.0 react-dom@^19.1.0

Then 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 dependency

Every 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 needs

RcFields 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:

  1. overrides — always wins
  2. _pagesettings, for the fields the CSV marks as page settings (the rates)
  3. formData
  4. _pagesettings again, as a late fallback for anything still missing
  5. 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 itpick() 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.6

Then 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 validate

npm 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% or 0.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), and Over $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 FilingStatus is 'single' | 'married' and buildParams derives it from includeSpouse — 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 build

testpages: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.