@robinking/retcalc-ror
v1.0.0
Published
RecCalcs-RoR — multi-locale rate-of-return retirement calculator for Sitecore XM. Framework-free calculation core, React components, and a JSS-ready rendering.
Maintainers
Readme
RecCalcs-RoR
Multi-locale rate-of-return retirement calculator for Sitecore XM. A framework-free calculation core, React components, and a JSS-ready rendering, published as one npm package.
The projection runs three rate-of-return scenarios — 2%, 5%, and 8% — and charts how long a portfolio lasts under each. That is where the RoR comes from.
This is the TypeScript successor to Shared-RetCalc. The projection maths is
unchanged — a parity suite runs the original ircModel.js in jsdom and asserts
that every series, portfolio value, and duration matches the new engine across
six locales and six input scenarios.
Install
npm install @robinking/retcalc-rorreact, react-dom, and chart.js are optional peer dependencies. Install
them only for the parts you use — the calculation core needs none of them.
npm install react react-dom chart.jsRequires Node 20 or later to build. The published bundles target ES2022.
Three entry points
| Import | Contains | Runs on |
|---|---|---|
| @robinking/retcalc-ror | Engine, locales, formatters, storage adapters, types | Server and client |
| @robinking/retcalc-ror/react | Provider, hooks, components | Client only |
| @robinking/retcalc-ror/sitecore | JSS-shaped component and field mapping | Client only |
The core has no DOM access, no globals, and no React. Import it from a Server
Component, a Node script, or a test runner without ceremony. Everything in
/react and /sitecore is marked "use client".
/sitecore carries no Sitecore dependency — the JSS prop shapes are declared
structurally, so a real ComponentRendering satisfies them and nothing is
installed on your behalf.
Quick start
import { RetirementCalculator } from '@robinking/retcalc-ror/react';
export default function ThankYouPage() {
return <RetirementCalculator locale="da-DK" />;
}That renders the sliders, the chart, and the three scenario blocks, reading the
person's answers from sessionStorage.formData and the rates from
sessionStorage._pagesettings.
Composing the pieces
Most Sitecore pages already own part of the layout. Compose instead:
import {
RetCalcProvider,
CalculatorControls,
PortfolioChart,
PaceBreakdown,
DisclosureRate,
} from '@robinking/retcalc-ror/react';
export function Calculator({ locale }: { locale: string }) {
return (
<RetCalcProvider locale={locale}>
<CalculatorControls labels={{ ageCurrent: 'Nuværende alder' }} />
<PortfolioChart />
<PaceBreakdown />
<p className="calc-disclosure">
Antager inflation på <DisclosureRate figure="inflation" /> % og
omkostninger på <DisclosureRate figure="cost" /> % årligt.
</p>
</RetCalcProvider>
);
}The % sign stays in your copy so each market keeps its own spacing.
Using the core alone
import { calculate, resolveLocale, resolveRates } from '@robinking/retcalc-ror';
const locale = resolveLocale('no-NO');
const rates = resolveRates({ locale });
const result = calculate(
{
ageCurrent: 55,
ageRetirement: 65,
savingsRetirementCurrent: 3_000_000,
savingsContributionAnnual: 200_000,
expensesNeedEachYear: 200_000,
},
rates,
);
result.durations[0].finalAge; // 82How it fits together
Three tiers, one direction of travel. Data enters from Sitecore and from
sessionStorage, converges on RetCalcProvider, and fans back out through one
context object. Nothing below the provider reaches back up, and no view
component calculates anything of its own.
flowchart TD
SC["Sitecore rendering<br/>datasource fields + params"] --> MAP["sitecore/mapping.ts"]
SS["sessionStorage<br/>formData, _pagesettings"] --> STO["core/storage.ts"]
MAP --> P["react/RetCalcProvider<br/>owns inputs, rates, result"]
STO --> P
P --> IN["core/inputs.ts"]
P --> RA["core/rates.ts"]
P --> EN["core/engine.ts"]
IN --> CTX["useRetCalc() context"]
RA --> CTX
EN --> CTX
CTX --> C1["CalculatorControls"]
CTX --> C2["PortfolioChart"]
CTX --> C3["PaceBreakdown"]
CTX --> C4["DisclosureRate"]
C1 -. "commit()" .-> SSEverything in the middle row is framework-free: inputs, rates, engine,
and storage are pure functions over plain objects. The React layer is the only
part that holds state, and the only part that knows what a component is.
Into the provider
mapping.ts unwraps every JSS { value } field and deletes the blank ones,
so an authored default layers under the packaged default rather than clobbering
it with an empty string. It hands the provider three things:
| Helper | Produces | Resolved from |
|---|---|---|
| resolveRenderingLocale | LocaleConfig | params.locale → props.language → <html lang> |
| resolveRenderingInputs | CalculatorInputs | datasource fields over getDefaultInputs(locale) |
| resolveRenderingRates | RateOverrides | the three rate fields, blanks dropped |
resolveRenderingLabels, resolveRenderingHeading, and
resolveRenderingVisibility supply the copy and the region flags alongside.
Down to the core
The provider calls three pure functions and memoizes each result:
readInputsFromSession(adapter)→Partial<CalculatorInputs>. WalksFIELD_REGISTRYto translate Sitecore's session keys —currentAge,personAssetValue— into engine field names —ageCurrent,savingsRetirementCurrent. Adding a sixth input means adding one entry to that registry; session reads, session writes, and the slider UI all derive from it.resolveRates({ adapter, locale, overrides })→ResolvedRates. See Data sources for the priority chain.calculate(inputs, rates, ageLifeExpectancy)→CalculationResult, holdingageData, threeScenarioResults, and threeDurationResults.
reconcileAges() runs on every input change, keeping retirement age at or above
current age.
Out through the context
One object, read by every component. Nobody recalculates locally:
| Component | Reads | Renders |
|---|---|---|
| CalculatorControls | inputs, locale, setInput, commit | Five sliders, formatted readouts |
| PortfolioChart | result.scenarios[i].paceArray, locale.chartColors | Chart.js line series, one per scenario, each filled to the axis |
| PaceBreakdown | result.scenarios, result.durations, locale | Three formatted PaceViewModel blocks |
| DisclosureRate | rates, locale.decimalSeparator | Inflation, cost, or combined figure |
DisclosureRate reading rates directly is the structural fix for the drift
problem in the vanilla build: the legal copy and the projection are now
provably the same numbers, because there is only one place the numbers exist.
chartColors holds three hsl() values, one per scenario, used opaque for both
the line and the area beneath it — so a market that changes its palette changes
both together.
Three solid areas stay readable because they nest and because of the order they
are painted in. The projection is monotonic in the rate of return, so the 2%
area lies inside the 5% area, which lies inside the 8% area, at every age.
Chart.js sorts datasets by (order, index) ascending and then draws that list
backwards, meaning the lowest order is painted last and ends up in front.
buildDatasets therefore assigns order: index — 2% in front, 8% at the rear —
rather than relying on array position. Reverse it and the 8% band would cover
the other two completely.
CHART_FILL_ALPHA in PortfolioChart.tsx drops the fills back to translucent
if the y-axis gridlines need to show through.
The render order
The provider deliberately renders more than once, and the sequence matters:
- First render — props only.
sessionStorageis never touched during render, so the server pass and the first client render agree. - Mount effect, once — falls back to
<html lang>if nolocaleprop was given, readsformData, then flipsisHydrated. - Rates re-resolve —
isHydratedmakes the adapter reachable, so_pagesettingsnow applies. Chart, blocks, and disclosure copy all update together. - Slider moves —
setInputrecalculates immediately. No debounce to tune, no recalculate button to press. - Slider released —
commit()writes back toformDataand bumpsstorageEpoch, which forces a fresh rate read in case the page seeded settings late.
A RetCalcValidationError from calculate() is caught and surfaced as
result: null, so a missing or non-numeric input renders empty result blocks
rather than throwing. Any other error rethrows.
Pass the locale explicitly
RetCalcProvider accepts a BCP-47 tag or a full LocaleConfig. Supply it
wherever you can — Sitecore knows the locale at render time, and passing it
keeps the server and client markup identical.
Left out, the provider starts on en-CA and switches to <html lang> after
mount, which briefly shows the wrong currency.
Tag matching is case-insensitive and falls back to the language subtag, so
"DA-dk", "da-DK", and "da" all resolve to da-DK. Anything unrecognised
resolves to en-CA.
Data sources
Inputs — sessionStorage.formData
The only source for the five inputs. There is no cookie and no query-string
fallback. A field absent from formData keeps the value in defaultInputs.
| Input | formData key |
|---|---|
| ageCurrent | currentAge |
| ageRetirement | retirementAge |
| savingsRetirementCurrent | personAssetValue |
| savingsContributionAnnual | currentAnnualRetirementSavings |
| expensesNeedEachYear | annualCashFlowNeededInRetirement |
Releasing a slider merges the current values back under those keys, leaving
unrelated submission keys such as country and language untouched.
Rates — sessionStorage._pagesettings
Resolved fresh on every calculation, highest priority first:
| Rate | Priority chain | Default |
|---|---|---|
| inflationRate | override → _pagesettings → locale config → default | 0.0228 |
| costRate | override → _pagesettings → default | 0.018 |
| taxRate | override → _pagesettings → default | 0.20 |
Only inflation has a per-locale fallback. A tax rate of zero is valid and means no gross-up; a rate of 1 or more is rejected rather than dividing by zero.
Accepted shapes
Three serializations are read for both namespaces:
// Sitecore array — production. The page-scoped entry wins.
[{ name: 'inflationRate', value: '0.0228', scope: 'page' }]
// Plain object
{ currentAge: 55, retirementAge: 65 }
// Flat keys
sessionStorage['formData.currentAge'] = '55';Supplying values yourself
Pass storage={null} to work entirely from props, or a memory adapter to
supply values directly:
import { createMemoryStorageAdapter } from '@robinking/retcalc-ror';
<RetCalcProvider
locale="es-ES"
storage={createMemoryStorageAdapter({ formData: JSON.stringify(answers) })}
/>Server rendering
The provider never reads sessionStorage during render, so the server pass and
the first client render agree. Stored values are applied in a mount effect, and
isHydrated tells you when that has happened:
const { isHydrated, result } = useRetCalc();PortfolioChart creates its Chart.js instance in an effect, so nothing about
Chart.js reaches the server bundle.
Locales
Twenty-one markets ship in the registry: en-AU, de-AT, nl-BE, fr-BE,
en-CA, de-CH, de-DE, da-DK, ca-ES, es-ES, fr-FR, en-GB,
en-IE, it-IT, ja-JP, nl-NL, no-NO, en-NZ, ar-SA, sv-SE,
en-SG.
Each carries currency, separators, inflation fallback, chart palette, and nine
translated strings. Every entry is frozen and owns its own chartColors, so
editing one can never leak into another that shares a copy block.
To adjust a market without forking the registry:
import { resolveLocale, withLocaleOverrides } from '@robinking/retcalc-ror';
const locale = withLocaleOverrides(resolveLocale('es-ES'), {
recalculateButtonText: 'Recalcular',
});Markup contract
The components emit the class and name attributes the Sitecore stylesheets
already target, so existing CSS keeps matching:
name="paceLow"/"paceMedium"/"paceHigh"on each scenario containername="paceLowFinalAge"andclass="finalAge"on the value spans, plusinvestmentDuration,estimatedText,finalValueid="lineChart"on the canvas.wrapper-slider,.slider__range,.slider__outputon the controls
To render entirely different markup, take the numbers and leave the elements:
<PaceBreakdown>
{(paces) => paces.map((pace) => <Row key={pace.paceKey} {...pace} />)}
</PaceBreakdown>Stylesheets are not bundled. A Sitecore RTE page keeps loading
ircSharedStyle.css and its market override as it does today. A Next/BYOC page
loads its own CSS modules instead and needs none of the legacy sheets — the
class names above are hooks the components emit either way, not a dependency on
any particular stylesheet.
Sitecore JSS
Register the packaged component under your rendering's name and the layout service supplies everything else:
// src/components/RetirementCalculator.tsx
export { RetCalcRendering as default } from '@robinking/retcalc-ror/sitecore';Datasource fields set the starting values, labels, and any rate overrides. Rendering parameters control which regions appear:
| Parameter | Default | Effect |
|---|---|---|
| locale | route language | BCP-47 tag |
| showControls | on | Render the sliders |
| showChart | on | Render the chart |
| showBreakdown | on | Render the scenario blocks |
| cssClass | main-container | Class on the outer element |
Authored values are fallbacks. A person's own answers still arrive from
sessionStorage.formData on mount and take precedence — the datasource is what
shows when the funnel supplied nothing.
Blank fields are treated as absent rather than as zero, so a cleared Number
field falls through to the market default instead of pinning a rate to 0.
examples/next-jss/ has the full wiring: the component, the factory
registration, a catch-all route, and the Sitecore item definition the mapping
expects. Those files are documentation, not part of the published package.
To build your own component instead, the mapping helpers are exported:
resolveRenderingLocale, resolveRenderingInputs, resolveRenderingRates,
resolveRenderingLabels, resolveRenderingVisibility.
Slider bounds
Every locale carries a moneyScale — a rough magnitude against a euro-scale
unit, used only to size the sliders and never in the projection maths. A
portfolio worth €3M is worth roughly ¥500M, so ja-JP scales by 150 and its
sliders span a correspondingly larger range.
import { getFieldBounds, getDefaultInputs, resolveLocale } from '@robinking/retcalc-ror';
getFieldBounds('savingsRetirementCurrent', resolveLocale('ja-JP'));
// { min: 0, max: 1_500_000_000, step: 7_500_000 }
getFieldBounds('ageCurrent', resolveLocale('ja-JP'));
// { min: 18, max: 95, step: 1 } — ages are universalThese are approximations chosen to round cleanly, not exchange rates. Override them per market once the bounds are agreed:
<CalculatorControls
bounds={{ savingsRetirementCurrent: { min: 0, max: 20_000_000, step: 100_000 } }}
/>What changed from Shared-RetCalc
| Was | Now |
|---|---|
| window.fiRrc global | Typed module exports and React context |
| Four <script defer> tags in load order | One import, no ordering to get right |
| ircModel.js | calculate(), calculateScenario(), calculateDuration() |
| ircCountryConfig.js | LOCALES, resolveLocale(), withLocaleOverrides() |
| ircView.js chart building | <PortfolioChart /> |
| ircView.js DOM writes | <PaceBreakdown />, <DisclosureRate /> |
| ircController.js input-mode detection | React state; the mode question disappears |
| Hard-coded en-CA slider ranges | getFieldBounds(), scaled per market |
| Manual chart.destroy() / rebuild | In-place updates, teardown on unmount |
| 150 ms debounce on slider drag | React batching |
| ?fiDebug=1 logging | Thrown RetCalcValidationError with named fields |
finalAge and investmentDuration are now structured rather than
pre-formatted. The engine returns { finalAge: 90, isCapped: true }; call
formatFinalAge() to get the >= 90 string the old build produced.
Two intentional formatting fixes
formatAbbreviatedCurrency diverges from the vanilla build in two places,
both of which the old code got visibly wrong in high-magnitude markets:
| Case | Was | Now |
|---|---|---|
| ≥ 1 billion | 1018760730.3489404 ¥ | 1.02B ¥ |
| < 1 thousand | $333.33333333333337 | $333.33 |
The ladder stopped at M, so anything larger fell through unabbreviated and
unrounded onto the chart's y-axis. Everything between a thousand and a billion
formats exactly as before.
Publishing
The package is scoped to @robinking and publishes publicly:
"publishConfig": { "access": "public" }npm defaults scoped packages to restricted, which needs a paid plan, so this line is what makes the free tier work. It also means the compiled calculator — the projection model, the 21-locale rate table, and the Sitecore field contract — is readable by anyone who finds the package.
npm run build
npm publishprepublishOnly runs the typecheck, the full test suite, and the build first,
so a broken tree cannot reach the registry.
"private" in package.json stays false. Setting it to true blocks
publishing entirely — it is not the same thing as a privately published
package, which is publishConfig.access.
Note that "license": "UNLICENSED" and public access pull against each other:
the code is downloadable by anyone, while the licence says nobody may use it.
That is a legitimate combination — published, all rights reserved — but if the
intent is genuinely open, the licence field should change too.
Full instructions, including the private and GitHub Packages alternatives, are in PUBLISHING.md.
Scripts
npm run build # dual ESM/CJS bundles with type declarations
npm run typecheck # strict tsc, no emit
npm test # parity, unit, and server-render suitesnpm run build runs scripts/add-use-client.mjs afterwards, which writes the
"use client" directive back onto the React bundles. Bundlers strip
module-level directives, and without it Next.js treats the components as server
modules.
Tests
| Suite | Covers |
|---|---|
| test/parity.test.ts | The original ircModel.js and ircCountryConfig.js loaded in jsdom, compared series by series against the new engine across six locales and six scenarios, under both default and _pagesettings rates |
| test/core.test.ts | Locale resolution, all three storage shapes, input persistence, rate guards, formatting |
| test/react.test.tsx | Server rendering, scenario ordering, disclosure figures, hydration safety |
| test/sitecore.test.tsx | Locale-scaled bounds, JSS field readers, rendering mapping, server render from a rendering alone |
The parity fixtures in test/fixtures/legacy/ are unmodified copies of the
shipped files. Keep them until every market has migrated — they are the only
independent check that the numbers have not moved.
Local testing in a browser
npm test proves the numbers. test-pages/ proves the page.
Four HTML files — en-CA, da-DK, es-ES, no-NO — carrying the markup the
preview site actually serves: section b6358dc9-… from
preview-fisher-marketing.vercel.app/da-dk/app/calc/main/thank-you, with the
ByocCalculator component's own bundle swapped for a single ES module that
drives the same DOM from this package's core.
npm install && npm run build # the pages import ../dist/index.js directlyOpen any page with Live Server. file:// will not work: these are ES
modules and CORS blocks them. There is no stylesheet copy step any more — both
sheets are checked in.
| URL | Exercises |
|---|---|
| index-da-DK.html | formData + _pagesettings — the seeded path |
| index-da-DK.html?clearSession=1 | Fallback to slider values, #rrcData, locale config, defaults |
| index-da-DK.html?fiDebug=1 | Resolved locale, rates, inputs, and durations in the console |
The ?fiDebug=1 flag from the vanilla build survives here, in the harness
rather than in the library — the engine itself throws RetCalcValidationError
instead of logging.
What these pages are for
They used to mirror the legacy Sitecore RTE, so the packaged page and the Shared-RetCalc page could be opened side by side. They now mirror the Next/BYOC output instead, because that is where the calculator is heading — the question worth answering is no longer "does the engine still drive the old markup" but "does it drive the markup Sitecore is about to serve".
The parity suite is what protects the numbers, and it is unaffected: it loads
the original ircModel.js in jsdom and compares series by series, independently
of any page.
Only da-dk exists in preview. The en-ca, es-es and no-no routes return a
stub with no calculator section, so those three carry copy transcribed from the
authoritative RTE files.
Stylesheets
Two per page, both checked into test-pages/css/:
6d44ad1b90a43050.css— the preview site's global sheet, verbatim from the Next build apart from four font paths rewritten to an absolute origin.retcalc-shared.css— generated from the two preview component sheets plus the test-only slider styles.
None of the legacy Shared-RetCalc sheets are loaded. Every selector in all seven
was run against the generated DOM: four matched nothing, and the two that
matched — blue-theme and ircSharedStyle — were overriding the preview's own
layout rather than adding to it. This changes nothing about production. The
React components still emit the legacy class names, and a Sitecore RTE page
still wants ircSharedStyle.css and its market override.
Both artefacts are generated. Edit test-pages/build/locales.mjs for copy or
slider ranges, then rerun:
npm run testpages # both, or testpages:css / testpages:htmlSetup and the expected disclosure figures per locale are in
test-pages/README-preview-pages.md.
Nothing in that folder ships: vitest and tsc do not see it, and the files
field keeps it out of the tarball.
Still to confirm
- Every locale funnel writes
formData. With the cookie gone, a market whose funnel never populated it will silently show defaults instead of the person's answers. Verify across all 21 before this ships beyond CA/DK/ES/NO. - Sitecore writes both namespaces before the app mounts. The provider reads
them in its mount effect; values written later are picked up on the next
commit()but not before. - Slider bounds per market.
moneyScalegets each market into the right order of magnitude, but the exact ranges need sign-off market by market. - Chart title translations for markets outside DK/ES/NO have not been through Fisher localization.
