TaxGlobe
Web app for calculating taxes across different Canadian jurisdictions
Overview
TaxGlobe is a client-side web application that calculates Canadian income tax for a given gross income and province. The user enters their annual income and selects a province from a dropdown; the application calculates both federal and provincial taxes by iterating through 2023 marginal tax bracket data, producing a full breakdown showing: tax owed in each bracket, total federal tax, total provincial tax, combined tax, effective tax rate (as a percentage of gross income), and net take-home income. All calculation logic runs in the browser with no server round-trip. Results update in real time as the user types or changes the province selection, with floating-point currency values rounded correctly to two decimal places using Math.round arithmetic to avoid the binary representation errors that affect naive floating-point addition.
Architecture
Tech Stack
- Vanilla JavaScript, Complete tax calculation engine; bracket data encoded as JS objects; real-time DOM updates via
addEventListener('input') - 2023 Canadian Tax Bracket Data, Federal CRA brackets and the provincial bracket tables encoded as arrays of
{ min, max, rate }objects - HTML5, Form with
type="number"income input and province<select>; results rendered into a structured table and summary card - CSS3, Clean card layout for results; responsive grid for the breakdown tables; colour-coded effective rate display
Build Process
The 2023 federal and provincial tax brackets were researched from CRA publications and encoded as JavaScript data objects. Each bracket entry specifies the lower bound, upper bound (or Infinity for the top bracket), and the marginal rate as a decimal.
// taxData.js - 2023 Canadian federal brackets
const federalBrackets = [
{ min: 0, max: 53359, rate: 0.15 },
{ min: 53359, max: 106717, rate: 0.205 },
{ min: 106717, max: 165430, rate: 0.26 },
{ min: 165430, max: 235675, rate: 0.29 },
{ min: 235675, max: Infinity, rate: 0.33 },
];
// Ontario provincial brackets (example)
const ontarioBrackets = [
{ min: 0, max: 49231, rate: 0.0505 },
{ min: 49231, max: 98463, rate: 0.0915 },
{ min: 98463, max: 150000, rate: 0.1116 },
{ min: 150000, max: 220000, rate: 0.1216 },
{ min: 220000, max: Infinity, rate: 0.1316 },
];
The core calculation function iterates through the bracket array. For each bracket, it computes the taxable amount within that bracket, the lesser of the income above the bracket floor and the bracket width, and multiplies by the marginal rate. Accumulated totals across all brackets give the total tax for that bracket set.
function calcTax(income, brackets) {
let totalTax = 0;
const breakdown = [];
for (const bracket of brackets) {
if (income <= bracket.min) break;
const taxableInBracket = Math.min(income, bracket.max) - bracket.min;
const taxInBracket = taxableInBracket * bracket.rate;
totalTax += taxInBracket;
breakdown.push({
min: bracket.min,
max: bracket.max,
rate: bracket.rate,
taxable: taxableInBracket,
tax: taxInBracket,
});
}
return { totalTax, breakdown };
}
After running both the federal and provincial calculations, totals are combined and derived figures computed. Floating-point values are rounded to cents using Math.round(value * 100) / 100 before display, preventing binary representation artefacts from showing incorrect cent values in the output.
function calcSummary(income, province) {
const fed = calcTax(income, federalBrackets);
const prov = calcTax(income, provinceBrackets[province]);
const total = fed.totalTax + prov.totalTax;
const round = v => Math.round(v * 100) / 100;
return {
federalTax: round(fed.totalTax),
provincialTax: round(prov.totalTax),
totalTax: round(total),
effectiveRate: round((total / income) * 100),
netIncome: round(income - total),
fedBreakdown: fed.breakdown,
provBreakdown: prov.breakdown,
};
}
The form uses a type="number" input with min="0" and step="1" for the income field. The province dropdown is populated from the keys of the bracket data object, so adding a new province's data automatically adds it to the dropdown without modifying the HTML. Both inputs use required for HTML5 validation.
Results are displayed in two sections: a summary card showing the headline figures (total tax, effective rate, net income) and two collapsible breakdown tables showing the per-bracket calculation for federal and provincial tax respectively. The effective rate is colour-coded, green for rates under 20%, amber for 20 to 30%, red for above 30%.
Both the income input and province select listen for input events. On each event, the calculation runs and the DOM is updated immediately, no submit button required. Input debouncing with a short timeout prevents excessive recalculation during rapid typing.
let debounceTimer;
[incomeInput, provinceSelect].forEach(el => {
el.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const income = parseFloat(incomeInput.value);
const province = provinceSelect.value;
if (!isNaN(income) && income > 0 && province) {
renderResults(calcSummary(income, province));
}
}, 150);
});
});
Calculation Flow
Challenges & Solutions
Floating-Point Precision in Currency Calculations. JavaScript's IEEE 754 floating-point arithmetic causes notorious precision errors in currency calculations. For example, 0.1 + 0.2 produces 0.30000000000000004 in JavaScript. In a tax calculator, these errors accumulate across bracket additions and can produce results like $15,234.999999 instead of $15,235.00. The solution was to apply Math.round(value * 100) / 100 as a final step on all currency output values, this rounds to the nearest cent before display. For summing bracket tax values, the accumulation uses raw floats (where the error is negligible per bracket) and only rounds at the final output stage.
Keeping Tax Bracket Data Accurate. Tax brackets change annually. Encoding them as static JavaScript data means the application will become stale after each tax year. The data structure was designed to make updates straightforward: each jurisdiction is a named key in the brackets object, and each bracket is a simple { min, max, rate } object that mirrors the CRA table format directly. Adding or updating a province requires only editing the data object, not touching any calculation logic. A // DATA VERSION: 2023 comment is placed prominently at the top of the data file to make staleness immediately visible.
What I Learned
- How Canadian marginal tax brackets work in practice: income is taxed at each bracket's rate only on the portion that falls within that bracket, not on the entire income
- Effective tax rate (total tax divided by gross income) versus marginal rate (the rate applied to the last dollar earned), a common source of public confusion that good UI can clarify
- IEEE 754 floating-point precision errors are real and unavoidable in JavaScript currency arithmetic; the correct mitigation is
Math.round(v * 100) / 100at output time, not during accumulation - Input debouncing with
setTimeout/clearTimeoutprevents expensive operations from running on every keystroke and is the standard pattern for real-time input-driven computation - Encoding structured data (tax brackets) as clean JavaScript objects rather than inline HTML makes the data easy to update, test, and eventually replace with an API call
- Separating calculation logic from rendering logic (pure
calcSummary()function vsrenderResults()function) makes both independently testable and reusable
How I Built It
Phase 1: Researching and encoding the bracket data. I pulled the 2023 federal brackets from CRA publications and the provincial tables, then encoded each jurisdiction as a named array of { min, max, rate } objects. Top brackets use Infinity as the upper bound so the same loop works for all of them.
Phase 2: The marginal tax engine. The core calcTax(income, brackets) function walks each bracket, taxes only the income slice that falls inside it, and records a per-bracket breakdown alongside the total. Because calculation is separate from rendering, the same pure function drives both the summary card and the detail tables.
Phase 3: Totals, rates, and correct rounding. Federal and provincial totals are combined, then effective rate and net income are derived. Every currency value passes through Math.round(v * 100) / 100 at output time, which is what keeps binary floating-point representation errors from leaking cent artefacts into the results.
Phase 4: The form and data-driven dropdown. The province dropdown is populated from the keys of the bracket data object, so adding a jurisdiction is a data edit, not an HTML edit. The income input is a type="number" field with HTML5 validation.
Phase 5: Real-time rendering with debouncing. Both inputs listen for input events and re-render results immediately, with a 150 ms debounce so rapid typing does not thrash the DOM. The results card shows headline figures plus collapsible per-bracket breakdown tables, with the effective rate colour-coded by band.
What You Need
| Category | Item | Notes |
|---|---|---|
| Tool | A modern web browser | The entire app runs client-side; no server is required |
| Tool | A local dev server (e.g. npx serve) | Optional for development; any static host works for deployment |
| Software | Code editor | Used for the bracket data file and the calculation engine |
| Software | CRA tax publications for the target year | The only data source needed; bracket data is the app's raw material |
| Skill | JavaScript fundamentals | Array iteration, object data structures, event listeners |
| Skill | How marginal tax brackets work | Each rate applies only to income inside that bracket, not to the whole income |
| Skill | Floating-point awareness | Knowing that 0.1 + 0.2 !== 0.3 in IEEE 754 and rounding at output |
| Skill | HTML/CSS | Form inputs, responsive results card, colour-coded rate display |
Challenges and Lessons Learned
Floating-point precision in money math. JavaScript's IEEE 754 arithmetic makes naive currency accumulation produce values like $15,234.999999. Rounding once at output with Math.round(v * 100) / 100 was the right fix; rounding during accumulation would have introduced a different, subtler bias. The lesson: in currency code, know exactly where your rounding boundaries are.
Data staleness by design. Hard-coding the 2023 brackets means the app needs an annual data update. I deliberately made the data file a clean mirror of the CRA table format with a prominent // DATA VERSION: 2023 marker, so the update is mechanical. The honest takeaway: any app built on regulated data needs a refresh plan, not just a refresh.
Separation of calculation and rendering. Keeping calcSummary() pure (inputs in, numbers out) meant the same logic could be tested, reused, and reasoned about independently of the DOM. That one habit made every later change cheaper.
What Is Next
- Adding the remaining provinces and territories so all Canadian jurisdictions are covered
- A data-file versioning scheme with a visible "tax year" indicator and staleness warning
- Comparison mode: side-by-side results for two provinces at the same income
- RRSP and deduction inputs to show tax savings from common deductions
- Client-side unit tests for the bracket engine, including boundary incomes at exact bracket edges
- An export option (CSV/PDF) for the full per-bracket breakdown