Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions __tests__/ad-manager.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(<AdManager />);
Expand All@@ -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(<AdManager />);
Expand All@@ -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(<AdManager />);
Expand DownExpand Up@@ -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(<AdManager />);

await vi.waitFor(() => {
expect(mockFetch).not.toHaveBeenCalled();
});
expect(screen.queryByText("Opa! Uma ajudinha?")).toBeNull();
});
});
4 changes: 2 additions & 2 deletions __tests__/duration-row.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(<DurationRow icon={Zap} iconClassName="text-indigo-500" label="Adic. Noturno" minutes={59.6} />);

expect(screen.getByText("0h 60m")).toBeInTheDocument();
expect(screen.getByText("1h 0m")).toBeInTheDocument();
});
});
67 changes: 67 additions & 0 deletions __tests__/duration.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
4 changes: 2 additions & 2 deletions app/globals.css
Original file line numberDiff line numberDiff line change
@@ -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 *));
Expand All@@ -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 {
Expand All@@ -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 */
}
2 changes: 1 addition & 1 deletion app/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@ export default function Home() {
<>
<script
type="application/ld+json"
// biome-ignore lint/security/noDangerouslySetInnerHtml: SEO
// biome-ignore lint/security/noDangerouslySetInnerHtml: safe only while every field below is a static literal — escape the payload before injecting anything user, API or CMS supplied
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
Expand Down
9 changes: 1 addition & 8 deletions components/molecules/duration-row.tsx
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
import type { ElementType } from "react";
import { formatHoursAndMinutes } from "@/lib/duration";
import { cn } from "@/lib/utils";

const MINUTES_PER_HOUR = 60;

function formatHoursAndMinutes(minutes: number): string {
const hours = Math.floor(minutes / MINUTES_PER_HOUR);
const remainingMinutes = Math.round(minutes % MINUTES_PER_HOUR);
return `${hours}h ${remainingMinutes}m`;
}

interface DurationRowProps {
icon: ElementType;
iconClassName: string;
Expand Down
4 changes: 3 additions & 1 deletion components/organisms/ad-manager.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,8 @@ export function AdManager() {
const enableAds = process.env.NEXT_PUBLIC_ENABLE_ADS === "true";

const checkAdBlock = useCallback(async () => {
if (!enableAds) return;

try {
await fetch("https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js", {
method: "HEAD",
Expand All@@ -29,7 +31,7 @@ export function AdManager() {
setIsAdBlockActive(true);
setShowAdBlockModal(true);
}
}, []);
}, [enableAds]);

const checkCooldowns = useCallback(() => {
if (!enableAds) return;
Expand Down
16 changes: 3 additions & 13 deletions components/organisms/journey-form.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

import { Coffee, LogIn, LogOut, Percent, RotateCcw, Settings, Zap } from "lucide-react";
import { useState } from "react";
import { formatPaddedDuration, parsePaddedDuration } from "@/lib/duration";
import { Button } from "../atoms/button";
import { Label } from "../atoms/label";
import { MaskedInput } from "../atoms/masked-input";
Expand All@@ -21,17 +22,6 @@ const EXIT_MODES = [
{ label: "MANUAL", isManual: true },
] as const;

function formatDuration(minutes: number): string {
const hours = Math.floor(minutes / MINUTES_PER_HOUR);
const remainingMinutes = minutes % MINUTES_PER_HOUR;
return `${hours.toString().padStart(2, "0")}:${remainingMinutes.toString().padStart(2, "0")}`;
}

function parseDuration(duration: string): number {
const [hours, minutes] = duration.split(":").map(Number);
return hours * MINUTES_PER_HOUR + minutes;
}

function isRealDuration(duration: string): boolean {
const [hours, minutes] = duration.split(":").map(Number);
return hours >= 0 && hours < HOURS_IN_DAY && minutes >= 0 && minutes < MINUTES_PER_HOUR;
Expand DownExpand Up@@ -135,11 +125,11 @@ export function JourneyForm({
<MaskedInput
id="daily-journey"
placeholder="08:48"
value={formatDuration(workMinutes)}
value={formatPaddedDuration(workMinutes)}
separator=":"
groupSizes={DURATION_GROUPS}
isValid={isRealDuration}
onCommit={(duration) => onWorkMinutesChange(parseDuration(duration))}
onCommit={(duration) => onWorkMinutesChange(parsePaddedDuration(duration))}
className="h-12 rounded-xl text-center text-lg font-bold focus-visible:ring-emerald-500"
/>
</div>
Expand Down
14 changes: 3 additions & 11 deletions components/organisms/work-calculator.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,15 +7,14 @@ import { useMemo } from "react";
import { useCurrentTime } from "@/hooks/use-current-time";
import { useWorkCalculator } from "@/hooks/use-work-calculator";
import { safeGAEvent } from "@/lib/analytics";
import { formatClock, minutesToSeconds } from "@/lib/duration";
import { formatClockTime } from "@/lib/utils";
import { CopyButton } from "../molecules/copy-button";
import { HeroPanel } from "../molecules/hero-panel";
import { CalculatorLayout } from "../templates/calculator-layout";
import { JourneyForm } from "./journey-form";
import { WorkSummary } from "./work-summary";

const SECONDS_PER_HOUR = 3600;
const SECONDS_PER_MINUTE = 60;
const PLACEHOLDER_CLOCK = "--:--:--";
const PLACEHOLDER_TIME = "--:--";

Expand All@@ -39,13 +38,6 @@ function toProgress(elapsed: number, total: number): number {
return Math.min(100, Math.max(0, (elapsed / total) * 100));
}

function formatClock(totalSeconds: number): string {
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
const minutes = Math.floor((totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
const seconds = Math.floor(totalSeconds % SECONDS_PER_MINUTE);
return [hours, minutes, seconds].map((part) => part.toString().padStart(2, "0")).join(":");
}

interface TimerInput {
currentTime: Date | null;
displayExit: string;
Expand DownExpand Up@@ -86,7 +78,7 @@ export function calculateTimerData({
return {
...labels,
label: "BALANÇO FINAL",
time: `${balanceSign < 0 ? "-" : "+"}${formatClock(Math.abs(balanceMinutes) * SECONDS_PER_MINUTE)}`,
time: `${balanceSign < 0 ? "-" : "+"}${formatClock(minutesToSeconds(Math.abs(balanceMinutes)))}`,
isOvertime: balanceSign > 0,
progress: toProgress(totalWorkedMinutes, workMinutes),
};
Expand DownExpand Up@@ -115,7 +107,7 @@ export function calculateTimerData({
label,
time,
isOvertime,
progress: toProgress(elapsedSeconds, workMinutes * SECONDS_PER_MINUTE),
progress: toProgress(elapsedSeconds, minutesToSeconds(workMinutes)),
};
}

Expand Down
12 changes: 2 additions & 10 deletions components/organisms/work-summary.tsx
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,7 @@
import { MoonStar, TrendingDown, TrendingUp, Zap } from "lucide-react";
import { formatSignedHoursAndMinutes } from "@/lib/duration";
import { DurationRow } from "../molecules/duration-row";

const MINUTES_PER_HOUR = 60;

function formatBalance(minutes: number): string {
const absoluteMinutes = Math.abs(minutes);
const hours = Math.floor(absoluteMinutes / MINUTES_PER_HOUR);
const remainingMinutes = absoluteMinutes % MINUTES_PER_HOUR;
return `${minutes < 0 ? "-" : "+"}${hours}h ${remainingMinutes}m`;
}

interface WorkSummaryProps {
balanceMinutes: number;
balanceSign: number;
Expand DownExpand Up@@ -51,7 +43,7 @@ export function WorkSummary({
<p
className={`text-4xl font-black tracking-tight tabular-nums ${isPositiveBalance ? "text-emerald-700 dark:text-emerald-400" : "text-rose-500"}`}
>
{formatBalance(balanceMinutes)}
{formatSignedHoursAndMinutes(balanceMinutes)}
</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{isPositiveBalance ? "Horas extras acumuladas" : "Horas em débito hoje"}
Expand Down
55 changes: 25 additions & 30 deletions lib/analytics.ts
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,31 @@
export function safeGAEvent(eventName: string, params?: Record<string, string | number>) {
type EventParams = Record<string, string | number>;

interface AnalyticsWindow {
dataLayer?: unknown[];
gtag?: (command: "event", eventName: string, params?: EventParams) => void;
}

const MAX_RETRIES = 10;
const RETRY_INTERVAL_MS = 500;

export function safeGAEvent(eventName: string, params?: EventParams) {
if (typeof window === "undefined") return;

const send = () => {
const dataLayer = (window as Record<string, unknown>).dataLayer as Array<Record<string, unknown>> | undefined;

if (dataLayer) {
if (params) {
(
window as Record<string, unknown> & {
gtag?: (...args: unknown[]) => void;
}
).gtag?.("event", eventName, params);
} else {
(
window as Record<string, unknown> & {
gtag?: (...args: unknown[]) => void;
}
).gtag?.("event", eventName);
}
return true;
}
return false;
const { dataLayer, gtag } = window as Window & AnalyticsWindow;
if (!dataLayer) return false;

if (params) gtag?.("event", eventName, params);
else gtag?.("event", eventName);

return true;
};

if (!send()) {
const maxRetries = 10;
let retries = 0;
const interval = setInterval(() => {
retries++;
if (send() || retries >= maxRetries) {
clearInterval(interval);
}
}, 500);
}
if (send()) return;

let retries = 0;
const interval = setInterval(() => {
retries += 1;
if (send() || retries >= MAX_RETRIES) clearInterval(interval);
}, RETRY_INTERVAL_MS);
}
Loading