Building a Multilingual Calculator Site: Metric vs Imperial Unit Handling (epoxy example)
When a calculator spans languages, unit systems and rounding rules collide with locale conventions. A single input — "how much epoxy do I need?" — might be answered in millimetres and litres in Munich, inches and US gall
When a calculator spans languages, unit systems and rounding rules collide with locale conventions. A single input — "how much epoxy do I need?" — might be answered in millimetres and litres in Munich, inches and US gallons in Houston, or centimetres and litres in São Paulo. Behind the scenes you want one source of truth so you don't end up with six different volume formulas diverging over time.
The approach that keeps things maintainable is simple in principle and a little fiddly in practice: store every value internally in SI units (metre, kilogram, second, litre) and only convert at the UI edge. The conversion layer becomes a thin, testable adapter between the user's units and the model, and the rest of your code never thinks about inches or gallons again.
SI as the internal model
In a material-estimation tool like Epoxy Planner, the model is just a few numbers: area, thickness, coverage rate, and the resulting volume. Pick SI once and commit.
- Length: metre (m). The user may type millimetres for a floor coating thickness — convert to metres the moment the value enters the model.
- Area: square metre (m²). Surface-area inputs in square feet convert to m² at the input; the calculator never sees ft².
- Volume: litre (L). A US user enters gallons; multiply by 3.78541 and store litres.
The rule of thumb: the conversion constant lives next to the input control, not inside the formula. That way your coverage-rate math is area_m2 * thickness_m * 1000 = litres, and it stays the same whether the input panel is German or English.
Converting at the UI edge
Define a small conversion map and use it consistently for input and display. Here is a minimal example covering the units a coating calculator typically needs:
const TO_SI = {
length: { mm: 1e-3, in: 0.0254, ft: 0.3048, m: 1 },
area: { ft2: 0.092903, m2: 1 },
volume: { 'gal-US': 3.78541, L: 1 },
};
function toSI(value, from) {
return Object.entries(TO_SI).reduce((best, [category, units]) => {
if (units[from] !== undefined) {
best[category] = value * units[from];
}
return best;
}, {});
}
// 20 mm film → metres, 500 sq ft → m², 5 US gal → litres
console.log(toSI(20, 'mm')); // { length: 0.02 }
console.log(toSI(500, 'ft2')); // { area: 46.4515 }
console.log(toSI(5, 'gal-US')); // { volume: 18.92705 }
Going the other way, display units are derived by dividing by the same constant. Because you only touch the conversion table, adding a new unit (say, imperial gallons) is a one-line change and the formula code is untouched.
Be aware of the direction mismatch for area and volume: ft² → m² uses 0.092903, but m² → ft² divides by that constant (≈10.7639). Using the same table with division works cleanly as long as you don't accidentally multiply by the "to SI" factor on the way out — a common off-by-one-constant bug.
Locale-aware number formatting
Once the calculator hands back litres, you have to render them for the user. In Western Europe and Latin America a comma is the decimal separator, and a dot groups thousands. Intl.NumberFormat handles this without you shipping locale tables.
function formatForLocale(value, locale) {
const fmt = new Intl.NumberFormat(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 4,
});
return fmt.format(value);
}
// 18.927 litres shown differently in Germany and Brazil
console.log(formatForLocale(18.92705, 'de-DE')); // "18,93"
console.log(formatForLocale(18.92705, 'pt-BR')); // "18,93"
console.log(formatForLocale(18.92705, 'en-US')); // "18.93"
The locales where a decimal comma matters for this kind of site include de, fr, es, it, and pt-BR. Round-trip carefully on input: if you let the user type into a number input, browsers already handle the locale separator, but a free-text field needs Number(str.replace(',', '.')) before conversion, otherwise "3,5" parses as 3 in JavaScript and silently truncates the order.
When Epoxy Planner localised the calculator, the German epoxy floor calculator at the German epoxy floor calculator URL needed the comma, and the Brazilian Portuguese version needed it too — the difference was in word order, not the decimal mark.
hreflang alternates
Search engines won't serve a French user a page formatted for US conventions if you tell them the relationship between your locale pages. Emit hreflang link elements for every language variant, plus an x-default:
<link rel="alternate" hreflang="de" href="https://epoxyplanner.com/de/..." />
<link rel="alternate" hreflang="fr" href="https://epoxyplanner.com/fr/..." />
<link rel="alternate" hreflang="es" href="https://epoxyplanner.com/es/..." />
<link rel="alternate" hreflang="it" href="https://epoxyplanner.com/it/..." />
<link rel="alternate" hreflang="pt-br" href="https://epoxyplanner.com/pt-br/..." />
<link rel="alternate" hreflang="x-default" href="https://epoxyplanner.com/" />
Two implementation notes that bite in practice. First, every alternate page must link back to every other alternate, including itself — a missing self-link is the most common hreflang audit finding. Second, the path prefix you choose (/de/, /fr/, /es/, /it/, /pt-br/) is what shows in the URL and in search results, so keep it stable. Switching from /pt/ to /pt-br/ mid-project forces a redirect chain that dilutes ranking.
Rounding rules for material orders
Coverage math is continuous; epoxy kits are discrete. A calculator that prints "18.93 L" is misleading when the store sells 5 L buckets and 20 L drums. The business rule for material orders is almost always "round up to the next available kit size," never round to nearest and never round down.
A clean way to implement this is a small ceilToKits helper that returns both the kit breakdown and the true purchase volume:
function ceilToKits(litres, kitSizes = [20, 5, 1]) {
let remaining = litres;
const kits = [];
for (const size of kitSizes) {
const count = Math.ceil(remaining / size);
if (count > 0) {
kits.push({ size, count });
remaining = 0;
break;
}
}
const purchasedLitres = kits.reduce((sum, k) => sum + k.size * k.count, 0);
return { kits, purchasedLitres, waste: purchasedLitres - litres };
}
// 18.93 L → one 20 L kit, 1.07 L over-pour
console.log(ceilToKits(18.92705));
Notice how the input to the function is still the SI value (litres). The kit sizes are business data, not unit data, so they live in product config, not in the converter. This separation is what lets you change from "5 L / 20 L" buckets to "1 gal / 5 gal" pails for a US variant by swapping one config array instead of rewriting the coverage math.
Putting it together
The architecture that survives localisation looks like this:
-
Input adapter — reads the user's unit selector, parses the locale-aware number, calls
toSI, hands an SI value to the model. - Model — coverage formula in metres, square metres and litres. No units, no locale, no rounding.
-
Output adapter — takes the litres, runs
ceilToKitswith the regional kit catalog, thenIntl.NumberFormatwith the page locale for display.
Every layer has a single responsibility and a clear contract. The converter only knows unit constants, the model only knows arithmetic, and the formatter only knows locales and kit sizes.
For a worked-through example of this pattern in a real product, see Epoxy Planner — the same coverage model powers the English, the German epoxy floor calculator, and the Brazilian Portuguese version, differing only at the UI edge.
Takeaways
- Store values in SI internally; convert at the UI edge so the formula never changes.
- Keep your
toSI/fromSItable small and test the inverse explicitly — area and volume factors invert, they don't mirror. - Use
Intl.NumberFormatfor every locale that writes decimals with a comma (de,fr,es,it,pt-BR), and normalise input before parsing. - Emit a complete, self-linking
hreflangset for/de/,/fr/,/es/,/it/,/pt-br/and anx-default. - Round material orders up to kit sizes with a dedicated helper; treat kit sizes as product config, not units.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.