From 69499df5675a1eb652993fd15b2c43c83c119f96 Mon Sep 17 00:00:00 2001 From: Rafael Martins Date: Sat, 1 Aug 2026 17:02:41 -0300 Subject: [PATCH 01/15] refactor: drop dead code and harden parseCurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- __tests__/utils.test.ts | 23 ++--------------------- hooks/use-mobile.ts | 21 --------------------- lib/utils.ts | 14 +++----------- 3 files changed, 5 insertions(+), 53 deletions(-) delete mode 100644 hooks/use-mobile.ts diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts index 9d6d541..4839e7f 100644 --- a/__tests__/utils.test.ts +++ b/__tests__/utils.test.ts @@ -3,7 +3,6 @@ import { cn, formatCurrency, formatCurrencySimple, - formatMinutes, parseCurrency, } from "@/lib/utils"; @@ -66,26 +65,8 @@ describe("parseCurrency", () => { it("strips non-digit characters", () => { expect(parseCurrency("R$ 1.000,00")).toBe(1000); }); -}); - -describe("formatMinutes", () => { - it("formats positive minutes", () => { - expect(formatMinutes(130)).toBe("02:10"); - }); - - it("formats zero", () => { - expect(formatMinutes(0)).toBe("00:00"); - }); - - it("formats negative minutes with sign", () => { - expect(formatMinutes(-90)).toBe("-01:30"); - }); - - it("pads single-digit hours and minutes", () => { - expect(formatMinutes(5)).toBe("00:05"); - }); - it("handles exact hours", () => { - expect(formatMinutes(120)).toBe("02:00"); + it("returns zero instead of Infinity for absurdly long input", () => { + expect(parseCurrency("9".repeat(400))).toBe(0); }); }); diff --git a/hooks/use-mobile.ts b/hooks/use-mobile.ts deleted file mode 100644 index 0a89231..0000000 --- a/hooks/use-mobile.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from "react"; - -const MOBILE_BREAKPOINT = 768; - -export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState( - undefined, - ); - - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); - }; - mql.addEventListener("change", onChange); - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); - return () => mql.removeEventListener("change", onChange); - }, []); - - return !!isMobile; -} diff --git a/lib/utils.ts b/lib/utils.ts index 7a84111..abccfe7 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -20,15 +20,7 @@ export function formatCurrencySimple(value: number): string { } export function parseCurrency(value: string): number { - const cleanValue = value.replace(/\D/g, ""); - return Number(cleanValue) / 100; -} - -export function formatMinutes(minutes: number): string { - const isNegative = minutes < 0; - const absMinutes = Math.abs(minutes); - const h = Math.floor(absMinutes / 60); - const m = Math.floor(absMinutes % 60); - const sign = isNegative ? "-" : ""; - return `${sign}${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`; + const digitsOnly = value.replace(/\D/g, ""); + const parsed = Number(digitsOnly) / 100; + return Number.isFinite(parsed) ? parsed : 0; } From 85c36712f152a705e519c7781c0f51413ba5033b Mon Sep 17 00:00:00 2001 From: Rafael Martins Date: Sat, 1 Aug 2026 17:02:41 -0300 Subject: [PATCH 02/15] fix(payroll): use the 2026 tax tables and round money correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- __tests__/payroll.test.ts | 94 +++++++++++++++++++++++++++++++++++++++ lib/payroll.ts | 91 +++++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 __tests__/payroll.test.ts create mode 100644 lib/payroll.ts diff --git a/__tests__/payroll.test.ts b/__tests__/payroll.test.ts new file mode 100644 index 0000000..10aa616 --- /dev/null +++ b/__tests__/payroll.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + calculateIncomeTax, + calculateSocialSecurity, + sanitizeAmount, +} from "@/lib/payroll"; + +describe("sanitizeAmount", () => { + it("keeps positive finite amounts untouched", () => { + expect(sanitizeAmount(1234.56)).toBe(1234.56); + }); + + it("collapses zero, negatives and non-finite values to zero", () => { + expect(sanitizeAmount(0)).toBe(0); + expect(sanitizeAmount(-500)).toBe(0); + expect(sanitizeAmount(Number.NaN)).toBe(0); + expect(sanitizeAmount(Number.POSITIVE_INFINITY)).toBe(0); + }); +}); + +describe("calculateSocialSecurity", () => { + it("applies the first bracket rate below the minimum wage", () => { + expect(calculateSocialSecurity(1000)).toBe(75); + }); + + it("matches the official contribution at every bracket boundary", () => { + expect(calculateSocialSecurity(1621)).toBe(121.58); + expect(calculateSocialSecurity(2902.84)).toBe(236.94); + expect(calculateSocialSecurity(4354.27)).toBe(411.11); + expect(calculateSocialSecurity(8475.55)).toBe(988.09); + }); + + it("caps the contribution at the ceiling", () => { + expect(calculateSocialSecurity(20000)).toBe(988.09); + expect(calculateSocialSecurity(8475.55)).toBe( + calculateSocialSecurity(100000), + ); + }); + + it("charges each bracket only on the portion inside it", () => { + expect(calculateSocialSecurity(5000)).toBe(501.51); + }); + + it("returns zero for empty or invalid salaries", () => { + expect(calculateSocialSecurity(0)).toBe(0); + expect(calculateSocialSecurity(-1000)).toBe(0); + expect(calculateSocialSecurity(Number.NaN)).toBe(0); + }); +}); + +describe("calculateIncomeTax", () => { + it("exempts salaries up to the exemption ceiling", () => { + expect(calculateIncomeTax(3000, calculateSocialSecurity(3000))).toBe(0); + expect(calculateIncomeTax(5000, calculateSocialSecurity(5000))).toBe(0); + }); + + it("clamps to zero when the reduction outgrows the tax", () => { + expect(calculateIncomeTax(5000.01, calculateSocialSecurity(5000.01))).toBe( + 0, + ); + }); + + it("reduces the tax partially inside the transition range", () => { + expect(calculateIncomeTax(5200, calculateSocialSecurity(5200))).toBe(71.62); + }); + + it("phases the reduction out linearly across the transition range", () => { + expect(calculateIncomeTax(6000, calculateSocialSecurity(6000))).toBe(385.1); + }); + + it("stops reducing once the phase-out ceiling is reached", () => { + expect(calculateIncomeTax(7350, calculateSocialSecurity(7350))).toBe(884.13); + }); + + it("applies the top bracket above the phase-out ceiling", () => { + expect(calculateIncomeTax(10000, calculateSocialSecurity(10000))).toBe( + 1569.55, + ); + }); + + it("prefers the simplified deduction when it beats the contribution", () => { + const contribution = calculateSocialSecurity(5500); + expect(contribution).toBeLessThan(607.2); + expect(calculateIncomeTax(5500, contribution)).toBe( + calculateIncomeTax(5500, 0), + ); + }); + + it("returns zero for empty or invalid salaries", () => { + expect(calculateIncomeTax(0, 0)).toBe(0); + expect(calculateIncomeTax(-5000, 0)).toBe(0); + expect(calculateIncomeTax(Number.NaN, Number.NaN)).toBe(0); + }); +}); diff --git a/lib/payroll.ts b/lib/payroll.ts new file mode 100644 index 0000000..fe81c16 --- /dev/null +++ b/lib/payroll.ts @@ -0,0 +1,91 @@ +interface ProgressiveBracket { + readonly ceiling: number; + readonly rate: number; +} + +interface IncomeTaxBracket extends ProgressiveBracket { + readonly deduction: number; +} + +const SOCIAL_SECURITY_BRACKETS: readonly ProgressiveBracket[] = [ + { ceiling: 1621.0, rate: 0.075 }, + { ceiling: 2902.84, rate: 0.09 }, + { ceiling: 4354.27, rate: 0.12 }, + { ceiling: 8475.55, rate: 0.14 }, +]; + +const INCOME_TAX_BRACKETS: readonly IncomeTaxBracket[] = [ + { ceiling: 2428.8, rate: 0, deduction: 0 }, + { ceiling: 2826.65, rate: 0.075, deduction: 182.16 }, + { ceiling: 3751.05, rate: 0.15, deduction: 394.16 }, + { ceiling: 4664.68, rate: 0.225, deduction: 675.49 }, + { ceiling: Number.POSITIVE_INFINITY, rate: 0.275, deduction: 908.73 }, +]; + +const SIMPLIFIED_DEDUCTION = 607.2; +const EXEMPTION_CEILING = 5000; +const REDUCTION_PHASE_OUT_CEILING = 7350; +const REDUCTION_INTERCEPT = 978.62; +const REDUCTION_SLOPE = 0.133145; + +export function sanitizeAmount(value: number): number { + return Number.isFinite(value) && value > 0 ? value : 0; +} + +function roundToCents(value: number): number { + const centsWithoutBinaryDust = Number((value * 100).toPrecision(12)); + return Math.round(centsWithoutBinaryDust) / 100; +} + +function sumProgressiveBrackets( + amount: number, + brackets: readonly ProgressiveBracket[], +): number { + let total = 0; + let lowerBound = 0; + + for (const { ceiling, rate } of brackets) { + if (amount <= lowerBound) break; + total += (Math.min(amount, ceiling) - lowerBound) * rate; + lowerBound = ceiling; + } + + return roundToCents(total); +} + +export function calculateSocialSecurity(grossSalary: number): number { + return sumProgressiveBrackets( + sanitizeAmount(grossSalary), + SOCIAL_SECURITY_BRACKETS, + ); +} + +function findIncomeTaxBracket(base: number): IncomeTaxBracket { + return ( + INCOME_TAX_BRACKETS.find(({ ceiling }) => base <= ceiling) ?? + INCOME_TAX_BRACKETS[INCOME_TAX_BRACKETS.length - 1] + ); +} + +function taxReductionFor(grossSalary: number): number { + if (grossSalary > REDUCTION_PHASE_OUT_CEILING) return 0; + return REDUCTION_INTERCEPT - REDUCTION_SLOPE * grossSalary; +} + +export function calculateIncomeTax( + grossSalary: number, + socialSecurity: number, +): number { + const gross = sanitizeAmount(grossSalary); + if (gross <= EXEMPTION_CEILING) return 0; + + const deductible = Math.max( + sanitizeAmount(socialSecurity), + SIMPLIFIED_DEDUCTION, + ); + const base = sanitizeAmount(gross - deductible); + const { rate, deduction } = findIncomeTaxBracket(base); + const tax = base * rate - deduction - taxReductionFor(gross); + + return roundToCents(sanitizeAmount(tax)); +} From 3ab81473145d712984cc74bcf1e3b8448dc246d3 Mon Sep 17 00:00:00 2001 From: Rafael Martins Date: Sat, 1 Aug 2026 17:02:41 -0300 Subject: [PATCH 03/15] fix(salary): survive corrupted storage and stop clobbering saved state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- __tests__/use-salary-calculator.test.ts | 220 ++++++++++++++++++------ hooks/use-salary-calculator.ts | 188 ++++++++++---------- lib/storage.ts | 22 +++ 3 files changed, 284 insertions(+), 146 deletions(-) create mode 100644 lib/storage.ts diff --git a/__tests__/use-salary-calculator.test.ts b/__tests__/use-salary-calculator.test.ts index 333682e..90633bf 100644 --- a/__tests__/use-salary-calculator.test.ts +++ b/__tests__/use-salary-calculator.test.ts @@ -1,97 +1,191 @@ import { act, renderHook } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { - calculateInss, - calculateIrrf, - useSalaryCalculator, -} from "../hooks/use-salary-calculator"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useSalaryCalculator } from "@/hooks/use-salary-calculator"; -describe("Salary Calculator Logic", () => { - it("calculates INSS correctly for low bracket", () => { - // Salary <= 1518 - expect(calculateInss(1500)).toBe(112.5); // 1500 * 0.075 +describe("useSalaryCalculator", () => { + beforeEach(() => { + localStorage.clear(); }); - it("calculates INSS correctly for higher brackets", () => { - // Salary 5000: - // 1518 * 0.075 = 113.85 - // (2793.88 - 1518) * 0.09 = 114.83 - // (4190.83 - 2793.88) * 0.12 = 167.63 - // (5000 - 4190.83) * 0.14 = 113.28 - // Total approx: 509.59 - expect(calculateInss(5000)).toBeCloseTo(509.59, 1); - }); + it("starts from the default salary and monthly hours", () => { + const { result } = renderHook(() => useSalaryCalculator()); - it("calculates INSS correctly above ceiling", () => { - // Ceiling is 8157.41 - const maxInss = calculateInss(8157.41); - expect(calculateInss(10000)).toBe(maxInss); + expect(result.current.grossSalary).toBe(5000); + expect(result.current.monthlyHours).toBe(220); }); - it("calculates IRRF correctly with deduction", () => { - // Salary 5000, INSS 509.59 -> Base 4490.41 - // Bracket 4664.68 -> 22.5% rate, 662.77 deduction - // IRRF = 4490.41 * 0.225 - 662.77 = 1010.34 - 662.77 = 347.57 - expect(calculateIrrf(5000, 509.59)).toBeCloseTo(347.57, 1); + it("derives net salary and rates from the current tax tables", () => { + const { result } = renderHook(() => useSalaryCalculator()); + + expect(result.current.autoInss).toBe(501.51); + expect(result.current.autoIrrf).toBe(0); + expect(result.current.stats.netSalary).toBe(4498.49); + expect(result.current.stats.hourlyRate).toBeCloseTo(20.4477, 4); + expect(result.current.stats.minuteRate).toBeCloseTo(0.3408, 4); }); - it("calculates IRRF correctly below exempt limit", () => { - // Salary 2000, INSS 160 -> Base 1840 (Exempt) - expect(calculateIrrf(2000, 160)).toBe(0); + it("restores previously stored values", () => { + localStorage.setItem("grossSalary", "9000"); + localStorage.setItem("monthlyHours", "180"); + localStorage.setItem( + "extraGains", + JSON.stringify([{ id: "gain-1", name: "Vale", value: 600 }]), + ); + localStorage.setItem( + "extraDeductions", + JSON.stringify([{ id: "deduction-1", name: "Plano", value: 250 }]), + ); + + const { result } = renderHook(() => useSalaryCalculator()); + + expect(result.current.grossSalary).toBe(9000); + expect(result.current.monthlyHours).toBe(180); + expect(result.current.stats.totalExtraGains).toBe(600); + expect(result.current.stats.totalExtraDeductions).toBe(250); }); -}); -describe("useSalaryCalculator Hook", () => { - it("initializes with default values", () => { + it("falls back to defaults when stored numbers are unusable", () => { + localStorage.setItem("grossSalary", "not-a-number"); + localStorage.setItem("monthlyHours", "-40"); + const { result } = renderHook(() => useSalaryCalculator()); + expect(result.current.grossSalary).toBe(5000); expect(result.current.monthlyHours).toBe(220); - expect(result.current.stats.hourlyRate).toBeCloseTo(18.83, 1); }); - it("adds and removes extra deductions", () => { + it("ignores stored lists that are corrupted or wrongly shaped", () => { + localStorage.setItem("extraGains", "{not json"); + localStorage.setItem("extraDeductions", JSON.stringify({ nope: true })); + + const { result } = renderHook(() => useSalaryCalculator()); + + expect(result.current.extraGains).toEqual([]); + expect(result.current.extraDeductions).toEqual([]); + }); + + it("drops individual stored items that fail validation", () => { + localStorage.setItem( + "extraGains", + JSON.stringify([ + { id: "valid", name: "Bônus", value: 100 }, + { id: "missing-value", name: "Quebrado" }, + { id: 42, name: "Id errado", value: 10 }, + { id: "nan", name: "NaN", value: Number.NaN }, + null, + ]), + ); + + const { result } = renderHook(() => useSalaryCalculator()); + + expect(result.current.extraGains).toEqual([ + { id: "valid", name: "Bônus", value: 100 }, + ]); + }); + + it("does not overwrite stored values before restoring them", () => { + localStorage.setItem("grossSalary", "7777"); + + renderHook(() => useSalaryCalculator()); + + expect(localStorage.getItem("grossSalary")).toBe("7777"); + }); + + it("persists changes made after the initial restore", () => { const { result } = renderHook(() => useSalaryCalculator()); act(() => { - result.current.addExtra("deduction"); + result.current.setGrossSalary(12000); + result.current.setMonthlyHours(200); }); - expect(result.current.extraDeductions.length).toBe(1); - const id = result.current.extraDeductions[0].id; + expect(localStorage.getItem("grossSalary")).toBe("12000"); + expect(localStorage.getItem("monthlyHours")).toBe("200"); + }); + + it("adds, updates and removes extra deductions", () => { + const { result } = renderHook(() => useSalaryCalculator()); + + act(() => { + result.current.addExtra("deduction"); + }); + const { id } = result.current.extraDeductions[0]; act(() => { - result.current.updateExtra(id, "deduction", "value", 100); + result.current.updateExtra(id, "deduction", "name", "Plano de Saúde"); + result.current.updateExtra(id, "deduction", "value", 320); }); - expect(result.current.extraDeductions[0].value).toBe(100); + expect(result.current.extraDeductions[0]).toEqual({ + id, + name: "Plano de Saúde", + value: 320, + }); + expect(result.current.stats.totalExtraDeductions).toBe(320); act(() => { result.current.removeExtra(id, "deduction"); }); - expect(result.current.extraDeductions.length).toBe(0); + expect(result.current.extraDeductions).toEqual([]); }); - it("adds and updates extra gains", () => { + it("adds, updates and removes extra gains", () => { const { result } = renderHook(() => useSalaryCalculator()); act(() => { result.current.addExtra("gain"); }); - - expect(result.current.extraGains.length).toBe(1); - const id = result.current.extraGains[0].id; + const { id } = result.current.extraGains[0]; act(() => { result.current.updateExtra(id, "gain", "value", 500); }); - expect(result.current.extraGains[0].value).toBe(500); - // Stats totalValue should increase - expect(result.current.stats.totalValue).toBeGreaterThan(4500); + expect(result.current.stats.totalExtraGains).toBe(500); + expect(result.current.stats.totalValue).toBe( + result.current.stats.netSalary + 500, + ); + + act(() => { + result.current.removeExtra(id, "gain"); + }); + + expect(result.current.extraGains).toEqual([]); }); - it("allows manual override for INSS and IRRF", () => { + it("coerces unusable extra values to zero instead of poisoning the totals", () => { + const { result } = renderHook(() => useSalaryCalculator()); + + act(() => { + result.current.addExtra("gain"); + }); + const { id } = result.current.extraGains[0]; + + act(() => { + result.current.updateExtra(id, "gain", "value", "abc"); + }); + + expect(result.current.extraGains[0].value).toBe(0); + expect(result.current.stats.totalValue).toBe(result.current.stats.netSalary); + }); + + it("leaves the list untouched when updating an unknown id", () => { + const { result } = renderHook(() => useSalaryCalculator()); + + act(() => { + result.current.addExtra("gain"); + }); + const before = result.current.extraGains; + + act(() => { + result.current.updateExtra("does-not-exist", "gain", "value", 999); + }); + + expect(result.current.extraGains).toEqual(before); + }); + + it("honours manual INSS and IRRF overrides", () => { const { result } = renderHook(() => useSalaryCalculator()); act(() => { @@ -103,4 +197,30 @@ describe("useSalaryCalculator Hook", () => { expect(result.current.stats.irrf).toBe(0); expect(result.current.stats.netSalary).toBe(5000); }); + + it("recomputes the income tax from a manual INSS override", () => { + const { result } = renderHook(() => useSalaryCalculator()); + + act(() => { + result.current.setGrossSalary(10000); + }); + const withAutoInss = result.current.autoIrrf; + + act(() => { + result.current.setManualInss(0); + }); + + expect(result.current.autoIrrf).toBeGreaterThan(withAutoInss); + }); + + it("avoids dividing by zero when the monthly hours are cleared", () => { + const { result } = renderHook(() => useSalaryCalculator()); + + act(() => { + result.current.setMonthlyHours(0); + }); + + expect(result.current.stats.hourlyRate).toBe(result.current.stats.totalValue); + expect(Number.isFinite(result.current.stats.hourlyRate)).toBe(true); + }); }); diff --git a/hooks/use-salary-calculator.ts b/hooks/use-salary-calculator.ts index 5496ba1..d6cb726 100644 --- a/hooks/use-salary-calculator.ts +++ b/hooks/use-salary-calculator.ts @@ -1,4 +1,10 @@ import { useEffect, useMemo, useState } from "react"; +import { + calculateIncomeTax, + calculateSocialSecurity, + sanitizeAmount, +} from "@/lib/payroll"; +import { readStoredList, readStoredNumber } from "@/lib/storage"; export interface ExtraItem { id: string; @@ -6,97 +12,89 @@ export interface ExtraItem { value: number; } -export function calculateInss(salary: number): number { - const brackets = [ - { limit: 1518.0, rate: 0.075 }, - { limit: 2793.88, rate: 0.09 }, - { limit: 4190.83, rate: 0.12 }, - { limit: 8157.41, rate: 0.14 }, - ]; - - let inss = 0; - const remaining = Math.min(salary, 8157.41); - let lastLimit = 0; - - for (const bracket of brackets) { - if (remaining > lastLimit) { - const amountInBracket = Math.min(remaining, bracket.limit) - lastLimit; - inss += amountInBracket * bracket.rate; - lastLimit = bracket.limit; - } else { - break; - } - } - - return Number(inss.toFixed(2)); +export type ExtraKind = "gain" | "deduction"; + +const DEFAULT_GROSS_SALARY = 5000; +const DEFAULT_MONTHLY_HOURS = 220; + +const STORAGE_KEYS = { + grossSalary: "grossSalary", + monthlyHours: "monthlyHours", + extraDeductions: "extraDeductions", + extraGains: "extraGains", +} as const; + +function isExtraItem(candidate: unknown): candidate is ExtraItem { + if (typeof candidate !== "object" || candidate === null) return false; + + const { id, name, value } = candidate as Partial; + return ( + typeof id === "string" && + typeof name === "string" && + typeof value === "number" && + Number.isFinite(value) + ); } -export function calculateIrrf(salary: number, inssAmount: number): number { - const base = salary - inssAmount; - const brackets = [ - { limit: 2259.2, rate: 0, deduction: 0 }, - { limit: 2826.65, rate: 0.075, deduction: 169.44 }, - { limit: 3751.05, rate: 0.15, deduction: 381.44 }, - { limit: 4664.68, rate: 0.225, deduction: 662.77 }, - { limit: Infinity, rate: 0.275, deduction: 896.0 }, - ]; - - const bracket = - brackets.find((b) => base <= b.limit) || brackets[brackets.length - 1]; - const irrf = base * bracket.rate - bracket.deduction; - return Math.max(0, Number(irrf.toFixed(2))); +function sumValues(items: readonly ExtraItem[]): number { + return items.reduce((total, item) => total + item.value, 0); } -export function useSalaryCalculator(initialSalary = 5000, initialHours = 220) { - const [grossSalary, setGrossSalary] = useState(initialSalary); - const [monthlyHours, setMonthlyHours] = useState(initialHours); +export function useSalaryCalculator( + initialSalary = DEFAULT_GROSS_SALARY, + initialHours = DEFAULT_MONTHLY_HOURS, +) { + const [grossSalary, setGrossSalary] = useState(initialSalary); + const [monthlyHours, setMonthlyHours] = useState(initialHours); const [manualInss, setManualInss] = useState(null); const [manualIrrf, setManualIrrf] = useState(null); const [extraDeductions, setExtraDeductions] = useState([]); const [extraGains, setExtraGains] = useState([]); + const [isRestored, setIsRestored] = useState(false); useEffect(() => { - const savedSalary = localStorage.getItem("grossSalary"); - const savedHours = localStorage.getItem("monthlyHours"); - const savedDeductions = localStorage.getItem("extraDeductions"); - const savedGains = localStorage.getItem("extraGains"); - - if (savedSalary) setGrossSalary(Number.parseFloat(savedSalary)); - if (savedHours) setMonthlyHours(Number.parseInt(savedHours, 10)); - if (savedDeductions) setExtraDeductions(JSON.parse(savedDeductions)); - if (savedGains) setExtraGains(JSON.parse(savedGains)); - }, []); + setGrossSalary(readStoredNumber(STORAGE_KEYS.grossSalary, initialSalary)); + setMonthlyHours(readStoredNumber(STORAGE_KEYS.monthlyHours, initialHours)); + setExtraDeductions( + readStoredList(STORAGE_KEYS.extraDeductions, isExtraItem), + ); + setExtraGains(readStoredList(STORAGE_KEYS.extraGains, isExtraItem)); + setIsRestored(true); + }, [initialSalary, initialHours]); useEffect(() => { - localStorage.setItem("grossSalary", grossSalary.toString()); - localStorage.setItem("monthlyHours", monthlyHours.toString()); - localStorage.setItem("extraDeductions", JSON.stringify(extraDeductions)); - localStorage.setItem("extraGains", JSON.stringify(extraGains)); - }, [grossSalary, monthlyHours, extraDeductions, extraGains]); + if (!isRestored) return; - const autoInss = useMemo(() => calculateInss(grossSalary), [grossSalary]); + localStorage.setItem(STORAGE_KEYS.grossSalary, grossSalary.toString()); + localStorage.setItem(STORAGE_KEYS.monthlyHours, monthlyHours.toString()); + localStorage.setItem( + STORAGE_KEYS.extraDeductions, + JSON.stringify(extraDeductions), + ); + localStorage.setItem(STORAGE_KEYS.extraGains, JSON.stringify(extraGains)); + }, [isRestored, grossSalary, monthlyHours, extraDeductions, extraGains]); + + const autoInss = useMemo( + () => calculateSocialSecurity(grossSalary), + [grossSalary], + ); const autoIrrf = useMemo( - () => calculateIrrf(grossSalary, manualInss ?? autoInss), + () => calculateIncomeTax(grossSalary, manualInss ?? autoInss), [grossSalary, manualInss, autoInss], ); const stats = useMemo(() => { - const inss = manualInss ?? autoInss; - const irrf = manualIrrf ?? autoIrrf; - const totalExtraDeductions = extraDeductions.reduce( - (acc, item) => acc + item.value, - 0, - ); - const totalExtraGains = extraGains.reduce( - (acc, item) => acc + item.value, - 0, - ); + const inss = sanitizeAmount(manualInss ?? autoInss); + const irrf = sanitizeAmount(manualIrrf ?? autoIrrf); + const totalExtraDeductions = sumValues(extraDeductions); + const totalExtraGains = sumValues(extraGains); - const netSalary = grossSalary - inss - irrf - totalExtraDeductions; + const netSalary = + sanitizeAmount(grossSalary) - inss - irrf - totalExtraDeductions; const totalValue = netSalary + totalExtraGains; - const hourlyRate = totalValue / (monthlyHours || 1); - const minuteRate = hourlyRate / 60; + const billableHours = monthlyHours > 0 ? monthlyHours : 1; + const hourlyRate = totalValue / billableHours; return { inss, @@ -106,7 +104,7 @@ export function useSalaryCalculator(initialSalary = 5000, initialHours = 220) { netSalary, totalValue, hourlyRate, - minuteRate, + minuteRate: hourlyRate / 60, }; }, [ grossSalary, @@ -119,43 +117,41 @@ export function useSalaryCalculator(initialSalary = 5000, initialHours = 220) { extraGains, ]); - const addExtra = (type: "gain" | "deduction") => { - const newItem = { + const addExtra = (kind: ExtraKind) => { + const newItem: ExtraItem = { id: crypto.randomUUID(), name: "", value: 0, }; - if (type === "gain") setExtraGains([...extraGains, newItem]); - else setExtraDeductions([...extraDeductions, newItem]); + const setItems = kind === "gain" ? setExtraGains : setExtraDeductions; + setItems((items) => [...items, newItem]); }; const updateExtra = ( id: string, - type: "gain" | "deduction", + kind: ExtraKind, field: "name" | "value", - val: string | number, + nextValue: string | number, ) => { - const list = type === "gain" ? [...extraGains] : [...extraDeductions]; - const index = list.findIndex((item) => item.id === id); - if (index > -1) { - list[index] = { - ...list[index], - [field]: - field === "value" - ? Number.isNaN(Number(val)) - ? 0 - : Number(val) - : val, - }; - if (type === "gain") setExtraGains(list); - else setExtraDeductions(list); - } + const setItems = kind === "gain" ? setExtraGains : setExtraDeductions; + setItems((items) => + items.map((item) => + item.id === id + ? { + ...item, + [field]: + field === "value" + ? sanitizeAmount(Number(nextValue)) + : nextValue, + } + : item, + ), + ); }; - const removeExtra = (id: string, type: "gain" | "deduction") => { - if (type === "gain") - setExtraGains(extraGains.filter((item) => item.id !== id)); - else setExtraDeductions(extraDeductions.filter((item) => item.id !== id)); + const removeExtra = (id: string, kind: ExtraKind) => { + const setItems = kind === "gain" ? setExtraGains : setExtraDeductions; + setItems((items) => items.filter((item) => item.id !== id)); }; return { diff --git a/lib/storage.ts b/lib/storage.ts new file mode 100644 index 0000000..5217e51 --- /dev/null +++ b/lib/storage.ts @@ -0,0 +1,22 @@ +export function readStoredNumber(key: string, fallback: number): number { + const raw = localStorage.getItem(key); + if (raw === null) return fallback; + + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +export function readStoredList( + key: string, + isItem: (candidate: unknown) => candidate is TItem, +): TItem[] { + const raw = localStorage.getItem(key); + if (raw === null) return []; + + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(isItem) : []; + } catch { + return []; + } +} From f7b0ce4388846e6160e24cf899dc624b12e83a9d Mon Sep 17 00:00:00 2001 From: Rafael Martins Date: Sat, 1 Aug 2026 17:02:41 -0300 Subject: [PATCH 04/15] fix: guard consent parsing and stop blocking pinch zoom `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 --- app/layout.tsx | 1 - components/molecules/cookie-consent.tsx | 24 +++++++++------------- components/organisms/analytics-wrapper.tsx | 11 ++-------- lib/consent.ts | 23 +++++++++++++++++++++ 4 files changed, 35 insertions(+), 24 deletions(-) create mode 100644 lib/consent.ts diff --git a/app/layout.tsx b/app/layout.tsx index 9401d2a..60531a6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -58,7 +58,6 @@ export const viewport = { ], width: "device-width", initialScale: 1, - maximumScale: 1, }; export default function RootLayout({ diff --git a/components/molecules/cookie-consent.tsx b/components/molecules/cookie-consent.tsx index 6cc2587..d4bfa82 100644 --- a/components/molecules/cookie-consent.tsx +++ b/components/molecules/cookie-consent.tsx @@ -4,8 +4,9 @@ import { Cookie, Shield, X } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useEffect, useState } from "react"; import { Button } from "@/components/atoms/button"; +import { readTelemetryConsent, writeTelemetryConsent } from "@/lib/consent"; -const CONSENT_KEY = "workload_cookie_consent"; +const BANNER_DELAY_MS = 1500; export function CookieConsent() { const [isVisible, setIsVisible] = useState(false); @@ -13,23 +14,18 @@ export function CookieConsent() { const [telemetryEnabled, setTelemetryEnabled] = useState(true); useEffect(() => { - const consent = localStorage.getItem(CONSENT_KEY); - if (!consent) { - setTimeout(() => setIsVisible(true), 1500); - } else { - const { telemetry } = JSON.parse(consent); - setTelemetryEnabled(telemetry); + const storedConsent = readTelemetryConsent(); + if (storedConsent !== null) { + setTelemetryEnabled(storedConsent); + return; } + + const timer = setTimeout(() => setIsVisible(true), BANNER_DELAY_MS); + return () => clearTimeout(timer); }, []); const saveConsent = (telemetry: boolean) => { - localStorage.setItem( - CONSENT_KEY, - JSON.stringify({ - telemetry, - timestamp: Date.now(), - }), - ); + writeTelemetryConsent(telemetry); setTelemetryEnabled(telemetry); setIsVisible(false); window.location.reload(); diff --git a/components/organisms/analytics-wrapper.tsx b/components/organisms/analytics-wrapper.tsx index c4a879a..51e96a9 100644 --- a/components/organisms/analytics-wrapper.tsx +++ b/components/organisms/analytics-wrapper.tsx @@ -2,21 +2,14 @@ import { GoogleAnalytics } from "@next/third-parties/google"; import { useEffect, useState } from "react"; - -const CONSENT_KEY = "workload_cookie_consent"; +import { readTelemetryConsent } from "@/lib/consent"; export function AnalyticsWrapper() { const [shouldLoad, setShouldLoad] = useState(false); const gaId = process.env.NEXT_PUBLIC_GA_ID; useEffect(() => { - const consent = localStorage.getItem(CONSENT_KEY); - if (consent) { - const { telemetry } = JSON.parse(consent); - if (telemetry) { - setShouldLoad(true); - } - } + setShouldLoad(readTelemetryConsent() === true); }, []); if (!gaId || !shouldLoad) return null; diff --git a/lib/consent.ts b/lib/consent.ts new file mode 100644 index 0000000..bd81c49 --- /dev/null +++ b/lib/consent.ts @@ -0,0 +1,23 @@ +const CONSENT_KEY = "workload_cookie_consent"; + +export function readTelemetryConsent(): boolean | null { + try { + const raw = localStorage.getItem(CONSENT_KEY); + if (raw === null) return null; + + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return null; + + const { telemetry } = parsed as { telemetry?: unknown }; + return typeof telemetry === "boolean" ? telemetry : null; + } catch { + return null; + } +} + +export function writeTelemetryConsent(telemetry: boolean): void { + localStorage.setItem( + CONSENT_KEY, + JSON.stringify({ telemetry, timestamp: Date.now() }), + ); +} From f68503f4bf91e23f443625bf188cf9a73cabf9e8 Mon Sep 17 00:00:00 2001 From: Rafael Martins Date: Sat, 1 Aug 2026 17:06:17 -0300 Subject: [PATCH 05/15] fix(work): make the overtime rates configurable and drop unreachable code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- __tests__/use-work-calculator.test.ts | 278 ++++++++++++++----- hooks/use-work-calculator.ts | 381 +++++++++++++++----------- 2 files changed, 420 insertions(+), 239 deletions(-) diff --git a/__tests__/use-work-calculator.test.ts b/__tests__/use-work-calculator.test.ts index dd008b9..b27d27a 100644 --- a/__tests__/use-work-calculator.test.ts +++ b/__tests__/use-work-calculator.test.ts @@ -4,126 +4,254 @@ import { calculateSuggestedExit, calculateWorkStats, useWorkCalculator, -} from "../hooks/use-work-calculator"; - -describe("Work Calculator Logic", () => { - const workMins = 8 * 60 + 48; // 528 - // Use a fixed Monday for testing to avoid weekend logic interference - const mockDate = "2025-01-06"; // Jan 6, 2025 is a Monday - - it("calculates suggested exit correctly", () => { - const entry = `${mockDate}T08:00`; - const lunchStart = `${mockDate}T12:00`; - const lunchEnd = `${mockDate}T13:00`; - // 4 hours morning (240 mins) - // 528 - 240 = 288 mins (4h 48m) afternoon - // 13:00 + 4h 48m = 17:48 - const exit = calculateSuggestedExit(entry, lunchStart, lunchEnd, workMins); - expect(exit).toBe(`${mockDate}T17:48`); - }); - - it("calculates regular work stats with exact exit", () => { - const entry = `${mockDate}T08:00`; - const lunchStart = `${mockDate}T12:00`; - const lunchEnd = `${mockDate}T13:00`; - const exit = `${mockDate}T17:48`; +} from "@/hooks/use-work-calculator"; - const stats = calculateWorkStats( - entry, - lunchStart, - lunchEnd, +const FULL_DAY_MINUTES = 8 * 60 + 48; +const MONDAY = "2025-01-06"; +const SATURDAY = "2025-01-11"; + +describe("calculateSuggestedExit", () => { + it("pushes the remaining minutes past the end of lunch", () => { + expect( + calculateSuggestedExit( + `${MONDAY}T08:00`, + `${MONDAY}T12:00`, + `${MONDAY}T13:00`, + FULL_DAY_MINUTES, + ), + ).toBe(`${MONDAY}T17:48`); + }); + + it("returns the entry unchanged when the times are out of order", () => { + expect( + calculateSuggestedExit( + `${MONDAY}T14:00`, + `${MONDAY}T12:00`, + `${MONDAY}T13:00`, + FULL_DAY_MINUTES, + ), + ).toBe(`${MONDAY}T14:00`); + }); + + it("returns the entry unchanged when a timestamp is unparseable", () => { + expect( + calculateSuggestedExit("not-a-date", "", "", FULL_DAY_MINUTES), + ).toBe("not-a-date"); + }); +}); + +describe("calculateWorkStats", () => { + const statsFor = (exit: string, workMinutes = FULL_DAY_MINUTES) => + calculateWorkStats( + `${MONDAY}T08:00`, + `${MONDAY}T12:00`, + `${MONDAY}T13:00`, exit, - workMins, + workMinutes, ); + + it("balances to zero on an exact day", () => { + const stats = statsFor(`${MONDAY}T17:48`); + expect(stats.balance).toBe(0); expect(stats.totalWorked).toBe(528); - expect(stats.overtime75).toBe(0); - expect(stats.overtime100).toBe(0); + expect(stats.firstTierMinutes).toBe(0); + expect(stats.extraTierMinutes).toBe(0); + }); + + it("reports a negative balance when leaving early", () => { + const stats = statsFor(`${MONDAY}T16:48`); + + expect(stats.balance).toBe(-60); + expect(stats.firstTierMinutes).toBe(0); + expect(stats.extraTierMinutes).toBe(0); + }); + + it("fills the first overtime tier before the next one", () => { + const stats = statsFor(`${MONDAY}T19:00`); + + expect(stats.balance).toBe(72); + expect(stats.firstTierMinutes).toBe(72); + expect(stats.extraTierMinutes).toBe(0); }); - it("calculates overtime", () => { - const entry = `${mockDate}T08:00`; - const lunchStart = `${mockDate}T12:00`; - const lunchEnd = `${mockDate}T13:00`; - const exit = `${mockDate}T20:00`; // 7h afternoon = 420 mins. Total = 660 mins. Overtime = 132 mins. + it("caps the first tier at two hours and spills the rest over", () => { + const stats = statsFor(`${MONDAY}T20:00`); + expect(stats.balance).toBe(132); + expect(stats.firstTierMinutes).toBe(120); + expect(stats.extraTierMinutes).toBe(12); + }); + + it("pays all weekend overtime at the higher tier", () => { const stats = calculateWorkStats( - entry, - lunchStart, - lunchEnd, - exit, - workMins, + `${SATURDAY}T08:00`, + `${SATURDAY}T12:00`, + `${SATURDAY}T13:00`, + `${SATURDAY}T20:00`, + FULL_DAY_MINUTES, ); - expect(stats.balance).toBe(132); - expect(stats.overtime75).toBe(120); // First 2h - expect(stats.overtime100).toBe(12); // Remaining - }); - - it("calculates night shift reduction correctly", () => { - const entry = `${mockDate}T20:00`; - const lunchStart = `2025-01-07T00:00`; // Next day - const lunchEnd = `2025-01-07T01:00`; - const exit = `2025-01-07T05:00`; - - // Total clock time worked: - // 20:00 to 00:00 = 4h - // 01:00 to 05:00 = 4h - // Total = 8h (480 mins) - // Night hours: 22:00-00:00 (2h) + 01:00-05:00 (4h) = 6h. - // 6h night = 6 * 60 = 360 mins. - // Equivalent: 360 * (60 / 52.5) = 411 mins approx. - // Bonus: 411 - 360 = 51 mins. - // Total worked = 480 + 51 = 531 mins. - - const stats = calculateWorkStats(entry, lunchStart, lunchEnd, exit, 480); + + expect(stats.firstTierMinutes).toBe(0); + expect(stats.extraTierMinutes).toBe(132); + }); + + it("converts night minutes with the reduced night hour", () => { + const stats = calculateWorkStats( + `${MONDAY}T20:00`, + "2025-01-07T00:00", + "2025-01-07T01:00", + "2025-01-07T05:00", + 480, + ); + expect(stats.nightMinutes).toBe(411); expect(stats.totalWorked).toBe(531); expect(stats.balance).toBe(51); }); + + it("excludes a lunch break taken inside the night window", () => { + const stats = calculateWorkStats( + `${MONDAY}T21:00`, + `${MONDAY}T23:00`, + "2025-01-07T00:00", + "2025-01-07T04:00", + 480, + ); + + const minutesInsideNightWindow = 360; + const lunchMinutesInsideNightWindow = 60; + const paidNightMinutes = + minutesInsideNightWindow - lunchMinutesInsideNightWindow; + + expect(stats.nightMinutes).toBe(Math.round(paidNightMinutes * (60 / 52.5))); + expect(stats.nightMinutes).toBe(343); + }); + + it("counts no night minutes for a purely daytime shift", () => { + expect(statsFor(`${MONDAY}T17:48`).nightMinutes).toBe(0); + }); + + it("returns zeroed stats when the times are out of order", () => { + const stats = calculateWorkStats( + `${MONDAY}T08:00`, + `${MONDAY}T12:00`, + `${MONDAY}T13:00`, + `${MONDAY}T09:00`, + FULL_DAY_MINUTES, + ); + + expect(stats).toEqual({ + balance: 0, + nightMinutes: 0, + firstTierMinutes: 0, + extraTierMinutes: 0, + totalWorked: 0, + }); + }); + + it("returns zeroed stats when a timestamp is unparseable", () => { + expect( + calculateWorkStats("nope", "nope", "nope", "nope", FULL_DAY_MINUTES) + .totalWorked, + ).toBe(0); + }); }); -describe("useWorkCalculator Hook", () => { +describe("useWorkCalculator", () => { beforeEach(() => { localStorage.clear(); vi.useFakeTimers(); - vi.setSystemTime(new Date("2025-01-06T10:00:00Z")); + vi.setSystemTime(new Date(`${MONDAY}T10:00:00`)); }); - it("initializes with default values and saves to localStorage", () => { + it("starts from the legal overtime defaults", () => { const { result } = renderHook(() => useWorkCalculator()); - expect(result.current.workMinutes).toBe(528); - expect(localStorage.getItem("workMinutes")).toBe("528"); + + expect(result.current.workMinutes).toBe(FULL_DAY_MINUTES); + expect(result.current.firstTierRate).toBe(50); + expect(result.current.extraTierRate).toBe(100); }); - it("loads values from localStorage", () => { + it("restores stored values", () => { localStorage.setItem("workMinutes", "480"); - localStorage.setItem("entry", "2025-01-06T09:00"); + localStorage.setItem("firstTierRate", "75"); + localStorage.setItem("entry", `${MONDAY}T09:00`); const { result } = renderHook(() => useWorkCalculator()); + expect(result.current.workMinutes).toBe(480); - expect(result.current.entry).toBe("2025-01-06T09:00"); + expect(result.current.firstTierRate).toBe(75); + expect(result.current.entry).toBe(`${MONDAY}T09:00`); + }); + + it("ignores stored timestamps that are not usable", () => { + localStorage.setItem("entry", "08:00"); + localStorage.setItem("lunchStart", `${MONDAY}Tnonsense`); + + const { result } = renderHook(() => useWorkCalculator()); + + expect(result.current.entry).toBe(`${MONDAY}T08:00`); + expect(result.current.lunchStart).toBe(`${MONDAY}T12:00`); }); - it("handles manual exit override", () => { + it("does not overwrite stored values before restoring them", () => { + localStorage.setItem("workMinutes", "400"); + + renderHook(() => useWorkCalculator()); + + expect(localStorage.getItem("workMinutes")).toBe("400"); + }); + + it("persists changes made after the restore", () => { const { result } = renderHook(() => useWorkCalculator()); + act(() => { + result.current.setWorkMinutes(480); + result.current.setFirstTierRate(75); + result.current.setExtraTierRate(110); + }); + + expect(localStorage.getItem("workMinutes")).toBe("480"); + expect(localStorage.getItem("firstTierRate")).toBe("75"); + expect(localStorage.getItem("extraTierRate")).toBe("110"); + }); + + it("prefers the manual exit over the suggested one", () => { + const { result } = renderHook(() => useWorkCalculator()); + + expect(result.current.displayExit).toBe(result.current.suggestedExit); + act(() => { result.current.setIsManualExit(true); - result.current.setExitOverride("2025-01-06T18:00"); + result.current.setExitOverride(`${MONDAY}T18:00`); }); - expect(result.current.displayExit).toBe("2025-01-06T18:00"); + expect(result.current.displayExit).toBe(`${MONDAY}T18:00`); }); - it("resets to defaults", () => { + it("restores every default, including the overtime rates", () => { const { result } = renderHook(() => useWorkCalculator()); act(() => { result.current.setWorkMinutes(480); + result.current.setFirstTierRate(75); + result.current.setExtraTierRate(120); + result.current.setIsManualExit(true); + result.current.setExitOverride(`${MONDAY}T22:00`); + }); + + act(() => { result.current.resetDefaults(); }); - expect(result.current.workMinutes).toBe(528); + expect(result.current.workMinutes).toBe(FULL_DAY_MINUTES); + expect(result.current.firstTierRate).toBe(50); + expect(result.current.extraTierRate).toBe(100); expect(result.current.isManualExit).toBe(false); + expect(result.current.exitOverride).toBe(""); + expect(result.current.entry).toBe(`${MONDAY}T08:00`); + expect(result.current.lunchStart).toBe(`${MONDAY}T12:00`); + expect(result.current.lunchEnd).toBe(`${MONDAY}T13:00`); }); }); diff --git a/hooks/use-work-calculator.ts b/hooks/use-work-calculator.ts index fe05d31..f12a481 100644 --- a/hooks/use-work-calculator.ts +++ b/hooks/use-work-calculator.ts @@ -1,5 +1,114 @@ import { addMinutes, differenceInMinutes, format, isValid } from "date-fns"; import { useEffect, useMemo, useState } from "react"; +import { readStoredNumber } from "@/lib/storage"; + +const NIGHT_SHIFT_START_HOUR = 22; +const NIGHT_SHIFT_END_HOUR = 5; +const NIGHT_HOUR_MINUTES = 52.5; +const MINUTES_PER_HOUR = 60; +const FIRST_TIER_LIMIT_MINUTES = 120; + +const DEFAULT_WORK_MINUTES = 8 * MINUTES_PER_HOUR + 48; +const DEFAULT_FIRST_TIER_RATE = 50; +const DEFAULT_EXTRA_TIER_RATE = 100; + +const STORAGE_KEYS = { + workMinutes: "workMinutes", + entry: "entry", + lunchStart: "lunchStart", + lunchEnd: "lunchEnd", + firstTierRate: "firstTierRate", + extraTierRate: "extraTierRate", +} as const; + +export interface WorkStats { + balance: number; + nightMinutes: number; + firstTierMinutes: number; + extraTierMinutes: number; + totalWorked: number; +} + +const EMPTY_STATS: WorkStats = { + balance: 0, + nightMinutes: 0, + firstTierMinutes: 0, + extraTierMinutes: 0, + totalWorked: 0, +}; + +function isChronological(...dates: readonly Date[]): boolean { + return dates.every( + (date, index) => + isValid(date) && (index === 0 || dates[index - 1] <= date), + ); +} + +function overlapInMinutes( + firstStart: Date, + firstEnd: Date, + secondStart: Date, + secondEnd: Date, +): number { + const start = Math.max(firstStart.getTime(), secondStart.getTime()); + const end = Math.min(firstEnd.getTime(), secondEnd.getTime()); + return end > start ? differenceInMinutes(new Date(end), new Date(start)) : 0; +} + +function countNightMinutes( + entryDate: Date, + exitDate: Date, + lunchStartDate: Date, + lunchEndDate: Date, +): number { + let nightMinutes = 0; + const window = new Date(entryDate); + window.setHours(0, 0, 0, 0); + window.setDate(window.getDate() - 1); + + while (window <= exitDate) { + const nightStart = new Date(window); + nightStart.setHours(NIGHT_SHIFT_START_HOUR, 0, 0, 0); + const nightEnd = new Date(window); + nightEnd.setDate(nightEnd.getDate() + 1); + nightEnd.setHours(NIGHT_SHIFT_END_HOUR, 0, 0, 0); + + const worked = overlapInMinutes( + nightStart, + nightEnd, + entryDate, + exitDate, + ); + const lunched = overlapInMinutes( + nightStart, + nightEnd, + lunchStartDate, + lunchEndDate, + ); + nightMinutes += Math.max(0, worked - lunched); + + window.setDate(window.getDate() + 1); + } + + return nightMinutes; +} + +function splitOvertime( + overtimeMinutes: number, + isWeekend: boolean, +): Pick { + if (isWeekend) { + return { firstTierMinutes: 0, extraTierMinutes: overtimeMinutes }; + } + + return { + firstTierMinutes: Math.min(overtimeMinutes, FIRST_TIER_LIMIT_MINUTES), + extraTierMinutes: Math.max( + 0, + overtimeMinutes - FIRST_TIER_LIMIT_MINUTES, + ), + }; +} export function calculateWorkStats( entry: string, @@ -7,113 +116,43 @@ export function calculateWorkStats( lunchEnd: string, displayExit: string, workMinutes: number, -) { - try { - const entryDate = new Date(entry); - const lunchStartDate = new Date(lunchStart); - const lunchEndDate = new Date(lunchEnd); - const exitDate = new Date(displayExit); - - if ( - !isValid(entryDate) || - !isValid(lunchStartDate) || - !isValid(lunchEndDate) || - !isValid(exitDate) || - entryDate > lunchStartDate || - lunchStartDate > lunchEndDate || - lunchEndDate > exitDate - ) { - return { - balance: 0, - nightMinutes: 0, - overtime75: 0, - overtime100: 0, - totalWorked: 0, - }; - } - - const morningMinutes = differenceInMinutes(lunchStartDate, entryDate); - const afternoonMinutes = differenceInMinutes(exitDate, lunchEndDate); - const totalWorked = morningMinutes + afternoonMinutes; - - let nightMinutesReal = 0; - const currentDay = new Date(entryDate); - currentDay.setHours(0, 0, 0, 0); - currentDay.setDate(currentDay.getDate() - 1); - - while (currentDay <= exitDate) { - const nightStart = new Date(currentDay); - nightStart.setHours(22, 0, 0, 0); - const nightEnd = new Date(currentDay); - nightEnd.setDate(nightEnd.getDate() + 1); - nightEnd.setHours(5, 0, 0, 0); - - const workStart = new Date( - Math.max(nightStart.getTime(), entryDate.getTime()), - ); - const workEnd = new Date( - Math.min(nightEnd.getTime(), exitDate.getTime()), - ); - - if (workStart < workEnd) { - let overlap = differenceInMinutes(workEnd, workStart); - const lunchOverlapStart = new Date( - Math.max(workStart.getTime(), lunchStartDate.getTime()), - ); - const lunchOverlapEnd = new Date( - Math.min(workEnd.getTime(), lunchEndDate.getTime()), - ); - - if (lunchOverlapStart < lunchOverlapEnd) { - overlap -= differenceInMinutes(lunchOverlapEnd, lunchOverlapStart); - } - nightMinutesReal += overlap; - } - currentDay.setDate(currentDay.getDate() + 1); - } - - const nightMinutesEquivalent = Math.round(nightMinutesReal * (60 / 52.5)); - const nightBonusMinutes = nightMinutesEquivalent - nightMinutesReal; - - const totalWorkedWithReduction = totalWorked + nightBonusMinutes; - const balance = totalWorkedWithReduction - workMinutes; - - const overtimeMinutes = Math.max(0, balance); - let overtime75 = 0; - let overtime100 = 0; - - const dayOfWeek = entryDate.getDay(); - const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; - - if (isWeekend) { - overtime100 = overtimeMinutes; - overtime75 = 0; - } else { - if (overtimeMinutes <= 120) { - overtime75 = overtimeMinutes; - overtime100 = 0; - } else { - overtime75 = 120; - overtime100 = overtimeMinutes - 120; - } - } - - return { - balance, - nightMinutes: nightMinutesEquivalent, - overtime75, - overtime100, - totalWorked: totalWorkedWithReduction, - }; - } catch (_e) { - return { - balance: 0, - nightMinutes: 0, - overtime75: 0, - overtime100: 0, - totalWorked: 0, - }; +): WorkStats { + const entryDate = new Date(entry); + const lunchStartDate = new Date(lunchStart); + const lunchEndDate = new Date(lunchEnd); + const exitDate = new Date(displayExit); + + if ( + !isChronological(entryDate, lunchStartDate, lunchEndDate, exitDate) + ) { + return EMPTY_STATS; } + + const workedBeforeLunch = differenceInMinutes(lunchStartDate, entryDate); + const workedAfterLunch = differenceInMinutes(exitDate, lunchEndDate); + const workedMinutes = workedBeforeLunch + workedAfterLunch; + + const nightMinutesWorked = countNightMinutes( + entryDate, + exitDate, + lunchStartDate, + lunchEndDate, + ); + const nightMinutesEquivalent = Math.round( + nightMinutesWorked * (MINUTES_PER_HOUR / NIGHT_HOUR_MINUTES), + ); + const nightBonusMinutes = nightMinutesEquivalent - nightMinutesWorked; + + const totalWorked = workedMinutes + nightBonusMinutes; + const balance = totalWorked - workMinutes; + const dayOfWeek = entryDate.getDay(); + + return { + balance, + nightMinutes: nightMinutesEquivalent, + totalWorked, + ...splitOvertime(Math.max(0, balance), dayOfWeek === 0 || dayOfWeek === 6), + }; } export function calculateSuggestedExit( @@ -121,73 +160,81 @@ export function calculateSuggestedExit( lunchStart: string, lunchEnd: string, workMinutes: number, -) { - try { - const entryDate = new Date(entry); - const lunchStartDate = new Date(lunchStart); - const lunchEndDate = new Date(lunchEnd); - - if ( - !isValid(entryDate) || - !isValid(lunchStartDate) || - !isValid(lunchEndDate) || - entryDate > lunchStartDate || - lunchStartDate > lunchEndDate - ) - return entry; - - const morningMinutes = differenceInMinutes(lunchStartDate, entryDate); - const remainingMinutes = workMinutes - morningMinutes; - const exitDate = addMinutes(lunchEndDate, remainingMinutes); - - return format(exitDate, "yyyy-MM-dd'T'HH:mm"); - } catch (_e) { - return entry; - } +): string { + const entryDate = new Date(entry); + const lunchStartDate = new Date(lunchStart); + const lunchEndDate = new Date(lunchEnd); + + if (!isChronological(entryDate, lunchStartDate, lunchEndDate)) return entry; + + const workedBeforeLunch = differenceInMinutes(lunchStartDate, entryDate); + const exitDate = addMinutes(lunchEndDate, workMinutes - workedBeforeLunch); + + return format(exitDate, "yyyy-MM-dd'T'HH:mm"); } -const DEFAULT_WORK_MINUTES = 8 * 60 + 48; // 8h 48m -const getTodayAt = (time: string) => { - const [h, m] = time.split(":").map(Number); - const d = new Date(); - d.setHours(h, m, 0, 0); - return format(d, "yyyy-MM-dd'T'HH:mm"); -}; +function todayAt(time: string): string { + const [hours, minutes] = time.split(":").map(Number); + const date = new Date(); + date.setHours(hours, minutes, 0, 0); + return format(date, "yyyy-MM-dd'T'HH:mm"); +} + +function readStoredTimestamp(key: string, fallback: string): string { + const stored = localStorage.getItem(key); + if (!stored?.includes("T") || !isValid(new Date(stored))) return fallback; + return stored; +} export function useWorkCalculator() { const [workMinutes, setWorkMinutes] = useState(DEFAULT_WORK_MINUTES); - const [entry, setEntry] = useState(() => getTodayAt("08:00")); - const [lunchStart, setLunchStart] = useState(() => getTodayAt("12:00")); - const [lunchEnd, setLunchEnd] = useState(() => getTodayAt("13:00")); + const [firstTierRate, setFirstTierRate] = useState(DEFAULT_FIRST_TIER_RATE); + const [extraTierRate, setExtraTierRate] = useState(DEFAULT_EXTRA_TIER_RATE); + const [entry, setEntry] = useState(() => todayAt("08:00")); + const [lunchStart, setLunchStart] = useState(() => todayAt("12:00")); + const [lunchEnd, setLunchEnd] = useState(() => todayAt("13:00")); const [exitOverride, setExitOverride] = useState(""); const [isManualExit, setIsManualExit] = useState(false); + const [isRestored, setIsRestored] = useState(false); useEffect(() => { - const savedWorkMinutes = localStorage.getItem("workMinutes"); - const savedEntry = localStorage.getItem("entry"); - const savedLunchStart = localStorage.getItem("lunchStart"); - const savedLunchEnd = localStorage.getItem("lunchEnd"); - - if (savedWorkMinutes) { - const parsed = parseInt(savedWorkMinutes, 10); - setWorkMinutes(Number.isNaN(parsed) ? DEFAULT_WORK_MINUTES : parsed); - } - - const isValidISO = (str: string | null) => - str?.includes("T") && isValid(new Date(str)); - - if (savedEntry && isValidISO(savedEntry)) setEntry(savedEntry); - if (savedLunchStart && isValidISO(savedLunchStart)) - setLunchStart(savedLunchStart); - if (savedLunchEnd && isValidISO(savedLunchEnd)) setLunchEnd(savedLunchEnd); + setWorkMinutes( + readStoredNumber(STORAGE_KEYS.workMinutes, DEFAULT_WORK_MINUTES), + ); + setFirstTierRate( + readStoredNumber(STORAGE_KEYS.firstTierRate, DEFAULT_FIRST_TIER_RATE), + ); + setExtraTierRate( + readStoredNumber(STORAGE_KEYS.extraTierRate, DEFAULT_EXTRA_TIER_RATE), + ); + setEntry((current) => readStoredTimestamp(STORAGE_KEYS.entry, current)); + setLunchStart((current) => + readStoredTimestamp(STORAGE_KEYS.lunchStart, current), + ); + setLunchEnd((current) => + readStoredTimestamp(STORAGE_KEYS.lunchEnd, current), + ); + setIsRestored(true); }, []); useEffect(() => { - localStorage.setItem("workMinutes", workMinutes.toString()); - localStorage.setItem("entry", entry); - localStorage.setItem("lunchStart", lunchStart); - localStorage.setItem("lunchEnd", lunchEnd); - }, [workMinutes, entry, lunchStart, lunchEnd]); + if (!isRestored) return; + + localStorage.setItem(STORAGE_KEYS.workMinutes, workMinutes.toString()); + localStorage.setItem(STORAGE_KEYS.firstTierRate, firstTierRate.toString()); + localStorage.setItem(STORAGE_KEYS.extraTierRate, extraTierRate.toString()); + localStorage.setItem(STORAGE_KEYS.entry, entry); + localStorage.setItem(STORAGE_KEYS.lunchStart, lunchStart); + localStorage.setItem(STORAGE_KEYS.lunchEnd, lunchEnd); + }, [ + isRestored, + workMinutes, + firstTierRate, + extraTierRate, + entry, + lunchStart, + lunchEnd, + ]); const suggestedExit = useMemo( () => calculateSuggestedExit(entry, lunchStart, lunchEnd, workMinutes), @@ -204,9 +251,11 @@ export function useWorkCalculator() { const resetDefaults = () => { setWorkMinutes(DEFAULT_WORK_MINUTES); - setEntry(getTodayAt("08:00")); - setLunchStart(getTodayAt("12:00")); - setLunchEnd(getTodayAt("13:00")); + setFirstTierRate(DEFAULT_FIRST_TIER_RATE); + setExtraTierRate(DEFAULT_EXTRA_TIER_RATE); + setEntry(todayAt("08:00")); + setLunchStart(todayAt("12:00")); + setLunchEnd(todayAt("13:00")); setIsManualExit(false); setExitOverride(""); }; @@ -214,6 +263,10 @@ export function useWorkCalculator() { return { workMinutes, setWorkMinutes, + firstTierRate, + setFirstTierRate, + extraTierRate, + setExtraTierRate, entry, setEntry, lunchStart, From 2daef5fa853cf6162f4834e459de77211aac328e Mon Sep 17 00:00:00 2001 From: Rafael Martins Date: Sat, 1 Aug 2026 17:08:10 -0300 Subject: [PATCH 06/15] chore: drop the React Native skill set 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 --- .../vercel-react-native-skills/AGENTS.md | 2897 ----------------- .../vercel-react-native-skills/README.md | 165 - .../vercel-react-native-skills/SKILL.md | 121 - .../rules/animation-derived-value.md | 53 - .../rules/animation-gesture-detector-press.md | 95 - .../rules/animation-gpu-properties.md | 65 - .../design-system-compound-components.md | 66 - .../rules/fonts-config-plugin.md | 71 - .../rules/imports-design-system-folder.md | 68 - .../rules/js-hoist-intl.md | 61 - .../rules/list-performance-callbacks.md | 44 - .../list-performance-function-references.md | 132 - .../rules/list-performance-images.md | 53 - .../rules/list-performance-inline-objects.md | 97 - .../rules/list-performance-item-expensive.md | 94 - .../rules/list-performance-item-memo.md | 82 - .../rules/list-performance-item-types.md | 104 - .../rules/list-performance-virtualize.md | 67 - .../rules/monorepo-native-deps-in-app.md | 46 - .../monorepo-single-dependency-versions.md | 63 - .../rules/navigation-native-navigators.md | 188 -- .../react-compiler-destructure-functions.md | 50 - ...react-compiler-reanimated-shared-values.md | 48 - .../rules/react-state-dispatcher.md | 91 - .../rules/react-state-fallback.md | 56 - .../rules/react-state-minimize.md | 65 - .../rules/rendering-no-falsy-and.md | 74 - .../rules/rendering-text-in-text-component.md | 36 - .../rules/scroll-position-no-state.md | 82 - .../rules/state-ground-truth.md | 80 - .../rules/ui-expo-image.md | 66 - .../rules/ui-image-gallery.md | 104 - .../rules/ui-measure-views.md | 78 - .../rules/ui-menus.md | 174 - .../rules/ui-native-modals.md | 77 - .../rules/ui-pressable.md | 61 - .../rules/ui-safe-area-scroll.md | 65 - .../rules/ui-scrollview-content-inset.md | 45 - .../rules/ui-styling.md | 87 - .claude/skills/vercel-react-native-skills | 1 - skills-lock.json | 202 +- 41 files changed, 98 insertions(+), 6076 deletions(-) delete mode 100644 .agents/skills/vercel-react-native-skills/AGENTS.md delete mode 100644 .agents/skills/vercel-react-native-skills/README.md delete mode 100644 .agents/skills/vercel-react-native-skills/SKILL.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/animation-derived-value.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/animation-gesture-detector-press.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/animation-gpu-properties.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/design-system-compound-components.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/fonts-config-plugin.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/imports-design-system-folder.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/js-hoist-intl.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/list-performance-callbacks.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/list-performance-function-references.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/list-performance-images.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/list-performance-inline-objects.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/list-performance-item-expensive.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/list-performance-item-memo.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/list-performance-item-types.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/list-performance-virtualize.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/monorepo-native-deps-in-app.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/monorepo-single-dependency-versions.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/navigation-native-navigators.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/react-compiler-destructure-functions.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/react-compiler-reanimated-shared-values.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/react-state-dispatcher.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/react-state-fallback.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/react-state-minimize.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/rendering-no-falsy-and.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/rendering-text-in-text-component.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/scroll-position-no-state.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/state-ground-truth.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/ui-expo-image.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/ui-image-gallery.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/ui-measure-views.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/ui-menus.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/ui-native-modals.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/ui-pressable.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/ui-safe-area-scroll.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/ui-scrollview-content-inset.md delete mode 100644 .agents/skills/vercel-react-native-skills/rules/ui-styling.md delete mode 120000 .claude/skills/vercel-react-native-skills diff --git a/.agents/skills/vercel-react-native-skills/AGENTS.md b/.agents/skills/vercel-react-native-skills/AGENTS.md deleted file mode 100644 index d263eb9..0000000 --- a/.agents/skills/vercel-react-native-skills/AGENTS.md +++ /dev/null @@ -1,2897 +0,0 @@ -# React Native Skills - -**Version 1.0.0** -Engineering -January 2026 - -> **Note:** -> This document is mainly for agents and LLMs to follow when maintaining, -> generating, or refactoring React Native codebases. Humans -> may also find it useful, but guidance here is optimized for automation -> and consistency by AI-assisted workflows. - ---- - -## Abstract - -Comprehensive performance optimization guide for React Native applications, designed for AI agents and LLMs. Contains 35+ rules across 13 categories, prioritized by impact from critical (core rendering, list performance) to incremental (fonts, imports). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation. - ---- - -## Table of Contents - -1. [Core Rendering](#1-core-rendering) — **CRITICAL** - - 1.1 [Never Use && with Potentially Falsy Values](#11-never-use--with-potentially-falsy-values) - - 1.2 [Wrap Strings in Text Components](#12-wrap-strings-in-text-components) -2. [List Performance](#2-list-performance) — **HIGH** - - 2.1 [Avoid Inline Objects in renderItem](#21-avoid-inline-objects-in-renderitem) - - 2.2 [Hoist callbacks to the root of lists](#22-hoist-callbacks-to-the-root-of-lists) - - 2.3 [Keep List Items Lightweight](#23-keep-list-items-lightweight) - - 2.4 [Optimize List Performance with Stable Object References](#24-optimize-list-performance-with-stable-object-references) - - 2.5 [Pass Primitives to List Items for Memoization](#25-pass-primitives-to-list-items-for-memoization) - - 2.6 [Use a List Virtualizer for Any List](#26-use-a-list-virtualizer-for-any-list) - - 2.7 [Use Compressed Images in Lists](#27-use-compressed-images-in-lists) - - 2.8 [Use Item Types for Heterogeneous Lists](#28-use-item-types-for-heterogeneous-lists) -3. [Animation](#3-animation) — **HIGH** - - 3.1 [Animate Transform and Opacity Instead of Layout Properties](#31-animate-transform-and-opacity-instead-of-layout-properties) - - 3.2 [Prefer useDerivedValue Over useAnimatedReaction](#32-prefer-usederivedvalue-over-useanimatedreaction) - - 3.3 [Use GestureDetector for Animated Press States](#33-use-gesturedetector-for-animated-press-states) -4. [Scroll Performance](#4-scroll-performance) — **HIGH** - - 4.1 [Never Track Scroll Position in useState](#41-never-track-scroll-position-in-usestate) -5. [Navigation](#5-navigation) — **HIGH** - - 5.1 [Use Native Navigators for Navigation](#51-use-native-navigators-for-navigation) -6. [React State](#6-react-state) — **MEDIUM** - - 6.1 [Minimize State Variables and Derive Values](#61-minimize-state-variables-and-derive-values) - - 6.2 [Use fallback state instead of initialState](#62-use-fallback-state-instead-of-initialstate) - - 6.3 [useState Dispatch updaters for State That Depends on Current Value](#63-usestate-dispatch-updaters-for-state-that-depends-on-current-value) -7. [State Architecture](#7-state-architecture) — **MEDIUM** - - 7.1 [State Must Represent Ground Truth](#71-state-must-represent-ground-truth) -8. [React Compiler](#8-react-compiler) — **MEDIUM** - - 8.1 [Destructure Functions Early in Render (React Compiler)](#81-destructure-functions-early-in-render-react-compiler) - - 8.2 [Use .get() and .set() for Reanimated Shared Values (not .value)](#82-use-get-and-set-for-reanimated-shared-values-not-value) -9. [User Interface](#9-user-interface) — **MEDIUM** - - 9.1 [Measuring View Dimensions](#91-measuring-view-dimensions) - - 9.2 [Modern React Native Styling Patterns](#92-modern-react-native-styling-patterns) - - 9.3 [Use contentInset for Dynamic ScrollView Spacing](#93-use-contentinset-for-dynamic-scrollview-spacing) - - 9.4 [Use contentInsetAdjustmentBehavior for Safe Areas](#94-use-contentinsetadjustmentbehavior-for-safe-areas) - - 9.5 [Use expo-image for Optimized Images](#95-use-expo-image-for-optimized-images) - - 9.6 [Use Galeria for Image Galleries and Lightbox](#96-use-galeria-for-image-galleries-and-lightbox) - - 9.7 [Use Native Menus for Dropdowns and Context Menus](#97-use-native-menus-for-dropdowns-and-context-menus) - - 9.8 [Use Native Modals Over JS-Based Bottom Sheets](#98-use-native-modals-over-js-based-bottom-sheets) - - 9.9 [Use Pressable Instead of Touchable Components](#99-use-pressable-instead-of-touchable-components) -10. [Design System](#10-design-system) — **MEDIUM** - - 10.1 [Use Compound Components Over Polymorphic Children](#101-use-compound-components-over-polymorphic-children) -11. [Monorepo](#11-monorepo) — **LOW** - - 11.1 [Install Native Dependencies in App Directory](#111-install-native-dependencies-in-app-directory) - - 11.2 [Use Single Dependency Versions Across Monorepo](#112-use-single-dependency-versions-across-monorepo) -12. [Third-Party Dependencies](#12-third-party-dependencies) — **LOW** - - 12.1 [Import from Design System Folder](#121-import-from-design-system-folder) -13. [JavaScript](#13-javascript) — **LOW** - - 13.1 [Hoist Intl Formatter Creation](#131-hoist-intl-formatter-creation) -14. [Fonts](#14-fonts) — **LOW** - - 14.1 [Load fonts natively at build time](#141-load-fonts-natively-at-build-time) - ---- - -## 1. Core Rendering - -**Impact: CRITICAL** - -Fundamental React Native rendering rules. Violations cause -runtime crashes or broken UI. - -### 1.1 Never Use && with Potentially Falsy Values - -**Impact: CRITICAL (prevents production crash)** - -Never use `{value && }` when `value` could be an empty string or - -`0`. These are falsy but JSX-renderable—React Native will try to render them as - -text outside a `` component, causing a hard crash in production. - -**Incorrect: crashes if count is 0 or name is ""** - -```tsx -function Profile({ name, count }: { name: string; count: number }) { - return ( - - {name && {name}} - {count && {count} items} - - ) -} -// If name="" or count=0, renders the falsy value → crash -``` - -**Correct: ternary with null** - -```tsx -function Profile({ name, count }: { name: string; count: number }) { - return ( - - {name ? {name} : null} - {count ? {count} items : null} - - ) -} -``` - -**Correct: explicit boolean coercion** - -```tsx -function Profile({ name, count }: { name: string; count: number }) { - return ( - - {!!name && {name}} - {!!count && {count} items} - - ) -} -``` - -**Best: early return** - -```tsx -function Profile({ name, count }: { name: string; count: number }) { - if (!name) return null - - return ( - - {name} - {count > 0 ? {count} items : null} - - ) -} -``` - -Early returns are clearest. When using conditionals inline, prefer ternary or - -explicit boolean checks. - -**Lint rule:** Enable `react/jsx-no-leaked-render` from - -[eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-no-leaked-render.md) - -to catch this automatically. - -### 1.2 Wrap Strings in Text Components - -**Impact: CRITICAL (prevents runtime crash)** - -Strings must be rendered inside ``. React Native crashes if a string is a - -direct child of ``. - -**Incorrect: crashes** - -```tsx -import { View } from 'react-native' - -function Greeting({ name }: { name: string }) { - return Hello, {name}! -} -// Error: Text strings must be rendered within a component. -``` - -**Correct:** - -```tsx -import { View, Text } from 'react-native' - -function Greeting({ name }: { name: string }) { - return ( - - Hello, {name}! - - ) -} -``` - ---- - -## 2. List Performance - -**Impact: HIGH** - -Optimizing virtualized lists (FlatList, LegendList, FlashList) -for smooth scrolling and fast updates. - -### 2.1 Avoid Inline Objects in renderItem - -**Impact: HIGH (prevents unnecessary re-renders of memoized list items)** - -Don't create new objects inside `renderItem` to pass as props. Inline objects - -create new references on every render, breaking memoization. Pass primitive - -values directly from `item` instead. - -**Incorrect: inline object breaks memoization** - -```tsx -function UserList({ users }: { users: User[] }) { - return ( - ( - - )} - /> - ) -} -``` - -**Incorrect: inline style object** - -```tsx -renderItem={({ item }) => ( - -)} -``` - -**Correct: pass item directly or primitives** - -```tsx -function UserList({ users }: { users: User[] }) { - return ( - ( - // Good: pass the item directly - - )} - /> - ) -} -``` - -**Correct: pass primitives, derive inside child** - -```tsx -renderItem={({ item }) => ( - -)} - -const UserRow = memo(function UserRow({ id, name, isActive }: Props) { - // Good: derive style inside memoized component - const backgroundColor = isActive ? 'green' : 'gray' - return {/* ... */} -}) -``` - -**Correct: hoist static styles in module scope** - -```tsx -const activeStyle = { backgroundColor: 'green' } -const inactiveStyle = { backgroundColor: 'gray' } - -renderItem={({ item }) => ( - -)} -``` - -Passing primitives or stable references allows `memo()` to skip re-renders when - -the actual values haven't changed. - -**Note:** If you have the React Compiler enabled, it handles memoization - -automatically and these manual optimizations become less critical. - -### 2.2 Hoist callbacks to the root of lists - -**Impact: MEDIUM (Fewer re-renders and faster lists)** - -When passing callback functions to list items, create a single instance of the - -callback at the root of the list. Items should then call it with a unique - -identifier. - -**Incorrect: creates a new callback on each render** - -```typescript -return ( - { - // bad: creates a new callback on each render - const onPress = () => handlePress(item.id) - return - }} - /> -) -``` - -**Correct: a single function instance passed to each item** - -```typescript -const onPress = useCallback(() => handlePress(item.id), [handlePress, item.id]) - -return ( - ( - - )} - /> -) -``` - -Reference: [https://example.com](https://example.com) - -### 2.3 Keep List Items Lightweight - -**Impact: HIGH (reduces render time for visible items during scroll)** - -List items should be as inexpensive as possible to render. Minimize hooks, avoid - -queries, and limit React Context access. Virtualized lists render many items - -during scroll—expensive items cause jank. - -**Incorrect: heavy list item** - -```tsx -function ProductRow({ id }: { id: string }) { - // Bad: query inside list item - const { data: product } = useQuery(['product', id], () => fetchProduct(id)) - // Bad: multiple context accesses - const theme = useContext(ThemeContext) - const user = useContext(UserContext) - const cart = useContext(CartContext) - // Bad: expensive computation - const recommendations = useMemo( - () => computeRecommendations(product), - [product] - ) - - return {/* ... */} -} -``` - -**Correct: lightweight list item** - -```tsx -function ProductRow({ name, price, imageUrl }: Props) { - // Good: receives only primitives, minimal hooks - return ( - - - {name} - {price} - - ) -} -``` - -**Move data fetching to parent:** - -```tsx -// Parent fetches all data once -function ProductList() { - const { data: products } = useQuery(['products'], fetchProducts) - - return ( - ( - - )} - /> - ) -} -``` - -**For shared values, use Zustand selectors instead of Context:** - -```tsx -// Incorrect: Context causes re-render when any cart value changes -function ProductRow({ id, name }: Props) { - const { items } = useContext(CartContext) - const inCart = items.includes(id) - // ... -} - -// Correct: Zustand selector only re-renders when this specific value changes -function ProductRow({ id, name }: Props) { - // use Set.has (created once at the root) instead of Array.includes() - const inCart = useCartStore((s) => s.items.has(id)) - // ... -} -``` - -**Guidelines for list items:** - -- No queries or data fetching - -- No expensive computations (move to parent or memoize at parent level) - -- Prefer Zustand selectors over React Context - -- Minimize useState/useEffect hooks - -- Pass pre-computed values as props - -The goal: list items should be simple rendering functions that take props and - -return JSX. - -### 2.4 Optimize List Performance with Stable Object References - -**Impact: CRITICAL (virtualization relies on reference stability)** - -Don't map or filter data before passing to virtualized lists. Virtualization - -relies on object reference stability to know what changed—new references cause - -full re-renders of all visible items. Attempt to prevent frequent renders at the - -list-parent level. - -Where needed, use context selectors within list items. - -**Incorrect: creates new object references on every keystroke** - -```tsx -function DomainSearch() { - const { keyword, setKeyword } = useKeywordZustandState() - const { data: tlds } = useTlds() - - // Bad: creates new objects on every render, reparenting the entire list on every keystroke - const domains = tlds.map((tld) => ({ - domain: `${keyword}.${tld.name}`, - tld: tld.name, - price: tld.price, - })) - - return ( - <> - - } - /> - - ) -} -``` - -**Correct: stable references, transform inside items** - -```tsx -const renderItem = ({ item }) => - -function DomainSearch() { - const { data: tlds } = useTlds() - - return ( - - ) -} - -function DomainItem({ tld }: { tld: Tld }) { - // good: transform within items, and don't pass the dynamic data as a prop - // good: use a selector function from zustand to receive a stable string back - const domain = useKeywordZustandState((s) => s.keyword + '.' + tld.name) - return {domain} -} -``` - -**Updating parent array reference:** - -```tsx -// good: creates a new array instance without mutating the inner objects -// good: parent array reference is unaffected by typing and updating "keyword" -const sortedTlds = tlds.toSorted((a, b) => a.name.localeCompare(b.name)) - -return -``` - -Creating a new array instance can be okay, as long as its inner object - -references are stable. For instance, if you sort a list of objects: - -Even though this creates a new array instance `sortedTlds`, the inner object - -references are stable. - -**With zustand for dynamic data: avoids parent re-renders** - -```tsx -function DomainItemFavoriteButton({ tld }: { tld: Tld }) { - const isFavorited = useFavoritesStore((s) => s.favorites.has(tld.id)) - return -} -``` - -Virtualization can now skip items that haven't changed when typing. Only visible - -items (~20) re-render on keystroke, rather than the parent. - -**Deriving state within list items based on parent data (avoids parent - -re-renders):** - -For components where the data is conditional based on the parent state, this - -pattern is even more important. For example, if you are checking if an item is - -favorited, toggling favorites only re-renders one component if the item itself - -is in charge of accessing the state rather than the parent: - -Note: if you're using the React Compiler, you can read React Context values - -directly within list items. Although this is slightly slower than using a - -Zustand selector in most cases, the effect may be negligible. - -### 2.5 Pass Primitives to List Items for Memoization - -**Impact: HIGH (enables effective memo() comparison)** - -When possible, pass only primitive values (strings, numbers, booleans) as props - -to list item components. Primitives enable shallow comparison in `memo()` to - -work correctly, skipping re-renders when values haven't changed. - -**Incorrect: object prop requires deep comparison** - -```tsx -type User = { id: string; name: string; email: string; avatar: string } - -const UserRow = memo(function UserRow({ user }: { user: User }) { - // memo() compares user by reference, not value - // If parent creates new user object, this re-renders even if data is same - return {user.name} -}) - -renderItem={({ item }) => } -``` - -This can still be optimized, but it is harder to memoize properly. - -**Correct: primitive props enable shallow comparison** - -```tsx -const UserRow = memo(function UserRow({ - id, - name, - email, -}: { - id: string - name: string - email: string -}) { - // memo() compares each primitive directly - // Re-renders only if id, name, or email actually changed - return {name} -}) - -renderItem={({ item }) => ( - -)} -``` - -**Pass only what you need:** - -```tsx -// Incorrect: passing entire item when you only need name - - -// Correct: pass only the fields the component uses - -``` - -**For callbacks, hoist or use item ID:** - -```tsx -// Incorrect: inline function creates new reference - handlePress(item.id)} /> - -// Correct: pass ID, handle in child - - -const UserRow = memo(function UserRow({ id, name }: Props) { - const handlePress = useCallback(() => { - // use id here - }, [id]) - return {name} -}) -``` - -Primitive props make memoization predictable and effective. - -**Note:** If you have the React Compiler enabled, you do not need to use - -`memo()` or `useCallback()`, but the object references still apply. - -### 2.6 Use a List Virtualizer for Any List - -**Impact: HIGH (reduced memory, faster mounts)** - -Use a list virtualizer like LegendList or FlashList instead of ScrollView with - -mapped children—even for short lists. Virtualizers only render visible items, - -reducing memory usage and mount time. ScrollView renders all children upfront, - -which gets expensive quickly. - -**Incorrect: ScrollView renders all items at once** - -```tsx -function Feed({ items }: { items: Item[] }) { - return ( - - {items.map((item) => ( - - ))} - - ) -} -// 50 items = 50 components mounted, even if only 10 visible -``` - -**Correct: virtualizer renders only visible items** - -```tsx -import { LegendList } from '@legendapp/list' - -function Feed({ items }: { items: Item[] }) { - return ( - } - keyExtractor={(item) => item.id} - estimatedItemSize={80} - /> - ) -} -// Only ~10-15 visible items mounted at a time -``` - -**Alternative: FlashList** - -```tsx -import { FlashList } from '@shopify/flash-list' - -function Feed({ items }: { items: Item[] }) { - return ( - } - keyExtractor={(item) => item.id} - /> - ) -} -``` - -Benefits apply to any screen with scrollable content—profiles, settings, feeds, - -search results. Default to virtualization. - -### 2.7 Use Compressed Images in Lists - -**Impact: HIGH (faster load times, less memory)** - -Always load compressed, appropriately-sized images in lists. Full-resolution - -images consume excessive memory and cause scroll jank. Request thumbnails from - -your server or use an image CDN with resize parameters. - -**Incorrect: full-resolution images** - -```tsx -function ProductItem({ product }: { product: Product }) { - return ( - - {/* 4000x3000 image loaded for a 100x100 thumbnail */} - - {product.name} - - ) -} -``` - -**Correct: request appropriately-sized image** - -```tsx -function ProductItem({ product }: { product: Product }) { - // Request a 200x200 image (2x for retina) - const thumbnailUrl = `${product.imageUrl}?w=200&h=200&fit=cover` - - return ( - - - {product.name} - - ) -} -``` - -Use an optimized image component with built-in caching and placeholder support, - -such as `expo-image` or `SolitoImage` (which uses `expo-image` under the hood). - -Request images at 2x the display size for retina screens. - -### 2.8 Use Item Types for Heterogeneous Lists - -**Impact: HIGH (efficient recycling, less layout thrashing)** - -When a list has different item layouts (messages, images, headers, etc.), use a - -`type` field on each item and provide `getItemType` to the list. This puts items - -into separate recycling pools so a message component never gets recycled into an - -image component. - -[LegendList getItemType](https://legendapp.com/open-source/list/api/props/#getitemtype-v2) - -**Incorrect: single component with conditionals** - -```tsx -type Item = { id: string; text?: string; imageUrl?: string; isHeader?: boolean } - -function ListItem({ item }: { item: Item }) { - if (item.isHeader) { - return - } - if (item.imageUrl) { - return - } - return -} - -function Feed({ items }: { items: Item[] }) { - return ( - } - recycleItems - /> - ) -} -``` - -**Correct: typed items with separate components** - -```tsx -type HeaderItem = { id: string; type: 'header'; title: string } -type MessageItem = { id: string; type: 'message'; text: string } -type ImageItem = { id: string; type: 'image'; url: string } -type FeedItem = HeaderItem | MessageItem | ImageItem - -function Feed({ items }: { items: FeedItem[] }) { - return ( - item.id} - getItemType={(item) => item.type} - renderItem={({ item }) => { - switch (item.type) { - case 'header': - return - case 'message': - return - case 'image': - return - } - }} - recycleItems - /> - ) -} -``` - -**Why this matters:** - -```tsx - item.id} - getItemType={(item) => item.type} - getEstimatedItemSize={(index, item, itemType) => { - switch (itemType) { - case 'header': - return 48 - case 'message': - return 72 - case 'image': - return 300 - default: - return 72 - } - }} - renderItem={({ item }) => { - /* ... */ - }} - recycleItems -/> -``` - -- **Recycling efficiency**: Items with the same type share a recycling pool - -- **No layout thrashing**: A header never recycles into an image cell - -- **Type safety**: TypeScript can narrow the item type in each branch - -- **Better size estimation**: Use `getEstimatedItemSize` with `itemType` for - - accurate estimates per type - ---- - -## 3. Animation - -**Impact: HIGH** - -GPU-accelerated animations, Reanimated patterns, and avoiding -render thrashing during gestures. - -### 3.1 Animate Transform and Opacity Instead of Layout Properties - -**Impact: HIGH (GPU-accelerated animations, no layout recalculation)** - -Avoid animating `width`, `height`, `top`, `left`, `margin`, or `padding`. These trigger layout recalculation on every frame. Instead, use `transform` (scale, translate) and `opacity` which run on the GPU without triggering layout. - -**Incorrect: animates height, triggers layout every frame** - -```tsx -import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' - -function CollapsiblePanel({ expanded }: { expanded: boolean }) { - const animatedStyle = useAnimatedStyle(() => ({ - height: withTiming(expanded ? 200 : 0), // triggers layout on every frame - overflow: 'hidden', - })) - - return {children} -} -``` - -**Correct: animates scaleY, GPU-accelerated** - -```tsx -import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' - -function CollapsiblePanel({ expanded }: { expanded: boolean }) { - const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - { scaleY: withTiming(expanded ? 1 : 0) }, - ], - opacity: withTiming(expanded ? 1 : 0), - })) - - return ( - - {children} - - ) -} -``` - -**Correct: animates translateY for slide animations** - -```tsx -import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' - -function SlideIn({ visible }: { visible: boolean }) { - const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - { translateY: withTiming(visible ? 0 : 100) }, - ], - opacity: withTiming(visible ? 1 : 0), - })) - - return {children} -} -``` - -GPU-accelerated properties: `transform` (translate, scale, rotate), `opacity`. Everything else triggers layout. - -### 3.2 Prefer useDerivedValue Over useAnimatedReaction - -**Impact: MEDIUM (cleaner code, automatic dependency tracking)** - -When deriving a shared value from another, use `useDerivedValue` instead of - -`useAnimatedReaction`. Derived values are declarative, automatically track - -dependencies, and return a value you can use directly. Animated reactions are - -for side effects, not derivations. - -[Reanimated useDerivedValue](https://docs.swmansion.com/react-native-reanimated/docs/core/useDerivedValue) - -**Incorrect: useAnimatedReaction for derivation** - -```tsx -import { useSharedValue, useAnimatedReaction } from 'react-native-reanimated' - -function MyComponent() { - const progress = useSharedValue(0) - const opacity = useSharedValue(1) - - useAnimatedReaction( - () => progress.value, - (current) => { - opacity.value = 1 - current - } - ) - - // ... -} -``` - -**Correct: useDerivedValue** - -```tsx -import { useSharedValue, useDerivedValue } from 'react-native-reanimated' - -function MyComponent() { - const progress = useSharedValue(0) - - const opacity = useDerivedValue(() => 1 - progress.get()) - - // ... -} -``` - -Use `useAnimatedReaction` only for side effects that don't produce a value - -(e.g., triggering haptics, logging, calling `runOnJS`). - -### 3.3 Use GestureDetector for Animated Press States - -**Impact: MEDIUM (UI thread animations, smoother press feedback)** - -For animated press states (scale, opacity on press), use `GestureDetector` with - -`Gesture.Tap()` and shared values instead of Pressable's - -`onPressIn`/`onPressOut`. Gesture callbacks run on the UI thread as worklets—no - -JS thread round-trip for press animations. - -[Gesture Handler Tap Gesture](https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/tap-gesture) - -**Incorrect: Pressable with JS thread callbacks** - -```tsx -import { Pressable } from 'react-native' -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, -} from 'react-native-reanimated' - -function AnimatedButton({ onPress }: { onPress: () => void }) { - const scale = useSharedValue(1) - - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - })) - - return ( - (scale.value = withTiming(0.95))} - onPressOut={() => (scale.value = withTiming(1))} - > - - Press me - - - ) -} -``` - -**Correct: GestureDetector with UI thread worklets** - -```tsx -import { Gesture, GestureDetector } from 'react-native-gesture-handler' -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, - interpolate, - runOnJS, -} from 'react-native-reanimated' - -function AnimatedButton({ onPress }: { onPress: () => void }) { - // Store the press STATE (0 = not pressed, 1 = pressed) - const pressed = useSharedValue(0) - - const tap = Gesture.Tap() - .onBegin(() => { - pressed.set(withTiming(1)) - }) - .onFinalize(() => { - pressed.set(withTiming(0)) - }) - .onEnd(() => { - runOnJS(onPress)() - }) - - // Derive visual values from the state - const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - { scale: interpolate(withTiming(pressed.get()), [0, 1], [1, 0.95]) }, - ], - })) - - return ( - - - Press me - - - ) -} -``` - -Store the press **state** (0 or 1), then derive the scale via `interpolate`. - -This keeps the shared value as ground truth. Use `runOnJS` to call JS functions - -from worklets. Use `.set()` and `.get()` for React Compiler compatibility. - ---- - -## 4. Scroll Performance - -**Impact: HIGH** - -Tracking scroll position without causing render thrashing. - -### 4.1 Never Track Scroll Position in useState - -**Impact: HIGH (prevents render thrashing during scroll)** - -Never store scroll position in `useState`. Scroll events fire rapidly—state - -updates cause render thrashing and dropped frames. Use a Reanimated shared value - -for animations or a ref for non-reactive tracking. - -**Incorrect: useState causes jank** - -```tsx -import { useState } from 'react' -import { - ScrollView, - NativeSyntheticEvent, - NativeScrollEvent, -} from 'react-native' - -function Feed() { - const [scrollY, setScrollY] = useState(0) - - const onScroll = (e: NativeSyntheticEvent) => { - setScrollY(e.nativeEvent.contentOffset.y) // re-renders on every frame - } - - return -} -``` - -**Correct: Reanimated for animations** - -```tsx -import Animated, { - useSharedValue, - useAnimatedScrollHandler, -} from 'react-native-reanimated' - -function Feed() { - const scrollY = useSharedValue(0) - - const onScroll = useAnimatedScrollHandler({ - onScroll: (e) => { - scrollY.value = e.contentOffset.y // runs on UI thread, no re-render - }, - }) - - return ( - - ) -} -``` - -**Correct: ref for non-reactive tracking** - -```tsx -import { useRef } from 'react' -import { - ScrollView, - NativeSyntheticEvent, - NativeScrollEvent, -} from 'react-native' - -function Feed() { - const scrollY = useRef(0) - - const onScroll = (e: NativeSyntheticEvent) => { - scrollY.current = e.nativeEvent.contentOffset.y // no re-render - } - - return -} -``` - ---- - -## 5. Navigation - -**Impact: HIGH** - -Using native navigators for stack and tab navigation instead of -JS-based alternatives. - -### 5.1 Use Native Navigators for Navigation - -**Impact: HIGH (native performance, platform-appropriate UI)** - -Always use native navigators instead of JS-based ones. Native navigators use - -platform APIs (UINavigationController on iOS, Fragment on Android) for better - -performance and native behavior. - -**For stacks:** Use `@react-navigation/native-stack` or expo-router's default - -stack (which uses native-stack). Avoid `@react-navigation/stack`. - -**For tabs:** Use `react-native-bottom-tabs` (native) or expo-router's native - -tabs. Avoid `@react-navigation/bottom-tabs` when native feel matters. - -- [React Navigation Native Stack](https://reactnavigation.org/docs/native-stack-navigator) - -- [React Native Bottom Tabs with React Navigation](https://oss.callstack.com/react-native-bottom-tabs/docs/guides/usage-with-react-navigation) - -- [React Native Bottom Tabs with Expo Router](https://oss.callstack.com/react-native-bottom-tabs/docs/guides/usage-with-expo-router) - -- [Expo Router Native Tabs](https://docs.expo.dev/router/advanced/native-tabs) - -**Incorrect: JS stack navigator** - -```tsx -import { createStackNavigator } from '@react-navigation/stack' - -const Stack = createStackNavigator() - -function App() { - return ( - - - - - ) -} -``` - -**Correct: native stack with react-navigation** - -```tsx -import { createNativeStackNavigator } from '@react-navigation/native-stack' - -const Stack = createNativeStackNavigator() - -function App() { - return ( - - - - - ) -} -``` - -**Correct: expo-router uses native stack by default** - -```tsx -// app/_layout.tsx -import { Stack } from 'expo-router' - -export default function Layout() { - return -} -``` - -**Incorrect: JS bottom tabs** - -```tsx -import { createBottomTabNavigator } from '@react-navigation/bottom-tabs' - -const Tab = createBottomTabNavigator() - -function App() { - return ( - - - - - ) -} -``` - -**Correct: native bottom tabs with react-navigation** - -```tsx -import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation' - -const Tab = createNativeBottomTabNavigator() - -function App() { - return ( - - ({ sfSymbol: 'house' }), - }} - /> - ({ sfSymbol: 'gear' }), - }} - /> - - ) -} -``` - -**Correct: expo-router native tabs** - -```tsx -// app/(tabs)/_layout.tsx -import { NativeTabs } from 'expo-router/unstable-native-tabs' - -export default function TabLayout() { - return ( - - - Home - - - - Settings - - - - ) -} -``` - -On iOS, native tabs automatically enable `contentInsetAdjustmentBehavior` on the - -first `ScrollView` at the root of each tab screen, so content scrolls correctly - -behind the translucent tab bar. If you need to disable this, use - -`disableAutomaticContentInsets` on the trigger. - -**Incorrect: custom header component** - -```tsx - , - }} -/> -``` - -**Correct: native header options** - -```tsx - -``` - -Native headers support iOS large titles, search bars, blur effects, and proper - -safe area handling automatically. - -- **Performance**: Native transitions and gestures run on the UI thread - -- **Platform behavior**: Automatic iOS large titles, Android material design - -- **System integration**: Scroll-to-top on tab tap, PiP avoidance, proper safe - - areas - -- **Accessibility**: Platform accessibility features work automatically - ---- - -## 6. React State - -**Impact: MEDIUM** - -Patterns for managing React state to avoid stale closures and -unnecessary re-renders. - -### 6.1 Minimize State Variables and Derive Values - -**Impact: MEDIUM (fewer re-renders, less state drift)** - -Use the fewest state variables possible. If a value can be computed from existing state or props, derive it during render instead of storing it in state. Redundant state causes unnecessary re-renders and can drift out of sync. - -**Incorrect: redundant state** - -```tsx -function Cart({ items }: { items: Item[] }) { - const [total, setTotal] = useState(0) - const [itemCount, setItemCount] = useState(0) - - useEffect(() => { - setTotal(items.reduce((sum, item) => sum + item.price, 0)) - setItemCount(items.length) - }, [items]) - - return ( - - {itemCount} items - Total: ${total} - - ) -} -``` - -**Correct: derived values** - -```tsx -function Cart({ items }: { items: Item[] }) { - const total = items.reduce((sum, item) => sum + item.price, 0) - const itemCount = items.length - - return ( - - {itemCount} items - Total: ${total} - - ) -} -``` - -**Another example:** - -```tsx -// Incorrect: storing both firstName, lastName, AND fullName -const [firstName, setFirstName] = useState('') -const [lastName, setLastName] = useState('') -const [fullName, setFullName] = useState('') - -// Correct: derive fullName -const [firstName, setFirstName] = useState('') -const [lastName, setLastName] = useState('') -const fullName = `${firstName} ${lastName}` -``` - -State should be the minimal source of truth. Everything else is derived. - -Reference: [https://react.dev/learn/choosing-the-state-structure](https://react.dev/learn/choosing-the-state-structure) - -### 6.2 Use fallback state instead of initialState - -**Impact: MEDIUM (reactive fallbacks without syncing)** - -Use `undefined` as initial state and nullish coalescing (`??`) to fall back to - -parent or server values. State represents user intent only—`undefined` means - -"user hasn't chosen yet." This enables reactive fallbacks that update when the - -source changes, not just on initial render. - -**Incorrect: syncs state, loses reactivity** - -```tsx -type Props = { fallbackEnabled: boolean } - -function Toggle({ fallbackEnabled }: Props) { - const [enabled, setEnabled] = useState(defaultEnabled) - // If fallbackEnabled changes, state is stale - // State mixes user intent with default value - - return -} -``` - -**Correct: state is user intent, reactive fallback** - -```tsx -type Props = { fallbackEnabled: boolean } - -function Toggle({ fallbackEnabled }: Props) { - const [_enabled, setEnabled] = useState(undefined) - const enabled = _enabled ?? defaultEnabled - // undefined = user hasn't touched it, falls back to prop - // If defaultEnabled changes, component reflects it - // Once user interacts, their choice persists - - return -} -``` - -**With server data:** - -```tsx -function ProfileForm({ data }: { data: User }) { - const [_theme, setTheme] = useState(undefined) - const theme = _theme ?? data.theme - // Shows server value until user overrides - // Server refetch updates the fallback automatically - - return -} -``` - -### 6.3 useState Dispatch updaters for State That Depends on Current Value - -**Impact: MEDIUM (avoids stale closures, prevents unnecessary re-renders)** - -When the next state depends on the current state, use a dispatch updater - -(`setState(prev => ...)`) instead of reading the state variable directly in a - -callback. This avoids stale closures and ensures you're comparing against the - -latest value. - -**Incorrect: reads state directly** - -```tsx -const [size, setSize] = useState(undefined) - -const onLayout = (e: LayoutChangeEvent) => { - const { width, height } = e.nativeEvent.layout - // size may be stale in this closure - if (size?.width !== width || size?.height !== height) { - setSize({ width, height }) - } -} -``` - -**Correct: dispatch updater** - -```tsx -const [size, setSize] = useState(undefined) - -const onLayout = (e: LayoutChangeEvent) => { - const { width, height } = e.nativeEvent.layout - setSize((prev) => { - if (prev?.width === width && prev?.height === height) return prev - return { width, height } - }) -} -``` - -Returning the previous value from the updater skips the re-render. - -For primitive states, you don't need to compare values before firing a - -re-render. - -**Incorrect: unnecessary comparison for primitive state** - -```tsx -const [size, setSize] = useState(undefined) - -const onLayout = (e: LayoutChangeEvent) => { - const { width, height } = e.nativeEvent.layout - setSize((prev) => (prev === width ? prev : width)) -} -``` - -**Correct: sets primitive state directly** - -```tsx -const [size, setSize] = useState(undefined) - -const onLayout = (e: LayoutChangeEvent) => { - const { width, height } = e.nativeEvent.layout - setSize(width) -} -``` - -However, if the next state depends on the current state, you should still use a - -dispatch updater. - -**Incorrect: reads state directly from the callback** - -```tsx -const [count, setCount] = useState(0) - -const onTap = () => { - setCount(count + 1) -} -``` - -**Correct: dispatch updater** - -```tsx -const [count, setCount] = useState(0) - -const onTap = () => { - setCount((prev) => prev + 1) -} -``` - ---- - -## 7. State Architecture - -**Impact: MEDIUM** - -Ground truth principles for state variables and derived values. - -### 7.1 State Must Represent Ground Truth - -**Impact: HIGH (cleaner logic, easier debugging, single source of truth)** - -State variables—both React `useState` and Reanimated shared values—should - -represent the actual state of something (e.g., `pressed`, `progress`, `isOpen`), - -not derived visual values (e.g., `scale`, `opacity`, `translateY`). Derive - -visual values from state using computation or interpolation. - -**Incorrect: storing the visual output** - -```tsx -const scale = useSharedValue(1) - -const tap = Gesture.Tap() - .onBegin(() => { - scale.set(withTiming(0.95)) - }) - .onFinalize(() => { - scale.set(withTiming(1)) - }) - -const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.get() }], -})) -``` - -**Correct: storing the state, deriving the visual** - -```tsx -const pressed = useSharedValue(0) // 0 = not pressed, 1 = pressed - -const tap = Gesture.Tap() - .onBegin(() => { - pressed.set(withTiming(1)) - }) - .onFinalize(() => { - pressed.set(withTiming(0)) - }) - -const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.95]) }], -})) -``` - -**Why this matters:** - -State variables should represent real "state", not necessarily a desired end - -result. - -1. **Single source of truth** — The state (`pressed`) describes what's - - happening; visuals are derived - -2. **Easier to extend** — Adding opacity, rotation, or other effects just - - requires more interpolations from the same state - -3. **Debugging** — Inspecting `pressed = 1` is clearer than `scale = 0.95` - -4. **Reusable logic** — The same `pressed` value can drive multiple visual - - properties - -**Same principle for React state:** - -```tsx -// Incorrect: storing derived values -const [isExpanded, setIsExpanded] = useState(false) -const [height, setHeight] = useState(0) - -useEffect(() => { - setHeight(isExpanded ? 200 : 0) -}, [isExpanded]) - -// Correct: derive from state -const [isExpanded, setIsExpanded] = useState(false) -const height = isExpanded ? 200 : 0 -``` - -State is the minimal truth. Everything else is derived. - ---- - -## 8. React Compiler - -**Impact: MEDIUM** - -Compatibility patterns for React Compiler with React Native and -Reanimated. - -### 8.1 Destructure Functions Early in Render (React Compiler) - -**Impact: HIGH (stable references, fewer re-renders)** - -This rule is only applicable if you are using the React Compiler. - -Destructure functions from hooks at the top of render scope. Never dot into - -objects to call functions. Destructured functions are stable references; dotting - -creates new references and breaks memoization. - -**Incorrect: dotting into object** - -```tsx -import { useRouter } from 'expo-router' - -function SaveButton(props) { - const router = useRouter() - - // bad: react-compiler will key the cache on "props" and "router", which are objects that change each render - const handlePress = () => { - props.onSave() - router.push('/success') // unstable reference - } - - return -} -``` - -**Correct: destructure early** - -```tsx -import { useRouter } from 'expo-router' - -function SaveButton({ onSave }) { - const { push } = useRouter() - - // good: react-compiler will key on push and onSave - const handlePress = () => { - onSave() - push('/success') // stable reference - } - - return -} -``` - -### 8.2 Use .get() and .set() for Reanimated Shared Values (not .value) - -**Impact: LOW (required for React Compiler compatibility)** - -With React Compiler enabled, use `.get()` and `.set()` instead of reading or - -writing `.value` directly on Reanimated shared values. The compiler can't track - -property access—explicit methods ensure correct behavior. - -**Incorrect: breaks with React Compiler** - -```tsx -import { useSharedValue } from 'react-native-reanimated' - -function Counter() { - const count = useSharedValue(0) - - const increment = () => { - count.value = count.value + 1 // opts out of react compiler - } - - return - -``` - -**Correct: compound components** - -```tsx -import { Pressable, Text } from 'react-native' - -function Button({ children }: { children: React.ReactNode }) { - return {children} -} - -function ButtonText({ children }: { children: React.ReactNode }) { - return {children} -} - -function ButtonIcon({ children }: { children: React.ReactNode }) { - return <>{children} -} - -// Usage is explicit and composable - - - -``` - ---- - -## 11. Monorepo - -**Impact: LOW** - -Dependency management and native module configuration in -monorepos. - -### 11.1 Install Native Dependencies in App Directory - -**Impact: CRITICAL (required for autolinking to work)** - -In a monorepo, packages with native code must be installed in the native app's - -directory directly. Autolinking only scans the app's `node_modules`—it won't - -find native dependencies installed in other packages. - -**Incorrect: native dep in shared package only** - -```typescript -packages/ - ui/ - package.json # has react-native-reanimated - app/ - package.json # missing react-native-reanimated -``` - -Autolinking fails—native code not linked. - -**Correct: native dep in app directory** - -```json -// packages/app/package.json -{ - "dependencies": { - "react-native-reanimated": "3.16.1" - } -} -``` - -Even if the shared package uses the native dependency, the app must also list it - -for autolinking to detect and link the native code. - -### 11.2 Use Single Dependency Versions Across Monorepo - -**Impact: MEDIUM (avoids duplicate bundles, version conflicts)** - -Use a single version of each dependency across all packages in your monorepo. - -Prefer exact versions over ranges. Multiple versions cause duplicate code in - -bundles, runtime conflicts, and inconsistent behavior across packages. - -Use a tool like syncpack to enforce this. As a last resort, use yarn resolutions - -or npm overrides. - -**Incorrect: version ranges, multiple versions** - -```json -// packages/app/package.json -{ - "dependencies": { - "react-native-reanimated": "^3.0.0" - } -} - -// packages/ui/package.json -{ - "dependencies": { - "react-native-reanimated": "^3.5.0" - } -} -``` - -**Correct: exact versions, single source of truth** - -```json -// package.json (root) -{ - "pnpm": { - "overrides": { - "react-native-reanimated": "3.16.1" - } - } -} - -// packages/app/package.json -{ - "dependencies": { - "react-native-reanimated": "3.16.1" - } -} - -// packages/ui/package.json -{ - "dependencies": { - "react-native-reanimated": "3.16.1" - } -} -``` - -Use your package manager's override/resolution feature to enforce versions at - -the root. When adding dependencies, specify exact versions without `^` or `~`. - ---- - -## 12. Third-Party Dependencies - -**Impact: LOW** - -Wrapping and re-exporting third-party dependencies for -maintainability. - -### 12.1 Import from Design System Folder - -**Impact: LOW (enables global changes and easy refactoring)** - -Re-export dependencies from a design system folder. App code imports from there, - -not directly from packages. This enables global changes and easy refactoring. - -**Incorrect: imports directly from package** - -```tsx -import { View, Text } from 'react-native' -import { Button } from '@ui/button' - -function Profile() { - return ( - - Hello - - - ) -} -``` - -**Correct: imports from design system** - -```tsx -import { View } from '@/components/view' -import { Text } from '@/components/text' -import { Button } from '@/components/button' - -function Profile() { - return ( - - Hello - - - ) -} -``` - -Start by simply re-exporting. Customize later without changing app code. - ---- - -## 13. JavaScript - -**Impact: LOW** - -Micro-optimizations like hoisting expensive object creation. - -### 13.1 Hoist Intl Formatter Creation - -**Impact: LOW-MEDIUM (avoids expensive object recreation)** - -Don't create `Intl.DateTimeFormat`, `Intl.NumberFormat`, or - -`Intl.RelativeTimeFormat` inside render or loops. These are expensive to - -instantiate. Hoist to module scope when the locale/options are static. - -**Incorrect: new formatter every render** - -```tsx -function Price({ amount }: { amount: number }) { - const formatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }) - return {formatter.format(amount)} -} -``` - -**Correct: hoisted to module scope** - -```tsx -const currencyFormatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', -}) - -function Price({ amount }: { amount: number }) { - return {currencyFormatter.format(amount)} -} -``` - -**For dynamic locales, memoize:** - -```tsx -const dateFormatter = useMemo( - () => new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }), - [locale] -) -``` - -**Common formatters to hoist:** - -```tsx -// Module-level formatters -const dateFormatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }) -const timeFormatter = new Intl.DateTimeFormat('en-US', { timeStyle: 'short' }) -const percentFormatter = new Intl.NumberFormat('en-US', { style: 'percent' }) -const relativeFormatter = new Intl.RelativeTimeFormat('en-US', { - numeric: 'auto', -}) -``` - -Creating `Intl` objects is significantly more expensive than `RegExp` or plain - -objects—each instantiation parses locale data and builds internal lookup tables. - ---- - -## 14. Fonts - -**Impact: LOW** - -Native font loading for improved performance. - -### 14.1 Load fonts natively at build time - -**Impact: LOW (fonts available at launch, no async loading)** - -Use the `expo-font` config plugin to embed fonts at build time instead of - -`useFonts` or `Font.loadAsync`. Embedded fonts are more efficient. - -[Expo Font Documentation](https://docs.expo.dev/versions/latest/sdk/font/) - -**Incorrect: async font loading** - -```tsx -import { useFonts } from 'expo-font' -import { Text, View } from 'react-native' - -function App() { - const [fontsLoaded] = useFonts({ - 'Geist-Bold': require('./assets/fonts/Geist-Bold.otf'), - }) - - if (!fontsLoaded) { - return null - } - - return ( - - Hello - - ) -} -``` - -**Correct: config plugin, fonts embedded at build** - -```tsx -import { Text, View } from 'react-native' - -function App() { - // No loading state needed—font is already available - return ( - - Hello - - ) -} -``` - -After adding fonts to the config plugin, run `npx expo prebuild` and rebuild the - -native app. - ---- - -## References - -1. [https://react.dev](https://react.dev) -2. [https://reactnative.dev](https://reactnative.dev) -3. [https://docs.swmansion.com/react-native-reanimated](https://docs.swmansion.com/react-native-reanimated) -4. [https://docs.swmansion.com/react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler) -5. [https://docs.expo.dev](https://docs.expo.dev) -6. [https://legendapp.com/open-source/legend-list](https://legendapp.com/open-source/legend-list) -7. [https://github.com/nandorojo/galeria](https://github.com/nandorojo/galeria) -8. [https://zeego.dev](https://zeego.dev) diff --git a/.agents/skills/vercel-react-native-skills/README.md b/.agents/skills/vercel-react-native-skills/README.md deleted file mode 100644 index 854db9f..0000000 --- a/.agents/skills/vercel-react-native-skills/README.md +++ /dev/null @@ -1,165 +0,0 @@ -# React Native Guidelines - -A structured repository for creating and maintaining React Native Best Practices -optimized for agents and LLMs. - -## Structure - -- `rules/` - Individual rule files (one per rule) - - `_sections.md` - Section metadata (titles, impacts, descriptions) - - `_template.md` - Template for creating new rules - - `area-description.md` - Individual rule files -- `metadata.json` - Document metadata (version, organization, abstract) -- **`AGENTS.md`** - Compiled output (generated) - -## Rules - -### Core Rendering (CRITICAL) - -- `rendering-text-in-text-component.md` - Wrap strings in Text components -- `rendering-no-falsy-and.md` - Avoid falsy && operator in JSX - -### List Performance (HIGH) - -- `list-performance-virtualize.md` - Use virtualized lists (LegendList, - FlashList) -- `list-performance-function-references.md` - Keep stable object references -- `list-performance-callbacks.md` - Hoist callbacks to list root -- `list-performance-inline-objects.md` - Avoid inline objects in renderItem -- `list-performance-item-memo.md` - Pass primitives for memoization -- `list-performance-item-expensive.md` - Keep list items lightweight -- `list-performance-images.md` - Use compressed images in lists -- `list-performance-item-types.md` - Use item types for heterogeneous lists - -### Animation (HIGH) - -- `animation-gpu-properties.md` - Animate transform/opacity instead of layout -- `animation-gesture-detector-press.md` - Use GestureDetector for press - animations -- `animation-derived-value.md` - Prefer useDerivedValue over useAnimatedReaction - -### Scroll Performance (HIGH) - -- `scroll-position-no-state.md` - Never track scroll in useState - -### Navigation (HIGH) - -- `navigation-native-navigators.md` - Use native stack and native tabs - -### React State (MEDIUM) - -- `react-state-dispatcher.md` - Use functional setState updates -- `react-state-fallback.md` - State should represent user intent only -- `react-state-minimize.md` - Minimize state variables, derive values - -### State Architecture (MEDIUM) - -- `state-ground-truth.md` - State must represent ground truth - -### React Compiler (MEDIUM) - -- `react-compiler-destructure-functions.md` - Destructure functions early -- `react-compiler-reanimated-shared-values.md` - Use .get()/.set() for shared - values - -### User Interface (MEDIUM) - -- `ui-expo-image.md` - Use expo-image for optimized images -- `ui-image-gallery.md` - Use Galeria for lightbox/galleries -- `ui-menus.md` - Native dropdown and context menus with Zeego -- `ui-native-modals.md` - Use native Modal with formSheet -- `ui-pressable.md` - Use Pressable instead of TouchableOpacity -- `ui-measure-views.md` - Measuring view dimensions -- `ui-safe-area-scroll.md` - Use contentInsetAdjustmentBehavior -- `ui-scrollview-content-inset.md` - Use contentInset for dynamic spacing -- `ui-styling.md` - Modern styling patterns (gap, boxShadow, gradients) - -### Design System (MEDIUM) - -- `design-system-compound-components.md` - Use compound components - -### Monorepo (LOW) - -- `monorepo-native-deps-in-app.md` - Install native deps in app directory -- `monorepo-single-dependency-versions.md` - Single dependency versions - -### Third-Party Dependencies (LOW) - -- `imports-design-system-folder.md` - Import from design system folder - -### JavaScript (LOW) - -- `js-hoist-intl.md` - Hoist Intl formatter creation - -### Fonts (LOW) - -- `fonts-config-plugin.md` - Load fonts natively at build time - -## Creating a New Rule - -1. Copy `rules/_template.md` to `rules/area-description.md` -2. Choose the appropriate area prefix: - - `rendering-` for Core Rendering - - `list-performance-` for List Performance - - `animation-` for Animation - - `scroll-` for Scroll Performance - - `navigation-` for Navigation - - `react-state-` for React State - - `state-` for State Architecture - - `react-compiler-` for React Compiler - - `ui-` for User Interface - - `design-system-` for Design System - - `monorepo-` for Monorepo - - `imports-` for Third-Party Dependencies - - `js-` for JavaScript - - `fonts-` for Fonts -3. Fill in the frontmatter and content -4. Ensure you have clear examples with explanations - -## Rule File Structure - -Each rule file should follow this structure: - -````markdown ---- -title: Rule Title Here -impact: MEDIUM -impactDescription: Optional description -tags: tag1, tag2, tag3 ---- - -## Rule Title Here - -Brief explanation of the rule and why it matters. - -**Incorrect (description of what's wrong):** - -```tsx -// Bad code example -``` -```` - -**Correct (description of what's right):** - -```tsx -// Good code example -``` - -Reference: [Link](https://example.com) - -``` - -## File Naming Convention - -- Files starting with `_` are special (excluded from build) -- Rule files: `area-description.md` (e.g., `animation-gpu-properties.md`) -- Section is automatically inferred from filename prefix -- Rules are sorted alphabetically by title within each section - -## Impact Levels - -- `CRITICAL` - Highest priority, causes crashes or broken UI -- `HIGH` - Significant performance improvements -- `MEDIUM` - Moderate performance improvements -- `LOW` - Incremental improvements -``` diff --git a/.agents/skills/vercel-react-native-skills/SKILL.md b/.agents/skills/vercel-react-native-skills/SKILL.md deleted file mode 100644 index 7340186..0000000 --- a/.agents/skills/vercel-react-native-skills/SKILL.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -name: vercel-react-native-skills -description: - React Native and Expo best practices for building performant mobile apps. Use - when building React Native components, optimizing list performance, - implementing animations, or working with native modules. Triggers on tasks - involving React Native, Expo, mobile performance, or native platform APIs. -license: MIT -metadata: - author: vercel - version: '1.0.0' ---- - -# React Native Skills - -Comprehensive best practices for React Native and Expo applications. Contains -rules across multiple categories covering performance, animations, UI patterns, -and platform-specific optimizations. - -## When to Apply - -Reference these guidelines when: - -- Building React Native or Expo apps -- Optimizing list and scroll performance -- Implementing animations with Reanimated -- Working with images and media -- Configuring native modules or fonts -- Structuring monorepo projects with native dependencies - -## Rule Categories by Priority - -| Priority | Category | Impact | Prefix | -| -------- | ---------------- | -------- | -------------------- | -| 1 | List Performance | CRITICAL | `list-performance-` | -| 2 | Animation | HIGH | `animation-` | -| 3 | Navigation | HIGH | `navigation-` | -| 4 | UI Patterns | HIGH | `ui-` | -| 5 | State Management | MEDIUM | `react-state-` | -| 6 | Rendering | MEDIUM | `rendering-` | -| 7 | Monorepo | MEDIUM | `monorepo-` | -| 8 | Configuration | LOW | `fonts-`, `imports-` | - -## Quick Reference - -### 1. List Performance (CRITICAL) - -- `list-performance-virtualize` - Use FlashList for large lists -- `list-performance-item-memo` - Memoize list item components -- `list-performance-callbacks` - Stabilize callback references -- `list-performance-inline-objects` - Avoid inline style objects -- `list-performance-function-references` - Extract functions outside render -- `list-performance-images` - Optimize images in lists -- `list-performance-item-expensive` - Move expensive work outside items -- `list-performance-item-types` - Use item types for heterogeneous lists - -### 2. Animation (HIGH) - -- `animation-gpu-properties` - Animate only transform and opacity -- `animation-derived-value` - Use useDerivedValue for computed animations -- `animation-gesture-detector-press` - Use Gesture.Tap instead of Pressable - -### 3. Navigation (HIGH) - -- `navigation-native-navigators` - Use native stack and native tabs over JS navigators - -### 4. UI Patterns (HIGH) - -- `ui-expo-image` - Use expo-image for all images -- `ui-image-gallery` - Use Galeria for image lightboxes -- `ui-pressable` - Use Pressable over TouchableOpacity -- `ui-safe-area-scroll` - Handle safe areas in ScrollViews -- `ui-scrollview-content-inset` - Use contentInset for headers -- `ui-menus` - Use native context menus -- `ui-native-modals` - Use native modals when possible -- `ui-measure-views` - Use onLayout, not measure() -- `ui-styling` - Use StyleSheet.create or Nativewind - -### 5. State Management (MEDIUM) - -- `react-state-minimize` - Minimize state subscriptions -- `react-state-dispatcher` - Use dispatcher pattern for callbacks -- `react-state-fallback` - Show fallback on first render -- `react-compiler-destructure-functions` - Destructure for React Compiler -- `react-compiler-reanimated-shared-values` - Handle shared values with compiler - -### 6. Rendering (MEDIUM) - -- `rendering-text-in-text-component` - Wrap text in Text components -- `rendering-no-falsy-and` - Avoid falsy && for conditional rendering - -### 7. Monorepo (MEDIUM) - -- `monorepo-native-deps-in-app` - Keep native dependencies in app package -- `monorepo-single-dependency-versions` - Use single versions across packages - -### 8. Configuration (LOW) - -- `fonts-config-plugin` - Use config plugins for custom fonts -- `imports-design-system-folder` - Organize design system imports -- `js-hoist-intl` - Hoist Intl object creation - -## How to Use - -Read individual rule files for detailed explanations and code examples: - -``` -rules/list-performance-virtualize.md -rules/animation-gpu-properties.md -``` - -Each rule file contains: - -- Brief explanation of why it matters -- Incorrect code example with explanation -- Correct code example with explanation -- Additional context and references - -## Full Compiled Document - -For the complete guide with all rules expanded: `AGENTS.md` diff --git a/.agents/skills/vercel-react-native-skills/rules/animation-derived-value.md b/.agents/skills/vercel-react-native-skills/rules/animation-derived-value.md deleted file mode 100644 index 310928a..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/animation-derived-value.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: Prefer useDerivedValue Over useAnimatedReaction -impact: MEDIUM -impactDescription: cleaner code, automatic dependency tracking -tags: animation, reanimated, derived-value ---- - -## Prefer useDerivedValue Over useAnimatedReaction - -When deriving a shared value from another, use `useDerivedValue` instead of -`useAnimatedReaction`. Derived values are declarative, automatically track -dependencies, and return a value you can use directly. Animated reactions are -for side effects, not derivations. - -**Incorrect (useAnimatedReaction for derivation):** - -```tsx -import { useSharedValue, useAnimatedReaction } from 'react-native-reanimated' - -function MyComponent() { - const progress = useSharedValue(0) - const opacity = useSharedValue(1) - - useAnimatedReaction( - () => progress.value, - (current) => { - opacity.value = 1 - current - } - ) - - // ... -} -``` - -**Correct (useDerivedValue):** - -```tsx -import { useSharedValue, useDerivedValue } from 'react-native-reanimated' - -function MyComponent() { - const progress = useSharedValue(0) - - const opacity = useDerivedValue(() => 1 - progress.get()) - - // ... -} -``` - -Use `useAnimatedReaction` only for side effects that don't produce a value -(e.g., triggering haptics, logging, calling `runOnJS`). - -Reference: -[Reanimated useDerivedValue](https://docs.swmansion.com/react-native-reanimated/docs/core/useDerivedValue) diff --git a/.agents/skills/vercel-react-native-skills/rules/animation-gesture-detector-press.md b/.agents/skills/vercel-react-native-skills/rules/animation-gesture-detector-press.md deleted file mode 100644 index 87c6782..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/animation-gesture-detector-press.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -title: Use GestureDetector for Animated Press States -impact: MEDIUM -impactDescription: UI thread animations, smoother press feedback -tags: animation, gestures, press, reanimated ---- - -## Use GestureDetector for Animated Press States - -For animated press states (scale, opacity on press), use `GestureDetector` with -`Gesture.Tap()` and shared values instead of Pressable's -`onPressIn`/`onPressOut`. Gesture callbacks run on the UI thread as worklets—no -JS thread round-trip for press animations. - -**Incorrect (Pressable with JS thread callbacks):** - -```tsx -import { Pressable } from 'react-native' -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, -} from 'react-native-reanimated' - -function AnimatedButton({ onPress }: { onPress: () => void }) { - const scale = useSharedValue(1) - - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - })) - - return ( - (scale.value = withTiming(0.95))} - onPressOut={() => (scale.value = withTiming(1))} - > - - Press me - - - ) -} -``` - -**Correct (GestureDetector with UI thread worklets):** - -```tsx -import { Gesture, GestureDetector } from 'react-native-gesture-handler' -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, - interpolate, - runOnJS, -} from 'react-native-reanimated' - -function AnimatedButton({ onPress }: { onPress: () => void }) { - // Store the press STATE (0 = not pressed, 1 = pressed) - const pressed = useSharedValue(0) - - const tap = Gesture.Tap() - .onBegin(() => { - pressed.set(withTiming(1)) - }) - .onFinalize(() => { - pressed.set(withTiming(0)) - }) - .onEnd(() => { - runOnJS(onPress)() - }) - - // Derive visual values from the state - const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - { scale: interpolate(withTiming(pressed.get()), [0, 1], [1, 0.95]) }, - ], - })) - - return ( - - - Press me - - - ) -} -``` - -Store the press **state** (0 or 1), then derive the scale via `interpolate`. -This keeps the shared value as ground truth. Use `runOnJS` to call JS functions -from worklets. Use `.set()` and `.get()` for React Compiler compatibility. - -Reference: -[Gesture Handler Tap Gesture](https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/tap-gesture) diff --git a/.agents/skills/vercel-react-native-skills/rules/animation-gpu-properties.md b/.agents/skills/vercel-react-native-skills/rules/animation-gpu-properties.md deleted file mode 100644 index 5fda095..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/animation-gpu-properties.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Animate Transform and Opacity Instead of Layout Properties -impact: HIGH -impactDescription: GPU-accelerated animations, no layout recalculation -tags: animation, performance, reanimated, transform, opacity ---- - -## Animate Transform and Opacity Instead of Layout Properties - -Avoid animating `width`, `height`, `top`, `left`, `margin`, or `padding`. These trigger layout recalculation on every frame. Instead, use `transform` (scale, translate) and `opacity` which run on the GPU without triggering layout. - -**Incorrect (animates height, triggers layout every frame):** - -```tsx -import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' - -function CollapsiblePanel({ expanded }: { expanded: boolean }) { - const animatedStyle = useAnimatedStyle(() => ({ - height: withTiming(expanded ? 200 : 0), // triggers layout on every frame - overflow: 'hidden', - })) - - return {children} -} -``` - -**Correct (animates scaleY, GPU-accelerated):** - -```tsx -import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' - -function CollapsiblePanel({ expanded }: { expanded: boolean }) { - const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - { scaleY: withTiming(expanded ? 1 : 0) }, - ], - opacity: withTiming(expanded ? 1 : 0), - })) - - return ( - - {children} - - ) -} -``` - -**Correct (animates translateY for slide animations):** - -```tsx -import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' - -function SlideIn({ visible }: { visible: boolean }) { - const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - { translateY: withTiming(visible ? 0 : 100) }, - ], - opacity: withTiming(visible ? 1 : 0), - })) - - return {children} -} -``` - -GPU-accelerated properties: `transform` (translate, scale, rotate), `opacity`. Everything else triggers layout. diff --git a/.agents/skills/vercel-react-native-skills/rules/design-system-compound-components.md b/.agents/skills/vercel-react-native-skills/rules/design-system-compound-components.md deleted file mode 100644 index d8239ee..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/design-system-compound-components.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: Use Compound Components Over Polymorphic Children -impact: MEDIUM -impactDescription: flexible composition, clearer API -tags: design-system, components, composition ---- - -## Use Compound Components Over Polymorphic Children - -Don't create components that can accept a string if they aren't a text node. If -a component can receive a string child, it must be a dedicated `*Text` -component. For components like buttons, which can have both a View (or -Pressable) together with text, use compound components, such a `Button`, -`ButtonText`, and `ButtonIcon`. - -**Incorrect (polymorphic children):** - -```tsx -import { Pressable, Text } from 'react-native' - -type ButtonProps = { - children: string | React.ReactNode - icon?: React.ReactNode -} - -function Button({ children, icon }: ButtonProps) { - return ( - - {icon} - {typeof children === 'string' ? {children} : children} - - ) -} - -// Usage is ambiguous - - -``` - -**Correct (compound components):** - -```tsx -import { Pressable, Text } from 'react-native' - -function Button({ children }: { children: React.ReactNode }) { - return {children} -} - -function ButtonText({ children }: { children: React.ReactNode }) { - return {children} -} - -function ButtonIcon({ children }: { children: React.ReactNode }) { - return <>{children} -} - -// Usage is explicit and composable - - - -``` diff --git a/.agents/skills/vercel-react-native-skills/rules/fonts-config-plugin.md b/.agents/skills/vercel-react-native-skills/rules/fonts-config-plugin.md deleted file mode 100644 index 39aa014..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/fonts-config-plugin.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: Load fonts natively at build time -impact: LOW -impactDescription: fonts available at launch, no async loading -tags: fonts, expo, performance, config-plugin ---- - -## Use Expo Config Plugin for Font Loading - -Use the `expo-font` config plugin to embed fonts at build time instead of -`useFonts` or `Font.loadAsync`. Embedded fonts are more efficient. - -**Incorrect (async font loading):** - -```tsx -import { useFonts } from 'expo-font' -import { Text, View } from 'react-native' - -function App() { - const [fontsLoaded] = useFonts({ - 'Geist-Bold': require('./assets/fonts/Geist-Bold.otf'), - }) - - if (!fontsLoaded) { - return null - } - - return ( - - Hello - - ) -} -``` - -**Correct (config plugin, fonts embedded at build):** - -```json -// app.json -{ - "expo": { - "plugins": [ - [ - "expo-font", - { - "fonts": ["./assets/fonts/Geist-Bold.otf"] - } - ] - ] - } -} -``` - -```tsx -import { Text, View } from 'react-native' - -function App() { - // No loading state needed—font is already available - return ( - - Hello - - ) -} -``` - -After adding fonts to the config plugin, run `npx expo prebuild` and rebuild the -native app. - -Reference: -[Expo Font Documentation](https://docs.expo.dev/versions/latest/sdk/font/) diff --git a/.agents/skills/vercel-react-native-skills/rules/imports-design-system-folder.md b/.agents/skills/vercel-react-native-skills/rules/imports-design-system-folder.md deleted file mode 100644 index 8466dcb..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/imports-design-system-folder.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -title: Import from Design System Folder -impact: LOW -impactDescription: enables global changes and easy refactoring -tags: imports, architecture, design-system ---- - -## Import from Design System Folder - -Re-export dependencies from a design system folder. App code imports from there, -not directly from packages. This enables global changes and easy refactoring. - -**Incorrect (imports directly from package):** - -```tsx -import { View, Text } from 'react-native' -import { Button } from '@ui/button' - -function Profile() { - return ( - - Hello - - - ) -} -``` - -**Correct (imports from design system):** - -```tsx -// components/view.tsx -import { View as RNView } from 'react-native' - -// ideal: pick the props you will actually use to control implementation -export function View( - props: Pick, 'style' | 'children'> -) { - return -} -``` - -```tsx -// components/text.tsx -export { Text } from 'react-native' -``` - -```tsx -// components/button.tsx -export { Button } from '@ui/button' -``` - -```tsx -import { View } from '@/components/view' -import { Text } from '@/components/text' -import { Button } from '@/components/button' - -function Profile() { - return ( - - Hello - - - ) -} -``` - -Start by simply re-exporting. Customize later without changing app code. diff --git a/.agents/skills/vercel-react-native-skills/rules/js-hoist-intl.md b/.agents/skills/vercel-react-native-skills/rules/js-hoist-intl.md deleted file mode 100644 index 9af1c35..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/js-hoist-intl.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: Hoist Intl Formatter Creation -impact: LOW-MEDIUM -impactDescription: avoids expensive object recreation -tags: javascript, intl, optimization, memoization ---- - -## Hoist Intl Formatter Creation - -Don't create `Intl.DateTimeFormat`, `Intl.NumberFormat`, or -`Intl.RelativeTimeFormat` inside render or loops. These are expensive to -instantiate. Hoist to module scope when the locale/options are static. - -**Incorrect (new formatter every render):** - -```tsx -function Price({ amount }: { amount: number }) { - const formatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }) - return {formatter.format(amount)} -} -``` - -**Correct (hoisted to module scope):** - -```tsx -const currencyFormatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', -}) - -function Price({ amount }: { amount: number }) { - return {currencyFormatter.format(amount)} -} -``` - -**For dynamic locales, memoize:** - -```tsx -const dateFormatter = useMemo( - () => new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }), - [locale] -) -``` - -**Common formatters to hoist:** - -```tsx -// Module-level formatters -const dateFormatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }) -const timeFormatter = new Intl.DateTimeFormat('en-US', { timeStyle: 'short' }) -const percentFormatter = new Intl.NumberFormat('en-US', { style: 'percent' }) -const relativeFormatter = new Intl.RelativeTimeFormat('en-US', { - numeric: 'auto', -}) -``` - -Creating `Intl` objects is significantly more expensive than `RegExp` or plain -objects—each instantiation parses locale data and builds internal lookup tables. diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-callbacks.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-callbacks.md deleted file mode 100644 index a0b3913..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-callbacks.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: Hoist callbacks to the root of lists -impact: MEDIUM -impactDescription: Fewer re-renders and faster lists -tags: tag1, tag2 ---- - -## List performance callbacks - -**Impact: HIGH (Fewer re-renders and faster lists)** - -When passing callback functions to list items, create a single instance of the -callback at the root of the list. Items should then call it with a unique -identifier. - -**Incorrect (creates a new callback on each render):** - -```typescript -return ( - { - // bad: creates a new callback on each render - const onPress = () => handlePress(item.id) - return - }} - /> -) -``` - -**Correct (a single function instance passed to each item):** - -```typescript -const onPress = useCallback(() => handlePress(item.id), [handlePress, item.id]) - -return ( - ( - - )} - /> -) -``` - -Reference: [Link to documentation or resource](https://example.com) diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-function-references.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-function-references.md deleted file mode 100644 index 9721929..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-function-references.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -title: Optimize List Performance with Stable Object References -impact: CRITICAL -impactDescription: virtualization relies on reference stability -tags: lists, performance, flatlist, virtualization ---- - -## Optimize List Performance with Stable Object References - -Don't map or filter data before passing to virtualized lists. Virtualization -relies on object reference stability to know what changed—new references cause -full re-renders of all visible items. Attempt to prevent frequent renders at the -list-parent level. - -Where needed, use context selectors within list items. - -**Incorrect (creates new object references on every keystroke):** - -```tsx -function DomainSearch() { - const { keyword, setKeyword } = useKeywordZustandState() - const { data: tlds } = useTlds() - - // Bad: creates new objects on every render, reparenting the entire list on every keystroke - const domains = tlds.map((tld) => ({ - domain: `${keyword}.${tld.name}`, - tld: tld.name, - price: tld.price, - })) - - return ( - <> - - } - /> - - ) -} -``` - -**Correct (stable references, transform inside items):** - -```tsx -const renderItem = ({ item }) => - -function DomainSearch() { - const { data: tlds } = useTlds() - - return ( - - ) -} - -function DomainItem({ tld }: { tld: Tld }) { - // good: transform within items, and don't pass the dynamic data as a prop - // good: use a selector function from zustand to receive a stable string back - const domain = useKeywordZustandState((s) => s.keyword + '.' + tld.name) - return {domain} -} -``` - -**Updating parent array reference:** - -Creating a new array instance can be okay, as long as its inner object -references are stable. For instance, if you sort a list of objects: - -```tsx -// good: creates a new array instance without mutating the inner objects -// good: parent array reference is unaffected by typing and updating "keyword" -const sortedTlds = tlds.toSorted((a, b) => a.name.localeCompare(b.name)) - -return -``` - -Even though this creates a new array instance `sortedTlds`, the inner object -references are stable. - -**With zustand for dynamic data (avoids parent re-renders):** - -```tsx -const useSearchStore = create<{ keyword: string }>(() => ({ keyword: '' })) - -function DomainSearch() { - const { data: tlds } = useTlds() - - return ( - <> - - } - /> - - ) -} - -function DomainItem({ tld }: { tld: Tld }) { - // Select only what you need—component only re-renders when keyword changes - const keyword = useSearchStore((s) => s.keyword) - const domain = `${keyword}.${tld.name}` - return {domain} -} -``` - -Virtualization can now skip items that haven't changed when typing. Only visible -items (~20) re-render on keystroke, rather than the parent. - -**Deriving state within list items based on parent data (avoids parent -re-renders):** - -For components where the data is conditional based on the parent state, this -pattern is even more important. For example, if you are checking if an item is -favorited, toggling favorites only re-renders one component if the item itself -is in charge of accessing the state rather than the parent: - -```tsx -function DomainItemFavoriteButton({ tld }: { tld: Tld }) { - const isFavorited = useFavoritesStore((s) => s.favorites.has(tld.id)) - return -} -``` - -Note: if you're using the React Compiler, you can read React Context values -directly within list items. Although this is slightly slower than using a -Zustand selector in most cases, the effect may be negligible. diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-images.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-images.md deleted file mode 100644 index 75a3baf..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-images.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: Use Compressed Images in Lists -impact: HIGH -impactDescription: faster load times, less memory -tags: lists, images, performance, optimization ---- - -## Use Compressed Images in Lists - -Always load compressed, appropriately-sized images in lists. Full-resolution -images consume excessive memory and cause scroll jank. Request thumbnails from -your server or use an image CDN with resize parameters. - -**Incorrect (full-resolution images):** - -```tsx -function ProductItem({ product }: { product: Product }) { - return ( - - {/* 4000x3000 image loaded for a 100x100 thumbnail */} - - {product.name} - - ) -} -``` - -**Correct (request appropriately-sized image):** - -```tsx -function ProductItem({ product }: { product: Product }) { - // Request a 200x200 image (2x for retina) - const thumbnailUrl = `${product.imageUrl}?w=200&h=200&fit=cover` - - return ( - - - {product.name} - - ) -} -``` - -Use an optimized image component with built-in caching and placeholder support, -such as `expo-image` or `SolitoImage` (which uses `expo-image` under the hood). -Request images at 2x the display size for retina screens. diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-inline-objects.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-inline-objects.md deleted file mode 100644 index d5b6514..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-inline-objects.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -title: Avoid Inline Objects in renderItem -impact: HIGH -impactDescription: prevents unnecessary re-renders of memoized list items -tags: lists, performance, flatlist, virtualization, memo ---- - -## Avoid Inline Objects in renderItem - -Don't create new objects inside `renderItem` to pass as props. Inline objects -create new references on every render, breaking memoization. Pass primitive -values directly from `item` instead. - -**Incorrect (inline object breaks memoization):** - -```tsx -function UserList({ users }: { users: User[] }) { - return ( - ( - - )} - /> - ) -} -``` - -**Incorrect (inline style object):** - -```tsx -renderItem={({ item }) => ( - -)} -``` - -**Correct (pass item directly or primitives):** - -```tsx -function UserList({ users }: { users: User[] }) { - return ( - ( - // Good: pass the item directly - - )} - /> - ) -} -``` - -**Correct (pass primitives, derive inside child):** - -```tsx -renderItem={({ item }) => ( - -)} - -const UserRow = memo(function UserRow({ id, name, isActive }: Props) { - // Good: derive style inside memoized component - const backgroundColor = isActive ? 'green' : 'gray' - return {/* ... */} -}) -``` - -**Correct (hoist static styles in module scope):** - -```tsx -const activeStyle = { backgroundColor: 'green' } -const inactiveStyle = { backgroundColor: 'gray' } - -renderItem={({ item }) => ( - -)} -``` - -Passing primitives or stable references allows `memo()` to skip re-renders when -the actual values haven't changed. - -**Note:** If you have the React Compiler enabled, it handles memoization -automatically and these manual optimizations become less critical. diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-expensive.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-item-expensive.md deleted file mode 100644 index f617a76..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-expensive.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -title: Keep List Items Lightweight -impact: HIGH -impactDescription: reduces render time for visible items during scroll -tags: lists, performance, virtualization, hooks ---- - -## Keep List Items Lightweight - -List items should be as inexpensive as possible to render. Minimize hooks, avoid -queries, and limit React Context access. Virtualized lists render many items -during scroll—expensive items cause jank. - -**Incorrect (heavy list item):** - -```tsx -function ProductRow({ id }: { id: string }) { - // Bad: query inside list item - const { data: product } = useQuery(['product', id], () => fetchProduct(id)) - // Bad: multiple context accesses - const theme = useContext(ThemeContext) - const user = useContext(UserContext) - const cart = useContext(CartContext) - // Bad: expensive computation - const recommendations = useMemo( - () => computeRecommendations(product), - [product] - ) - - return {/* ... */} -} -``` - -**Correct (lightweight list item):** - -```tsx -function ProductRow({ name, price, imageUrl }: Props) { - // Good: receives only primitives, minimal hooks - return ( - - - {name} - {price} - - ) -} -``` - -**Move data fetching to parent:** - -```tsx -// Parent fetches all data once -function ProductList() { - const { data: products } = useQuery(['products'], fetchProducts) - - return ( - ( - - )} - /> - ) -} -``` - -**For shared values, use Zustand selectors instead of Context:** - -```tsx -// Incorrect: Context causes re-render when any cart value changes -function ProductRow({ id, name }: Props) { - const { items } = useContext(CartContext) - const inCart = items.includes(id) - // ... -} - -// Correct: Zustand selector only re-renders when this specific value changes -function ProductRow({ id, name }: Props) { - // use Set.has (created once at the root) instead of Array.includes() - const inCart = useCartStore((s) => s.items.has(id)) - // ... -} -``` - -**Guidelines for list items:** - -- No queries or data fetching -- No expensive computations (move to parent or memoize at parent level) -- Prefer Zustand selectors over React Context -- Minimize useState/useEffect hooks -- Pass pre-computed values as props - -The goal: list items should be simple rendering functions that take props and -return JSX. diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-memo.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-item-memo.md deleted file mode 100644 index 634935e..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-memo.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -title: Pass Primitives to List Items for Memoization -impact: HIGH -impactDescription: enables effective memo() comparison -tags: lists, performance, memo, primitives ---- - -## Pass Primitives to List Items for Memoization - -When possible, pass only primitive values (strings, numbers, booleans) as props -to list item components. Primitives enable shallow comparison in `memo()` to -work correctly, skipping re-renders when values haven't changed. - -**Incorrect (object prop requires deep comparison):** - -```tsx -type User = { id: string; name: string; email: string; avatar: string } - -const UserRow = memo(function UserRow({ user }: { user: User }) { - // memo() compares user by reference, not value - // If parent creates new user object, this re-renders even if data is same - return {user.name} -}) - -renderItem={({ item }) => } -``` - -This can still be optimized, but it is harder to memoize properly. - -**Correct (primitive props enable shallow comparison):** - -```tsx -const UserRow = memo(function UserRow({ - id, - name, - email, -}: { - id: string - name: string - email: string -}) { - // memo() compares each primitive directly - // Re-renders only if id, name, or email actually changed - return {name} -}) - -renderItem={({ item }) => ( - -)} -``` - -**Pass only what you need:** - -```tsx -// Incorrect: passing entire item when you only need name - - -// Correct: pass only the fields the component uses - -``` - -**For callbacks, hoist or use item ID:** - -```tsx -// Incorrect: inline function creates new reference - handlePress(item.id)} /> - -// Correct: pass ID, handle in child - - -const UserRow = memo(function UserRow({ id, name }: Props) { - const handlePress = useCallback(() => { - // use id here - }, [id]) - return {name} -}) -``` - -Primitive props make memoization predictable and effective. - -**Note:** If you have the React Compiler enabled, you do not need to use -`memo()` or `useCallback()`, but the object references still apply. diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-types.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-item-types.md deleted file mode 100644 index 1027e4e..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-types.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -title: Use Item Types for Heterogeneous Lists -impact: HIGH -impactDescription: efficient recycling, less layout thrashing -tags: list, performance, recycling, heterogeneous, LegendList ---- - -## Use Item Types for Heterogeneous Lists - -When a list has different item layouts (messages, images, headers, etc.), use a -`type` field on each item and provide `getItemType` to the list. This puts items -into separate recycling pools so a message component never gets recycled into an -image component. - -**Incorrect (single component with conditionals):** - -```tsx -type Item = { id: string; text?: string; imageUrl?: string; isHeader?: boolean } - -function ListItem({ item }: { item: Item }) { - if (item.isHeader) { - return - } - if (item.imageUrl) { - return - } - return -} - -function Feed({ items }: { items: Item[] }) { - return ( - } - recycleItems - /> - ) -} -``` - -**Correct (typed items with separate components):** - -```tsx -type HeaderItem = { id: string; type: 'header'; title: string } -type MessageItem = { id: string; type: 'message'; text: string } -type ImageItem = { id: string; type: 'image'; url: string } -type FeedItem = HeaderItem | MessageItem | ImageItem - -function Feed({ items }: { items: FeedItem[] }) { - return ( - item.id} - getItemType={(item) => item.type} - renderItem={({ item }) => { - switch (item.type) { - case 'header': - return - case 'message': - return - case 'image': - return - } - }} - recycleItems - /> - ) -} -``` - -**Why this matters:** - -- **Recycling efficiency**: Items with the same type share a recycling pool -- **No layout thrashing**: A header never recycles into an image cell -- **Type safety**: TypeScript can narrow the item type in each branch -- **Better size estimation**: Use `getEstimatedItemSize` with `itemType` for - accurate estimates per type - -```tsx - item.id} - getItemType={(item) => item.type} - getEstimatedItemSize={(index, item, itemType) => { - switch (itemType) { - case 'header': - return 48 - case 'message': - return 72 - case 'image': - return 300 - default: - return 72 - } - }} - renderItem={({ item }) => { - /* ... */ - }} - recycleItems -/> -``` - -Reference: -[LegendList getItemType](https://legendapp.com/open-source/list/api/props/#getitemtype-v2) diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-virtualize.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-virtualize.md deleted file mode 100644 index 8a393ba..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-virtualize.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Use a List Virtualizer for Any List -impact: HIGH -impactDescription: reduced memory, faster mounts -tags: lists, performance, virtualization, scrollview ---- - -## Use a List Virtualizer for Any List - -Use a list virtualizer like LegendList or FlashList instead of ScrollView with -mapped children—even for short lists. Virtualizers only render visible items, -reducing memory usage and mount time. ScrollView renders all children upfront, -which gets expensive quickly. - -**Incorrect (ScrollView renders all items at once):** - -```tsx -function Feed({ items }: { items: Item[] }) { - return ( - - {items.map((item) => ( - - ))} - - ) -} -// 50 items = 50 components mounted, even if only 10 visible -``` - -**Correct (virtualizer renders only visible items):** - -```tsx -import { LegendList } from '@legendapp/list' - -function Feed({ items }: { items: Item[] }) { - return ( - } - keyExtractor={(item) => item.id} - estimatedItemSize={80} - /> - ) -} -// Only ~10-15 visible items mounted at a time -``` - -**Alternative (FlashList):** - -```tsx -import { FlashList } from '@shopify/flash-list' - -function Feed({ items }: { items: Item[] }) { - return ( - } - keyExtractor={(item) => item.id} - /> - ) -} -``` - -Benefits apply to any screen with scrollable content—profiles, settings, feeds, -search results. Default to virtualization. diff --git a/.agents/skills/vercel-react-native-skills/rules/monorepo-native-deps-in-app.md b/.agents/skills/vercel-react-native-skills/rules/monorepo-native-deps-in-app.md deleted file mode 100644 index ff85d76..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/monorepo-native-deps-in-app.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -title: Install Native Dependencies in App Directory -impact: CRITICAL -impactDescription: required for autolinking to work -tags: monorepo, native, autolinking, installation ---- - -## Install Native Dependencies in App Directory - -In a monorepo, packages with native code must be installed in the native app's -directory directly. Autolinking only scans the app's `node_modules`—it won't -find native dependencies installed in other packages. - -**Incorrect (native dep in shared package only):** - -``` -packages/ - ui/ - package.json # has react-native-reanimated - app/ - package.json # missing react-native-reanimated -``` - -Autolinking fails—native code not linked. - -**Correct (native dep in app directory):** - -``` -packages/ - ui/ - package.json # has react-native-reanimated - app/ - package.json # also has react-native-reanimated -``` - -```json -// packages/app/package.json -{ - "dependencies": { - "react-native-reanimated": "3.16.1" - } -} -``` - -Even if the shared package uses the native dependency, the app must also list it -for autolinking to detect and link the native code. diff --git a/.agents/skills/vercel-react-native-skills/rules/monorepo-single-dependency-versions.md b/.agents/skills/vercel-react-native-skills/rules/monorepo-single-dependency-versions.md deleted file mode 100644 index 1087dfa..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/monorepo-single-dependency-versions.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: Use Single Dependency Versions Across Monorepo -impact: MEDIUM -impactDescription: avoids duplicate bundles, version conflicts -tags: monorepo, dependencies, installation ---- - -## Use Single Dependency Versions Across Monorepo - -Use a single version of each dependency across all packages in your monorepo. -Prefer exact versions over ranges. Multiple versions cause duplicate code in -bundles, runtime conflicts, and inconsistent behavior across packages. - -Use a tool like syncpack to enforce this. As a last resort, use yarn resolutions -or npm overrides. - -**Incorrect (version ranges, multiple versions):** - -```json -// packages/app/package.json -{ - "dependencies": { - "react-native-reanimated": "^3.0.0" - } -} - -// packages/ui/package.json -{ - "dependencies": { - "react-native-reanimated": "^3.5.0" - } -} -``` - -**Correct (exact versions, single source of truth):** - -```json -// package.json (root) -{ - "pnpm": { - "overrides": { - "react-native-reanimated": "3.16.1" - } - } -} - -// packages/app/package.json -{ - "dependencies": { - "react-native-reanimated": "3.16.1" - } -} - -// packages/ui/package.json -{ - "dependencies": { - "react-native-reanimated": "3.16.1" - } -} -``` - -Use your package manager's override/resolution feature to enforce versions at -the root. When adding dependencies, specify exact versions without `^` or `~`. diff --git a/.agents/skills/vercel-react-native-skills/rules/navigation-native-navigators.md b/.agents/skills/vercel-react-native-skills/rules/navigation-native-navigators.md deleted file mode 100644 index 035c5fd..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/navigation-native-navigators.md +++ /dev/null @@ -1,188 +0,0 @@ ---- -title: Use Native Navigators for Navigation -impact: HIGH -impactDescription: native performance, platform-appropriate UI -tags: navigation, react-navigation, expo-router, native-stack, tabs ---- - -## Use Native Navigators for Navigation - -Always use native navigators instead of JS-based ones. Native navigators use -platform APIs (UINavigationController on iOS, Fragment on Android) for better -performance and native behavior. - -**For stacks:** Use `@react-navigation/native-stack` or expo-router's default -stack (which uses native-stack). Avoid `@react-navigation/stack`. - -**For tabs:** Use `react-native-bottom-tabs` (native) or expo-router's native -tabs. Avoid `@react-navigation/bottom-tabs` when native feel matters. - -### Stack Navigation - -**Incorrect (JS stack navigator):** - -```tsx -import { createStackNavigator } from '@react-navigation/stack' - -const Stack = createStackNavigator() - -function App() { - return ( - - - - - ) -} -``` - -**Correct (native stack with react-navigation):** - -```tsx -import { createNativeStackNavigator } from '@react-navigation/native-stack' - -const Stack = createNativeStackNavigator() - -function App() { - return ( - - - - - ) -} -``` - -**Correct (expo-router uses native stack by default):** - -```tsx -// app/_layout.tsx -import { Stack } from 'expo-router' - -export default function Layout() { - return -} -``` - -### Tab Navigation - -**Incorrect (JS bottom tabs):** - -```tsx -import { createBottomTabNavigator } from '@react-navigation/bottom-tabs' - -const Tab = createBottomTabNavigator() - -function App() { - return ( - - - - - ) -} -``` - -**Correct (native bottom tabs with react-navigation):** - -```tsx -import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation' - -const Tab = createNativeBottomTabNavigator() - -function App() { - return ( - - ({ sfSymbol: 'house' }), - }} - /> - ({ sfSymbol: 'gear' }), - }} - /> - - ) -} -``` - -**Correct (expo-router native tabs):** - -```tsx -// app/(tabs)/_layout.tsx -import { NativeTabs } from 'expo-router/unstable-native-tabs' - -export default function TabLayout() { - return ( - - - Home - - - - Settings - - - - ) -} -``` - -On iOS, native tabs automatically enable `contentInsetAdjustmentBehavior` on the -first `ScrollView` at the root of each tab screen, so content scrolls correctly -behind the translucent tab bar. If you need to disable this, use -`disableAutomaticContentInsets` on the trigger. - -### Prefer Native Header Options Over Custom Components - -**Incorrect (custom header component):** - -```tsx - , - }} -/> -``` - -**Correct (native header options):** - -```tsx - -``` - -Native headers support iOS large titles, search bars, blur effects, and proper -safe area handling automatically. - -### Why Native Navigators - -- **Performance**: Native transitions and gestures run on the UI thread -- **Platform behavior**: Automatic iOS large titles, Android material design -- **System integration**: Scroll-to-top on tab tap, PiP avoidance, proper safe - areas -- **Accessibility**: Platform accessibility features work automatically - -Reference: - -- [React Navigation Native Stack](https://reactnavigation.org/docs/native-stack-navigator) -- [React Native Bottom Tabs with React Navigation](https://oss.callstack.com/react-native-bottom-tabs/docs/guides/usage-with-react-navigation) -- [React Native Bottom Tabs with Expo Router](https://oss.callstack.com/react-native-bottom-tabs/docs/guides/usage-with-expo-router) -- [Expo Router Native Tabs](https://docs.expo.dev/router/advanced/native-tabs) diff --git a/.agents/skills/vercel-react-native-skills/rules/react-compiler-destructure-functions.md b/.agents/skills/vercel-react-native-skills/rules/react-compiler-destructure-functions.md deleted file mode 100644 index f76c25a..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/react-compiler-destructure-functions.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: Destructure Functions Early in Render (React Compiler) -impact: HIGH -impactDescription: stable references, fewer re-renders -tags: rerender, hooks, performance, react-compiler ---- - -## Destructure Functions Early in Render - -This rule is only applicable if you are using the React Compiler. - -Destructure functions from hooks at the top of render scope. Never dot into -objects to call functions. Destructured functions are stable references; dotting -creates new references and breaks memoization. - -**Incorrect (dotting into object):** - -```tsx -import { useRouter } from 'expo-router' - -function SaveButton(props) { - const router = useRouter() - - // bad: react-compiler will key the cache on "props" and "router", which are objects that change each render - const handlePress = () => { - props.onSave() - router.push('/success') // unstable reference - } - - return -} -``` - -**Correct (destructure early):** - -```tsx -import { useRouter } from 'expo-router' - -function SaveButton({ onSave }) { - const { push } = useRouter() - - // good: react-compiler will key on push and onSave - const handlePress = () => { - onSave() - push('/success') // stable reference - } - - return -} -``` diff --git a/.agents/skills/vercel-react-native-skills/rules/react-compiler-reanimated-shared-values.md b/.agents/skills/vercel-react-native-skills/rules/react-compiler-reanimated-shared-values.md deleted file mode 100644 index 0dcbaf4..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/react-compiler-reanimated-shared-values.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -title: Use .get() and .set() for Reanimated Shared Values (not .value) -impact: LOW -impactDescription: required for React Compiler compatibility -tags: reanimated, react-compiler, shared-values ---- - -## Use .get() and .set() for Shared Values with React Compiler - -With React Compiler enabled, use `.get()` and `.set()` instead of reading or -writing `.value` directly on Reanimated shared values. The compiler can't track -property access—explicit methods ensure correct behavior. - -**Incorrect (breaks with React Compiler):** - -```tsx -import { useSharedValue } from 'react-native-reanimated' - -function Counter() { - const count = useSharedValue(0) - - const increment = () => { - count.value = count.value + 1 // opts out of react compiler - } - - return ); + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + }); + + it("blocks the click handler when disabled", async () => { + const handleClick = vi.fn(); + const user = userEvent.setup(); + const { container } = render( + , + ); + const button = container.querySelector("button") as HTMLButtonElement; + await user.click(button); + + expect(handleClick).not.toHaveBeenCalled(); + }); }); diff --git a/__tests__/card.test.tsx b/__tests__/card.test.tsx new file mode 100644 index 0000000..cfe9d71 --- /dev/null +++ b/__tests__/card.test.tsx @@ -0,0 +1,90 @@ +import { render } from "@testing-library/react"; +import { createRef } from "react"; +import { describe, expect, it } from "vitest"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/atoms/card"; + +describe("Card", () => { + it("renders its children", () => { + const { getByText } = render(card content); + expect(getByText("card content")).toBeInTheDocument(); + }); + + it("forwards the ref to the underlying div", () => { + const ref = createRef(); + render(card); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + + it("merges custom className", () => { + const { container } = render(card); + expect(container.firstElementChild?.className).toContain("my-custom"); + }); +}); + +describe("CardHeader", () => { + it("renders its children", () => { + const { getByText } = render(header content); + expect(getByText("header content")).toBeInTheDocument(); + }); + + it("forwards the ref to the underlying div", () => { + const ref = createRef(); + render(header); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + + it("merges custom className", () => { + const { container } = render( + header, + ); + expect(container.firstElementChild?.className).toContain("my-custom"); + }); +}); + +describe("CardTitle", () => { + it("renders its children as an h3", () => { + const { container, getByText } = render( + title content, + ); + expect(getByText("title content")).toBeInTheDocument(); + expect(container.querySelector("h3")).not.toBeNull(); + }); + + it("forwards the ref to the underlying heading", () => { + const ref = createRef(); + render(title); + expect(ref.current).toBeInstanceOf(HTMLHeadingElement); + }); + + it("merges custom className", () => { + const { container } = render( + title, + ); + expect(container.querySelector("h3")?.className).toContain("my-custom"); + }); +}); + +describe("CardContent", () => { + it("renders its children", () => { + const { getByText } = render(content body); + expect(getByText("content body")).toBeInTheDocument(); + }); + + it("forwards the ref to the underlying div", () => { + const ref = createRef(); + render(content); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + + it("merges custom className", () => { + const { container } = render( + content, + ); + expect(container.firstElementChild?.className).toContain("my-custom"); + }); +}); diff --git a/__tests__/consent.test.ts b/__tests__/consent.test.ts new file mode 100644 index 0000000..a38e3d4 --- /dev/null +++ b/__tests__/consent.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { readTelemetryConsent, writeTelemetryConsent } from "@/lib/consent"; + +const CONSENT_KEY = "workload_cookie_consent"; + +describe("readTelemetryConsent", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("returns null when the key is missing", () => { + expect(readTelemetryConsent()).toBeNull(); + }); + + it("returns true when telemetry is true", () => { + localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: true })); + expect(readTelemetryConsent()).toBe(true); + }); + + it("returns false when telemetry is false", () => { + localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: false })); + expect(readTelemetryConsent()).toBe(false); + }); + + it("returns null when the stored value is malformed JSON", () => { + localStorage.setItem(CONSENT_KEY, "{not-json"); + expect(readTelemetryConsent()).toBeNull(); + }); + + it("returns null when the stored value is JSON null", () => { + localStorage.setItem(CONSENT_KEY, JSON.stringify(null)); + expect(readTelemetryConsent()).toBeNull(); + }); + + it("returns null when the stored value is a JSON array", () => { + localStorage.setItem(CONSENT_KEY, JSON.stringify([1, 2, 3])); + expect(readTelemetryConsent()).toBeNull(); + }); + + it("returns null when telemetry is not a boolean", () => { + localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: "yes" })); + expect(readTelemetryConsent()).toBeNull(); + }); +}); + +describe("writeTelemetryConsent", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("writes both telemetry and a numeric timestamp", () => { + writeTelemetryConsent(true); + const stored = JSON.parse(localStorage.getItem(CONSENT_KEY) as string); + expect(stored.telemetry).toBe(true); + expect(typeof stored.timestamp).toBe("number"); + }); +}); diff --git a/__tests__/form-field.test.tsx b/__tests__/form-field.test.tsx new file mode 100644 index 0000000..575fcdc --- /dev/null +++ b/__tests__/form-field.test.tsx @@ -0,0 +1,32 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { FormField } from "@/components/molecules/form-field"; + +describe("FormField", () => { + it("associates the label with the input by id", () => { + const { getByLabelText } = render( + , + ); + expect(getByLabelText("Hours")).toBeInTheDocument(); + }); + + it("renders the labelIcon and icon", () => { + const { getByTestId } = render( + } + icon={} + />, + ); + expect(getByTestId("label-icon")).toBeInTheDocument(); + expect(getByTestId("input-icon")).toBeInTheDocument(); + }); + + it("merges custom className", () => { + const { container } = render( + , + ); + expect(container.firstElementChild?.className).toContain("my-custom"); + }); +}); diff --git a/__tests__/google-ad.test.tsx b/__tests__/google-ad.test.tsx index 1d05ae9..2d7d478 100644 --- a/__tests__/google-ad.test.tsx +++ b/__tests__/google-ad.test.tsx @@ -50,4 +50,15 @@ describe("GoogleAd", () => { const { container } = render(); expect(container.querySelector(".my-custom-class")).toBeTruthy(); }); + + it("swallows the error when pushing to adsbygoogle throws", () => { + vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); + (window as Record).adsbygoogle = { + push: () => { + throw new Error("blocked"); + }, + }; + expect(() => render()).not.toThrow(); + (window as Record).adsbygoogle = undefined; + }); }); diff --git a/__tests__/input.test.tsx b/__tests__/input.test.tsx new file mode 100644 index 0000000..12dd8da --- /dev/null +++ b/__tests__/input.test.tsx @@ -0,0 +1,42 @@ +import { render } from "@testing-library/react"; +import { createRef } from "react"; +import { describe, expect, it } from "vitest"; +import { Input } from "@/components/atoms/input"; + +describe("Input", () => { + it("renders without an icon", () => { + const { container } = render(); + expect(container.querySelector("svg")).toBeNull(); + const input = container.querySelector("input"); + expect(input?.className).not.toContain("pl-12"); + }); + + it("renders with an icon and applies the icon padding class", () => { + const { container } = render( + } placeholder="with icon" />, + ); + expect( + container.querySelector('[data-testid="input-icon"]'), + ).not.toBeNull(); + const input = container.querySelector("input"); + expect(input?.className).toContain("pl-12"); + }); + + it("forwards the ref to the underlying input", () => { + const ref = createRef(); + render(); + expect(ref.current).toBeInstanceOf(HTMLInputElement); + }); + + it("merges custom className", () => { + const { container } = render(); + expect(container.querySelector("input")?.className).toContain("my-custom"); + }); + + it("passes the type prop through", () => { + const { container } = render(); + expect(container.querySelector("input")?.getAttribute("type")).toBe( + "email", + ); + }); +}); diff --git a/__tests__/label.test.tsx b/__tests__/label.test.tsx new file mode 100644 index 0000000..457e36c --- /dev/null +++ b/__tests__/label.test.tsx @@ -0,0 +1,27 @@ +import { render } from "@testing-library/react"; +import { createRef } from "react"; +import { describe, expect, it } from "vitest"; +import { Label } from "@/components/atoms/label"; + +describe("Label", () => { + it("renders its children", () => { + const { getByText } = render(); + expect(getByText("label content")).toBeInTheDocument(); + }); + + it("associates with a control via htmlFor", () => { + const { getByText } = render(); + expect(getByText("field label")).toHaveAttribute("for", "field-id"); + }); + + it("forwards the ref to the underlying label", () => { + const ref = createRef(); + render(); + expect(ref.current).toBeInstanceOf(HTMLLabelElement); + }); + + it("merges custom className", () => { + const { getByText } = render(); + expect(getByText("label").className).toContain("my-custom"); + }); +}); diff --git a/__tests__/layout.test.tsx b/__tests__/layout.test.tsx new file mode 100644 index 0000000..23d0b18 --- /dev/null +++ b/__tests__/layout.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import RootLayout, { metadata, viewport } from "@/app/layout"; + +describe("RootLayout", () => { + it("renders its children", () => { + render( + +
layout child
+
, + ); + expect(screen.getByText("layout child")).toBeInTheDocument(); + }); +}); + +describe("metadata", () => { + it("exposes the expected title, description and social metadata", () => { + expect(metadata.title).toEqual({ + default: "WorkLoad | Calculadora Inteligente de Horas e Salário", + template: "%s | WorkLoad", + }); + expect(metadata.description).toBe( + "Calcule sua jornada de trabalho, horas extras, adicional noturno e salário CLT de forma simples, rápida e precisa.", + ); + expect(metadata.alternates).toEqual({ canonical: "/" }); + expect(metadata.openGraph?.url).toBe("https://workload.devrma.com"); + expect(metadata.twitter?.card).toBe("summary_large_image"); + }); +}); + +describe("viewport", () => { + it("does not disable pinch-to-zoom", () => { + expect(viewport).not.toHaveProperty("maximumScale"); + expect(viewport).not.toHaveProperty("userScalable"); + }); + + it("exposes the expected width, initial scale and theme colors", () => { + expect(viewport.width).toBe("device-width"); + expect(viewport.initialScale).toBe(1); + expect(viewport.themeColor).toEqual([ + { media: "(prefers-color-scheme: light)", color: "#ffffff" }, + { media: "(prefers-color-scheme: dark)", color: "#0a0a0a" }, + ]); + }); +}); diff --git a/__tests__/manifest.test.ts b/__tests__/manifest.test.ts new file mode 100644 index 0000000..7e1e806 --- /dev/null +++ b/__tests__/manifest.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import manifest from "@/app/manifest"; + +describe("manifest", () => { + it("returns the expected web app manifest", () => { + expect(manifest()).toEqual({ + name: "WorkLoad - Calculadora de Horas", + short_name: "WorkLoad", + description: + "Calcule sua jornada de trabalho de forma simples e intuitiva.", + start_url: "/", + display: "standalone", + background_color: "#0a0a0a", + theme_color: "#6366f1", + icons: [ + { + src: "/icon-192x192.png", + sizes: "192x192", + type: "image/png", + }, + { + src: "/icon-512x512.png", + sizes: "512x512", + type: "image/png", + }, + ], + }); + }); +}); diff --git a/__tests__/robots.test.ts b/__tests__/robots.test.ts new file mode 100644 index 0000000..7c08c46 --- /dev/null +++ b/__tests__/robots.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import robots from "@/app/robots"; + +describe("robots", () => { + it("returns the expected robots rules", () => { + expect(robots()).toEqual({ + rules: { + userAgent: "*", + allow: "/", + }, + sitemap: "https://workload.devrma.com/sitemap.xml", + }); + }); +}); diff --git a/__tests__/sitemap.test.ts b/__tests__/sitemap.test.ts new file mode 100644 index 0000000..b625ac1 --- /dev/null +++ b/__tests__/sitemap.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import sitemap from "@/app/sitemap"; + +describe("sitemap", () => { + it("returns the expected sitemap entries", () => { + const result = sitemap(); + expect(result).toHaveLength(1); + expect(result[0]?.url).toBe("https://workload.devrma.com"); + expect(result[0]?.changeFrequency).toBe("weekly"); + expect(result[0]?.priority).toBe(1); + expect(result[0]?.lastModified).toBeInstanceOf(Date); + }); +}); diff --git a/__tests__/stat-box.test.tsx b/__tests__/stat-box.test.tsx new file mode 100644 index 0000000..6f1d4f6 --- /dev/null +++ b/__tests__/stat-box.test.tsx @@ -0,0 +1,76 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { StatBox } from "@/components/molecules/stat-box"; + +describe("StatBox", () => { + it("renders with the default variant", () => { + const { container, getByText } = render( + , + ); + expect(getByText("Total")).toBeInTheDocument(); + expect(getByText("10h")).toBeInTheDocument(); + expect(container.firstElementChild?.className).toContain("border-blue-100"); + }); + + it("renders with the success variant", () => { + const { container } = render( + , + ); + expect(container.firstElementChild?.className).toContain( + "border-emerald-100", + ); + }); + + it("renders with the warning variant", () => { + const { container } = render( + , + ); + expect(container.firstElementChild?.className).toContain( + "border-amber-100", + ); + }); + + it("renders with the danger variant", () => { + const { container } = render( + , + ); + expect(container.firstElementChild?.className).toContain("border-red-100"); + }); + + it("renders with the purple variant", () => { + const { container } = render( + , + ); + expect(container.firstElementChild?.className).toContain( + "border-purple-100", + ); + }); + + it("renders subValue only when provided", () => { + const { queryByText, rerender } = render( + , + ); + expect(queryByText("extra info")).toBeNull(); + + rerender(); + expect(queryByText("extra info")).not.toBeNull(); + }); + + it("renders the icon", () => { + const { getByTestId } = render( + } + />, + ); + expect(getByTestId("stat-icon")).toBeInTheDocument(); + }); + + it("merges custom className", () => { + const { container } = render( + , + ); + expect(container.firstElementChild?.className).toContain("my-custom"); + }); +}); diff --git a/__tests__/theme-provider.test.tsx b/__tests__/theme-provider.test.tsx new file mode 100644 index 0000000..296a2a6 --- /dev/null +++ b/__tests__/theme-provider.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ThemeProvider } from "@/components/theme-provider"; + +describe("ThemeProvider", () => { + it("renders its children through next-themes", () => { + render( + +
child content
+
, + ); + expect(screen.getByText("child content")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/use-salary-calculator.test.ts b/__tests__/use-salary-calculator.test.ts index 90633bf..ec59366 100644 --- a/__tests__/use-salary-calculator.test.ts +++ b/__tests__/use-salary-calculator.test.ts @@ -167,7 +167,9 @@ describe("useSalaryCalculator", () => { }); expect(result.current.extraGains[0].value).toBe(0); - expect(result.current.stats.totalValue).toBe(result.current.stats.netSalary); + expect(result.current.stats.totalValue).toBe( + result.current.stats.netSalary, + ); }); it("leaves the list untouched when updating an unknown id", () => { @@ -220,7 +222,9 @@ describe("useSalaryCalculator", () => { result.current.setMonthlyHours(0); }); - expect(result.current.stats.hourlyRate).toBe(result.current.stats.totalValue); + expect(result.current.stats.hourlyRate).toBe( + result.current.stats.totalValue, + ); expect(Number.isFinite(result.current.stats.hourlyRate)).toBe(true); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 856168a..db911cc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,11 +9,17 @@ export default defineConfig({ globals: true, setupFiles: ["./vitest.setup.ts"], exclude: ["**/node_modules/**", "**/tests/e2e/**"], - reporters: ["default", "junit"], + reporters: process.env.CI ? ["default", "junit"] : ["default"], outputFile: "./coverage/junit.xml", coverage: { provider: "v8", reporter: ["text", "html", "lcov"], + include: [ + "app/**/*.{ts,tsx}", + "components/**/*.{ts,tsx}", + "hooks/**/*.ts", + "lib/**/*.ts", + ], exclude: [ "node_modules/**", "tests/e2e/**", diff --git a/vitest.setup.ts b/vitest.setup.ts index 2bd05e9..15e3620 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -6,6 +6,10 @@ afterEach(() => { cleanup(); }); +vi.mock("next/font/google", () => ({ + Inter: () => ({ className: "font-inter" }), +})); + Object.defineProperty(window, "matchMedia", { writable: true, value: vi.fn().mockImplementation((query) => ({ From ac2b316321962fbc93acfdebfb379b185cd17166 Mon Sep 17 00:00:00 2001 From: Rafael Martins Date: Sat, 1 Aug 2026 17:21:01 -0300 Subject: [PATCH 11/15] test(e2e): stop a test from passing without asserting anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- playwright.config.ts | 3 ++- tests/e2e/google-tracking.spec.ts | 6 ++++-- tests/e2e/salary-calculator.spec.ts | 7 ------- tests/e2e/work-calculator.spec.ts | 7 ------- 4 files changed, 6 insertions(+), 17 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index f90e6cc..c6135e4 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -24,7 +24,7 @@ export default defineConfig({ localStorage: [ { name: "workload_cookie_consent", - value: JSON.stringify({ telemetry: true, timestamp: Date.now() }), + value: JSON.stringify({ telemetry: true, timestamp: 0 }), }, ], }, @@ -51,5 +51,6 @@ export default defineConfig({ command: process.env.CI ? "npm run start" : "npm run dev", url: "http://localhost:3000", reuseExistingServer: !process.env.CI, + timeout: 120_000, }, }); diff --git a/tests/e2e/google-tracking.spec.ts b/tests/e2e/google-tracking.spec.ts index 1579350..4f9bafa 100644 --- a/tests/e2e/google-tracking.spec.ts +++ b/tests/e2e/google-tracking.spec.ts @@ -29,8 +29,10 @@ test.describe("Google Tracking & Ads", () => { test("should show side ads on desktop after delay", async ({ page }) => { const viewport = page.viewportSize(); - const isDesktop = viewport && viewport.width >= 1536; - if (!isDesktop) return; + test.skip( + !viewport || viewport.width < 1536, + "side ads only render from the 2xl breakpoint up", + ); await page.click('button:has-text("Aceitar Tudo")'); diff --git a/tests/e2e/salary-calculator.spec.ts b/tests/e2e/salary-calculator.spec.ts index e43a639..cd16507 100644 --- a/tests/e2e/salary-calculator.spec.ts +++ b/tests/e2e/salary-calculator.spec.ts @@ -3,13 +3,6 @@ import { expect, test } from "@playwright/test"; test.describe("Salary Calculator (Custo da Hora)", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); - - const consentBanner = page.getByText("Respeitamos sua privacidade"); - if (await consentBanner.isVisible()) { - await page.click('button:has-text("Aceitar Tudo")'); - await expect(consentBanner).not.toBeVisible(); - } - await page.click('button:has-text("Custo da Hora")'); }); diff --git a/tests/e2e/work-calculator.spec.ts b/tests/e2e/work-calculator.spec.ts index cccbbfa..be05d89 100644 --- a/tests/e2e/work-calculator.spec.ts +++ b/tests/e2e/work-calculator.spec.ts @@ -3,13 +3,6 @@ import { expect, test } from "@playwright/test"; test.describe("Work Calculator (Jornada)", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); - - const consentBanner = page.getByText("Respeitamos sua privacidade"); - if (await consentBanner.isVisible()) { - await page.click('button:has-text("Aceitar Tudo")'); - await expect(consentBanner).not.toBeVisible(); - } - await page.click('button:has-text("Jornada")'); }); From 7ab2c7260fad4d9cafbd0dfadfdad7bc3469ed14 Mon Sep 17 00:00:00 2001 From: Rafael Martins Date: Sat, 1 Aug 2026 17:21:48 -0300 Subject: [PATCH 12/15] test: assert the twitter card without narrowing the metadata union `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 --- __tests__/layout.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/layout.test.tsx b/__tests__/layout.test.tsx index 23d0b18..d878c14 100644 --- a/__tests__/layout.test.tsx +++ b/__tests__/layout.test.tsx @@ -24,7 +24,7 @@ describe("metadata", () => { ); expect(metadata.alternates).toEqual({ canonical: "/" }); expect(metadata.openGraph?.url).toBe("https://workload.devrma.com"); - expect(metadata.twitter?.card).toBe("summary_large_image"); + expect(metadata.twitter).toMatchObject({ card: "summary_large_image" }); }); }); From f88145983100f572862390a8844ab57ccde2ae08 Mon Sep 17 00:00:00 2001 From: Rafael Martins Date: Sat, 1 Aug 2026 17:38:30 -0300 Subject: [PATCH 13/15] perf: replace two independent one-second timers with one shared clock `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 --- __tests__/use-current-time.test.ts | 42 ++++++++++++++++++++++++++++++ hooks/use-current-time.ts | 20 ++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 __tests__/use-current-time.test.ts create mode 100644 hooks/use-current-time.ts diff --git a/__tests__/use-current-time.test.ts b/__tests__/use-current-time.test.ts new file mode 100644 index 0000000..11b5510 --- /dev/null +++ b/__tests__/use-current-time.test.ts @@ -0,0 +1,42 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useCurrentTime } from "@/hooks/use-current-time"; + +describe("useCurrentTime", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("exposes the current date once mounted", () => { + const { result } = renderHook(() => useCurrentTime()); + + expect(result.current).toBeInstanceOf(Date); + }); + + it("advances every second", () => { + const { result } = renderHook(() => useCurrentTime()); + const firstTick = result.current; + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(Number(result.current)).toBeGreaterThan(Number(firstTick)); + }); + + it("stops ticking after unmount", () => { + const { result, unmount } = renderHook(() => useCurrentTime()); + const lastTick = result.current; + + unmount(); + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(result.current).toBe(lastTick); + }); +}); diff --git a/hooks/use-current-time.ts b/hooks/use-current-time.ts new file mode 100644 index 0000000..fbc1b03 --- /dev/null +++ b/hooks/use-current-time.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const TICK_INTERVAL_MS = 1000; + +export function useCurrentTime(): Date | null { + const [currentTime, setCurrentTime] = useState(null); + + useEffect(() => { + setCurrentTime(new Date()); + const timer = setInterval( + () => setCurrentTime(new Date()), + TICK_INTERVAL_MS, + ); + return () => clearInterval(timer); + }, []); + + return currentTime; +} From dc6b275b86d92450462ebc4a9195a4f9b70d83b6 Mon Sep 17 00:00:00 2001 From: Rafael Martins Date: Sat, 1 Aug 2026 17:38:30 -0300 Subject: [PATCH 14/15] refactor: break the calculators into Atomic Design components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 ``, 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 --- __tests__/calculator-layout.test.tsx | 31 ++ __tests__/collapsible-panel.test.tsx | 36 ++ __tests__/copy-button.test.tsx | 108 ++++ __tests__/date-time-input.test.tsx | 135 +++++ __tests__/duration-row.test.tsx | 46 ++ __tests__/extra-entry-list.test.tsx | 50 ++ __tests__/extra-entry-row.test.tsx | 73 +++ __tests__/hero-panel.test.tsx | 47 ++ __tests__/journey-form.test.tsx | 162 ++++++ __tests__/masked-input.test.tsx | 161 ++++++ __tests__/page.test.tsx | 117 +++++ __tests__/salary-calculator.test.tsx | 98 ++++ __tests__/tax-details-panel.test.tsx | 212 ++++++++ __tests__/work-calculator.test.tsx | 262 ++++++++++ __tests__/work-summary.test.tsx | 98 ++++ app/page.tsx | 23 +- components/atoms/masked-input.tsx | 84 +++ components/molecules/collapsible-panel.tsx | 35 ++ components/molecules/copy-button.tsx | 80 +++ components/molecules/date-time-input.tsx | 121 ++--- components/molecules/duration-row.tsx | 39 ++ components/molecules/extra-entry-list.tsx | 46 ++ components/molecules/extra-entry-row.tsx | 68 +++ components/molecules/hero-panel.tsx | 65 +++ components/organisms/journey-form.tsx | 246 +++++++++ components/organisms/salary-calculator.tsx | 195 +++++++ components/organisms/tax-details-panel.tsx | 136 +++++ components/organisms/work-calculator.tsx | 291 +++++++++++ components/organisms/work-summary.tsx | 98 ++++ components/salary-calculator.tsx | 387 -------------- components/templates/calculator-layout.tsx | 40 ++ components/work-calculator.tsx | 573 --------------------- 32 files changed, 3113 insertions(+), 1050 deletions(-) create mode 100644 __tests__/calculator-layout.test.tsx create mode 100644 __tests__/collapsible-panel.test.tsx create mode 100644 __tests__/copy-button.test.tsx create mode 100644 __tests__/date-time-input.test.tsx create mode 100644 __tests__/duration-row.test.tsx create mode 100644 __tests__/extra-entry-list.test.tsx create mode 100644 __tests__/extra-entry-row.test.tsx create mode 100644 __tests__/hero-panel.test.tsx create mode 100644 __tests__/journey-form.test.tsx create mode 100644 __tests__/masked-input.test.tsx create mode 100644 __tests__/page.test.tsx create mode 100644 __tests__/salary-calculator.test.tsx create mode 100644 __tests__/tax-details-panel.test.tsx create mode 100644 __tests__/work-calculator.test.tsx create mode 100644 __tests__/work-summary.test.tsx create mode 100644 components/atoms/masked-input.tsx create mode 100644 components/molecules/collapsible-panel.tsx create mode 100644 components/molecules/copy-button.tsx create mode 100644 components/molecules/duration-row.tsx create mode 100644 components/molecules/extra-entry-list.tsx create mode 100644 components/molecules/extra-entry-row.tsx create mode 100644 components/molecules/hero-panel.tsx create mode 100644 components/organisms/journey-form.tsx create mode 100644 components/organisms/salary-calculator.tsx create mode 100644 components/organisms/tax-details-panel.tsx create mode 100644 components/organisms/work-calculator.tsx create mode 100644 components/organisms/work-summary.tsx delete mode 100644 components/salary-calculator.tsx create mode 100644 components/templates/calculator-layout.tsx delete mode 100644 components/work-calculator.tsx diff --git a/__tests__/calculator-layout.test.tsx b/__tests__/calculator-layout.test.tsx new file mode 100644 index 0000000..fa0f8a4 --- /dev/null +++ b/__tests__/calculator-layout.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { CalculatorLayout } from "@/components/templates/calculator-layout"; + +describe("CalculatorLayout", () => { + it("renders both regions", () => { + render( + Sua Jornada} + aside={

Painel destaque

} + />, + ); + + expect( + screen.getByRole("heading", { name: "Sua Jornada" }), + ).toBeInTheDocument(); + expect(screen.getByText("Painel destaque")).toBeInTheDocument(); + }); + + it("applies the calculator accent class", () => { + const { container } = render( + Formulário

} + aside={

Destaque

} + />, + ); + + expect(container.firstElementChild).toHaveClass("selection:bg-blue-500/30"); + }); +}); diff --git a/__tests__/collapsible-panel.test.tsx b/__tests__/collapsible-panel.test.tsx new file mode 100644 index 0000000..7d3af7f --- /dev/null +++ b/__tests__/collapsible-panel.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { CollapsiblePanel } from "@/components/molecules/collapsible-panel"; + +describe("CollapsiblePanel", () => { + it("hides its content while closed", () => { + render( + +

Conteúdo

+
, + ); + + expect(screen.queryByText("Conteúdo")).toBeNull(); + }); + + it("reveals its content while open", () => { + render( + +

Conteúdo

+
, + ); + + expect(screen.getByText("Conteúdo")).toBeInTheDocument(); + }); + + it("exposes the id so a trigger can reference it", () => { + render( + +

Conteúdo

+
, + ); + + const panel = document.getElementById("painel"); + expect(panel).toHaveClass("overflow-hidden", "mt-6"); + }); +}); diff --git a/__tests__/copy-button.test.tsx b/__tests__/copy-button.test.tsx new file mode 100644 index 0000000..f6a5eca --- /dev/null +++ b/__tests__/copy-button.test.tsx @@ -0,0 +1,108 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CopyButton } from "@/components/molecules/copy-button"; + +function setClipboard(clipboard: unknown) { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + writable: true, + value: clipboard, + }); +} + +describe("CopyButton", () => { + afterEach(() => { + setClipboard(undefined); + vi.useRealTimers(); + }); + + it("copies the value and reports success", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const onCopied = vi.fn(); + const user = userEvent.setup(); + setClipboard({ writeText }); + + render( + , + ); + await user.click(screen.getByRole("button", { name: "Copiar horário" })); + + expect(writeText).toHaveBeenCalledWith("18:48"); + expect(onCopied).toHaveBeenCalledOnce(); + expect(screen.getByRole("status")).toHaveTextContent("Copiado!"); + }); + + it("returns to the idle state after the confirmation delay", async () => { + vi.useFakeTimers(); + setClipboard({ writeText: vi.fn().mockResolvedValue(undefined) }); + render(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Copiar horário" })); + }); + expect(screen.getByRole("status")).toHaveTextContent("Copiado!"); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + }); + + it("warns the user when the clipboard API is unavailable", async () => { + const user = userEvent.setup(); + setClipboard(undefined); + + render(); + await user.click(screen.getByRole("button", { name: "Copiar horário" })); + + expect(screen.getByRole("status")).toHaveTextContent( + "Não foi possível copiar", + ); + }); + + it("warns the user when writing to the clipboard is rejected", async () => { + const onCopied = vi.fn(); + const user = userEvent.setup(); + setClipboard({ writeText: vi.fn().mockRejectedValue(new Error("denied")) }); + + render( + , + ); + await user.click(screen.getByRole("button", { name: "Copiar horário" })); + + expect(screen.getByRole("status")).toHaveTextContent( + "Não foi possível copiar", + ); + expect(onCopied).not.toHaveBeenCalled(); + }); + + it("clears the failure warning after its own delay", async () => { + vi.useFakeTimers(); + setClipboard(undefined); + + render(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Copiar horário" })); + }); + + act(() => { + vi.advanceTimersByTime(4000); + }); + + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + }); + + it("can be triggered from the keyboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + setClipboard({ writeText }); + + render(); + await user.tab(); + await user.keyboard("{Enter}"); + + expect(writeText).toHaveBeenCalledWith("18:48"); + }); +}); diff --git a/__tests__/date-time-input.test.tsx b/__tests__/date-time-input.test.tsx new file mode 100644 index 0000000..3e54a10 --- /dev/null +++ b/__tests__/date-time-input.test.tsx @@ -0,0 +1,135 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { format } from "date-fns"; +import { LogIn } from "lucide-react"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { DateTimeInput } from "@/components/molecules/date-time-input"; + +function InputHarness({ + initialValue = "2026-02-01T08:00", + onChange, +}: { + initialValue?: string; + onChange?: (value: string) => void; +}) { + const [value, setValue] = useState(initialValue); + + return ( + { + setValue(next); + onChange?.(next); + }} + /> + ); +} + +describe("DateTimeInput", () => { + it("labels the date field through the visible label and the time field explicitly", () => { + render(); + + expect(screen.getByLabelText("Entrada")).toHaveValue("01/02/2026"); + expect(screen.getByLabelText("Hora para Entrada")).toHaveValue("08:00"); + }); + + it("reports a new date keeping the current time", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const dateField = screen.getByLabelText("Entrada"); + await user.clear(dateField); + await user.type(dateField, "15032026"); + + expect(onChange).toHaveBeenCalledWith("2026-03-15T08:00"); + }); + + it("reports a new time keeping the current date", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const timeField = screen.getByLabelText("Hora para Entrada"); + await user.clear(timeField); + await user.type(timeField, "0930"); + + expect(onChange).toHaveBeenCalledWith("2026-02-01T09:30"); + }); + + it("restores the previous date when an impossible one is typed", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const dateField = screen.getByLabelText("Entrada"); + await user.clear(dateField); + await user.type(dateField, "31022026"); + await user.tab(); + + expect(dateField).toHaveValue("01/02/2026"); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("restores the previous time when an impossible one is typed", async () => { + const user = userEvent.setup(); + render(); + + const timeField = screen.getByLabelText("Hora para Entrada"); + await user.clear(timeField); + await user.type(timeField, "2599"); + await user.tab(); + + expect(timeField).toHaveValue("08:00"); + }); + + it("falls back to today when there is no date yet", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + expect(screen.getByLabelText("Entrada")).toHaveValue(""); + + await user.type(screen.getByLabelText("Hora para Entrada"), "0715"); + + expect(onChange).toHaveBeenCalledWith( + `${format(new Date(), "yyyy-MM-dd")}T07:15`, + ); + }); + + it("falls back to midnight when there is no time yet", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Entrada"), "10032026"); + + expect(onChange).toHaveBeenCalledWith("2026-03-10T00:00"); + }); + + it("keeps an unrecognised date part visible instead of blanking it", () => { + render(); + + expect(screen.getByLabelText("Entrada")).toHaveValue("indefinido"); + }); + + it("uses the provided id for the date field", () => { + render( + , + ); + + expect(screen.getByLabelText("Saída Real")).toHaveAttribute( + "id", + "saida-real", + ); + }); +}); diff --git a/__tests__/duration-row.test.tsx b/__tests__/duration-row.test.tsx new file mode 100644 index 0000000..f5750e5 --- /dev/null +++ b/__tests__/duration-row.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import { Zap } from "lucide-react"; +import { describe, expect, it } from "vitest"; +import { DurationRow } from "@/components/molecules/duration-row"; + +describe("DurationRow", () => { + it("shows hours and minutes for the given amount", () => { + render( + , + ); + + expect(screen.getByText("Extra 50%")).toBeInTheDocument(); + expect(screen.getByText("2h 30m")).toBeInTheDocument(); + }); + + it("shows a zeroed duration when there is nothing to report", () => { + render( + , + ); + + expect(screen.getByText("0h 0m")).toBeInTheDocument(); + }); + + it("rounds fractional minutes", () => { + render( + , + ); + + expect(screen.getByText("0h 60m")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/extra-entry-list.test.tsx b/__tests__/extra-entry-list.test.tsx new file mode 100644 index 0000000..6a9314b --- /dev/null +++ b/__tests__/extra-entry-list.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ExtraEntryList } from "@/components/molecules/extra-entry-list"; + +describe("ExtraEntryList", () => { + it("renders its title, its entries and the add action", () => { + render( + +

Plano de Saúde

+
, + ); + + expect(screen.getByText("Outros Descontos")).toBeInTheDocument(); + expect(screen.getByText("Plano de Saúde")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Adicionar desconto" }), + ).toHaveTextContent("Adicionar"); + expect(document.getElementById("extra-deductions-list")).toContainElement( + screen.getByText("Plano de Saúde"), + ); + }); + + it("adds an entry when the add action is pressed", async () => { + const onAdd = vi.fn(); + const user = userEvent.setup(); + render( + + {null} + , + ); + + const addButton = screen.getByRole("button", { name: "Adicionar ganho" }); + expect(addButton).toHaveClass("text-emerald-600"); + + await user.click(addButton); + expect(onAdd).toHaveBeenCalledOnce(); + }); +}); diff --git a/__tests__/extra-entry-row.test.tsx b/__tests__/extra-entry-row.test.tsx new file mode 100644 index 0000000..fdab87c --- /dev/null +++ b/__tests__/extra-entry-row.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { ExtraEntryRow } from "@/components/molecules/extra-entry-row"; + +function RowHarness({ onRemove }: { onRemove?: () => void }) { + const [name, setName] = useState(""); + const [value, setValue] = useState(0); + + return ( + onRemove?.()} + /> + ); +} + +describe("ExtraEntryRow", () => { + it("labels every control for assistive technology", () => { + render(); + + expect(screen.getByLabelText("Descrição do desconto")).toBeInTheDocument(); + expect(screen.getByLabelText("Valor do desconto")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Remover desconto" }), + ).toBeInTheDocument(); + expect( + screen.getByPlaceholderText("Nome (ex: Plano de Saúde)"), + ).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Valor")).toBeInTheDocument(); + }); + + it("keeps the typed description", async () => { + const user = userEvent.setup(); + render(); + + await user.type( + screen.getByLabelText("Descrição do desconto"), + "Plano Odonto", + ); + + expect(screen.getByLabelText("Descrição do desconto")).toHaveValue( + "Plano Odonto", + ); + }); + + it("formats the typed amount as currency", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Valor do desconto"), "5000"); + + expect(screen.getByLabelText("Valor do desconto")).toHaveValue("50,00"); + }); + + it("asks to be removed when the remove button is pressed", async () => { + const onRemove = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Remover desconto" })); + + expect(onRemove).toHaveBeenCalledOnce(); + }); +}); diff --git a/__tests__/hero-panel.test.tsx b/__tests__/hero-panel.test.tsx new file mode 100644 index 0000000..23c2c1e --- /dev/null +++ b/__tests__/hero-panel.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from "@testing-library/react"; +import { Clock } from "lucide-react"; +import { describe, expect, it } from "vitest"; +import { HeroPanel } from "@/components/molecules/hero-panel"; + +describe("HeroPanel", () => { + it("renders the label and the highlighted value", () => { + render( + , + ); + + expect(screen.getByText("FALTAM")).toBeInTheDocument(); + expect(screen.getByText("01:23:45")).toBeInTheDocument(); + }); + + it("renders badge, children and footer when provided", () => { + render( + 10:00:00} + footer={

Resumo Financeiro

} + > +

por minuto

+
, + ); + + expect(screen.getByText("10:00:00")).toBeInTheDocument(); + expect(screen.getByText("por minuto")).toBeInTheDocument(); + expect(screen.getByText("Resumo Financeiro")).toBeInTheDocument(); + }); + + it("omits the footer separator when there is no footer", () => { + render( + , + ); + + expect(document.querySelector(".border-t")).toBeNull(); + }); +}); diff --git a/__tests__/journey-form.test.tsx b/__tests__/journey-form.test.tsx new file mode 100644 index 0000000..9a0cd19 --- /dev/null +++ b/__tests__/journey-form.test.tsx @@ -0,0 +1,162 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { JourneyForm } from "@/components/organisms/journey-form"; + +const DEFAULT_WORK_MINUTES = 8 * 60 + 48; + +function JourneyHarness({ + onReset = vi.fn(), + onManualExitChange, +}: { + onReset?: () => void; + onManualExitChange?: (manual: boolean) => void; +}) { + const [workMinutes, setWorkMinutes] = useState(DEFAULT_WORK_MINUTES); + const [firstTierRate, setFirstTierRate] = useState(50); + const [extraTierRate, setExtraTierRate] = useState(100); + const [entry, setEntry] = useState("2026-02-02T08:00"); + const [lunchStart, setLunchStart] = useState("2026-02-02T12:00"); + const [lunchEnd, setLunchEnd] = useState("2026-02-02T13:00"); + const [exitValue, setExitValue] = useState("2026-02-02T17:48"); + const [isManualExit, setIsManualExit] = useState(false); + + return ( + { + setIsManualExit(manual); + onManualExitChange?.(manual); + }} + onReset={onReset} + /> + ); +} + +describe("JourneyForm", () => { + it("renders every journey moment as a labelled field", () => { + render(); + + expect( + screen.getByRole("heading", { name: "Sua Jornada" }), + ).toBeInTheDocument(); + expect(screen.getByLabelText("Entrada")).toBeInTheDocument(); + expect(screen.getByLabelText("Saída Almoço")).toBeInTheDocument(); + expect(screen.getByLabelText("Volta Almoço")).toBeInTheDocument(); + expect(screen.getByLabelText("Saída Real")).toBeInTheDocument(); + }); + + it("keeps the settings panel collapsed until requested", async () => { + const user = userEvent.setup(); + render(); + + const settingsToggle = screen.getByRole("button", { + name: "Configurações da Jornada", + }); + expect(settingsToggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByLabelText("Tempo de Trabalho Diário")).toBeNull(); + + await user.click(settingsToggle); + + expect(settingsToggle).toHaveAttribute("aria-expanded", "true"); + expect( + screen.getByLabelText("Tempo de Trabalho Diário"), + ).toBeInTheDocument(); + }); + + it("shows the daily journey as a masked duration and accepts a new one", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Configurações da Jornada" }), + ); + const journeyField = screen.getByLabelText("Tempo de Trabalho Diário"); + expect(journeyField).toHaveValue("08:48"); + + await user.clear(journeyField); + await user.type(journeyField, "0800"); + await user.tab(); + + expect(journeyField).toHaveValue("08:00"); + }); + + it("restores the daily journey when an incomplete duration is left behind", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Configurações da Jornada" }), + ); + const journeyField = screen.getByLabelText("Tempo de Trabalho Diário"); + await user.clear(journeyField); + await user.type(journeyField, "9"); + await user.tab(); + + expect(journeyField).toHaveValue("08:48"); + }); + + it("lets both overtime rates be adjusted", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Configurações da Jornada" }), + ); + + const firstTierField = screen.getByLabelText("Adicional até 2h extras (%)"); + await user.clear(firstTierField); + await user.type(firstTierField, "75"); + expect(firstTierField).toHaveValue(75); + + const extraTierField = screen.getByLabelText("Adicional acima de 2h (%)"); + await user.clear(extraTierField); + await user.type(extraTierField, "120"); + expect(extraTierField).toHaveValue(120); + }); + + it("switches between automatic and manual exit", async () => { + const onManualExitChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const autoButton = screen.getByRole("button", { name: "AUTO" }); + const manualButton = screen.getByRole("button", { name: "MANUAL" }); + expect(autoButton).toHaveAttribute("aria-pressed", "true"); + expect(manualButton).toHaveAttribute("aria-pressed", "false"); + + await user.click(manualButton); + + expect(onManualExitChange).toHaveBeenCalledWith(true); + expect(manualButton).toHaveAttribute("aria-pressed", "true"); + + await user.click(autoButton); + + expect(onManualExitChange).toHaveBeenLastCalledWith(false); + }); + + it("asks for a reset when the reset action is used", async () => { + const onReset = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Resetar Horários" })); + + expect(onReset).toHaveBeenCalledOnce(); + }); +}); diff --git a/__tests__/masked-input.test.tsx b/__tests__/masked-input.test.tsx new file mode 100644 index 0000000..992c211 --- /dev/null +++ b/__tests__/masked-input.test.tsx @@ -0,0 +1,161 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { MaskedInput } from "@/components/atoms/masked-input"; + +const TIME_GROUPS = [2, 2] as const; +const DATE_GROUPS = [2, 2, 4] as const; + +const isRealTime = (masked: string) => { + const [hours, minutes] = masked.split(":").map(Number); + return hours >= 0 && hours < 24 && minutes >= 0 && minutes < 60; +}; + +function TimeHarness({ onCommit }: { onCommit?: (value: string) => void }) { + const [value, setValue] = useState("08:00"); + + return ( + <> + { + setValue(committed); + onCommit?.(committed); + }} + /> + + + ); +} + +describe("MaskedInput", () => { + it("inserts the separator while digits are typed", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Hora"); + + await user.clear(input); + await user.type(input, "0"); + expect(input).toHaveValue("0"); + + await user.type(input, "8"); + expect(input).toHaveValue("08"); + + await user.type(input, "4"); + expect(input).toHaveValue("08:4"); + + await user.type(input, "5"); + expect(input).toHaveValue("08:45"); + }); + + it("ignores non-digit characters and extra digits", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Hora"); + + await user.clear(input); + await user.type(input, "1a2b3c49"); + + expect(input).toHaveValue("12:34"); + }); + + it("commits the value as soon as the mask is complete and valid", async () => { + const onCommit = vi.fn(); + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Hora"); + + await user.clear(input); + await user.type(input, "1015"); + + expect(onCommit).toHaveBeenCalledWith("10:15"); + }); + + it("never commits an invalid complete value", async () => { + const onCommit = vi.fn(); + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Hora"); + + await user.clear(input); + await user.type(input, "9999"); + + expect(input).toHaveValue("99:99"); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("restores the committed value when blurred while incomplete", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Hora"); + + await user.clear(input); + await user.type(input, "07"); + await user.tab(); + + expect(input).toHaveValue("08:00"); + }); + + it("restores the committed value when blurred while invalid", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Hora"); + + await user.clear(input); + await user.type(input, "2588"); + await user.tab(); + + expect(input).toHaveValue("08:00"); + }); + + it("keeps a valid value after blurring", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Hora"); + + await user.clear(input); + await user.type(input, "1830"); + await user.tab(); + + expect(input).toHaveValue("18:30"); + }); + + it("follows the value when it changes outside the field", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Definir externamente" }), + ); + + expect(screen.getByLabelText("Hora")).toHaveValue("23:59"); + }); + + it("supports masks with more than one separator", async () => { + const onCommit = vi.fn(); + const user = userEvent.setup(); + render( + true} + onCommit={onCommit} + />, + ); + const input = screen.getByLabelText("Data"); + + await user.type(input, "01022026"); + + expect(input).toHaveValue("01/02/2026"); + expect(onCommit).toHaveBeenCalledWith("01/02/2026"); + }); +}); diff --git a/__tests__/page.test.tsx b/__tests__/page.test.tsx new file mode 100644 index 0000000..e266d39 --- /dev/null +++ b/__tests__/page.test.tsx @@ -0,0 +1,117 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderToString } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import Home from "@/app/page"; +import { safeGAEvent } from "@/lib/analytics"; + +vi.mock("@/lib/analytics", () => ({ + safeGAEvent: vi.fn(), +})); + +const themeState: { resolvedTheme: string | undefined; setTheme: () => void } = + { + resolvedTheme: undefined, + setTheme: vi.fn(), + }; + +vi.mock("next-themes", () => ({ + useTheme: () => themeState, +})); + +vi.mock("@/components/organisms/work-calculator", () => ({ + WorkCalculator: () =>

Painel da jornada

, +})); + +vi.mock("@/components/organisms/salary-calculator", () => ({ + SalaryCalculator: () =>

Painel do custo da hora

, +})); + +describe("Home", () => { + beforeEach(() => { + vi.clearAllMocks(); + themeState.resolvedTheme = undefined; + }); + + it("renders the whole shell on the server instead of a blank document", () => { + const markup = renderToString(); + + expect(markup).toContain("WorkLoad"); + expect(markup).toContain("Jornada"); + expect(markup).toContain("Custo da Hora"); + expect(markup).toContain("Pular para o conteúdo principal"); + expect(markup).toContain("--:--:--"); + }); + + it("describes the application for search engines", () => { + const markup = renderToString(); + + expect(markup).toContain("application/ld+json"); + expect(markup).toContain("WebApplication"); + }); + + it("shows the live clock once the client takes over", () => { + render(); + + expect(screen.getByText(/^\d{2}:\d{2}:\d{2}$/)).toBeInTheDocument(); + }); + + it("reports the session metadata on mount", () => { + render(); + + expect(safeGAEvent).toHaveBeenCalledWith( + "session_metadata", + expect.objectContaining({ viewport_width: window.innerWidth }), + ); + }); + + it("starts on the journey view", () => { + render(); + + expect(screen.getByText("Painel da jornada")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Jornada" })).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + + it("switches to the hourly cost view and tracks it", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Custo da Hora" })); + + expect(safeGAEvent).toHaveBeenCalledWith("switch_tab", { tab: "salary" }); + expect( + await screen.findByText("Painel do custo da hora"), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Jornada" })); + + expect(safeGAEvent).toHaveBeenCalledWith("switch_tab", { tab: "work" }); + }); + + it("offers the dark theme while the light one is active", async () => { + themeState.resolvedTheme = "light"; + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTitle("Alternar tema")); + + expect(safeGAEvent).toHaveBeenCalledWith("toggle_theme", { + theme: "dark", + }); + }); + + it("offers the light theme while the dark one is active", async () => { + themeState.resolvedTheme = "dark"; + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTitle("Alternar tema")); + + expect(safeGAEvent).toHaveBeenCalledWith("toggle_theme", { + theme: "light", + }); + }); +}); diff --git a/__tests__/salary-calculator.test.tsx b/__tests__/salary-calculator.test.tsx new file mode 100644 index 0000000..0ad69d3 --- /dev/null +++ b/__tests__/salary-calculator.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SalaryCalculator } from "@/components/organisms/salary-calculator"; + +vi.mock("@/lib/analytics", () => ({ + safeGAEvent: vi.fn(), +})); + +describe("SalaryCalculator", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("shows the stored salary, the workload and the resulting hourly value", () => { + render(); + + expect( + screen.getByRole("heading", { name: "Custo da Hora" }), + ).toBeInTheDocument(); + expect(screen.getByLabelText("Salário Bruto (R$)")).toHaveValue("5.000,00"); + expect(screen.getByLabelText("Carga Horária Mensal")).toHaveValue(220); + expect(screen.getByText("Valor da Hora")).toBeInTheDocument(); + expect(screen.getByText("Resumo Financeiro")).toBeInTheDocument(); + }); + + it("summarises net salary, total received and total deductions", () => { + render(); + + expect(screen.getByText("Salário Líquido")).toBeInTheDocument(); + expect(screen.getByText("Total Recebido")).toBeInTheDocument(); + expect(screen.getByText("Total Descontos")).toBeInTheDocument(); + }); + + it("recalculates the hourly value when the salary changes", async () => { + const user = userEvent.setup(); + render(); + + const salaryField = screen.getByLabelText("Salário Bruto (R$)"); + await user.clear(salaryField); + await user.type(salaryField, "100000"); + + expect(salaryField).toHaveValue("1.000,00"); + }); + + it("leaves the workload field empty when no hours are stored", () => { + localStorage.setItem("monthlyHours", "0"); + + render(); + + expect(screen.getByLabelText("Carga Horária Mensal")).toHaveValue(null); + }); + + it("accepts a new workload", async () => { + const user = userEvent.setup(); + render(); + + const hoursField = screen.getByLabelText("Carga Horária Mensal"); + await user.clear(hoursField); + await user.type(hoursField, "200"); + + expect(hoursField).toHaveValue(200); + }); + + it("reveals the taxes and deductions panel on demand", async () => { + const user = userEvent.setup(); + render(); + + const detailsToggle = screen.getByRole("button", { + name: "Impostos e Descontos", + }); + expect(detailsToggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByLabelText("INSS (R$)")).toBeNull(); + + await user.click(detailsToggle); + + expect(detailsToggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByLabelText("INSS (R$)")).toBeInTheDocument(); + expect(screen.getByText("Outros Descontos")).toBeInTheDocument(); + expect(screen.getByText("Ganhos Extras (Líquido)")).toBeInTheDocument(); + }); + + it("adds a deduction row through the panel", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Impostos e Descontos" }), + ); + await user.click( + screen.getByRole("button", { name: "Adicionar desconto" }), + ); + + expect( + screen.getByPlaceholderText("Nome (ex: Plano de Saúde)"), + ).toBeInTheDocument(); + }); +}); diff --git a/__tests__/tax-details-panel.test.tsx b/__tests__/tax-details-panel.test.tsx new file mode 100644 index 0000000..a74cb2e --- /dev/null +++ b/__tests__/tax-details-panel.test.tsx @@ -0,0 +1,212 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TaxDetailsPanel } from "@/components/organisms/tax-details-panel"; +import { safeGAEvent } from "@/lib/analytics"; + +vi.mock("@/lib/analytics", () => ({ + safeGAEvent: vi.fn(), +})); + +const baseProps = { + manualInss: null, + manualIrrf: null, + autoInss: 500, + autoIrrf: 250, + extraDeductions: [], + extraGains: [], +}; + +describe("TaxDetailsPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("suggests the calculated taxes as placeholders", () => { + render( + , + ); + + expect(screen.getByLabelText("INSS (R$)")).toHaveAttribute( + "placeholder", + "500,00", + ); + expect(screen.getByLabelText("IRRF (R$)")).toHaveAttribute( + "placeholder", + "250,00", + ); + }); + + it("shows the manual taxes when they override the calculated ones", () => { + render( + , + ); + + expect(screen.getByLabelText("INSS (R$)")).toHaveValue("400,00"); + expect(screen.getByLabelText("IRRF (R$)")).toHaveValue("180,00"); + }); + + it("reports a manual tax amount and clears it back to automatic", async () => { + const onManualInssChange = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + const inssField = screen.getByLabelText("INSS (R$)"); + expect(inssField).toHaveValue("400,00"); + + await user.clear(inssField); + expect(onManualInssChange).toHaveBeenCalledWith(null); + + await user.type(inssField, "1"); + expect(onManualInssChange).toHaveBeenLastCalledWith(4000.01); + }); + + it("reports a manual income tax amount", async () => { + const onManualIrrfChange = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + await user.type(screen.getByLabelText("IRRF (R$)"), "7"); + + expect(onManualIrrfChange).toHaveBeenLastCalledWith(0.07); + }); + + it("tracks the creation of a deduction", async () => { + const onAddExtra = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + await user.click( + screen.getByRole("button", { name: "Adicionar desconto" }), + ); + + expect(onAddExtra).toHaveBeenCalledWith("deduction"); + expect(safeGAEvent).toHaveBeenCalledWith("add_deduction"); + }); + + it("tracks the creation of a gain", async () => { + const onAddExtra = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Adicionar ganho" })); + + expect(onAddExtra).toHaveBeenCalledWith("gain"); + expect(safeGAEvent).toHaveBeenCalledWith("add_gain"); + }); + + it("edits and removes an existing deduction", async () => { + const onUpdateExtra = vi.fn(); + const onRemoveExtra = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + await user.type(screen.getByLabelText("Descrição do desconto"), "V"); + expect(onUpdateExtra).toHaveBeenCalledWith("one", "deduction", "name", "V"); + + await user.type(screen.getByLabelText("Valor do desconto"), "5"); + expect(onUpdateExtra).toHaveBeenLastCalledWith( + "one", + "deduction", + "value", + 0.05, + ); + + await user.click(screen.getByRole("button", { name: "Remover desconto" })); + expect(onRemoveExtra).toHaveBeenCalledWith("one", "deduction"); + }); + + it("edits and removes an existing gain", async () => { + const onUpdateExtra = vi.fn(); + const onRemoveExtra = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + await user.type(screen.getByLabelText("Descrição do ganho"), "V"); + expect(onUpdateExtra).toHaveBeenCalledWith("two", "gain", "name", "V"); + + await user.type(screen.getByLabelText("Valor do ganho"), "5"); + expect(onUpdateExtra).toHaveBeenLastCalledWith( + "two", + "gain", + "value", + 0.05, + ); + + await user.click(screen.getByRole("button", { name: "Remover ganho" })); + expect(onRemoveExtra).toHaveBeenCalledWith("two", "gain"); + }); +}); diff --git a/__tests__/work-calculator.test.tsx b/__tests__/work-calculator.test.tsx new file mode 100644 index 0000000..3b98af5 --- /dev/null +++ b/__tests__/work-calculator.test.tsx @@ -0,0 +1,262 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + calculateTimerData, + WorkCalculator, +} from "@/components/organisms/work-calculator"; +import { safeGAEvent } from "@/lib/analytics"; + +vi.mock("@/lib/analytics", () => ({ + safeGAEvent: vi.fn(), +})); + +const ENTRY = "2099-06-01T08:00"; +const LUNCH_START = "2099-06-01T12:00"; +const LUNCH_END = "2099-06-01T13:00"; +const SUGGESTED_EXIT = "2099-06-01T17:48"; + +function storeJourney() { + localStorage.setItem("entry", ENTRY); + localStorage.setItem("lunchStart", LUNCH_START); + localStorage.setItem("lunchEnd", LUNCH_END); +} + +function setClipboard(clipboard: unknown) { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + writable: true, + value: clipboard, + }); +} + +describe("calculateTimerData", () => { + const baseInput = { + currentTime: new Date("2099-06-01T10:00:00"), + displayExit: SUGGESTED_EXIT, + entry: ENTRY, + workMinutes: 528, + isManualExit: false, + balanceMinutes: 0, + balanceSign: 0, + totalWorkedMinutes: 528, + }; + + it("counts down to the exit", () => { + const timer = calculateTimerData(baseInput); + + expect(timer).toMatchObject({ + label: "FALTAM", + time: "07:48:00", + isOvertime: false, + entryLabel: "08:00", + exitLabel: "17:48", + }); + expect(timer.progress).toBeCloseTo((120 / 528) * 100); + }); + + it("counts up once the exit has passed", () => { + const timer = calculateTimerData({ + ...baseInput, + currentTime: new Date("2099-06-01T18:48:00"), + }); + + expect(timer).toMatchObject({ + label: "HORA EXTRA", + time: "01:00:00", + isOvertime: true, + }); + expect(timer.progress).toBe(100); + }); + + it("waits for the client clock before counting", () => { + const timer = calculateTimerData({ ...baseInput, currentTime: null }); + + expect(timer).toMatchObject({ + label: "FALTAM", + time: "--:--:--", + progress: 0, + }); + }); + + it("shows the final balance in manual mode", () => { + const timer = calculateTimerData({ + ...baseInput, + isManualExit: true, + balanceMinutes: 75, + balanceSign: 1, + }); + + expect(timer).toMatchObject({ + label: "BALANÇO FINAL", + time: "+01:15:00", + isOvertime: true, + progress: 100, + }); + }); + + it("shows a negative final balance in manual mode", () => { + const timer = calculateTimerData({ + ...baseInput, + isManualExit: true, + balanceMinutes: -30, + balanceSign: -1, + }); + + expect(timer).toMatchObject({ + label: "BALANÇO FINAL", + time: "-00:30:00", + isOvertime: false, + }); + }); + + it("treats an exactly balanced day as on target, not overtime", () => { + const timer = calculateTimerData({ + ...baseInput, + isManualExit: true, + balanceMinutes: 0, + balanceSign: 0, + }); + + expect(timer).toMatchObject({ + label: "BALANÇO FINAL", + time: "+00:00:00", + isOvertime: false, + }); + }); + + it("never lets the manual progress leave the 0-100 range", () => { + const overworked = calculateTimerData({ + ...baseInput, + isManualExit: true, + totalWorkedMinutes: 900, + }); + expect(overworked.progress).toBe(100); + + const withoutJourney = calculateTimerData({ + ...baseInput, + isManualExit: true, + workMinutes: 0, + }); + expect(withoutJourney.progress).toBe(0); + }); + + it("never lets the countdown progress leave the 0-100 range", () => { + const withoutJourney = calculateTimerData({ ...baseInput, workMinutes: 0 }); + expect(withoutJourney.progress).toBe(0); + + const beforeEntry = calculateTimerData({ + ...baseInput, + currentTime: new Date("2099-06-01T06:00:00"), + }); + expect(beforeEntry.progress).toBe(0); + }); + + it("waits when the exit is not a real moment", () => { + const timer = calculateTimerData({ ...baseInput, displayExit: "" }); + + expect(timer).toMatchObject({ + label: "Aguardando...", + time: "00:00:00", + progress: 0, + exitLabel: "--:--", + }); + }); + + it("keeps the countdown without progress when the entry is unusable", () => { + const timer = calculateTimerData({ ...baseInput, entry: "invalido" }); + + expect(timer).toMatchObject({ + label: "FALTAM", + progress: 0, + entryLabel: "--:--", + }); + }); +}); + +describe("WorkCalculator", () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + storeJourney(); + }); + + it("renders the journey form and the countdown side by side", () => { + render(); + + expect( + screen.getByRole("heading", { name: "Sua Jornada" }), + ).toBeInTheDocument(); + expect(screen.getByText("FALTAM")).toBeInTheDocument(); + expect(screen.getByText("Saída Sugerida")).toBeInTheDocument(); + expect(screen.getAllByText("17:48").length).toBeGreaterThan(0); + }); + + it("shows the day balance and the overtime tiers", () => { + render(); + + expect( + screen.getByRole("heading", { name: "Balanço do Dia" }), + ).toBeInTheDocument(); + expect(screen.getByText("+0h 0m")).toBeInTheDocument(); + expect(screen.getByText("Extra 50%")).toBeInTheDocument(); + expect(screen.getByText("Extra 100%")).toBeInTheDocument(); + }); + + it("copies the exit time and tracks the event", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + setClipboard({ writeText }); + render(); + + await user.click(screen.getByRole("button", { name: "Copiar horário" })); + + expect(writeText).toHaveBeenCalledWith("17:48"); + expect(safeGAEvent).toHaveBeenCalledWith("copy_to_clipboard", { + value: "17:48", + }); + setClipboard(undefined); + }); + + it("switches to the final balance when manual mode is chosen", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "MANUAL" })); + + expect(safeGAEvent).toHaveBeenCalledWith("toggle_manual_mode", { + value: "manual", + }); + expect(screen.getByText("BALANÇO FINAL")).toBeInTheDocument(); + expect(screen.getAllByText("Saída Real").length).toBeGreaterThan(0); + + await user.click(screen.getByRole("button", { name: "AUTO" })); + + expect(safeGAEvent).toHaveBeenCalledWith("toggle_manual_mode", { + value: "auto", + }); + }); + + it("turns manual as soon as the real exit is edited", async () => { + const user = userEvent.setup(); + render(); + + const exitTimeField = screen.getByLabelText("Hora para Saída Real"); + await user.clear(exitTimeField); + await user.type(exitTimeField, "1900"); + + expect(screen.getByRole("button", { name: "MANUAL" })).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + + it("restores the defaults and tracks the reset", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Resetar Horários" })); + + expect(safeGAEvent).toHaveBeenCalledWith("reset_defaults"); + }); +}); diff --git a/__tests__/work-summary.test.tsx b/__tests__/work-summary.test.tsx new file mode 100644 index 0000000..8c51ed6 --- /dev/null +++ b/__tests__/work-summary.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { WorkSummary } from "@/components/organisms/work-summary"; + +describe("WorkSummary", () => { + it("celebrates a positive balance", () => { + render( + , + ); + + expect(screen.getByText("+2h 15m")).toBeInTheDocument(); + expect(screen.getByText("Horas extras acumuladas")).toBeInTheDocument(); + }); + + it("reports a negative balance as a debt", () => { + render( + , + ); + + expect(screen.getByText("-0h 45m")).toBeInTheDocument(); + expect(screen.getByText("Horas em débito hoje")).toBeInTheDocument(); + }); + + it("names the overtime tiers after the configured rates", () => { + render( + , + ); + + expect(screen.getByText("Extra 75%")).toBeInTheDocument(); + expect(screen.getByText("Extra 110%")).toBeInTheDocument(); + expect(screen.getByText("Adic. Noturno")).toBeInTheDocument(); + expect(screen.getByText("1h 0m")).toBeInTheDocument(); + expect(screen.getByText("0h 30m")).toBeInTheDocument(); + expect(screen.getByText("1h 30m")).toBeInTheDocument(); + }); + + it("does not present the overtime tiers as statutory rates", () => { + render( + , + ); + + expect(screen.queryByText("Extras (CLT)")).toBeNull(); + expect( + screen.getByRole("heading", { name: "Extras e Adicionais" }), + ).toBeInTheDocument(); + }); +}); + +describe("WorkSummary balance sign", () => { + it("reads an exactly balanced day as on target", () => { + render( + , + ); + + expect(screen.getByText("+0h 0m")).toBeInTheDocument(); + expect(screen.queryByText("Horas em débito hoje")).toBeNull(); + }); +}); diff --git a/app/page.tsx b/app/page.tsx index 8f6a8d7..60d6613 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,20 +6,21 @@ import { AnimatePresence, motion } from "motion/react"; import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; import { Button } from "@/components/atoms/button"; -import SalaryCalculator from "@/components/salary-calculator"; -import WorkCalculator from "@/components/work-calculator"; +import { SalaryCalculator } from "@/components/organisms/salary-calculator"; +import { WorkCalculator } from "@/components/organisms/work-calculator"; +import { useCurrentTime } from "@/hooks/use-current-time"; import { safeGAEvent } from "@/lib/analytics"; type View = "work" | "salary"; +const PLACEHOLDER_CLOCK = "--:--:--"; + export default function Home() { const [activeView, setActiveView] = useState("work"); - const [mounted, setMounted] = useState(false); - const [currentTime, setCurrentTime] = useState(new Date()); + const currentTime = useCurrentTime(); const { setTheme, resolvedTheme } = useTheme(); useEffect(() => { - requestAnimationFrame(() => setMounted(true)); safeGAEvent("session_metadata", { screen_width: window.screen.width, screen_height: window.screen.height, @@ -31,14 +32,6 @@ export default function Home() { }); }, []); - useEffect(() => { - if (!mounted) return; - const timer = setInterval(() => setCurrentTime(new Date()), 1000); - return () => clearInterval(timer); - }, [mounted]); - - if (!mounted) return null; - return ( <>