Skip to content

Repository files navigation

Pricing Calculator

Tests Tests Coverage Lighthouse Performance Lighthouse Accessibility Lighthouse Best Practices a11y PWA License PRs Welcome

Live app: calc.sterlingspares.com

An offline-first web app for list-price trading margins. You enter the printed price, describe how cost and selling price are discounted off it, layer on the rebates your supplier and your customer actually give you, and it tells you what you make.

Built for automotive parts distribution, but the arithmetic is the same in any trade that buys at a discount off a list price and sells at a smaller one — FMCG, electronics, pharma, hardware, stationery. If you have ever worked out a quote by subtracting one percentage from another and then remembered the quarterly rebate, this is that sum.

No build step, no framework, no runtime dependencies.

The calculator with a worked example on screen


What problem it solves

Distribution prices downwards. There is a printed price on the pack — MRP in India, where it is a legal ceiling nobody in the chain may exceed; RRP or list price elsewhere — and everyone buys at a discount off it. A distributor buys at, say, 40% off and sells at 25% off. The gap is the business.

That shape is not specific to any one trade. Whether the box holds a brake pad, a shampoo sachet or a laptop, the sum is the same.

What makes that hard to hold in your head is that the invoice discount is not the real one. On top of it sit incentives — cash discount for paying promptly, early-bird for ordering in a window, quarterly and annual volume rebates, seasonal schemes. None appear on the invoice line, all change what the stock actually cost you. Some of them you receive; others you pass on to your own customer. Meanwhile every figure has to be tracked both excl tax (what profit is computed from, since the tax is passed through) and incl tax (what you actually quote and negotiate). The app calls that tax GST, and the rate is yours to set — 18% and 5% are one tap because they are India's common slabs, but any rate from 0 to 100% works, so VAT at 20% or GST at 10% behaves identically.

This app does that arithmetic, live, so a quote can be checked before it is given rather than regretted after.

Who it fits

Any list-price trade Auto parts, FMCG, electronics, pharma, hardware — anywhere you buy and sell at discounts off a printed price, with rebates that never touch the invoice
Import and export The cost side and the sale side can be shown in different currencies, so buying in one and selling in another is a supported case rather than a workaround. Landed costs are per-unit, inbound and outbound
Outside India Set Show to Cost & Sale in your own currency and nothing on the calculator is in rupees — the list price included. The maths is all ratios and one tax rate, so the unit is yours to choose

Rupees remain the internal unit everything is stored in, but that is an implementation detail rather than something you see: under Cost & Sale every field and figure, MRP among them, is entered and shown in the currency you pick. Under a one-sided scope the printed price deliberately stays in rupees — an exporter quoting in dollars still buys against a rupee MRP, and a rate update must not restate a figure fixed by law.

A worked example

The screenshot above, step by step. A part with an MRP of ₹1,000 at 18% GST, bought at 40% off, sold at 25% off, with a 2% cash discount and 1% early-bird rebate from the supplier:

MRP (incl GST) ₹1,000.00 the printed ceiling
CP excl GST ₹600.00 40% off
− CP incentives ₹18.00 2% cash discount + 1% early bird
Effective CP ₹582.00 what the stock really cost
SP excl GST ₹750.00 25% off — ₹885.00 incl GST, the quoted price
Profit ₹168.00 ₹750.00 − ₹582.00
GP % 22.40% 168 ÷ 750
Markup % 28.87% 168 ÷ 582
Break-even SP (incl GST) ₹686.76 incl GST — below this you lose money

Without the two incentives the same deal shows ₹150.00 profit and 20.00% GP. That 3% off-invoice is a fifth of the profit, and it is exactly the part that is easy to forget.

Terms

