fix: correct the 2026 tax tables and clear the technical debt behind #6 and #7 - #9
Merged
Merged
Conversation
`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>
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This was referenced Aug 1, 2026
codecov-commenter
commented
Aug 1, 2026
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
devRMA
marked this pull request as ready for review
August 1, 2026 21:45
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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:
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 × brutoonly 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 to121,57499999999999and rounded down to 121,57 instead of 121,58.The other wrong number: phantom overtime on night shifts
calculateSuggestedExitandcalculateWorkStatsdisagreed 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: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 - workedBeforeLunchnegative, placing the suggested exit before lunch ended. That made the range non-chronological, whichcalculateWorkStatsrejects 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
JSON.parseran 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.parseFloaton a corrupt salary producedNaN, which was then written back tolocalStorage, so the app stayed broken on every subsequent load.flex-1on a bare<input>keepsmin-width: auto, flooring the extras row at its intrinsic width: 380px inside a 245px container at a 375px viewport, withoverflow-hiddenon an ancestor. The button sat ~70px off screen — not reachable by touch, not reachable by scrolling.maximumScale: 1disabled pinch zoom, failing WCAG 1.4.4 for anyone who needs to magnify the page.progresswas clamped in one branch and not the other, so a zero-length journey producedwidth: "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.
app/layout.tsxneeded no exclusion — it only failed to import becausenext/font/googleis normally transformed by SWC during a Next build, so outside that compilationInterarrives as an object rather than a callable. A three-line mock makes it testable, and the test now assertsviewporthas nomaximumScaleso the pinch-zoom fix cannot regress silently.One e2e test was passing without asserting anything:
should show side ads on desktop after delaybailed out with a barereturnon narrow viewports, reporting success on Mobile Chrome and Mobile Safari without running a check.Structure
work-calculator.tsx(573 lines) andsalary-calculator.tsx(387) sat loose incomponents/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:hiddenandhidden 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 onesetIntervalin the codebase.Dead code and dead config
useIsMobilehad zero callers.formatMinuteswas referenced only by its own test, giving five tests' worth of false confidence in a function nothing shipped. BothcalculateWorkStatsandcalculateSuggestedExitwrapped their bodies intry/catch, butnew Date(garbage)returns an Invalid Date rather than throwing — the catch blocks were unreachable..biomeignorehas done nothing since Biome 2 dropped support for it.