Skip to content

fix: correct the 2026 tax tables and clear the technical debt behind #6 and #7 - #9

Merged
devRMA merged 15 commits into
chore/infra-and-scriptsfrom
fix/tech-debt-and-bugs
Aug 1, 2026
Merged

fix: correct the 2026 tax tables and clear the technical debt behind #6 and #7#9
devRMA merged 15 commits into
chore/infra-and-scriptsfrom
fix/tech-debt-and-bugs

Conversation

@devRMA

@devRMAdevRMA commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Technical debt found while investigating #6 and #7. No new features — every change here either fixes a wrong number, an unreachable control, or a test that was not testing.

The headline: every net salary the app produced was wrong

The two tax tables disagreed on their reference year. INSS was 2025 (minimum wage 1.518,00, ceiling 8.157,41); IRRF was older still, the February 2024 table (exempt to 2.259,20). Neither had been updated for 2026, and the calculator had never implemented the two rules from Lei 15.270/2025 that dominate its target salary range.

At the app's own default salary of R$ 5.000,00 with a 220h month:

INSSIRRFNetHourly rate
Before509,60347,574.142,8318,83
After501,510,004.498,4920,45

The app understated take-home pay by R$ 355,66/month — R$ 4.267,92 a year — and "Custo da Hora", its headline output, was off by 7,9%. Above the reduction's phase-out the error narrows to a flat R$ 8,09/month of over-withheld INSS at every salary over 4.354,27.

Values are from Portaria Interministerial MPS/MF nº 13 de 09/01/2026 (Anexo II) and the 2026 IRRF table:

  • INSS — 7,5% / 9% / 12% / 14% over 1.621,00 / 2.902,84 / 4.354,27 / 8.475,55, capping the contribution at 988,09.
  • IRRF — exempt to 2.428,80, then 7,5% / 15% / 22,5% / 27,5% with deductions 182,16 / 394,16 / 675,49 / 908,73.
  • Simplified deduction of 607,20/month, applied whenever it beats the contribution — which is every salary below roughly 5.755, i.e. most of this app's users.
  • The reduction that zeroes tax to 5.000,00 and phases out linearly to 7.350,00.

The last two are interdependent, which is why implementing either alone would have been worse than neither: the published reduction formula 978,62 − 0,133145 × bruto only lands on zero at 7.350,00 when the simplified deduction is the one being applied. Implemented together the result is continuous at both ends — verified at 5.000,01 and at 7.350,00.

Rounding was also losing a cent. Math.round(value * 100) inherits binary representation dust: 1.621,00 × 7,5% evaluates to 121,57499999999999 and rounded down to 121,57 instead of 121,58.

The other wrong number: phantom overtime on night shifts

