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
23 changes: 23 additions & 0 deletions __tests__/duration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,8 @@ import {
formatHoursAndMinutes,
formatPaddedDuration,
formatSignedHoursAndMinutes,
isRealDuration,
minutesToHours,
minutesToSeconds,
parsePaddedDuration,
splitHoursAndMinutes,
Expand DownExpand Up@@ -65,3 +67,24 @@ describe("minutesToSeconds", () => {
expect(minutesToSeconds(90)).toBe(5400);
});
});

describe("minutesToHours", () => {
it("converts minutes to fractional hours", () => {
expect(minutesToHours(0)).toBe(0);
expect(minutesToHours(528)).toBeCloseTo(8.8, 10);
});
});

describe("isRealDuration", () => {
it("accepts durations inside a day", () => {
expect(isRealDuration("00:00")).toBe(true);
expect(isRealDuration("08:48")).toBe(true);
expect(isRealDuration("23:59")).toBe(true);
});

it("rejects impossible hours and minutes", () => {
expect(isRealDuration("24:00")).toBe(false);
expect(isRealDuration("08:60")).toBe(false);
expect(isRealDuration("ab:cd")).toBe(false);
});
});
14 changes: 4 additions & 10 deletions components/atoms/label.tsx
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,11 @@
import * as React from "react";
import { cn } from "@/lib/utils";

export const labelClasses =
"flex items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-neutral-500 dark:text-neutral-400";

const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"flex items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-neutral-500 dark:text-neutral-400",
className,
)}
{...props}
/>
),
({ className, ...props }, ref) => <label ref={ref} className={cn(labelClasses, className)} {...props} />,
);
Label.displayName = "Label";

Expand Down
12 changes: 2 additions & 10 deletions components/organisms/journey-form.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

import { Coffee, LogIn, LogOut, Percent, RotateCcw, Settings, Zap } from "lucide-react";
import { useState } from "react";
import { formatPaddedDuration, parsePaddedDuration } from "@/lib/duration";
import { DURATION_GROUP_SIZES, formatPaddedDuration, isRealDuration, parsePaddedDuration } from "@/lib/duration";
import { Button } from "../atoms/button";
import { Label } from "../atoms/label";
import { MaskedInput } from "../atoms/masked-input";
Expand All@@ -13,20 +13,12 @@ import { FormField } from "../molecules/form-field";

const SETTINGS_PANEL_ID = "journey-settings";
const RESET_DIALOG_TITLE_ID = "journey-reset-title";
const DURATION_GROUPS = [2, 2] as const;
const MINUTES_PER_HOUR = 60;
const HOURS_IN_DAY = 24;

const EXIT_MODES = [
{ label: "AUTO", isManual: false },
{ label: "MANUAL", isManual: true },
] as const;

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;
}

interface JourneyFormProps {
workMinutes: number;
onWorkMinutesChange: (minutes: number) => void;
Expand DownExpand Up@@ -127,7 +119,7 @@ export function JourneyForm({
placeholder="08:48"
value={formatPaddedDuration(workMinutes)}
separator=":"
groupSizes={DURATION_GROUPS}
groupSizes={DURATION_GROUP_SIZES}
isValid={isRealDuration}
onCommit={(duration) => onWorkMinutesChange(parsePaddedDuration(duration))}
className="h-12 rounded-xl text-center text-lg font-bold focus-visible:ring-emerald-500"
Expand Down
10 changes: 7 additions & 3 deletions hooks/use-salary-calculator.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
import { useEffect, useMemo, useState } from "react";
import { calculateIncomeTax, calculateSocialSecurity, sanitizeAmount, type WorkRegime } from "@/lib/payroll";
import {
calculateIncomeTax,
calculateSocialSecurity,
sanitizeAmount,
WORK_REGIMES,
type WorkRegime,
} from "@/lib/payroll";
import { amountForPeriod, SALARY_PERIODS, type SalaryPeriod } from "@/lib/salary-period";
import { readStoredList, readStoredNumber } from "@/lib/storage";

Expand All@@ -17,8 +23,6 @@ const DEFAULT_DAILY_HOURS = 8;
const DEFAULT_REGIME: WorkRegime = "clt";
const DEFAULT_PERIOD: SalaryPeriod = "hour";

const WORK_REGIMES: readonly WorkRegime[] = ["clt", "empregado-publico", "estatutario"];

const STORAGE_KEYS = {
grossSalary: "grossSalary",
monthlyHours: "monthlyHours",
Expand Down
12 changes: 12 additions & 0 deletions lib/duration.ts
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
const MINUTES_PER_HOUR = 60;
const SECONDS_PER_MINUTE = 60;
const SECONDS_PER_HOUR = MINUTES_PER_HOUR * SECONDS_PER_MINUTE;
const HOURS_PER_DAY = 24;

export const DURATION_GROUP_SIZES = [2, 2] as const;

function pad(value: number): string {
return value.toString().padStart(2, "0");
}

export function isRealDuration(duration: string): boolean {
const [hours, minutes] = duration.split(":").map(Number);
return hours >= 0 && hours < HOURS_PER_DAY && minutes >= 0 && minutes < MINUTES_PER_HOUR;
}

export function splitHoursAndMinutes(minutes: number): { hours: number; minutes: number } {
const wholeMinutes = Math.round(minutes);

Expand DownExpand Up@@ -44,3 +52,7 @@ export function formatClock(totalSeconds: number): string {
export function minutesToSeconds(minutes: number): number {
return minutes * SECONDS_PER_MINUTE;
}

export function minutesToHours(minutes: number): number {
return minutes / MINUTES_PER_HOUR;
}
36 changes: 36 additions & 0 deletions lib/payroll.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,42 @@ interface IncomeTaxBracket extends IncomeTaxRate {

export type WorkRegime = "clt" | "empregado-publico" | "estatutario";

interface WorkRegimeInfo {
readonly value: WorkRegime;
readonly label: string;
readonly summary: string;
readonly who: string;
readonly impact: string;
}

export const WORK_REGIME_INFO: readonly WorkRegimeInfo[] = [
{
value: "clt",
label: "CLT",
summary: "Carteira assinada na iniciativa privada",
who: "Quem tem contrato regido pela CLT em empresa privada: carteira assinada, FGTS, aviso prévio e férias com 1/3.",
impact:
"INSS pelo INSS/RGPS, com alíquotas progressivas de 7,5% a 14% e teto de contribuição em R$ 8.475,55 — acima disso o desconto trava em R$ 988,09.",
},
{
value: "empregado-publico",
label: "Empregado Público",
summary: "Concursado de empresa pública, sob CLT",
who: "Quem passou em concurso para empresa pública ou sociedade de economia mista (Correios, Caixa, Petrobras). O vínculo continua sendo CLT.",
impact: "Desconto de INSS idêntico ao do CLT: mesmas faixas, mesmo teto de R$ 988,09.",
},
{
value: "estatutario",
label: "Estatutário",
summary: "Servidor público efetivo, com regime próprio",
who: "Servidor efetivo regido por estatuto (RJU) e vinculado a um regime próprio de previdência (RPPS), não ao INSS.",
impact:
"A contribuição não para no teto do INSS: as faixas seguem subindo até 22% sobre a parcela mais alta, então salários maiores descontam bem mais.",
},
];

export const WORK_REGIMES: readonly WorkRegime[] = WORK_REGIME_INFO.map(({ value }) => value);

const GENERAL_REGIME_BRACKETS: readonly ProgressiveBracket[] = [
{ ceiling: 1621.0, rate: 0.075 },
{ ceiling: 2902.84, rate: 0.09 },
Expand Down