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.
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.
| 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.
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.
| 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.
Using it Pricing · Incentives & costs · Analysis & output · Working with the app · Mobile · Keyboard shortcuts
Under it Platform · Accessibility · Security · Measured quality · Tests · Architecture
git clone https://github.com/sterlingspares/calc.git
cd calc
python3 -m http.server 8000 # or any static serverOpening 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
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.
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% GSTlabels 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.
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.
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 |
| 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 |
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.
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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 it — Right 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.
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.
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.
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.
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.
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.
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 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
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
- Filters —
All·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.
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 + Zand⌘/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
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.
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.
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.
A mobile-oriented 4-step card interface — Q toggles it.
- MRP — MRP, GST rate, solve-for mode
- CP — cost price (discount % or manual ₹)
- SP — selling price (or profit, when solving for SP/CP)
- 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.
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.
| 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.
- 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.
| 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.
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-ctrltoken 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/footerlandmarks, 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-visiblering 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 - Motion —
prefers-reduced-motiondisables 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.
- Content Security Policy via meta tag, with
script-src 'self'— no'unsafe-inline'. The markup carries noon*attributes at all; every interaction is routed through a delegated handler registry keyed bydata-click/data-input/data-changeattributes. - One external origin, declared narrowly. Fonts and icons are same-origin;
the only outbound request is the exchange-rate feed, so
connect-srcis'self' https://open.er-api.comand nothing else. The app makes exactly onefetch()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
localStorageare 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
_headers—X-Frame-Options: DENYandframe-ancestors 'none'(neither can be set from a meta tag), plusnosniff,Referrer-Policy,Permissions-Policyand COOP/CORP.
_headersis 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.
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.
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 suiteEvery 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.
Static files, served as-is. No bundler, transpiler or minifier — what is in the repo is what the browser runs.
-
index.htmlis markup only. It carries no inline script and noon*attributes; elements declare intent withdata-click,data-input,data-change,data-focusanddata-blur, and a small delegated dispatcher looks the handler up in a registry. That is what lets the CSP bescript-src 'self'. -
assets/app.jsis the core: calculation, incentives, history, settings, persistence. Loaded withdefer, so the DOM is parsed before it initialises. -
assets/app-extra.jsholds 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
fillSummaryon 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.jsonexists only for the test suite; the app itself has no runtime dependencies.
MIT Licence.



