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-row.test.tsx b/__tests__/duration-row.test.tsx
index e51a990..48fa6bd 100644
--- a/__tests__/duration-row.test.tsx
+++ b/__tests__/duration-row.test.tsx
@@ -17,9 +17,9 @@ describe("DurationRow", () => {
expect(screen.getByText("0h 0m")).toBeInTheDocument();
});
- it("rounds fractional minutes", () => {
+ it("rounds fractional minutes up into the next hour", () => {
render();
- expect(screen.getByText("0h 60m")).toBeInTheDocument();
+ expect(screen.getByText("1h 0m")).toBeInTheDocument();
});
});
diff --git a/__tests__/duration.test.ts b/__tests__/duration.test.ts
new file mode 100644
index 0000000..823b62c
--- /dev/null
+++ b/__tests__/duration.test.ts
@@ -0,0 +1,67 @@
+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 });
+ });
+
+ it("carries a rounded-up remainder into the hour instead of reporting 60 minutes", () => {
+ expect(splitHoursAndMinutes(59.6)).toEqual({ hours: 1, minutes: 0 });
+ expect(splitHoursAndMinutes(119.6)).toEqual({ hours: 2, minutes: 0 });
+ expect(formatHoursAndMinutes(59.6)).toBe("1h 0m");
+ expect(formatPaddedDuration(59.6)).toBe("01:00");
+ });
+});
+
+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() {
<>