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: React Compiler compatible**
-
-```tsx
-import { useSharedValue } from 'react-native-reanimated'
-
-function Counter() {
- const count = useSharedValue(0)
-
- const increment = () => {
- count.set(count.get() + 1)
- }
-
- return
-}
-```
-
-See the
-
-[Reanimated docs](https://docs.swmansion.com/react-native-reanimated/docs/core/useSharedValue/#react-compiler-support)
-
-for more.
-
----
-
-## 9. User Interface
-
-**Impact: MEDIUM**
-
-Native UI patterns for images, menus, modals, styling, and
-platform-consistent interfaces.
-
-### 9.1 Measuring View Dimensions
-
-**Impact: MEDIUM (synchronous measurement, avoid unnecessary re-renders)**
-
-Use both `useLayoutEffect` (synchronous) and `onLayout` (for updates). The sync
-
-measurement gives you the initial size immediately; `onLayout` keeps it current
-
-when the view changes. For non-primitive states, use a dispatch updater to
-
-compare values and avoid unnecessary re-renders.
-
-**Height only:**
-
-```tsx
-import { useLayoutEffect, useRef, useState } from 'react'
-import { View, LayoutChangeEvent } from 'react-native'
-
-function MeasuredBox({ children }: { children: React.ReactNode }) {
- const ref = useRef(null)
- const [height, setHeight] = useState(undefined)
-
- useLayoutEffect(() => {
- // Sync measurement on mount (RN 0.82+)
- const rect = ref.current?.getBoundingClientRect()
- if (rect) setHeight(rect.height)
- // Pre-0.82: ref.current?.measure((x, y, w, h) => setHeight(h))
- }, [])
-
- const onLayout = (e: LayoutChangeEvent) => {
- setHeight(e.nativeEvent.layout.height)
- }
-
- return (
-
- {children}
-
- )
-}
-```
-
-**Both dimensions:**
-
-```tsx
-import { useLayoutEffect, useRef, useState } from 'react'
-import { View, LayoutChangeEvent } from 'react-native'
-
-type Size = { width: number; height: number }
-
-function MeasuredBox({ children }: { children: React.ReactNode }) {
- const ref = useRef(null)
- const [size, setSize] = useState(undefined)
-
- useLayoutEffect(() => {
- const rect = ref.current?.getBoundingClientRect()
- if (rect) setSize({ width: rect.width, height: rect.height })
- }, [])
-
- const onLayout = (e: LayoutChangeEvent) => {
- const { width, height } = e.nativeEvent.layout
- setSize((prev) => {
- // for non-primitive states, compare values before firing a re-render
- if (prev?.width === width && prev?.height === height) return prev
- return { width, height }
- })
- }
-
- return (
-
- {children}
-
- )
-}
-```
-
-Use functional setState to compare—don't read state directly in the callback.
-
-### 9.2 Modern React Native Styling Patterns
-
-**Impact: MEDIUM (consistent design, smoother borders, cleaner layouts)**
-
-Follow these styling patterns for cleaner, more consistent React Native code.
-
-**Always use `borderCurve: 'continuous'` with `borderRadius`:**
-
-**Use `gap` instead of margin for spacing between elements:**
-
-```tsx
-// Incorrect – margin on children
-
- Title
- Subtitle
-
-
-// Correct – gap on parent
-
- Title
- Subtitle
-
-```
-
-**Use `padding` for space within, `gap` for space between:**
-
-```tsx
-
- First
- Second
-
-```
-
-**Use `experimental_backgroundImage` for linear gradients:**
-
-```tsx
-// Incorrect – third-party gradient library
-
-
-// Correct – native CSS gradient syntax
-
-```
-
-**Use CSS `boxShadow` string syntax for shadows:**
-
-```tsx
-// Incorrect – legacy shadow objects or elevation
-{ shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1 }
-{ elevation: 4 }
-
-// Correct – CSS box-shadow syntax
-{ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' }
-```
-
-**Avoid multiple font sizes – use weight and color for emphasis:**
-
-```tsx
-// Incorrect – varying font sizes for hierarchy
-Title
-Subtitle
-Caption
-
-// Correct – consistent size, vary weight and color
-Title
-Subtitle
-Caption
-```
-
-Limiting font sizes creates visual consistency. Use `fontWeight` (bold/semibold)
-
-and grayscale colors for hierarchy instead.
-
-### 9.3 Use contentInset for Dynamic ScrollView Spacing
-
-**Impact: LOW (smoother updates, no layout recalculation)**
-
-When adding space to the top or bottom of a ScrollView that may change
-
-(keyboard, toolbars, dynamic content), use `contentInset` instead of padding.
-
-Changing `contentInset` doesn't trigger layout recalculation—it adjusts the
-
-scroll area without re-rendering content.
-
-**Incorrect: padding causes layout recalculation**
-
-```tsx
-function Feed({ bottomOffset }: { bottomOffset: number }) {
- return (
-
- {children}
-
- )
-}
-// Changing bottomOffset triggers full layout recalculation
-```
-
-**Correct: contentInset for dynamic spacing**
-
-```tsx
-function Feed({ bottomOffset }: { bottomOffset: number }) {
- return (
-
- {children}
-
- )
-}
-// Changing bottomOffset only adjusts scroll bounds
-```
-
-Use `scrollIndicatorInsets` alongside `contentInset` to keep the scroll
-
-indicator aligned. For static spacing that never changes, padding is fine.
-
-### 9.4 Use contentInsetAdjustmentBehavior for Safe Areas
-
-**Impact: MEDIUM (native safe area handling, no layout shifts)**
-
-Use `contentInsetAdjustmentBehavior="automatic"` on the root ScrollView instead of wrapping content in SafeAreaView or manual padding. This lets iOS handle safe area insets natively with proper scroll behavior.
-
-**Incorrect: SafeAreaView wrapper**
-
-```tsx
-import { SafeAreaView, ScrollView, View, Text } from 'react-native'
-
-function MyScreen() {
- return (
-
-
-
- Content
-
-
-
- )
-}
-```
-
-**Incorrect: manual safe area padding**
-
-```tsx
-import { ScrollView, View, Text } from 'react-native'
-import { useSafeAreaInsets } from 'react-native-safe-area-context'
-
-function MyScreen() {
- const insets = useSafeAreaInsets()
-
- return (
-
-
- Content
-
-
- )
-}
-```
-
-**Correct: native content inset adjustment**
-
-```tsx
-import { ScrollView, View, Text } from 'react-native'
-
-function MyScreen() {
- return (
-
-
- Content
-
-
- )
-}
-```
-
-The native approach handles dynamic safe areas (keyboard, toolbars) and allows content to scroll behind the status bar naturally.
-
-### 9.5 Use expo-image for Optimized Images
-
-**Impact: HIGH (memory efficiency, caching, blurhash placeholders, progressive loading)**
-
-Use `expo-image` instead of React Native's `Image`. It provides memory-efficient caching, blurhash placeholders, progressive loading, and better performance for lists.
-
-**Incorrect: React Native Image**
-
-```tsx
-import { Image } from 'react-native'
-
-function Avatar({ url }: { url: string }) {
- return
-}
-```
-
-**Correct: expo-image**
-
-```tsx
-import { Image } from 'expo-image'
-
-function Avatar({ url }: { url: string }) {
- return
-}
-```
-
-**With blurhash placeholder:**
-
-```tsx
-
-```
-
-**With priority and caching:**
-
-```tsx
-
-```
-
-**Key props:**
-
-- `placeholder` — Blurhash or thumbnail while loading
-
-- `contentFit` — `cover`, `contain`, `fill`, `scale-down`
-
-- `transition` — Fade-in duration (ms)
-
-- `priority` — `low`, `normal`, `high`
-
-- `cachePolicy` — `memory`, `disk`, `memory-disk`, `none`
-
-- `recyclingKey` — Unique key for list recycling
-
-For cross-platform (web + native), use `SolitoImage` from `solito/image` which uses `expo-image` under the hood.
-
-Reference: [https://docs.expo.dev/versions/latest/sdk/image/](https://docs.expo.dev/versions/latest/sdk/image/)
-
-### 9.6 Use Galeria for Image Galleries and Lightbox
-
-**Impact: MEDIUM**
-
-For image galleries with lightbox (tap to fullscreen), use `@nandorojo/galeria`.
-
-It provides native shared element transitions with pinch-to-zoom, double-tap
-
-zoom, and pan-to-close. Works with any image component including `expo-image`.
-
-**Incorrect: custom modal implementation**
-
-```tsx
-function ImageGallery({ urls }: { urls: string[] }) {
- const [selected, setSelected] = useState(null)
-
- return (
- <>
- {urls.map((url) => (
- setSelected(url)}>
-
-
- ))}
- setSelected(null)}>
-
-
- >
- )
-}
-```
-
-**Correct: Galeria with expo-image**
-
-```tsx
-import { Galeria } from '@nandorojo/galeria'
-import { Image } from 'expo-image'
-
-function ImageGallery({ urls }: { urls: string[] }) {
- return (
-
- {urls.map((url, index) => (
-
-
-
- ))}
-
- )
-}
-```
-
-**Single image:**
-
-```tsx
-import { Galeria } from '@nandorojo/galeria'
-import { Image } from 'expo-image'
-
-function Avatar({ url }: { url: string }) {
- return (
-
-
-
-
-
- )
-}
-```
-
-**With low-res thumbnails and high-res fullscreen:**
-
-```tsx
-
- {lowResUrls.map((url, index) => (
-
-
-
- ))}
-
-```
-
-**With FlashList:**
-
-```tsx
-
- (
-
-
-
- )}
- numColumns={3}
- estimatedItemSize={100}
- />
-
-```
-
-Works with `expo-image`, `SolitoImage`, `react-native` Image, or any image
-
-component.
-
-Reference: [https://github.com/nandorojo/galeria](https://github.com/nandorojo/galeria)
-
-### 9.7 Use Native Menus for Dropdowns and Context Menus
-
-**Impact: HIGH (native accessibility, platform-consistent UX)**
-
-Use native platform menus instead of custom JS implementations. Native menus
-
-provide built-in accessibility, consistent platform UX, and better performance.
-
-Use [zeego](https://zeego.dev) for cross-platform native menus.
-
-**Incorrect: custom JS menu**
-
-```tsx
-import { useState } from 'react'
-import { View, Pressable, Text } from 'react-native'
-
-function MyMenu() {
- const [open, setOpen] = useState(false)
-
- return (
-
- setOpen(!open)}>
- Open Menu
-
- {open && (
-
- console.log('edit')}>
- Edit
-
- console.log('delete')}>
- Delete
-
-
- )}
-
- )
-}
-```
-
-**Correct: native menu with zeego**
-
-```tsx
-import * as DropdownMenu from 'zeego/dropdown-menu'
-
-function MyMenu() {
- return (
-
-
-
- Open Menu
-
-
-
-
- console.log('edit')}>
- Edit
-
-
- console.log('delete')}
- >
- Delete
-
-
-
- )
-}
-```
-
-**Context menu: long-press**
-
-```tsx
-import * as ContextMenu from 'zeego/context-menu'
-
-function MyContextMenu() {
- return (
-
-
-
- Long press me
-
-
-
-
- console.log('copy')}>
- Copy
-
-
- console.log('paste')}>
- Paste
-
-
-
- )
-}
-```
-
-**Checkbox items:**
-
-```tsx
-import * as DropdownMenu from 'zeego/dropdown-menu'
-
-function SettingsMenu() {
- const [notifications, setNotifications] = useState(true)
-
- return (
-
-
-
- Settings
-
-
-
-
- setNotifications((prev) => !prev)}
- >
-
- Notifications
-
-
-
- )
-}
-```
-
-**Submenus:**
-
-```tsx
-import * as DropdownMenu from 'zeego/dropdown-menu'
-
-function MenuWithSubmenu() {
- return (
-
-
-
- Options
-
-
-
-
- console.log('home')}>
- Home
-
-
-
-
- More Options
-
-
-
-
- Settings
-
-
-
- Help
-
-
-
-
-
- )
-}
-```
-
-Reference: [https://zeego.dev/components/dropdown-menu](https://zeego.dev/components/dropdown-menu)
-
-### 9.8 Use Native Modals Over JS-Based Bottom Sheets
-
-**Impact: HIGH (native performance, gestures, accessibility)**
-
-Use native `` with `presentationStyle="formSheet"` or React Navigation
-
-v7's native form sheet instead of JS-based bottom sheet libraries. Native modals
-
-have built-in gestures, accessibility, and better performance. Rely on native UI
-
-for low-level primitives.
-
-**Incorrect: JS-based bottom sheet**
-
-```tsx
-import BottomSheet from 'custom-js-bottom-sheet'
-
-function MyScreen() {
- const sheetRef = useRef(null)
-
- return (
-
-
- )
-}
-```
-
-**Correct: native Modal with formSheet**
-
-```tsx
-import { Modal, View, Text, Button } from 'react-native'
-
-function MyScreen() {
- const [visible, setVisible] = useState(false)
-
- return (
-
-
- )
-}
-```
-
-**Correct: React Navigation v7 native form sheet**
-
-```tsx
-// In your navigator
-
-```
-
-Native modals provide swipe-to-dismiss, proper keyboard avoidance, and
-
-accessibility out of the box.
-
-### 9.9 Use Pressable Instead of Touchable Components
-
-**Impact: LOW (modern API, more flexible)**
-
-Never use `TouchableOpacity` or `TouchableHighlight`. Use `Pressable` from
-
-`react-native` or `react-native-gesture-handler` instead.
-
-**Incorrect: legacy Touchable components**
-
-```tsx
-import { TouchableOpacity } from 'react-native'
-
-function MyButton({ onPress }: { onPress: () => void }) {
- return (
-
- Press me
-
- )
-}
-```
-
-**Correct: Pressable**
-
-```tsx
-import { Pressable } from 'react-native'
-
-function MyButton({ onPress }: { onPress: () => void }) {
- return (
-
- Press me
-
- )
-}
-```
-
-**Correct: Pressable from gesture handler for lists**
-
-```tsx
-import { Pressable } from 'react-native-gesture-handler'
-
-function ListItem({ onPress }: { onPress: () => void }) {
- return (
-
- Item
-
- )
-}
-```
-
-Use `react-native-gesture-handler` Pressable inside scrollable lists for better
-
-gesture coordination, as long as you are using the ScrollView from
-
-`react-native-gesture-handler` as well.
-
-**For animated press states (scale, opacity changes):** Use `GestureDetector`
-
-with Reanimated shared values instead of Pressable's style callback. See the
-
-`animation-gesture-detector-press` rule.
-
----
-
-## 10. Design System
-
-**Impact: MEDIUM**
-
-Architecture patterns for building maintainable component
-libraries.
-
-### 10.1 Use Compound Components Over Polymorphic Children
-
-**Impact: MEDIUM (flexible composition, clearer API)**
-
-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
-}>Save
-
-```
-
-**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
-}>Save
-
-```
-
-**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
-}
-```
-
-**Correct (React Compiler compatible):**
-
-```tsx
-import { useSharedValue } from 'react-native-reanimated'
-
-function Counter() {
- const count = useSharedValue(0)
-
- const increment = () => {
- count.set(count.get() + 1)
- }
-
- return
-}
-```
-
-See the
-[Reanimated docs](https://docs.swmansion.com/react-native-reanimated/docs/core/useSharedValue/#react-compiler-support)
-for more.
diff --git a/.agents/skills/vercel-react-native-skills/rules/react-state-dispatcher.md b/.agents/skills/vercel-react-native-skills/rules/react-state-dispatcher.md
deleted file mode 100644
index 93e8b6d..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/react-state-dispatcher.md
+++ /dev/null
@@ -1,91 +0,0 @@
----
-title: useState Dispatch updaters for State That Depends on Current Value
-impact: MEDIUM
-impactDescription: avoids stale closures, prevents unnecessary re-renders
-tags: state, hooks, useState, callbacks
----
-
-## Use Dispatch Updaters for State That Depends on Current Value
-
-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)
-}
-```
diff --git a/.agents/skills/vercel-react-native-skills/rules/react-state-fallback.md b/.agents/skills/vercel-react-native-skills/rules/react-state-fallback.md
deleted file mode 100644
index 204f346..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/react-state-fallback.md
+++ /dev/null
@@ -1,56 +0,0 @@
----
-title: Use fallback state instead of initialState
-impact: MEDIUM
-impactDescription: reactive fallbacks without syncing
-tags: state, hooks, derived-state, props, initialState
----
-
-## Use fallback state instead of initialState
-
-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
-}
-```
diff --git a/.agents/skills/vercel-react-native-skills/rules/react-state-minimize.md b/.agents/skills/vercel-react-native-skills/rules/react-state-minimize.md
deleted file mode 100644
index 64605b6..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/react-state-minimize.md
+++ /dev/null
@@ -1,65 +0,0 @@
----
-title: Minimize State Variables and Derive Values
-impact: MEDIUM
-impactDescription: fewer re-renders, less state drift
-tags: state, derived-state, hooks, optimization
----
-
-## Minimize State Variables and Derive Values
-
-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: [Choosing the State Structure](https://react.dev/learn/choosing-the-state-structure)
diff --git a/.agents/skills/vercel-react-native-skills/rules/rendering-no-falsy-and.md b/.agents/skills/vercel-react-native-skills/rules/rendering-no-falsy-and.md
deleted file mode 100644
index 30f05d3..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/rendering-no-falsy-and.md
+++ /dev/null
@@ -1,74 +0,0 @@
----
-title: Never Use && with Potentially Falsy Values
-impact: CRITICAL
-impactDescription: prevents production crash
-tags: rendering, conditional, jsx, crash
----
-
-## Never Use && with Potentially Falsy Values
-
-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.
diff --git a/.agents/skills/vercel-react-native-skills/rules/rendering-text-in-text-component.md b/.agents/skills/vercel-react-native-skills/rules/rendering-text-in-text-component.md
deleted file mode 100644
index fd1b9f4..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/rendering-text-in-text-component.md
+++ /dev/null
@@ -1,36 +0,0 @@
----
-title: Wrap Strings in Text Components
-impact: CRITICAL
-impactDescription: prevents runtime crash
-tags: rendering, text, core
----
-
-## Wrap Strings in Text Components
-
-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}!
-
- )
-}
-```
diff --git a/.agents/skills/vercel-react-native-skills/rules/scroll-position-no-state.md b/.agents/skills/vercel-react-native-skills/rules/scroll-position-no-state.md
deleted file mode 100644
index a5760cd..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/scroll-position-no-state.md
+++ /dev/null
@@ -1,82 +0,0 @@
----
-title: Never Track Scroll Position in useState
-impact: HIGH
-impactDescription: prevents render thrashing during scroll
-tags: scroll, performance, reanimated, useRef
----
-
-## Never Track Scroll Position in useState
-
-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
-}
-```
diff --git a/.agents/skills/vercel-react-native-skills/rules/state-ground-truth.md b/.agents/skills/vercel-react-native-skills/rules/state-ground-truth.md
deleted file mode 100644
index c3c4bd9..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/state-ground-truth.md
+++ /dev/null
@@ -1,80 +0,0 @@
----
-title: State Must Represent Ground Truth
-impact: HIGH
-impactDescription: cleaner logic, easier debugging, single source of truth
-tags: state, derived-state, reanimated, hooks
----
-
-## State Must Represent Ground 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.
diff --git a/.agents/skills/vercel-react-native-skills/rules/ui-expo-image.md b/.agents/skills/vercel-react-native-skills/rules/ui-expo-image.md
deleted file mode 100644
index 72d768f..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/ui-expo-image.md
+++ /dev/null
@@ -1,66 +0,0 @@
----
-title: Use expo-image for Optimized Images
-impact: HIGH
-impactDescription: memory efficiency, caching, blurhash placeholders, progressive loading
-tags: images, performance, expo-image, ui
----
-
-## Use expo-image for Optimized Images
-
-Use `expo-image` instead of React Native's `Image`. It provides memory-efficient caching, blurhash placeholders, progressive loading, and better performance for lists.
-
-**Incorrect (React Native Image):**
-
-```tsx
-import { Image } from 'react-native'
-
-function Avatar({ url }: { url: string }) {
- return
-}
-```
-
-**Correct (expo-image):**
-
-```tsx
-import { Image } from 'expo-image'
-
-function Avatar({ url }: { url: string }) {
- return
-}
-```
-
-**With blurhash placeholder:**
-
-```tsx
-
-```
-
-**With priority and caching:**
-
-```tsx
-
-```
-
-**Key props:**
-
-- `placeholder` — Blurhash or thumbnail while loading
-- `contentFit` — `cover`, `contain`, `fill`, `scale-down`
-- `transition` — Fade-in duration (ms)
-- `priority` — `low`, `normal`, `high`
-- `cachePolicy` — `memory`, `disk`, `memory-disk`, `none`
-- `recyclingKey` — Unique key for list recycling
-
-For cross-platform (web + native), use `SolitoImage` from `solito/image` which uses `expo-image` under the hood.
-
-Reference: [expo-image](https://docs.expo.dev/versions/latest/sdk/image/)
diff --git a/.agents/skills/vercel-react-native-skills/rules/ui-image-gallery.md b/.agents/skills/vercel-react-native-skills/rules/ui-image-gallery.md
deleted file mode 100644
index ef26d96..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/ui-image-gallery.md
+++ /dev/null
@@ -1,104 +0,0 @@
----
-title: Use Galeria for Image Galleries and Lightbox
-impact: MEDIUM
-impactDescription:
- native shared element transitions, pinch-to-zoom, pan-to-close
-tags: images, gallery, lightbox, expo-image, ui
----
-
-## Use Galeria for Image Galleries and Lightbox
-
-For image galleries with lightbox (tap to fullscreen), use `@nandorojo/galeria`.
-It provides native shared element transitions with pinch-to-zoom, double-tap
-zoom, and pan-to-close. Works with any image component including `expo-image`.
-
-**Incorrect (custom modal implementation):**
-
-```tsx
-function ImageGallery({ urls }: { urls: string[] }) {
- const [selected, setSelected] = useState(null)
-
- return (
- <>
- {urls.map((url) => (
- setSelected(url)}>
-
-
- ))}
- setSelected(null)}>
-
-
- >
- )
-}
-```
-
-**Correct (Galeria with expo-image):**
-
-```tsx
-import { Galeria } from '@nandorojo/galeria'
-import { Image } from 'expo-image'
-
-function ImageGallery({ urls }: { urls: string[] }) {
- return (
-
- {urls.map((url, index) => (
-
-
-
- ))}
-
- )
-}
-```
-
-**Single image:**
-
-```tsx
-import { Galeria } from '@nandorojo/galeria'
-import { Image } from 'expo-image'
-
-function Avatar({ url }: { url: string }) {
- return (
-
-
-
-
-
- )
-}
-```
-
-**With low-res thumbnails and high-res fullscreen:**
-
-```tsx
-
- {lowResUrls.map((url, index) => (
-
-
-
- ))}
-
-```
-
-**With FlashList:**
-
-```tsx
-
- (
-
-
-
- )}
- numColumns={3}
- estimatedItemSize={100}
- />
-
-```
-
-Works with `expo-image`, `SolitoImage`, `react-native` Image, or any image
-component.
-
-Reference: [Galeria](https://github.com/nandorojo/galeria)
diff --git a/.agents/skills/vercel-react-native-skills/rules/ui-measure-views.md b/.agents/skills/vercel-react-native-skills/rules/ui-measure-views.md
deleted file mode 100644
index 8b783fe..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/ui-measure-views.md
+++ /dev/null
@@ -1,78 +0,0 @@
----
-title: Measuring View Dimensions
-impact: MEDIUM
-impactDescription: synchronous measurement, avoid unnecessary re-renders
-tags: layout, measurement, onLayout, useLayoutEffect
----
-
-## Measuring View Dimensions
-
-Use both `useLayoutEffect` (synchronous) and `onLayout` (for updates). The sync
-measurement gives you the initial size immediately; `onLayout` keeps it current
-when the view changes. For non-primitive states, use a dispatch updater to
-compare values and avoid unnecessary re-renders.
-
-**Height only:**
-
-```tsx
-import { useLayoutEffect, useRef, useState } from 'react'
-import { View, LayoutChangeEvent } from 'react-native'
-
-function MeasuredBox({ children }: { children: React.ReactNode }) {
- const ref = useRef(null)
- const [height, setHeight] = useState(undefined)
-
- useLayoutEffect(() => {
- // Sync measurement on mount (RN 0.82+)
- const rect = ref.current?.getBoundingClientRect()
- if (rect) setHeight(rect.height)
- // Pre-0.82: ref.current?.measure((x, y, w, h) => setHeight(h))
- }, [])
-
- const onLayout = (e: LayoutChangeEvent) => {
- setHeight(e.nativeEvent.layout.height)
- }
-
- return (
-
- {children}
-
- )
-}
-```
-
-**Both dimensions:**
-
-```tsx
-import { useLayoutEffect, useRef, useState } from 'react'
-import { View, LayoutChangeEvent } from 'react-native'
-
-type Size = { width: number; height: number }
-
-function MeasuredBox({ children }: { children: React.ReactNode }) {
- const ref = useRef(null)
- const [size, setSize] = useState(undefined)
-
- useLayoutEffect(() => {
- const rect = ref.current?.getBoundingClientRect()
- if (rect) setSize({ width: rect.width, height: rect.height })
- }, [])
-
- const onLayout = (e: LayoutChangeEvent) => {
- const { width, height } = e.nativeEvent.layout
- setSize((prev) => {
- // for non-primitive states, compare values before firing a re-render
- if (prev?.width === width && prev?.height === height) return prev
- return { width, height }
- })
- }
-
- return (
-
- {children}
-
- )
-}
-```
-
-Use functional setState to compare—don't read state directly in the callback.
diff --git a/.agents/skills/vercel-react-native-skills/rules/ui-menus.md b/.agents/skills/vercel-react-native-skills/rules/ui-menus.md
deleted file mode 100644
index 5168bc2..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/ui-menus.md
+++ /dev/null
@@ -1,174 +0,0 @@
----
-title: Use Native Menus for Dropdowns and Context Menus
-impact: HIGH
-impactDescription: native accessibility, platform-consistent UX
-tags: user-interface, menus, context-menus, zeego, accessibility
----
-
-## Use Native Menus for Dropdowns and Context Menus
-
-Use native platform menus instead of custom JS implementations. Native menus
-provide built-in accessibility, consistent platform UX, and better performance.
-Use [zeego](https://zeego.dev) for cross-platform native menus.
-
-**Incorrect (custom JS menu):**
-
-```tsx
-import { useState } from 'react'
-import { View, Pressable, Text } from 'react-native'
-
-function MyMenu() {
- const [open, setOpen] = useState(false)
-
- return (
-
- setOpen(!open)}>
- Open Menu
-
- {open && (
-
- console.log('edit')}>
- Edit
-
- console.log('delete')}>
- Delete
-
-
- )}
-
- )
-}
-```
-
-**Correct (native menu with zeego):**
-
-```tsx
-import * as DropdownMenu from 'zeego/dropdown-menu'
-
-function MyMenu() {
- return (
-
-
-
- Open Menu
-
-
-
-
- console.log('edit')}>
- Edit
-
-
- console.log('delete')}
- >
- Delete
-
-
-
- )
-}
-```
-
-**Context menu (long-press):**
-
-```tsx
-import * as ContextMenu from 'zeego/context-menu'
-
-function MyContextMenu() {
- return (
-
-
-
- Long press me
-
-
-
-
- console.log('copy')}>
- Copy
-
-
- console.log('paste')}>
- Paste
-
-
-
- )
-}
-```
-
-**Checkbox items:**
-
-```tsx
-import * as DropdownMenu from 'zeego/dropdown-menu'
-
-function SettingsMenu() {
- const [notifications, setNotifications] = useState(true)
-
- return (
-
-
-
- Settings
-
-
-
-
- setNotifications((prev) => !prev)}
- >
-
- Notifications
-
-
-
- )
-}
-```
-
-**Submenus:**
-
-```tsx
-import * as DropdownMenu from 'zeego/dropdown-menu'
-
-function MenuWithSubmenu() {
- return (
-
-
-
- Options
-
-
-
-
- console.log('home')}>
- Home
-
-
-
-
- More Options
-
-
-
-
- Settings
-
-
-
- Help
-
-
-
-
-
- )
-}
-```
-
-Reference: [Zeego Documentation](https://zeego.dev/components/dropdown-menu)
diff --git a/.agents/skills/vercel-react-native-skills/rules/ui-native-modals.md b/.agents/skills/vercel-react-native-skills/rules/ui-native-modals.md
deleted file mode 100644
index f560e11..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/ui-native-modals.md
+++ /dev/null
@@ -1,77 +0,0 @@
----
-title: Use Native Modals Over JS-Based Bottom Sheets
-impact: HIGH
-impactDescription: native performance, gestures, accessibility
-tags: modals, bottom-sheet, native, react-navigation
----
-
-## Use Native Modals Over JS-Based Bottom Sheets
-
-Use native `` with `presentationStyle="formSheet"` or React Navigation
-v7's native form sheet instead of JS-based bottom sheet libraries. Native modals
-have built-in gestures, accessibility, and better performance. Rely on native UI
-for low-level primitives.
-
-**Incorrect (JS-based bottom sheet):**
-
-```tsx
-import BottomSheet from 'custom-js-bottom-sheet'
-
-function MyScreen() {
- const sheetRef = useRef(null)
-
- return (
-
-
- )
-}
-```
-
-**Correct (native Modal with formSheet):**
-
-```tsx
-import { Modal, View, Text, Button } from 'react-native'
-
-function MyScreen() {
- const [visible, setVisible] = useState(false)
-
- return (
-
-
- )
-}
-```
-
-**Correct (React Navigation v7 native form sheet):**
-
-```tsx
-// In your navigator
-
-```
-
-Native modals provide swipe-to-dismiss, proper keyboard avoidance, and
-accessibility out of the box.
diff --git a/.agents/skills/vercel-react-native-skills/rules/ui-pressable.md b/.agents/skills/vercel-react-native-skills/rules/ui-pressable.md
deleted file mode 100644
index 31c3d20..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/ui-pressable.md
+++ /dev/null
@@ -1,61 +0,0 @@
----
-title: Use Pressable Instead of Touchable Components
-impact: LOW
-impactDescription: modern API, more flexible
-tags: ui, pressable, touchable, gestures
----
-
-## Use Pressable Instead of Touchable Components
-
-Never use `TouchableOpacity` or `TouchableHighlight`. Use `Pressable` from
-`react-native` or `react-native-gesture-handler` instead.
-
-**Incorrect (legacy Touchable components):**
-
-```tsx
-import { TouchableOpacity } from 'react-native'
-
-function MyButton({ onPress }: { onPress: () => void }) {
- return (
-
- Press me
-
- )
-}
-```
-
-**Correct (Pressable):**
-
-```tsx
-import { Pressable } from 'react-native'
-
-function MyButton({ onPress }: { onPress: () => void }) {
- return (
-
- Press me
-
- )
-}
-```
-
-**Correct (Pressable from gesture handler for lists):**
-
-```tsx
-import { Pressable } from 'react-native-gesture-handler'
-
-function ListItem({ onPress }: { onPress: () => void }) {
- return (
-
- Item
-
- )
-}
-```
-
-Use `react-native-gesture-handler` Pressable inside scrollable lists for better
-gesture coordination, as long as you are using the ScrollView from
-`react-native-gesture-handler` as well.
-
-**For animated press states (scale, opacity changes):** Use `GestureDetector`
-with Reanimated shared values instead of Pressable's style callback. See the
-`animation-gesture-detector-press` rule.
diff --git a/.agents/skills/vercel-react-native-skills/rules/ui-safe-area-scroll.md b/.agents/skills/vercel-react-native-skills/rules/ui-safe-area-scroll.md
deleted file mode 100644
index 79812bc..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/ui-safe-area-scroll.md
+++ /dev/null
@@ -1,65 +0,0 @@
----
-title: Use contentInsetAdjustmentBehavior for Safe Areas
-impact: MEDIUM
-impactDescription: native safe area handling, no layout shifts
-tags: safe-area, scrollview, layout
----
-
-## Use contentInsetAdjustmentBehavior for Safe Areas
-
-Use `contentInsetAdjustmentBehavior="automatic"` on the root ScrollView instead of wrapping content in SafeAreaView or manual padding. This lets iOS handle safe area insets natively with proper scroll behavior.
-
-**Incorrect (SafeAreaView wrapper):**
-
-```tsx
-import { SafeAreaView, ScrollView, View, Text } from 'react-native'
-
-function MyScreen() {
- return (
-
-
-
- Content
-
-
-
- )
-}
-```
-
-**Incorrect (manual safe area padding):**
-
-```tsx
-import { ScrollView, View, Text } from 'react-native'
-import { useSafeAreaInsets } from 'react-native-safe-area-context'
-
-function MyScreen() {
- const insets = useSafeAreaInsets()
-
- return (
-
-
- Content
-
-
- )
-}
-```
-
-**Correct (native content inset adjustment):**
-
-```tsx
-import { ScrollView, View, Text } from 'react-native'
-
-function MyScreen() {
- return (
-
-
- Content
-
-
- )
-}
-```
-
-The native approach handles dynamic safe areas (keyboard, toolbars) and allows content to scroll behind the status bar naturally.
diff --git a/.agents/skills/vercel-react-native-skills/rules/ui-scrollview-content-inset.md b/.agents/skills/vercel-react-native-skills/rules/ui-scrollview-content-inset.md
deleted file mode 100644
index bbebc3b..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/ui-scrollview-content-inset.md
+++ /dev/null
@@ -1,45 +0,0 @@
----
-title: Use contentInset for Dynamic ScrollView Spacing
-impact: LOW
-impactDescription: smoother updates, no layout recalculation
-tags: scrollview, layout, contentInset, performance
----
-
-## Use contentInset for Dynamic ScrollView Spacing
-
-When adding space to the top or bottom of a ScrollView that may change
-(keyboard, toolbars, dynamic content), use `contentInset` instead of padding.
-Changing `contentInset` doesn't trigger layout recalculation—it adjusts the
-scroll area without re-rendering content.
-
-**Incorrect (padding causes layout recalculation):**
-
-```tsx
-function Feed({ bottomOffset }: { bottomOffset: number }) {
- return (
-
- {children}
-
- )
-}
-// Changing bottomOffset triggers full layout recalculation
-```
-
-**Correct (contentInset for dynamic spacing):**
-
-```tsx
-function Feed({ bottomOffset }: { bottomOffset: number }) {
- return (
-
- {children}
-
- )
-}
-// Changing bottomOffset only adjusts scroll bounds
-```
-
-Use `scrollIndicatorInsets` alongside `contentInset` to keep the scroll
-indicator aligned. For static spacing that never changes, padding is fine.
diff --git a/.agents/skills/vercel-react-native-skills/rules/ui-styling.md b/.agents/skills/vercel-react-native-skills/rules/ui-styling.md
deleted file mode 100644
index 3908de3..0000000
--- a/.agents/skills/vercel-react-native-skills/rules/ui-styling.md
+++ /dev/null
@@ -1,87 +0,0 @@
----
-title: Modern React Native Styling Patterns
-impact: MEDIUM
-impactDescription: consistent design, smoother borders, cleaner layouts
-tags: styling, css, layout, shadows, gradients
----
-
-## Modern React Native Styling Patterns
-
-Follow these styling patterns for cleaner, more consistent React Native code.
-
-**Always use `borderCurve: 'continuous'` with `borderRadius`:**
-
-```tsx
-// Incorrect
-{ borderRadius: 12 }
-
-// Correct – smoother iOS-style corners
-{ borderRadius: 12, borderCurve: 'continuous' }
-```
-
-**Use `gap` instead of margin for spacing between elements:**
-
-```tsx
-// Incorrect – margin on children
-
- Title
- Subtitle
-
-
-// Correct – gap on parent
-
- Title
- Subtitle
-
-```
-
-**Use `padding` for space within, `gap` for space between:**
-
-```tsx
-
- First
- Second
-
-```
-
-**Use `experimental_backgroundImage` for linear gradients:**
-
-```tsx
-// Incorrect – third-party gradient library
-
-
-// Correct – native CSS gradient syntax
-
-```
-
-**Use CSS `boxShadow` string syntax for shadows:**
-
-```tsx
-// Incorrect – legacy shadow objects or elevation
-{ shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1 }
-{ elevation: 4 }
-
-// Correct – CSS box-shadow syntax
-{ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' }
-```
-
-**Avoid multiple font sizes – use weight and color for emphasis:**
-
-```tsx
-// Incorrect – varying font sizes for hierarchy
-Title
-Subtitle
-Caption
-
-// Correct – consistent size, vary weight and color
-Title
-Subtitle
-Caption
-```
-
-Limiting font sizes creates visual consistency. Use `fontWeight` (bold/semibold)
-and grayscale colors for hierarchy instead.
diff --git a/.claude/skills/vercel-react-native-skills b/.claude/skills/vercel-react-native-skills
deleted file mode 120000
index 8c98843..0000000
--- a/.claude/skills/vercel-react-native-skills
+++ /dev/null
@@ -1 +0,0 @@
-../../.agents/skills/vercel-react-native-skills
\ No newline at end of file
diff --git a/skills-lock.json b/skills-lock.json
index 73fd2d4..4ecacc6 100644
--- a/skills-lock.json
+++ b/skills-lock.json
@@ -1,106 +1,100 @@
{
- "version": 1,
- "skills": {
- "cavecrew": {
- "source": "juliusbrussee/caveman",
- "sourceType": "github",
- "skillPath": "skills/cavecrew/SKILL.md",
- "computedHash": "c5527c994fbd4c22b36714e3b124a0f167a533d114ab164fb1d35e2123533917"
- },
- "caveman": {
- "source": "juliusbrussee/caveman",
- "sourceType": "github",
- "skillPath": "skills/caveman/SKILL.md",
- "computedHash": "d18cdf73a5f5c496d681a43a6846336b110223bdffa6817b8992529c57f1a815"
- },
- "caveman-commit": {
- "source": "juliusbrussee/caveman",
- "sourceType": "github",
- "skillPath": "skills/caveman-commit/SKILL.md",
- "computedHash": "790a4eeace0be35c6691faf923518ba5bd50f1f1305d1101d09dd4971be94e00"
- },
- "caveman-compress": {
- "source": "juliusbrussee/caveman",
- "sourceType": "github",
- "skillPath": "skills/caveman-compress/SKILL.md",
- "computedHash": "84517cd4cf7a49d8d2bc1baf00f61bc359306c6ad9389756b6937eff958bd374"
- },
- "caveman-help": {
- "source": "juliusbrussee/caveman",
- "sourceType": "github",
- "skillPath": "skills/caveman-help/SKILL.md",
- "computedHash": "dd85267e76baad76995157e7b9f762dfa557cd58951ee92af0c283f48aa26537"
- },
- "caveman-review": {
- "source": "juliusbrussee/caveman",
- "sourceType": "github",
- "skillPath": "skills/caveman-review/SKILL.md",
- "computedHash": "fb7214a1c5793bae6ba8b1be4329e2e6f40dbec6dd911dfb335ad29f09c316a1"
- },
- "caveman-stats": {
- "source": "juliusbrussee/caveman",
- "sourceType": "github",
- "skillPath": "skills/caveman-stats/SKILL.md",
- "computedHash": "47ce2de3d6cb39a75047b5c962e4eb3da15594e7397c94103e9a104d42626553"
- },
- "compress": {
- "source": "JuliusBrussee/caveman",
- "sourceType": "github",
- "computedHash": "05c97bc3120108acd0b80bdef7fb4fa7c224ba83c8d384ccbc97f92e8a065918"
- },
- "gh-stack": {
- "source": "github/gh-stack",
- "sourceType": "github",
- "skillPath": "skills/gh-stack/SKILL.md",
- "computedHash": "10a4da93d82f822cbcd2eec43ec8ccf02d70bc98662b86531c6fb00de9856c65"
- },
- "multi-stage-dockerfile": {
- "source": "github/awesome-copilot",
- "sourceType": "github",
- "skillPath": "skills/multi-stage-dockerfile/SKILL.md",
- "computedHash": "698bf93a84dae18fd7bd0b1d35c21427307fde78fbdf4624be9726f5f98189ca"
- },
- "next-best-practices": {
- "source": "vercel-labs/openreview",
- "sourceType": "github",
- "skillPath": ".agents/skills/next-best-practices/SKILL.md",
- "computedHash": "db85045827ebeb83ac8dbc992b69188755eeb74dcf01cbf5e694d58aa494a106"
- },
- "next-cache-components": {
- "source": "vercel-labs/openreview",
- "sourceType": "github",
- "skillPath": ".agents/skills/next-cache-components/SKILL.md",
- "computedHash": "f835d8226c28abf012cc013928e2627c8a7a046de61127ef229be1711bd1f255"
- },
- "next-upgrade": {
- "source": "vercel-labs/openreview",
- "sourceType": "github",
- "skillPath": ".agents/skills/next-upgrade/SKILL.md",
- "computedHash": "e3aeb1f9e8a68d24df0c1c2e3c8ab97ede09a7251a2c76988318d02254346979"
- },
- "vercel-composition-patterns": {
- "source": "vercel-labs/openreview",
- "sourceType": "github",
- "skillPath": ".agents/skills/vercel-composition-patterns/SKILL.md",
- "computedHash": "bdc99908989f5d6904aae71e97b123f61ee0a4f8085101d5064f69bb7c9ac7bd"
- },
- "vercel-react-best-practices": {
- "source": "vercel-labs/openreview",
- "sourceType": "github",
- "skillPath": ".agents/skills/vercel-react-best-practices/SKILL.md",
- "computedHash": "090ba7e6666985d200d366d0683590947c8ce365976c8e79dfb06c6bdc561fec"
- },
- "vercel-react-native-skills": {
- "source": "vercel-labs/openreview",
- "sourceType": "github",
- "skillPath": ".agents/skills/vercel-react-native-skills/SKILL.md",
- "computedHash": "76d7054a1981b60efd010baca567a8073875339c236fd121bb7f547730be6310"
- },
- "web-design-guidelines": {
- "source": "vercel-labs/openreview",
- "sourceType": "github",
- "skillPath": ".agents/skills/web-design-guidelines/SKILL.md",
- "computedHash": "f3bc47f890f42a44db1007ab390709ec368e4b8c089baee6b0007182236ac474"
- }
- }
+ "version": 1,
+ "skills": {
+ "cavecrew": {
+ "source": "juliusbrussee/caveman",
+ "sourceType": "github",
+ "skillPath": "skills/cavecrew/SKILL.md",
+ "computedHash": "c5527c994fbd4c22b36714e3b124a0f167a533d114ab164fb1d35e2123533917"
+ },
+ "caveman": {
+ "source": "juliusbrussee/caveman",
+ "sourceType": "github",
+ "skillPath": "skills/caveman/SKILL.md",
+ "computedHash": "d18cdf73a5f5c496d681a43a6846336b110223bdffa6817b8992529c57f1a815"
+ },
+ "caveman-commit": {
+ "source": "juliusbrussee/caveman",
+ "sourceType": "github",
+ "skillPath": "skills/caveman-commit/SKILL.md",
+ "computedHash": "790a4eeace0be35c6691faf923518ba5bd50f1f1305d1101d09dd4971be94e00"
+ },
+ "caveman-compress": {
+ "source": "juliusbrussee/caveman",
+ "sourceType": "github",
+ "skillPath": "skills/caveman-compress/SKILL.md",
+ "computedHash": "84517cd4cf7a49d8d2bc1baf00f61bc359306c6ad9389756b6937eff958bd374"
+ },
+ "caveman-help": {
+ "source": "juliusbrussee/caveman",
+ "sourceType": "github",
+ "skillPath": "skills/caveman-help/SKILL.md",
+ "computedHash": "dd85267e76baad76995157e7b9f762dfa557cd58951ee92af0c283f48aa26537"
+ },
+ "caveman-review": {
+ "source": "juliusbrussee/caveman",
+ "sourceType": "github",
+ "skillPath": "skills/caveman-review/SKILL.md",
+ "computedHash": "fb7214a1c5793bae6ba8b1be4329e2e6f40dbec6dd911dfb335ad29f09c316a1"
+ },
+ "caveman-stats": {
+ "source": "juliusbrussee/caveman",
+ "sourceType": "github",
+ "skillPath": "skills/caveman-stats/SKILL.md",
+ "computedHash": "47ce2de3d6cb39a75047b5c962e4eb3da15594e7397c94103e9a104d42626553"
+ },
+ "compress": {
+ "source": "JuliusBrussee/caveman",
+ "sourceType": "github",
+ "computedHash": "05c97bc3120108acd0b80bdef7fb4fa7c224ba83c8d384ccbc97f92e8a065918"
+ },
+ "gh-stack": {
+ "source": "github/gh-stack",
+ "sourceType": "github",
+ "skillPath": "skills/gh-stack/SKILL.md",
+ "computedHash": "10a4da93d82f822cbcd2eec43ec8ccf02d70bc98662b86531c6fb00de9856c65"
+ },
+ "multi-stage-dockerfile": {
+ "source": "github/awesome-copilot",
+ "sourceType": "github",
+ "skillPath": "skills/multi-stage-dockerfile/SKILL.md",
+ "computedHash": "698bf93a84dae18fd7bd0b1d35c21427307fde78fbdf4624be9726f5f98189ca"
+ },
+ "next-best-practices": {
+ "source": "vercel-labs/openreview",
+ "sourceType": "github",
+ "skillPath": ".agents/skills/next-best-practices/SKILL.md",
+ "computedHash": "db85045827ebeb83ac8dbc992b69188755eeb74dcf01cbf5e694d58aa494a106"
+ },
+ "next-cache-components": {
+ "source": "vercel-labs/openreview",
+ "sourceType": "github",
+ "skillPath": ".agents/skills/next-cache-components/SKILL.md",
+ "computedHash": "f835d8226c28abf012cc013928e2627c8a7a046de61127ef229be1711bd1f255"
+ },
+ "next-upgrade": {
+ "source": "vercel-labs/openreview",
+ "sourceType": "github",
+ "skillPath": ".agents/skills/next-upgrade/SKILL.md",
+ "computedHash": "e3aeb1f9e8a68d24df0c1c2e3c8ab97ede09a7251a2c76988318d02254346979"
+ },
+ "vercel-composition-patterns": {
+ "source": "vercel-labs/openreview",
+ "sourceType": "github",
+ "skillPath": ".agents/skills/vercel-composition-patterns/SKILL.md",
+ "computedHash": "bdc99908989f5d6904aae71e97b123f61ee0a4f8085101d5064f69bb7c9ac7bd"
+ },
+ "vercel-react-best-practices": {
+ "source": "vercel-labs/openreview",
+ "sourceType": "github",
+ "skillPath": ".agents/skills/vercel-react-best-practices/SKILL.md",
+ "computedHash": "090ba7e6666985d200d366d0683590947c8ce365976c8e79dfb06c6bdc561fec"
+ },
+ "web-design-guidelines": {
+ "source": "vercel-labs/openreview",
+ "sourceType": "github",
+ "skillPath": ".agents/skills/web-design-guidelines/SKILL.md",
+ "computedHash": "f3bc47f890f42a44db1007ab390709ec368e4b8c089baee6b0007182236ac474"
+ }
+ }
}
From 9a7e24ede3da65cf0b89947f84b7e2ce805bb773 Mon Sep 17 00:00:00 2001
From: Rafael Martins
Date: Sat, 1 Aug 2026 17:12:29 -0300
Subject: [PATCH 07/15] fix(work): credit the reduced night hour in the
suggested exit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`calculateSuggestedExit` and `calculateWorkStats` disagreed about the same
journey. The stats function credits the reduced night hour (CLT art. 73:
52'30" counts as a full hour), the exit suggestion did not. So AUTO mode
would propose a time, the user would leave then, and the app would
immediately book overtime that was never worked:
entry 21:00, lunch 01:00-02:00, journey 08:48
suggested 06:48 -> balance +51 min, all billed as overtime
Both paths now go through one `nightBonusMinutes` helper, and the exit is
refined until the credited total matches the journey. Where the reduced
hour makes an exact match unreachable — no whole number of minutes maps to
420 credited minutes, only 419 or 421 — it settles on the closest, so the
residue is at most one minute instead of an hour of phantom overtime.
`workMinutes - workedBeforeLunch` could also go negative when lunch ran
long, placing the suggested exit before the end of lunch. That made the
range non-chronological, which `calculateWorkStats` rejects by returning
zeroed stats — so the UI showed a balanced day while hiding real overtime.
Clamped at zero.
Co-Authored-By: Claude Opus 5
---
__tests__/use-work-calculator.test.ts | 62 +++++++++++++++++++-
hooks/use-work-calculator.ts | 83 +++++++++++++++++++--------
2 files changed, 120 insertions(+), 25 deletions(-)
diff --git a/__tests__/use-work-calculator.test.ts b/__tests__/use-work-calculator.test.ts
index b27d27a..cff877e 100644
--- a/__tests__/use-work-calculator.test.ts
+++ b/__tests__/use-work-calculator.test.ts
@@ -34,9 +34,67 @@ describe("calculateSuggestedExit", () => {
});
it("returns the entry unchanged when a timestamp is unparseable", () => {
+ expect(calculateSuggestedExit("not-a-date", "", "", FULL_DAY_MINUTES)).toBe(
+ "not-a-date",
+ );
+ });
+
+ it("never suggests an exit before the end of lunch", () => {
+ expect(
+ calculateSuggestedExit(
+ `${MONDAY}T08:00`,
+ `${MONDAY}T18:00`,
+ `${MONDAY}T19:00`,
+ FULL_DAY_MINUTES,
+ ),
+ ).toBe(`${MONDAY}T19:00`);
+ });
+
+ const balanceAtSuggestedExit = (
+ entry: string,
+ lunchStart: string,
+ lunchEnd: string,
+ workMinutes: number,
+ ) =>
+ calculateWorkStats(
+ entry,
+ lunchStart,
+ lunchEnd,
+ calculateSuggestedExit(entry, lunchStart, lunchEnd, workMinutes),
+ workMinutes,
+ ).balance;
+
+ it("lands on a zero balance for a daytime journey", () => {
+ expect(
+ balanceAtSuggestedExit(
+ `${MONDAY}T08:00`,
+ `${MONDAY}T12:00`,
+ `${MONDAY}T13:00`,
+ FULL_DAY_MINUTES,
+ ),
+ ).toBe(0);
+ });
+
+ it("credits the reduced night hour so a night journey also lands on zero", () => {
expect(
- calculateSuggestedExit("not-a-date", "", "", FULL_DAY_MINUTES),
- ).toBe("not-a-date");
+ balanceAtSuggestedExit(
+ `${MONDAY}T21:00`,
+ "2025-01-07T01:00",
+ "2025-01-07T02:00",
+ FULL_DAY_MINUTES,
+ ),
+ ).toBe(0);
+ });
+
+ it("gets within a minute when the reduced night hour makes zero unreachable", () => {
+ const balance = balanceAtSuggestedExit(
+ `${MONDAY}T22:00`,
+ "2025-01-07T00:00",
+ "2025-01-07T00:30",
+ 420,
+ );
+
+ expect(Math.abs(balance)).toBeLessThanOrEqual(1);
});
});
diff --git a/hooks/use-work-calculator.ts b/hooks/use-work-calculator.ts
index f12a481..0f5a499 100644
--- a/hooks/use-work-calculator.ts
+++ b/hooks/use-work-calculator.ts
@@ -7,6 +7,7 @@ const NIGHT_SHIFT_END_HOUR = 5;
const NIGHT_HOUR_MINUTES = 52.5;
const MINUTES_PER_HOUR = 60;
const FIRST_TIER_LIMIT_MINUTES = 120;
+const EXIT_REFINEMENT_PASSES = 6;
const DEFAULT_WORK_MINUTES = 8 * MINUTES_PER_HOUR + 48;
const DEFAULT_FIRST_TIER_RATE = 50;
@@ -39,8 +40,7 @@ const EMPTY_STATS: WorkStats = {
function isChronological(...dates: readonly Date[]): boolean {
return dates.every(
- (date, index) =>
- isValid(date) && (index === 0 || dates[index - 1] <= date),
+ (date, index) => isValid(date) && (index === 0 || dates[index - 1] <= date),
);
}
@@ -73,12 +73,7 @@ function countNightMinutes(
nightEnd.setDate(nightEnd.getDate() + 1);
nightEnd.setHours(NIGHT_SHIFT_END_HOUR, 0, 0, 0);
- const worked = overlapInMinutes(
- nightStart,
- nightEnd,
- entryDate,
- exitDate,
- );
+ const worked = overlapInMinutes(nightStart, nightEnd, entryDate, exitDate);
const lunched = overlapInMinutes(
nightStart,
nightEnd,
@@ -93,6 +88,16 @@ function countNightMinutes(
return nightMinutes;
}
+function nightEquivalentMinutes(nightMinutesWorked: number): number {
+ return Math.round(
+ nightMinutesWorked * (MINUTES_PER_HOUR / NIGHT_HOUR_MINUTES),
+ );
+}
+
+function nightBonusMinutes(nightMinutesWorked: number): number {
+ return nightEquivalentMinutes(nightMinutesWorked) - nightMinutesWorked;
+}
+
function splitOvertime(
overtimeMinutes: number,
isWeekend: boolean,
@@ -103,10 +108,7 @@ function splitOvertime(
return {
firstTierMinutes: Math.min(overtimeMinutes, FIRST_TIER_LIMIT_MINUTES),
- extraTierMinutes: Math.max(
- 0,
- overtimeMinutes - FIRST_TIER_LIMIT_MINUTES,
- ),
+ extraTierMinutes: Math.max(0, overtimeMinutes - FIRST_TIER_LIMIT_MINUTES),
};
}
@@ -122,9 +124,7 @@ export function calculateWorkStats(
const lunchEndDate = new Date(lunchEnd);
const exitDate = new Date(displayExit);
- if (
- !isChronological(entryDate, lunchStartDate, lunchEndDate, exitDate)
- ) {
+ if (!isChronological(entryDate, lunchStartDate, lunchEndDate, exitDate)) {
return EMPTY_STATS;
}
@@ -138,23 +138,37 @@ export function calculateWorkStats(
lunchStartDate,
lunchEndDate,
);
- const nightMinutesEquivalent = Math.round(
- nightMinutesWorked * (MINUTES_PER_HOUR / NIGHT_HOUR_MINUTES),
- );
- const nightBonusMinutes = nightMinutesEquivalent - nightMinutesWorked;
-
- const totalWorked = workedMinutes + nightBonusMinutes;
+ const totalWorked = workedMinutes + nightBonusMinutes(nightMinutesWorked);
const balance = totalWorked - workMinutes;
const dayOfWeek = entryDate.getDay();
return {
balance,
- nightMinutes: nightMinutesEquivalent,
+ nightMinutes: nightEquivalentMinutes(nightMinutesWorked),
totalWorked,
...splitOvertime(Math.max(0, balance), dayOfWeek === 0 || dayOfWeek === 6),
};
}
+function creditedMinutes(
+ entryDate: Date,
+ lunchStartDate: Date,
+ lunchEndDate: Date,
+ exitDate: Date,
+): number {
+ const worked =
+ differenceInMinutes(lunchStartDate, entryDate) +
+ differenceInMinutes(exitDate, lunchEndDate);
+ const nightWorked = countNightMinutes(
+ entryDate,
+ exitDate,
+ lunchStartDate,
+ lunchEndDate,
+ );
+
+ return worked + nightBonusMinutes(nightWorked);
+}
+
export function calculateSuggestedExit(
entry: string,
lunchStart: string,
@@ -168,7 +182,30 @@ export function calculateSuggestedExit(
if (!isChronological(entryDate, lunchStartDate, lunchEndDate)) return entry;
const workedBeforeLunch = differenceInMinutes(lunchStartDate, entryDate);
- const exitDate = addMinutes(lunchEndDate, workMinutes - workedBeforeLunch);
+ let exitDate = addMinutes(
+ lunchEndDate,
+ Math.max(0, workMinutes - workedBeforeLunch),
+ );
+ let surplus =
+ creditedMinutes(entryDate, lunchStartDate, lunchEndDate, exitDate) -
+ workMinutes;
+
+ for (
+ let pass = 0;
+ pass < EXIT_REFINEMENT_PASSES && surplus !== 0;
+ pass += 1
+ ) {
+ const candidate = addMinutes(exitDate, -surplus);
+ if (candidate < lunchEndDate) break;
+
+ const candidateSurplus =
+ creditedMinutes(entryDate, lunchStartDate, lunchEndDate, candidate) -
+ workMinutes;
+ if (Math.abs(candidateSurplus) >= Math.abs(surplus)) break;
+
+ exitDate = candidate;
+ surplus = candidateSurplus;
+ }
return format(exitDate, "yyyy-MM-dd'T'HH:mm");
}
From 7a654cf13d114deb5df6537bd888be81b28f01d8 Mon Sep 17 00:00:00 2001
From: Rafael Martins
Date: Sat, 1 Aug 2026 17:15:08 -0300
Subject: [PATCH 08/15] chore: stop format-gating a generated lockfile, drop
dead .biomeignore
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`skills-lock.json` is written by the skills tooling with two-space indent
while the project formats with tabs, so `biome ci .` failed on it and any
`lint:fix` rewrote all 98 lines — pure diff noise on a generated file.
Exclude it via `files.includes` instead.
`.biomeignore` has done nothing since Biome 2 dropped support for it. Its
only entry was `app/globals.css`, which passes the CSS checks anyway now
that `css.parser.tailwindDirectives` is configured, so the file has no
replacement — it just goes away.
Co-Authored-By: Claude Opus 5
---
.biomeignore | 1 -
biome.json | 3 ++-
2 files changed, 2 insertions(+), 2 deletions(-)
delete mode 100644 .biomeignore
diff --git a/.biomeignore b/.biomeignore
deleted file mode 100644
index e493205..0000000
--- a/.biomeignore
+++ /dev/null
@@ -1 +0,0 @@
-app/globals.css
diff --git a/biome.json b/biome.json
index 54af66c..92d3577 100644
--- a/biome.json
+++ b/biome.json
@@ -6,7 +6,8 @@
"useIgnoreFile": true
},
"files": {
- "ignoreUnknown": false
+ "ignoreUnknown": false,
+ "includes": ["**", "!skills-lock.json"]
},
"formatter": {
"enabled": true,
From 80be91eacae48006728f923e48d3da64f08f8cdb Mon Sep 17 00:00:00 2001
From: Rafael Martins
Date: Sat, 1 Aug 2026 17:20:00 -0300
Subject: [PATCH 09/15] fix(storage): treat a blank stored value as absent,
drop a dead branch
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`Number("")` is 0, so a stored empty string resolved to 0 rather than the
fallback — a cleared "carga horária" came back as zero hours instead of the
default 220. Blank and whitespace-only values now fall back.
The income tax table carried a `Number.POSITIVE_INFINITY` sentinel row,
which made `find` always succeed and left the `??` fallback permanently
unreachable. The top rate is now its own value rather than a fake bracket,
so both paths are real and exercised.
Co-Authored-By: Claude Opus 5
---
__tests__/payroll.test.ts | 4 ++-
__tests__/storage.test.ts | 73 +++++++++++++++++++++++++++++++++++++++
lib/payroll.ts | 16 ++++++---
lib/storage.ts | 2 +-
4 files changed, 88 insertions(+), 7 deletions(-)
create mode 100644 __tests__/storage.test.ts
diff --git a/__tests__/payroll.test.ts b/__tests__/payroll.test.ts
index 10aa616..20cbaf3 100644
--- a/__tests__/payroll.test.ts
+++ b/__tests__/payroll.test.ts
@@ -69,7 +69,9 @@ describe("calculateIncomeTax", () => {
});
it("stops reducing once the phase-out ceiling is reached", () => {
- expect(calculateIncomeTax(7350, calculateSocialSecurity(7350))).toBe(884.13);
+ expect(calculateIncomeTax(7350, calculateSocialSecurity(7350))).toBe(
+ 884.13,
+ );
});
it("applies the top bracket above the phase-out ceiling", () => {
diff --git a/__tests__/storage.test.ts b/__tests__/storage.test.ts
new file mode 100644
index 0000000..c0ed1a3
--- /dev/null
+++ b/__tests__/storage.test.ts
@@ -0,0 +1,73 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { readStoredList, readStoredNumber } from "@/lib/storage";
+
+describe("readStoredNumber", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("returns the fallback when the key is missing", () => {
+ expect(readStoredNumber("missing-key", 42)).toBe(42);
+ });
+
+ it("returns the parsed value when it is valid", () => {
+ localStorage.setItem("valid-key", "10");
+ expect(readStoredNumber("valid-key", 0)).toBe(10);
+ });
+
+ it("returns the fallback when the value is non-numeric", () => {
+ localStorage.setItem("non-numeric-key", "not-a-number");
+ expect(readStoredNumber("non-numeric-key", 5)).toBe(5);
+ });
+
+ it("returns the fallback when the value is negative", () => {
+ localStorage.setItem("negative-key", "-1");
+ expect(readStoredNumber("negative-key", 7)).toBe(7);
+ });
+
+ it("returns zero when the stored value is zero", () => {
+ localStorage.setItem("zero-key", "0");
+ expect(readStoredNumber("zero-key", 99)).toBe(0);
+ });
+
+ it("falls back when the stored value is blank", () => {
+ localStorage.setItem("empty-key", "");
+ expect(readStoredNumber("empty-key", 3)).toBe(3);
+
+ localStorage.setItem("blank-key", " ");
+ expect(readStoredNumber("blank-key", 3)).toBe(3);
+ });
+});
+
+describe("readStoredList", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ const isString = (candidate: unknown): candidate is string =>
+ typeof candidate === "string";
+
+ it("returns an empty array when the key is missing", () => {
+ expect(readStoredList("missing-list", isString)).toEqual([]);
+ });
+
+ it("returns the parsed array when it is valid", () => {
+ localStorage.setItem("valid-list", JSON.stringify(["a", "b"]));
+ expect(readStoredList("valid-list", isString)).toEqual(["a", "b"]);
+ });
+
+ it("returns an empty array when the JSON is invalid", () => {
+ localStorage.setItem("invalid-json", "{not-json");
+ expect(readStoredList("invalid-json", isString)).toEqual([]);
+ });
+
+ it("returns an empty array when the JSON is not an array", () => {
+ localStorage.setItem("not-an-array", JSON.stringify({ a: 1 }));
+ expect(readStoredList("not-an-array", isString)).toEqual([]);
+ });
+
+ it("filters out array items that fail the predicate", () => {
+ localStorage.setItem("mixed-list", JSON.stringify(["a", 1, "b", null]));
+ expect(readStoredList("mixed-list", isString)).toEqual(["a", "b"]);
+ });
+});
diff --git a/lib/payroll.ts b/lib/payroll.ts
index fe81c16..21804e9 100644
--- a/lib/payroll.ts
+++ b/lib/payroll.ts
@@ -3,10 +3,15 @@ interface ProgressiveBracket {
readonly rate: number;
}
-interface IncomeTaxBracket extends ProgressiveBracket {
+interface IncomeTaxRate {
+ readonly rate: number;
readonly deduction: number;
}
+interface IncomeTaxBracket extends IncomeTaxRate {
+ readonly ceiling: number;
+}
+
const SOCIAL_SECURITY_BRACKETS: readonly ProgressiveBracket[] = [
{ ceiling: 1621.0, rate: 0.075 },
{ ceiling: 2902.84, rate: 0.09 },
@@ -19,9 +24,10 @@ const INCOME_TAX_BRACKETS: readonly IncomeTaxBracket[] = [
{ 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 TOP_INCOME_TAX_RATE: IncomeTaxRate = { rate: 0.275, deduction: 908.73 };
+
const SIMPLIFIED_DEDUCTION = 607.2;
const EXEMPTION_CEILING = 5000;
const REDUCTION_PHASE_OUT_CEILING = 7350;
@@ -60,10 +66,10 @@ export function calculateSocialSecurity(grossSalary: number): number {
);
}
-function findIncomeTaxBracket(base: number): IncomeTaxBracket {
+function findIncomeTaxRate(base: number): IncomeTaxRate {
return (
INCOME_TAX_BRACKETS.find(({ ceiling }) => base <= ceiling) ??
- INCOME_TAX_BRACKETS[INCOME_TAX_BRACKETS.length - 1]
+ TOP_INCOME_TAX_RATE
);
}
@@ -84,7 +90,7 @@ export function calculateIncomeTax(
SIMPLIFIED_DEDUCTION,
);
const base = sanitizeAmount(gross - deductible);
- const { rate, deduction } = findIncomeTaxBracket(base);
+ const { rate, deduction } = findIncomeTaxRate(base);
const tax = base * rate - deduction - taxReductionFor(gross);
return roundToCents(sanitizeAmount(tax));
diff --git a/lib/storage.ts b/lib/storage.ts
index 5217e51..1128a45 100644
--- a/lib/storage.ts
+++ b/lib/storage.ts
@@ -1,6 +1,6 @@
export function readStoredNumber(key: string, fallback: number): number {
const raw = localStorage.getItem(key);
- if (raw === null) return fallback;
+ if (raw === null || raw.trim() === "") return fallback;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
From 6f88e50d708bd8879aaedb1878d5ddbc41ad3f2f Mon Sep 17 00:00:00 2001
From: Rafael Martins
Date: Sat, 1 Aug 2026 17:20:00 -0300
Subject: [PATCH 10/15] test: measure coverage honestly and cover the untested
periphery
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Vitest's v8 provider only reports files that a test imported, so the suite
advertised 95.8% while measuring 9 files out of 28. With `coverage.include`
pointing at app/, components/, hooks/ and lib/, the real baseline was
49.09% statements and 37.35% branches — fifteen source files had never been
imported by a test at all.
Cover the periphery that had nothing: lib/storage, lib/consent,
lib/analytics, the manifest/robots/sitemap route handlers, the layout, the
theme provider, the analytics wrapper, all four atoms, and the form-field
and stat-box molecules.
`app/layout.tsx` needs no exclusion. It only failed to import because
`next/font/google` is normally transformed by SWC during a Next build, so
outside that compilation `Inter` arrives as an object rather than a
callable. A three-line mock in the setup file makes it testable, and the
test now also asserts `viewport` has no `maximumScale`, so the pinch-zoom
fix cannot silently regress.
Two tests were asserting things the environment cannot deliver: the adblock
modal never leaves the DOM under jsdom because AnimatePresence's exit
animation never completes, so that case now asserts the behaviour that
actually distinguishes dismiss from confirm — that no reload is triggered.
The junit reporter is now CI-only, so local runs stop writing an artifact
into coverage/.
Co-Authored-By: Claude Opus 5
---
__tests__/ad-manager.test.tsx | 15 +++++
__tests__/analytics-wrapper.test.tsx | 51 ++++++++++++++
__tests__/analytics.test.ts | 71 +++++++++++++++++++
__tests__/button.test.tsx | 23 ++++++-
__tests__/card.test.tsx | 90 +++++++++++++++++++++++++
__tests__/consent.test.ts | 57 ++++++++++++++++
__tests__/form-field.test.tsx | 32 +++++++++
__tests__/google-ad.test.tsx | 11 +++
__tests__/input.test.tsx | 42 ++++++++++++
__tests__/label.test.tsx | 27 ++++++++
__tests__/layout.test.tsx | 45 +++++++++++++
__tests__/manifest.test.ts | 29 ++++++++
__tests__/robots.test.ts | 14 ++++
__tests__/sitemap.test.ts | 13 ++++
__tests__/stat-box.test.tsx | 76 +++++++++++++++++++++
__tests__/theme-provider.test.tsx | 14 ++++
__tests__/use-salary-calculator.test.ts | 8 ++-
vitest.config.ts | 8 ++-
vitest.setup.ts | 4 ++
19 files changed, 626 insertions(+), 4 deletions(-)
create mode 100644 __tests__/analytics-wrapper.test.tsx
create mode 100644 __tests__/analytics.test.ts
create mode 100644 __tests__/card.test.tsx
create mode 100644 __tests__/consent.test.ts
create mode 100644 __tests__/form-field.test.tsx
create mode 100644 __tests__/input.test.tsx
create mode 100644 __tests__/label.test.tsx
create mode 100644 __tests__/layout.test.tsx
create mode 100644 __tests__/manifest.test.ts
create mode 100644 __tests__/robots.test.ts
create mode 100644 __tests__/sitemap.test.ts
create mode 100644 __tests__/stat-box.test.tsx
create mode 100644 __tests__/theme-provider.test.tsx
diff --git a/__tests__/ad-manager.test.tsx b/__tests__/ad-manager.test.tsx
index 858be76..f7dba6f 100644
--- a/__tests__/ad-manager.test.tsx
+++ b/__tests__/ad-manager.test.tsx
@@ -92,6 +92,21 @@ describe("AdManager", () => {
});
});
+ it("closes the adblock modal without reloading when dismissed", async () => {
+ vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID);
+ mockFetch.mockRejectedValueOnce(new Error("blocked"));
+
+ render();
+
+ await vi.waitFor(() => {
+ expect(screen.getByText("Opa! Uma ajudinha?")).toBeDefined();
+ });
+
+ fireEvent.click(screen.getByText("Continuar com AdBlock ativo"));
+
+ expect(mockReload).not.toHaveBeenCalled();
+ });
+
it("reloads the page and closes the modal when confirming adblock is disabled", async () => {
vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID);
mockFetch.mockRejectedValueOnce(new Error("blocked"));
diff --git a/__tests__/analytics-wrapper.test.tsx b/__tests__/analytics-wrapper.test.tsx
new file mode 100644
index 0000000..6ae7b97
--- /dev/null
+++ b/__tests__/analytics-wrapper.test.tsx
@@ -0,0 +1,51 @@
+import { render } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { AnalyticsWrapper } from "@/components/organisms/analytics-wrapper";
+
+const CONSENT_KEY = "workload_cookie_consent";
+
+vi.mock("@next/third-parties/google", () => ({
+ GoogleAnalytics: ({ gaId }: { gaId: string }) => (
+
+ ),
+}));
+
+describe("AnalyticsWrapper", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("renders nothing when there is no stored consent", () => {
+ vi.stubEnv("NEXT_PUBLIC_GA_ID", "GA-TEST-ID");
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders nothing when consent is stored but telemetry is false", () => {
+ vi.stubEnv("NEXT_PUBLIC_GA_ID", "GA-TEST-ID");
+ localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: false }));
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders nothing when NEXT_PUBLIC_GA_ID is unset even with consent granted", () => {
+ vi.stubEnv("NEXT_PUBLIC_GA_ID", "");
+ localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: true }));
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders GoogleAnalytics when consent is granted and the env var is present", () => {
+ vi.stubEnv("NEXT_PUBLIC_GA_ID", "GA-TEST-ID");
+ localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: true }));
+ const { getByTestId } = render();
+ expect(getByTestId("google-analytics")).toHaveAttribute(
+ "data-ga-id",
+ "GA-TEST-ID",
+ );
+ });
+});
diff --git a/__tests__/analytics.test.ts b/__tests__/analytics.test.ts
new file mode 100644
index 0000000..c1c978e
--- /dev/null
+++ b/__tests__/analytics.test.ts
@@ -0,0 +1,71 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { safeGAEvent } from "@/lib/analytics";
+
+describe("safeGAEvent", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ (window as Record).dataLayer = undefined;
+ (window as Record).gtag = undefined;
+ });
+
+ it("does nothing when there is no window to report to", () => {
+ vi.stubGlobal("window", undefined);
+ const setIntervalSpy = vi.spyOn(global, "setInterval");
+
+ expect(() => safeGAEvent("test_event")).not.toThrow();
+ expect(setIntervalSpy).not.toHaveBeenCalled();
+
+ vi.unstubAllGlobals();
+ });
+
+ it("sends the event immediately with params when dataLayer already exists", () => {
+ (window as Record).dataLayer = [];
+ const gtag = vi.fn();
+ (window as Record).gtag = gtag;
+
+ safeGAEvent("test_event", { foo: "bar" });
+
+ expect(gtag).toHaveBeenCalledWith("event", "test_event", { foo: "bar" });
+ });
+
+ it("sends the event immediately without params when dataLayer already exists", () => {
+ (window as Record).dataLayer = [];
+ const gtag = vi.fn();
+ (window as Record).gtag = gtag;
+
+ safeGAEvent("test_event");
+
+ expect(gtag).toHaveBeenCalledWith("event", "test_event");
+ });
+
+ it("retries until dataLayer becomes available", () => {
+ const gtag = vi.fn();
+ (window as Record).gtag = gtag;
+
+ safeGAEvent("test_event");
+ expect(gtag).not.toHaveBeenCalled();
+
+ vi.advanceTimersByTime(500);
+ expect(gtag).not.toHaveBeenCalled();
+
+ (window as Record).dataLayer = [];
+ vi.advanceTimersByTime(500);
+ expect(gtag).toHaveBeenCalledWith("event", "test_event");
+ });
+
+ it("gives up and clears the interval after the max retries", () => {
+ const clearIntervalSpy = vi.spyOn(global, "clearInterval");
+ const gtag = vi.fn();
+ (window as Record).gtag = gtag;
+
+ safeGAEvent("test_event");
+ vi.advanceTimersByTime(500 * 10);
+
+ expect(gtag).not.toHaveBeenCalled();
+ expect(clearIntervalSpy).toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/button.test.tsx b/__tests__/button.test.tsx
index 3435ded..97cc421 100644
--- a/__tests__/button.test.tsx
+++ b/__tests__/button.test.tsx
@@ -1,5 +1,6 @@
import { render } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
import { Button } from "@/components/atoms/button";
describe("Button", () => {
@@ -59,4 +60,24 @@ describe("Button", () => {
const button = container.querySelector("button");
expect(button?.className).toContain("my-custom");
});
+
+ it("forwards the ref to the underlying button element", () => {
+ const ref = { current: null as HTMLButtonElement | null };
+ render();
+ 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 (
<>