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

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-ror

react, 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.js

Requires 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; // 82

How 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()" .-> SS

Everything 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.localeprops.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>. Walks FIELD_REGISTRY to 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, holding ageData, three ScenarioResults, and three DurationResults.

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:

  1. First render — props only. sessionStorage is never touched during render, so the server pass and the first client render agree.
  2. Mount effect, once — falls back to <html lang> if no locale prop was given, reads formData, then flips isHydrated.
  3. Rates re-resolveisHydrated makes the adapter reachable, so _pagesettings now applies. Chart, blocks, and disclosure copy all update together.
  4. Slider movessetInput recalculates immediately. No debounce to tune, no recalculate button to press.
  5. Slider releasedcommit() writes back to formData and bumps storageEpoch, 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 container
  • name="paceLowFinalAge" and class="finalAge" on the value spans, plus investmentDuration, estimatedText, finalValue
  • id="lineChart" on the canvas
  • .wrapper-slider, .slider__range, .slider__output on 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 universal

These 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 publish

prepublishOnly 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 suites

npm 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 directly

Open 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:html

Setup 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. moneyScale gets 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.