From 41745ec0b07c94fe721486d9a62d75b3ed34b8f8 Mon Sep 17 00:00:00 2001 From: Rafael Martins Date: Sun, 2 Aug 2026 15:02:16 -0300 Subject: [PATCH 1/2] refactor: collapse the duplicated duration and analytics code Four functions formatted the same minutes: formatHoursAndMinutes in duration-row, formatBalance in work-summary (the same one plus a sign), formatDuration in journey-form and formatClock in work-calculator, with MINUTES_PER_HOUR redeclared across five files. They now come from one lib/duration module. safeGAEvent kept two branches emitting equivalent gtag calls and rebuilt the same window cast four times; both collapse into one typed helper. The retry loop stays: sendGAEvent from @next/third-parties drops the event and warns when dataLayer is missing, so it cannot replace a poll that exists to cover the gap until GA loads after consent. AdManager probed googlesyndication.com on every page load and popped the adblock modal regardless of NEXT_PUBLIC_ENABLE_ADS. With the flag off, which is what .env currently ships, a visitor running an adblocker was asked to disable it for ads that never render. Three existing tests only passed because they inherited that behaviour; they now enable ads explicitly, and a new one pins the flag being honoured. The blanket biome-ignore-all in globals.css narrows to the reduced-motion block that actually needs !important, and the JSON-LD suppression states the ceiling it is safe under instead of just "SEO". Co-Authored-By: Claude Opus 5 --- __tests__/ad-manager.test.tsx | 16 +++++++ __tests__/duration.test.ts | 60 ++++++++++++++++++++++++ app/globals.css | 4 +- app/page.tsx | 2 +- components/molecules/duration-row.tsx | 9 +--- components/organisms/ad-manager.tsx | 4 +- components/organisms/journey-form.tsx | 16 ++----- components/organisms/work-calculator.tsx | 14 ++---- components/organisms/work-summary.tsx | 12 +---- lib/analytics.ts | 55 ++++++++++------------ lib/duration.ts | 44 +++++++++++++++++ 11 files changed, 160 insertions(+), 76 deletions(-) create mode 100644 __tests__/duration.test.ts create mode 100644 lib/duration.ts diff --git a/__tests__/ad-manager.test.tsx b/__tests__/ad-manager.test.tsx index d41bed3..6514236 100644 --- a/__tests__/ad-manager.test.tsx +++ b/__tests__/ad-manager.test.tsx @@ -96,6 +96,7 @@ describe("AdManager", () => { it("shows adblock modal when fetch fails", async () => { vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); + vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); mockFetch.mockRejectedValueOnce(new Error("blocked")); render(); @@ -107,6 +108,7 @@ describe("AdManager", () => { it("closes the adblock modal without reloading when dismissed", async () => { vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); + vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); mockFetch.mockRejectedValueOnce(new Error("blocked")); render(); @@ -122,6 +124,7 @@ describe("AdManager", () => { it("reloads the page and closes the modal when confirming adblock is disabled", async () => { vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); + vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true"); mockFetch.mockRejectedValueOnce(new Error("blocked")); render(); @@ -172,4 +175,17 @@ describe("AdManager", () => { expect(localStorage.getItem(VIDEO_AD_KEY)).not.toBeNull(); expect(screen.queryByText("Vídeo da Semana")).toBeNull(); }); + + it("never nags about adblock while ads are switched off", async () => { + vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); + vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "false"); + mockFetch.mockRejectedValueOnce(new Error("blocked")); + + render(); + + await vi.waitFor(() => { + expect(mockFetch).not.toHaveBeenCalled(); + }); + expect(screen.queryByText("Opa! Uma ajudinha?")).toBeNull(); + }); }); diff --git a/__tests__/duration.test.ts b/__tests__/duration.test.ts new file mode 100644 index 0000000..cf6f384 --- /dev/null +++ b/__tests__/duration.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + formatClock, + formatHoursAndMinutes, + formatPaddedDuration, + formatSignedHoursAndMinutes, + minutesToSeconds, + parsePaddedDuration, + splitHoursAndMinutes, +} from "@/lib/duration"; + +describe("splitHoursAndMinutes", () => { + it("splits minutes into whole hours and a rounded remainder", () => { + expect(splitHoursAndMinutes(0)).toEqual({ hours: 0, minutes: 0 }); + expect(splitHoursAndMinutes(59)).toEqual({ hours: 0, minutes: 59 }); + expect(splitHoursAndMinutes(60)).toEqual({ hours: 1, minutes: 0 }); + expect(splitHoursAndMinutes(528)).toEqual({ hours: 8, minutes: 48 }); + expect(splitHoursAndMinutes(90.4)).toEqual({ hours: 1, minutes: 30 }); + }); +}); + +describe("formatHoursAndMinutes", () => { + it("reads as hours and minutes", () => { + expect(formatHoursAndMinutes(0)).toBe("0h 0m"); + expect(formatHoursAndMinutes(125)).toBe("2h 5m"); + }); +}); + +describe("formatSignedHoursAndMinutes", () => { + it("always carries a sign", () => { + expect(formatSignedHoursAndMinutes(0)).toBe("+0h 0m"); + expect(formatSignedHoursAndMinutes(75)).toBe("+1h 15m"); + expect(formatSignedHoursAndMinutes(-75)).toBe("-1h 15m"); + }); +}); + +describe("formatPaddedDuration and parsePaddedDuration", () => { + it("round-trips a padded duration", () => { + expect(formatPaddedDuration(528)).toBe("08:48"); + expect(formatPaddedDuration(0)).toBe("00:00"); + expect(parsePaddedDuration("08:48")).toBe(528); + expect(parsePaddedDuration(formatPaddedDuration(479))).toBe(479); + }); +}); + +describe("formatClock", () => { + it("pads every part of the clock", () => { + expect(formatClock(0)).toBe("00:00:00"); + expect(formatClock(3661)).toBe("01:01:01"); + expect(formatClock(86399)).toBe("23:59:59"); + expect(formatClock(1.9)).toBe("00:00:01"); + }); +}); + +describe("minutesToSeconds", () => { + it("converts minutes to seconds", () => { + expect(minutesToSeconds(0)).toBe(0); + expect(minutesToSeconds(90)).toBe(5400); + }); +}); diff --git a/app/globals.css b/app/globals.css index aacad52..36ea869 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,5 +1,3 @@ -/* biome-ignore-all lint/complexity/noImportantStyles: a universal selector loses to every utility class, so the reduced-motion reset below cannot take effect without it */ - @import "tailwindcss"; @custom-variant dark (&:where(.dark, .dark *)); @@ -13,6 +11,7 @@ } @media (prefers-reduced-motion: reduce) { + /* biome-ignore-start lint/complexity/noImportantStyles: a universal selector loses to every utility class, so this reset cannot take effect without it */ *, *::before, *::after { @@ -21,4 +20,5 @@ transition-duration: 0.01ms !important; scroll-behavior: auto !important; } + /* biome-ignore-end lint/complexity/noImportantStyles: scoped to the reduced-motion reset */ } diff --git a/app/page.tsx b/app/page.tsx index aedab40..f306055 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -31,7 +31,7 @@ export default function Home() { <>