calculateSuggestedExit and calculateWorkStats disagreed about the same journey. The stats function credits the reduced night hour (CLT art. 73 — 52'30" counts as an hour); the exit suggestion did not. AUTO mode's whole contract is "leave at this time and your balance is zero", and for any shift touching 22:00–05:00 it broke that:

entry 21:00, lunch 01:00–02:00, journey 08:48
suggested 06:48 -> balance +51 min, all of it billed as overtime

Both paths now share one helper. Where the reduced hour makes an exact match unreachable — no whole number of minutes maps to 420 credited minutes, only 419 or 421 — it settles on the closest, so the residue is at most a minute.

Separately, a long lunch drove workMinutes - workedBeforeLunch negative, placing the suggested exit before lunch ended. That made the range non-chronological, which calculateWorkStats rejects by returning zeroed stats — so the UI reported a balanced day while hiding real overtime.

Overtime rates were presenting a collective agreement as law

The tiers were hardcoded at 75% and 100% under an "Extras (CLT)" heading. The statutory floor is 50% (CF art. 7º, XVI); 75% comes from collective agreements. The rates are now editable and default to the legal floor, so a 75% agreement is still representable and everyone else gets correct numbers by default.

Crashes and a control nobody could reach

  • The calculator could be bricked by its own storage.JSON.parse ran unguarded on the stored extras inside a mount effect, and the project has no error boundary — one malformed entry blanked the page with no recovery short of clearing site data. Worse, Number.parseFloat on a corrupt salary produced NaN, which was then written back to localStorage, so the app stayed broken on every subsequent load.
  • The delete button was unreachable on mobile.flex-1 on a bare <input> keeps min-width: auto, flooring the extras row at its intrinsic width: 380px inside a 245px container at a 375px viewport, with overflow-hidden on an ancestor. The button sat ~70px off screen — not reachable by touch, not reachable by scrolling.
  • The read and write effects raced on mount. The write closed over pre-restore state and overwrote saved values with defaults before the restore landed.
  • maximumScale: 1 disabled pinch zoom, failing WCAG 1.4.4 for anyone who needs to magnify the page.
  • The clipboard write was unguarded and unawaited: it threw in insecure contexts and reported "Copiado!" even when the write was rejected.
  • progress was clamped in one branch and not the other, so a zero-length journey produced width: "Infinity%". A zero balance also rendered as overtime in the hero card while the summary card treated it as on target.

Tests that were not testing

Coverage advertised 95.8%. Vitest's v8 provider only reports files that a test imported, so that number described 9 files out of 28. The real baseline was 49.09% statements / 37.35% branches, with fifteen source files never imported by a test at all.

BeforeAfter
Statements49.09%100%
Branches37.35%100%
Tests72286

app/layout.tsx needed no exclusion — it only failed to import because next/font/google is normally transformed by SWC during a Next build, so outside that compilation Inter arrives as an object rather than a callable. A three-line mock makes it testable, and the test now asserts viewport has no maximumScale so the pinch-zoom fix cannot regress silently.

One e2e test was passing without asserting anything: should show side ads on desktop after delay bailed out with a bare return on narrow viewports, reporting success on Mobile Chrome and Mobile Safari without running a check.

Structure

work-calculator.tsx (573 lines) and salary-calculator.tsx (387) sat loose in components/ root, which AGENT.md reserves for nothing — they are Organisms. The masked numeric input was implemented three separate times, and work-calculator carried two near-duplicate copies of the hero card (lg:hidden and hidden lg:block) that had already drifted apart. Largest component is now 291 lines.

Both files also ran their own setInterval(..., 1000), re-rendering the whole tree twice a second to advance the same clock. There is now exactly one setInterval in the codebase.

Dead code and dead config

useIsMobile had zero callers. formatMinutes was referenced only by its own test, giving five tests' worth of false confidence in a function nothing shipped. Both calculateWorkStats and calculateSuggestedExit wrapped their bodies in try/catch, but new Date(garbage) returns an Invalid Date rather than throwing — the catch blocks were unreachable. .biomeignore has done nothing since Biome 2 dropped support for it.

devRMAand others added 15 commits August 1, 2026 17:02
`useIsMobile` had zero callers and `formatMinutes` was referenced only by
its own test — work-calculator.tsx formats balances with its own private
helper. Both were carrying maintenance and coverage weight for nothing.
parseCurrency divided a digit-only string by 100 with no finiteness check,
so a long paste (400 digits) produced Infinity and poisoned every derived
total. Clamp it to 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two tables in use disagreed on their reference year: the INSS brackets
were 2025 (minimum wage 1.518,00, ceiling 8.157,41) while the IRRF table
was older still (first bracket 2.259,20). Every net-salary figure the app
produced was wrong.
Extract the domain into lib/payroll.ts with the values in force for 2026
per Portaria Interministerial MPS/MF nº 13 de 09/01/2026:
- INSS: 7,5% / 9% / 12% / 14% over 1.621,00 / 2.902,84 / 4.354,27 /
8.475,55, capping the contribution at 988,09.
- IRRF: exempt to 2.428,80, then 7,5% / 15% / 22,5% / 27,5% with
deductions 182,16 / 394,16 / 675,49 / 908,73.
Also implement the two Lei 15.270/2025 rules the calculator never had: the
607,20 simplified deduction (taken whenever it beats the contribution) and
the reduction that zeroes tax up to 5.000,00 and phases out linearly to
7.350,00. The two are interdependent — the published reduction formula only
lands on zero at 7.350,00 when the simplified deduction is the one applied,
so implementing either alone yields a discontinuity at both ends.
Rounding went through Math.round(value * 100), which loses a cent whenever
the product carries binary representation dust: 1.621,00 x 7,5% evaluates
to 121,57499999999999 and rounded down to 121,57 instead of 121,58.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects in the persistence path:
- `JSON.parse` ran unguarded on the stored extras, so one malformed entry
threw during the mount effect and took the whole calculator down with no
way to recover short of clearing site data.
- `Number.parseFloat` on a non-numeric stored salary yielded NaN, which
then propagated silently through every derived total.
- The read and write effects both ran on mount, and the write closed over
the pre-restore state — so it wrote the defaults over the saved values
before the restore landed. Gate the write until the restore completes.
Validate each stored item against its shape and drop only the bad ones,
guard the hourly rate against a zero divisor, and route amounts through
sanitizeAmount so a non-finite input can no longer reach the totals.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`maximumScale: 1` in the viewport disabled pinch zoom, failing WCAG 1.4.4
(Resize Text) for anyone who needs to magnify the page. Drop it.
The consent key and its unguarded `JSON.parse` were duplicated across
cookie-consent.tsx and analytics-wrapper.tsx, so corrupted consent data
crashed both. Move both to lib/consent.ts behind a validating reader, and
clear the banner timeout on unmount.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…code
The overtime tiers were hardcoded at 75% and 100% behind an "Extras (CLT)"
label, but 75% is a collective-agreement figure, not law — the statutory
floor is 50% (CF art. 7, XVI). Default to the legal floor and let the rates
be edited, so a 75% agreement is still representable and the numbers are
correct out of the box for everyone else.
`overtime75`/`overtime100` are now `firstTierMinutes`/`extraTierMinutes`,
since the split no longer implies a fixed percentage.
Both `calculateWorkStats` and `calculateSuggestedExit` wrapped their bodies
in try/catch, but `new Date(garbage)` returns an Invalid Date rather than
throwing and `isValid` already covered it — the catch blocks were dead and
uncoverable. The night-window arithmetic also repeated the same clamp four
times; it is now one `overlapInMinutes` helper.
Persistence had the same mount race as the salary hook: the write effect ran
before the restore and overwrote saved values with defaults.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
WorkLoad is a Next.js web app with no React Native or Expo surface, so the
72k of mobile-only guidance was only diluting the skills an agent has to
sift through to find the rules that apply here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`calculateSuggestedExit` and `calculateWorkStats` disagreed about the same
journey. The stats function credits the reduced night hour (CLT art. 73:
52'30" counts as a full hour), the exit suggestion did not. So AUTO mode
would propose a time, the user would leave then, and the app would
immediately book overtime that was never worked:
entry 21:00, lunch 01:00-02:00, journey 08:48
suggested 06:48 -> balance +51 min, all billed as overtime
Both paths now go through one `nightBonusMinutes` helper, and the exit is
refined until the credited total matches the journey. Where the reduced
hour makes an exact match unreachable — no whole number of minutes maps to
420 credited minutes, only 419 or 421 — it settles on the closest, so the
residue is at most one minute instead of an hour of phantom overtime.
`workMinutes - workedBeforeLunch` could also go negative when lunch ran
long, placing the suggested exit before the end of lunch. That made the
range non-chronological, which `calculateWorkStats` rejects by returning
zeroed stats — so the UI showed a balanced day while hiding real overtime.
Clamped at zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`skills-lock.json` is written by the skills tooling with two-space indent
while the project formats with tabs, so `biome ci .` failed on it and any
`lint:fix` rewrote all 98 lines — pure diff noise on a generated file.
Exclude it via `files.includes` instead.
`.biomeignore` has done nothing since Biome 2 dropped support for it. Its
only entry was `app/globals.css`, which passes the CSS checks anyway now
that `css.parser.tailwindDirectives` is configured, so the file has no
replacement — it just goes away.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Number("")` is 0, so a stored empty string resolved to 0 rather than the
fallback — a cleared "carga horária" came back as zero hours instead of the
default 220. Blank and whitespace-only values now fall back.
The income tax table carried a `Number.POSITIVE_INFINITY` sentinel row,
which made `find` always succeed and left the `??` fallback permanently
unreachable. The top rate is now its own value rather than a fake bracket,
so both paths are real and exercised.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Vitest's v8 provider only reports files that a test imported, so the suite
advertised 95.8% while measuring 9 files out of 28. With `coverage.include`
pointing at app/, components/, hooks/ and lib/, the real baseline was
49.09% statements and 37.35% branches — fifteen source files had never been
imported by a test at all.
Cover the periphery that had nothing: lib/storage, lib/consent,
lib/analytics, the manifest/robots/sitemap route handlers, the layout, the
theme provider, the analytics wrapper, all four atoms, and the form-field
and stat-box molecules.
`app/layout.tsx` needs no exclusion. It only failed to import because
`next/font/google` is normally transformed by SWC during a Next build, so
outside that compilation `Inter` arrives as an object rather than a
callable. A three-line mock in the setup file makes it testable, and the
test now also asserts `viewport` has no `maximumScale`, so the pinch-zoom
fix cannot silently regress.
Two tests were asserting things the environment cannot deliver: the adblock
modal never leaves the DOM under jsdom because AnimatePresence's exit
animation never completes, so that case now asserts the behaviour that
actually distinguishes dismiss from confirm — that no reload is triggered.
The junit reporter is now CI-only, so local runs stop writing an artifact
into coverage/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`should show side ads on desktop after delay` bailed out with a bare
`return` on narrow viewports, so it reported success while asserting
nothing on Mobile Chrome and Mobile Safari — a green tick on two of the
three projects that never ran a check. `test.skip` reports it as skipped.
The consent handling in both calculator specs was dead: `storageState`
already pre-grants consent, and `await banner.isVisible()` resolves
immediately rather than waiting, so the branch never ran. Left in place it
would have turned into a real flake the moment anyone touched
`storageState`, because the banner only appears after a 1.5s timeout and
accepting it triggers a page reload.
`storageState` also stamped `Date.now()` at config load. Nothing reads that
timestamp — there is no consent expiry anywhere — so it was non-determinism
for free.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Metadata["twitter"]` is a union whose members do not all carry `card`, so
reading the property directly failed `tsc`. Match on the object instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`app/page.tsx` and `work-calculator.tsx` each ran their own
`setInterval(..., 1000)`, so the whole tree re-rendered twice a second to
advance a clock that reads the same value in both places. One hook now owns
the tick, and it is the only `setInterval` left in the codebase.
It starts as `null` and fills in from an effect, which keeps the server and
client markup identical without the component having to blank itself out
while it waits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`work-calculator.tsx` (573 lines) and `salary-calculator.tsx` (387) sat
loose in `components/` root, which AGENT.md reserves for nothing — they are
Organisms. Both were deeply nested, duplicated each other's layout shell and
hero panel, and re-implemented the masked numeric input three times.
Extracted, with the largest file now 291 lines:
- atoms/masked-input — the strip-format-validate-revert input, previously
written once for the date, once for the time and once for the journey
duration.
- molecules/hero-panel — the big coloured number card. work-calculator had
two near-duplicate copies of it, one `lg:hidden` and one `hidden lg:block`,
which had already drifted apart.
- molecules/collapsible-panel, copy-button, duration-row, extra-entry-row,
extra-entry-list, templates/calculator-layout — the rest of the shared
structure.
- organisms/journey-form, work-summary, tax-details-panel, and the two
calculators.
Behaviour fixed along the way:
The extra gain and deduction rows put `flex-1` on a bare `<input>`, so
`min-width: auto` floored the row at its intrinsic width — 380px inside a
245px container at a 375px viewport, with `overflow-hidden` on an ancestor.
The delete button sat roughly 70px off screen, unreachable by touch and
unreachable by scrolling. `min-w-0` on the flexible field and `w-24 sm:w-32`
on the value field bring it back.
The clipboard write called `navigator.clipboard.writeText` with no
availability check and no rejection handling, so it threw in insecure
contexts and silently claimed success elsewhere. It is now guarded, awaited,
and reports failure through a live region instead of showing "Copiado!".
The journey settings expose the two overtime rates, and the overtime rows
label themselves from the configured percentages rather than hardcoding 75%.
`progress` was clamped in one branch of the countdown and not the other, so
a zero-length journey produced `width: "Infinity%"`. A zero balance also
rendered as overtime in the hero card while the summary card treated it as
on target; both now read from the same expression.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`cookie-consent.tsx` was the last real gap at 46% statements — the banner
delay, the consent-seeded toggle, both persistence paths, the settings
dialog and the floating privacy button had no coverage at all. Closing it,
plus one branch each in google-ad and ad-manager, brings the suite to 100%
statements, branches, functions and lines over app/, components/, hooks/
and lib/.
The threshold is now enforced at 100 for all four metrics, so the number
cannot drift back the way it did before — coverage was reported as 95.8%
while measuring a third of the codebase.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercelBot commented Aug 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
workloadReadyReadyPreviewAug 1, 2026 9:11pm

@devRMAdevRMA changed the title fix/tech debt and bugsfix: correct the 2026 tax tables and clear the technical debt behind #6 and #7Aug 1, 2026
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@devRMAdevRMA self-assigned this Aug 1, 2026
@devRMA
devRMA marked this pull request as ready for review August 1, 2026 21:45
@devRMA
devRMA merged commit 9a0661f into mainAug 1, 2026
4 checks passed
@devRMA
devRMA deleted the fix/tech-debt-and-bugs branch August 1, 2026 21:47
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@devRMA@codecov-commenter