Term Means
MRP Maximum Retail Price — the price printed on the pack, and a legal ceiling in India. Read it as RRP or list price elsewhere; every price is a discount off it
CP Cost Price — what you pay your supplier
SP Selling Price — what you charge your customer
GST The pass-through sales tax. 18% and 5% are one tap (India's common slabs); any rate 0–100% works, so VAT behaves the same
Incentive An off-invoice discount — cash discount, rebate, scheme — that changes real cost without changing the invoice line
Effective CP / SP The price after incentives and landed costs. Profit is computed from these, not from the invoice figures
Landed cost Per-unit freight, insurance or handling — added to CP inbound, deducted from SP outbound
GP % Profit as a share of selling price
Markup % Profit as a share of cost

Note the last two. GP % is profit ÷ SP; Markup % is profit ÷ CP — always the larger of the two, as in the example above. Parts of the trade say "margin" when they mean markup, which is exactly why it is worth being sure which one a supplier is quoting.


Contents

Using it Pricing · Incentives & costs · Analysis & output · Working with the app · Mobile · Keyboard shortcuts

Under it Platform · Accessibility · Security · Measured quality · Tests · Architecture


Quick start

git clone https://github.com/sterlingspares/calc.git
cd calc
python3 -m http.server 8000      # or any static server

Opening index.html from disk works too — the app runs, but the browser blocks file:// font requests, so it falls back to the system typeface.

index.html              markup only
assets/styles.css       all styling
assets/app.js           core behaviour
assets/app-extra.js     what-if, compare, quote, converters, exports — on demand
assets/fonts.css        self-hosted @font-face rules
assets/fonts/           three woff2 files
sw.js                   service worker
manifest.json           PWA manifest
_headers                response headers for the host
tests/                  test suite (dev-only)
docs/                   README screenshots — all four are generated (npm run screenshots)
robots.txt · sitemap.xml  indexing

Pricing

The control bar carries only what changes per calculation: GST, what to Calculate, and Reset. Presets have their own header button (or E), and the currency control lives in Settings → Pricing → Currency — neither changes from one quote to the next.

MRP and GST

Every price derives from the Maximum Retail Price, and every price is shown in two forms: excl GST for accounting and incl GST as the sticker price.

  • 18% (default) or 5% — keyboard 1 / 2
  • Any other rate — type it into the Other % box in the control bar (0–100 %, decimals allowed). It feeds straight into every calculation, and all excl/incl X% GST labels update to match. The box highlights while a custom rate is live, so exactly one of the three options ever looks selected — and typing a rate that already has a pill (18 or 5) hands over to it and empties the box rather than leaving two controls filled in.

Price input methods

CP and SP each have three independent input modes:

Each card shows the two prices that result; the derivation — the two discount percentages and the GST amount — folds away behind Show details, collapsed by default on desktop as well as mobile.

Mode How it works
Discount excl GST Enter a % discount on MRP; yields the price excl GST
Nett Discount incl GST Enter a % discount directly on the MRP incl GST
Manual ₹ entry Type the price in rupees

In Discount excl GST mode the discount applies to the MRP incl GST and produces the price excl GST. MRP 1000 at 40% gives CP excl 600, not 1000 / 1.18 × 0.6. This trips people up when writing tests.

Solve modes

Choose what the calculator computes and what you supply:

Mode You enter It computes Key
Profit (default) MRP · CP · SP Profit ₹, GP %, Markup % P
Selling Price MRP · CP · target profit Selling Price L
Cost Price MRP · SP · target profit Cost Price K

Profit display

Mode Formula
₹ Value Eff. SP excl GST − Eff. CP excl GST
GP % Profit ÷ Eff. SP excl GST × 100
Markup % Profit ÷ Eff. CP excl GST × 100

Quantity and order value

Set a Quantity in the MRP bar. Per-unit figures are unaffected; above 1 the summary gains an order block:

Row Meaning
Quantity Units in the order
Order Value (incl GST) SP incl GST × qty
Total Profit ₹ Per-unit profit × qty

Quantity is saved with history entries and included in the CSV export. Absolute (₹) incentives are per-unit, consistent with every other figure.

Rounding

Settings → Rounding: Off, ₹1, ₹5, or any step typed into the Other ₹ box (₹20, ₹0.50, ₹100 — anything above zero, decimals allowed).

Rounding applies to the incl-GST sticker price, with excl-GST and profit derived from the rounded figure, so what you quote and what you bank stay consistent. It covers the main calculator and every quote line, and persists in share links.

The live option is always highlighted — including the custom box, which fills with the accent colour when a custom step is what's active. The step is also shown in the field itself, so the state is never signalled by colour alone. Typing a step that already has a chip (1 or 5) selects that chip and clears the box, the same way the GST control behaves.

Number formatting

Grouping follows the currency, not the app. Rupees group Indian-style (₹98,76,54,321.55); every other currency groups in thousands ($987,654,321.55). Percentages show two decimal places.

Currency

Rupees are the unit everything is stored and calculated in. The Show control picks which side of the deal a foreign currency applies to, and in which currency — twenty are available:

Scope What changes
Cost CP, effective CP, CP incentives, inbound landed cost
Sale SP, effective SP, SP incentives, outbound landed cost, profit, break-even, order value, rounding
Cost & Sale both of the above

Profit follows the sale side, because that is the currency the money arrives in. MRP follows Cost & Sale only. Under a one-sided scope it stays in rupees — an exporter quoting abroad still buys against a rupee MRP, and a rate update must not restate a figure fixed by law. Under Cost & Sale the whole deal has been declared to be in another currency, so the printed price is too, which is how a trader outside India works entirely in theirs.

Fields on a converted side are entered in that currency too — a box showing $ holds dollars. Switching currency re-expresses what you typed, so a ₹50 landed cost becomes $0.52 rather than silently becoming fifty dollars. Percentages are left alone. Whatever is on screen, the underlying deal is unchanged: effective CP and SP in rupees are identical across all three scopes.

Rounding rounds the sticker price, so its step follows the sale side: a $1 step rounds the quote to whole dollars, and the rupee figure behind it is then not round — which is the point when quoting abroad.

Rates Fetched from open.er-api.com — free, no key, updated daily
When Never on load. Only when you first switch to a foreign currency, or press Rates
Offline The last fetch is cached and reused, labelled with its age (1 USD = ₹95.69 · 4h ago)
Override Settings → Pricing → Currency takes a rate you type, for a contracted rate or no connection. Yours wins over the feed. The row is always there; it enables once a currency is chosen
Scope Carried in share links along with the currency, so a link opens the way it was sent
No rate Amounts show . A rupee figure is never shown wearing a foreign symbol

The rate is deliberately not carried in share links — only the currency is, so the recipient fetches a current rate instead of inheriting a stale one.


Incentives & costs

Incentives are the discounts that never appear on the invoice line — cash discount, early-bird, quarterly and annual rebates, schemes. They change what you effectively pay and receive, which is what the profit is actually made of.

Effective CP = CP excl GST − CP incentives + Landed CP
Effective SP = SP excl GST − SP incentives − Landed SP

The two panels sit side by side on desktop and stack on a phone.

CP incentives

Five ship by default, each independently togglable:

Code Name (customisable) Input
CD Cash Discount % or ₹ — on CP excl or incl GST (selectable)
EB Early Bird Discount % of CP excl GST
QT Quarterly Discount % of CP excl GST
AN Annual Discount % of CP excl GST
SC Scheme % of CP excl GST or fixed ₹

The panel footer shows Total incentive ₹, Effective incentive % on CP and Effective CP excl GST.

SP incentives

The same set applied against SP instead — for modelling what you give away, such as a scheme passed on to the dealer. CD can be calculated on SP excl or incl GST, and Scheme can be % or fixed ₹. The footer shows Total SP incentive ₹, Eff. incentive % on SP and Effective SP excl GST.

Editing incentives

Each panel has its own Edit button beside the collapse chevron. CP and SP are edited independently — the two lists need not match.

Action How
Rename The label becomes a text field — type a new name (max 30 chars)
Delete Tap the red badge on the row (iOS-style), then confirm
Add Tap + Add incentive below the grid

Tap Done to leave edit mode. Tapping Edit on a collapsed panel expands it first — the rows you are editing live inside it — and collapsing a panel while editing counts as Done.

  • New incentives get the same % / ₹ Absolute choice the Scheme row has: percentage deducts a share of the base price excl GST, absolute deducts a flat rupee value. They default to percentage, and the unit beside the input flips between % and to match. Each keeps its own setting, per panel.
  • Deleting asks for confirmation, takes effect in the calculation immediately, and is undoable. The built-in five can be deleted too — including CD and Scheme, along with their extra option rows.
  • Toggle states and entered values survive entering and leaving edit mode.
  • Your list and custom names persist across sessions.

Landed costs

Two per-unit fields in the MRP bar, both excl GST, pulling in opposite directions:

Field Meaning Effect
Landed CP ₹ (+₹) Inbound freight, insurance, handling Added to effective CP
Landed SP ₹ (−₹) Outbound delivery, packing, freight to the customer Deducted from effective SP

Both flow through every derived figure — profit, GP %, margin, break-even, the solver, history and share links. Direction is shown by the +₹ / −₹ prefix, not only by colour.

Because the outbound cost is a flat amount taken off the top, break-even grosses it up by the SP-incentive ratio: the list price has to cover it before incentives take their proportion.

Presets

Incentive setups repeat — one supplier's terms, one dealer's scheme. A preset snapshots both panels entirely: the rows, their names, their % / ₹ modes, which are switched on, the values in them, and the CD/Scheme base selectors.

Everything lives in one manager, reached from the Presets button in the header, Settings → Features → Saved presets, or E:

Action Where What it does
Load Manage Applies a saved setup to both panels
Save Manage Names and stores the current setup
Rename Manage Renames without touching the contents
Update Manage Replaces a saved preset with what is on screen
Delete Manage Removes it, after confirming

Naming happens in the app's own dialog, not a browser prompt(). It validates as you type: an empty name is refused, and a name that already exists warns that saving will replace it — without blocking, since replacing is often the intent.

Nothing is replaced or removed without a dialog in front of it. Save always asks for the name, Update and Delete always confirm, and cancelling any of them leaves the stored preset exactly as it was. Every preset action is undoable.

Presets persist in localStorage and are re-validated on load, so a corrupted or hand-edited entry is dropped rather than trusted.


Analysis & output

Summary

A sticky bar below the cards. It carries only what the cards do not — effective CP and SP, the incentive totals, profit, GP %, Markup %, break-even and the order block — rather than repeating the prices shown directly above it. Values below your floor limits are flagged.

Break-even

Two thresholds, both quoted incl GST because that is what gets negotiated:

Shown in the Profit card, beside the profit they are measured against:

Row Meaning
Break-even SP (incl GST) Price at which profit reaches zero
SP at GP floor (incl GST) Price at which GP % hits your Settings floor

Both are stated before SP incentives — those reduce what you actually receive, so the quotable price is grossed back up accordingly.

Target-margin solver

In the summary, beside Markup %, enter a Target GP % and it answers the question directly rather than making you converge on it by trial and error. What it says depends on which side of the target you are on:

Short of it — the incentive you would need, the effective CP that implies, and the gap from where you are now:

Needs 14.20% total CP incentive (₹514.80 eff. CP). You have 9.00% — ₹31.20 more per unit. Currently 11.40%.

Already past it — how much room you have before you lose it. Asking what incentive gets you down to a target has no useful answer, so it reports the cushion on effective CP instead:

Already there — GP is 25.00%, above the 12.00% target. Effective CP has ₹10.40 of room per unit (up to ₹70.40) before GP drops to 12.00%.

Sitting on itRight on target — GP is 25.00%. No change needed.

Targets outside 0–99.9% are refused by name (Target GP % must be between 0 and 99.9) rather than being mistaken for missing input, and if a reachable target would need more incentive than the cost price itself, it says so rather than printing a nonsense figure. Landed costs are accounted for throughout.

What-if analysis

Three side-by-side SP scenarios (A · B · C) to compare before committing. Each takes its own SP input (discount % or manual ₹) and shows SP, Profit ₹, GP % and Markup %. The highest-GP scenario is highlighted automatically, and everything updates live.

Tools

Three things that are useful beside a calculation but are not part of one live under Tools in the header — the quote builder and the two converters. On a phone they are in the hamburger menu instead. Press T to open it.

The Tools menu open in the header, listing Quote builder, GP to Markup and Currency converter

GP % / markup % converter

Two ways of quoting the same profit, over different denominators — which is why a supplier offering "25%" and a customer expecting "25%" can mean different money. Tools → GP ⇄ Markup takes either and returns the other:

GP % Markup %
20.00 25.00
25.00 33.33
50.00 100.00

markup = GP ÷ (100 − GP) and GP = markup ÷ (100 + markup). It shows the result as money as well — buy at ₹100.00, sell at ₹125.00 — because a percentage on its own is easy to nod along to. Use this calculation seeds it from whatever is on screen; with nothing to pull in it stays put and says what is missing, rather than disappearing. The two ends with no finite answer (100% GP, −100% markup) say so rather than blanking.

Currency converter

Tools → Currency converter converts an amount between any two of the twenty currencies, at the same rates the rest of the app uses. Type into either side and the other follows; reverses the pair, carrying the converted amount back as the new input.

The currency converter, showing $250.00 converted to ₹20,833.33 at 1 USD = ₹83.33

It is deliberately separate from the Show setting in Settings → Pricing. That one restates the whole calculation in another currency; this is a scratch pad, and leaves the calculation alone. Every rate is quoted per rupee, so a cross-pair like USD → EUR goes through the rupee — exact, rather than a second lookup. When a rate is not known it says so, with a route to fixing it, instead of showing a blank box.

Quote builder

A multi-line quoting tool — press M, or use Tools → Quote builder.

Each line takes a description, MRP, quantity and net CP/SP discounts, and computes SP incl GST, line value, line profit and GP %. Lines below your GP floor are flagged red. A totals row gives line count, total units, order value, total profit and blended GP % across the quote.

Action Description
Add line Append a blank line
Add current calculation Pull MRP, quantity and both discounts in from the main calculator
Export CSV Per-line breakdown plus a totals row
Copy quote Formatted plain text, for email or WhatsApp
Clear all Remove every line, after confirmation

Lines use the calculator's current GST rate and rounding setting. Incentives are not applied per line — enter the net discount you are quoting. The quote persists across sessions.

Copy, share and export

  • Copy summary — formatted text block to the clipboard (⌘/Ctrl + C)
  • WhatsApp — opens a pre-filled message
  • Email — opens a mailto with the summary
  • PDF — print-friendly layout via browser print (Ctrl + P)
  • Share link — encodes the full calculator state into a URL

Working with the app

History

Calculations auto-save after 900 ms of inactivity once both CP and SP are filled (toggleable in Settings). Up to 50 entries are kept, across page reloads.

Each card shows time (relative, absolute on hover), tag, quantity if above 1, MRP, CP excl, SP excl, CP incentives ₹, Profit ₹, GP %, Markup % and GST rate.

Search, filter, tag

  • Search matches tags, GST rate, date and any numeric value in the entry
  • FiltersAll · Profit + · Loss · Below floor · Tagged
  • Tags+ Tag on any entry labels it (a dealer or customer name, max 24 chars). Click to edit, clear to remove. Tags are searchable and exported.

The panel header shows N of M while a search or filter is active.

Actions — Save current · Compare (side-by-side against the current state with ↑↓ deltas for CP, SP, Profit, GP %, Markup %) · delete one entry (×) · Export CSV · Clear all.

Undo / redo

Every state-changing action is undoable — including typed values. Correcting an MRP, a discount, a quantity, a landed cost or an incentive rate is a step you can walk back, as are the chips, toggles and dropdowns.

One field edit is one step, not one per keystroke: the state is captured when you enter the field and recorded when you leave it, so ⌘/Ctrl + Z takes back the whole number rather than the last digit. Entering a field and leaving it untouched records nothing.

Anything that does not change what the app saves — opening a dialog, switching a Settings tab, searching history — stays out of the stack, so undo never burns a step on a no-op.

  • Undo / Redo buttons in the header (Undo also in the mobile menu)
  • ⌘/Ctrl + Z and ⌘/Ctrl + ⇧ + Z — these work even while typing in a field, and take the edit in progress with them
  • The toast names what it reverted: Undid: cost discount
  • Destructive actions show a toast with an inline Undo
  • Up to 40 steps retained

Settings

Grouped into four tabs — a nav rail on desktop, a scrollable strip of pills on a phone — rather than one long scroll:

Tab Holds
General Dark mode · Auto-save
Pricing Floor limits · Rounding · Currency
Features The eight switches · Saved presets
Help App tour · Keyboard shortcuts

Arrow keys, Home and End move between tabs, and a tab whose sections are all switched off hides rather than leading nowhere.

First run

A six-step tour opens on a first visit, written for someone who arrived without reading any of this. It starts with the problem rather than the features, offers to fill the example in so the screen is working rather than empty, warns that GP % and Markup % are not the same figure, and says plainly that the tax rate and the currency are yours to set. Restart it any time from Settings → Help.

Setting Description
Dark mode Full dark colour scheme
Minimum GP % Flags values red below this threshold
Minimum Markup % Flags values red below this threshold
Auto-save Toggle automatic history logging
Rounding ₹1, ₹5, a custom step, or off
Features Switch off anything you do not use
Saved presets Open the preset manager
Exchange rates Update now, and set a manual rate
App tour Restart the six-step tour
Keyboard shortcuts View all shortcuts
Reset everything Clear every saved key and start as if the app were new

Floor limits, auto-save preference and theme persist across sessions.

Turning features off

Not every distributor quotes abroad, pays freight separately or passes incentives on to customers. Settings → Features switches any of these off, and it disappears from the screen:

Presets the header button, menu entry and the manager
GP / markup converter its entry in Tools
Currency converter its entry in Tools
Quote builder its entry in Tools, the menu item and the bottom-nav tab
What-if scenarios the button on the summary
Landed costs both fields in the MRP bar
Target GP solver the row under break-even
Incentives on CP · Incentives on SP the whole panel; the Incentives tab goes when both are off

Switching one off clears what it holds, so it says what that is first — "This will clear 2 saved presets" — and does nothing until you confirm. A feature holding nothing goes quietly. Turning one back on is immediate and asks nothing, but does not bring cleared values back.

Off means unreachable, not merely hidden: the keyboard shortcut, the bottom-nav tab and a restored share link all stop opening it, and a value left in a hidden field cannot move a figure — a disabled landed cost reads as zero even if the box still has something in it. Every switch is undoable and persists across sessions.


Quick (flashcard) mode

A mobile-oriented 4-step card interface — Q toggles it.

  1. MRP — MRP, GST rate, solve-for mode
  2. CP — cost price (discount % or manual ₹)
  3. SP — selling price (or profit, when solving for SP/CP)
  4. Result — profit, GP %, Markup %, effective prices

Swipe left/right or use / Enter and to move between cards. Quick mode remembers its own last inputs and restores them when you return.


Mobile

The calculator on a phone, showing the sticky result bar, floating action button and bottom navigation

The layout adapts below 800 px.

  • Sticky result bar — Profit, GP % and Markup % stay pinned above the bottom nav in Default mode, so you can watch them move while editing discounts instead of scrolling to the summary and back. Values below your floors turn amber; losses turn red.
  • Floating action button — a thumb-reachable ⊕ above the bottom-right corner opens the six primary actions (Save to history, Copy summary, WhatsApp, Share link, Email, Export PDF), which otherwise live in the top header. Tap the scrim or press Escape to dismiss. Default mode only.
  • Bottom nav — Calc · Incentives · Summary · History · Quote
  • Pull to reset — in Default mode, pull down from the top of the page to reveal the reset bar; past 90 px, release to reset all inputs and clear the saved session.
  • Quote builder — stacked card per line, no sideways scrolling, action bar pinned to the bottom of the dialog. Rotating switches layout automatically.
  • Dialogs — every modal is constrained to the viewport with a scrolling body, so its buttons are always reachable.
  • Touch targets — controls meet the 44 px guidance, with press feedback.
  • Zoom — pinch-zoom works; double-tap zoom is suppressed only on controls.

Keyboard shortcuts

Key Action Key Action
? Keyboard shortcuts 1 GST 18%
S Settings 2 GST 5%
E Saved presets P Solve for Profit
T Tools menu L Solve for Selling Price
M Quote builder K Solve for Cost Price
R Reset all inputs ⌘/Ctrl + Z Undo
Q Default / Quick mode ⌘/Ctrl + ⇧ + Z Redo
⌘/Ctrl + S Save to history ⌘/Ctrl + C Copy summary

Any GST rate other than 18% or 5% goes in the Other % box. P and S were taken by Solve-for-Profit and Settings, so presets use E.


Platform

Offline / PWA

  • Service worker (sw.js) precaches the markup, both script bundles, the stylesheet, all three fonts and the icons, and serves them stale-while-revalidate — the app works fully offline after the first visit.
  • Web app manifest — installable as a standalone app on Android and iOS.
  • A banner appears when a new version is deployed.

State persistence

Key Contents
pc-state Full calculator state (MRP, quantity, CP/SP inputs, modes, incentives, landed costs, rounding, floor limits, auto-save pref)
pc-history History array — up to 50 entries
pc-labels Custom incentive names and the CP/SP incentive lists
pc-presets Saved incentive presets
pc-fx Cached exchange rates, their age, and any manual override
pc-features Which optional features are switched off
pc-qstate Quick mode inputs and settings
pc-quote Quote builder lines
pc-theme Dark / light preference
ob-done Onboarding completion flag

A Settings → Help → Reset everything clears exactly these keys — not localStorage.clear(), since the origin may hold something that is not ours — and reloads, so the app comes up the way a new visitor sees it.

URL share (?s=…) encodes calculator state as base64 JSON and takes priority over localStorage on load. Every field is allow-listed on the way in — an unrecognised or malformed payload is discarded, not partially applied.


Accessibility

Targets WCAG 2.1 Level AA, verified on every push. Lighthouse accessibility score: 100.

  • Contrast — every text/background pair meets 4.5:1 in both themes, and every interactive control is identifiable at 3:1 (WCAG 1.4.11) by its border or its fill. Control boundaries use a dedicated --border-ctrl token rather than the softer decorative --border, and the selected state of a pill is held to the same bar — in the dark theme it was previously within 1.09:1 of the surface behind it, so only the label brightness said it was chosen
  • Structure — one h1, section headings throughout, main/nav/header/ footer landmarks, and a skip link as the first tab stop
  • Names — every input and button exposes an accessible name, and visible labels are contained in their accessible names
  • Keyboard — everything is reachable and operable; panel headers are real buttons with aria-expanded; no handler is mouse-only
  • Focus — a visible :focus-visible ring in a soft mid grey rather than near-black (on the wrapper, for inputs styled that way), a focus trap in every dialog, and focus returned to the opener on close. Confirmation dialogs focus Cancel, never the destructive button
  • Live regions — results are announced politely and debounced, including floor-limit warnings
  • Colour is never the only signal — landed-cost direction carries a +₹/−₹ prefix, deltas carry ↑/↓ glyphs, the active rounding step is printed in its field
  • Motionprefers-reduced-motion disables animation and transitions
  • Zoom — pinch-zoom to 5× is available; only double-tap zoom is suppressed, per control

Checked with axe-core (WCAG 2.0/2.1 A + AA + best practice) across the default view, all six dialogs and the FAB menu, in both themes — plus direct assertions for contrast, headings, focus behaviour and reduced motion, which axe cannot evaluate without layout.

Security

  • Content Security Policy via meta tag, with script-src 'self' — no 'unsafe-inline'. The markup carries no on* attributes at all; every interaction is routed through a delegated handler registry keyed by data-click / data-input / data-change attributes.
  • One external origin, declared narrowly. Fonts and icons are same-origin; the only outbound request is the exchange-rate feed, so connect-src is 'self' https://open.er-api.com and nothing else. The app makes exactly one fetch() call, never during load, and works fully offline without it — a browser test confirms the policy admits that host and refuses any other.
  • One escaping helper (escHtml) for every value interpolated into markup, rather than escaping open-coded per call site.
  • Stored data is untrusted. Incentive keys from localStorage are validated against /^[A-Za-z0-9_-]{1,24}$/ before use in element ids, and malformed entries are dropped with a console warning. Share-link payloads are allow-listed field by field.
  • Response headers ship in _headersX-Frame-Options: DENY and frame-ancestors 'none' (neither can be set from a meta tag), plus nosniff, Referrer-Policy, Permissions-Policy and COOP/CORP.

_headers is understood by Netlify and Cloudflare Pages. GitHub Pages ignores it — on that host the clickjacking protection has to move into a CDN or proxy in front.


Measured quality

Every number here is reproducible from the repo — none is hand-written.

Metric Value Reproduce with
Tests 1549 passing, 7 suites npm test
Statement coverage 82.7% (app.js 86.3%, app-extra.js 72.3%) npm run coverage
Lighthouse Performance 97 npm i -D lighthouse && npm run lighthouse
Lighthouse Accessibility 100
Lighthouse Best Practices 100
Lighthouse SEO 100

Lighthouse runs against a local server that gzips and sets the same cache headers as _headers, on emulated mobile with throttling — so the scores reflect a realistic deployment rather than an unconfigured static host. Measured: FCP 1.5s, LCP 2.5s, TBT 0–10ms, CLS 0. Take a single Total Blocking Time reading with salt — it is noisy on a shared machine, and one run in five here reported 740ms against a median of 0.

The main-thread cost is Style & Layout at ~325ms, not scripting (70ms eval, 32ms parse) — a 1,043-node document against 809 CSS rules, thirteen of which use :has(). That is what showing a keyboard-only focus ring on wrapper elements costs, and it buys more than it costs.

Two audits score zero by choice. unminified-javascript (~39KB) is the no-build-step trade: what is in the repo is what the browser runs. unused-javascript (~88KB) is mostly the deferred bundle, warmed during idle so the quote builder and what-if open instantly — paid for after load, not before.

The app is indexable, with a robots.txt, a sitemap, and share-card metadata that says what the tool does rather than who built it.

Coverage is measured by mapping V8 coverage back to each bundle. The app runs inside jsdom as an inline script, so c8 and nyc attribute everything to the document URL; tests/coverage.js recovers the offsets and folds in the browser suite's own Chromium coverage. No badge is generated automatically — re-run the commands after significant changes.


Tests

1549 assertions across seven suites. They load the real index.html, assets/styles.css and both script bundles, and drive the actual application functions — no application code is mocked. Node 22 or newer.

npm install   # jsdom, dev-only
npm test
Suite Assertions Covers
features 1093 GST, incentives, quantity, landed costs, rounding, undo/redo of typed values and controls, quote maths, history
errors 33 every failure path logs; a clean run stays silent; storage and payload recovery
mobile 78 modal layering, touch targets, sticky result bar, responsive quote layouts
fab 50 floating action button behaviour and z-index ordering
modes 85 solve modes, price input modes, what-if, comparison, Quick mode, wizard, share-link round trip, theming, auto-save
a11y 108 contrast ratios, structure, names, keyboard operability, focus trap, live regions, reduced motion, axe-core
browser 102 real Chromium over HTTP: asset loading, CSS cascade, defer timing, clicks, dialogs, control-bar spacing, mobile viewport, axe with layout

Individual suites: npm run test:features, test:errors, test:mobile, test:fab, test:modes, test:a11y, test:browser. Full assertion output: npm run test:verbose.

The first six run in jsdom and need nothing beyond npm install. The seventh drives a real browser and skips itself if Chromium is unavailable, so npm test still passes without one:

npx playwright install chromium   # only needed for the browser suite

Every push and pull request runs them via GitHub Actions — the jsdom suites on Node 22 and 24, and the browser suite in a dedicated job with REQUIRE_BROWSER=1 so a missing browser fails rather than silently skips. See tests/README.md for how the harness works and how to add a suite.


Architecture

Static files, served as-is. No bundler, transpiler or minifier — what is in the repo is what the browser runs.

  • index.html is markup only. It carries no inline script and no on* attributes; elements declare intent with data-click, data-input, data-change, data-focus and data-blur, and a small delegated dispatcher looks the handler up in a registry. That is what lets the CSP be script-src 'self'.

  • assets/app.js is the core: calculation, incentives, history, settings, persistence. Loaded with defer, so the DOM is parsed before it initialises.

  • assets/app-extra.js holds what-if, comparison, the quote builder, Quick mode, the wizard, CSV export, both converters, the preset manager, the text-entry dialog and the share/copy/PDF exports. It is fetched on first use and warmed up during idle time, keeping it off the critical path.

    The rule for what may live here: nothing that runs before a user asks for it. The target-GP solver was moved out once and had to come back — it is called from fillSummary on every calculation, including the first, so its shim pulled the bundle straight onto the critical path. A browser test now blocks the bundle outright and checks a first calculation still comes out complete.

  • Plain ES5-compatible browser JavaScript throughout — no framework, no polyfills.

  • Fonts are self-hosted: Syne (headings), DM Sans (UI), JetBrains Mono (numbers), latin subset, one variable woff2 per family.

  • package.json exists only for the test suite; the app itself has no runtime dependencies.

MIT Licence.