Skip to content
Open
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
18 changes: 18 additions & 0 deletions docs/pt-br/utilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -1862,6 +1862,24 @@ subBusinessDays(new Date('not a date'), 1); // null
subBusinessDays(new Date(2024, 0, 2), 1.5); // null (não é um número inteiro)
```

Para o n-ésimo dia útil de um mês, ou o último, comece do dia logo fora do mês:

```javascript
import { addBusinessDays, subBusinessDays } from '@brazilian-utils/brazilian-utils';

// n-ésimo dia útil do mês: some n a partir do último dia do mês anterior
addBusinessDays(new Date(2024, 0, 0), 5); // Date, 2024-01-08 00:00 (5º dia útil de janeiro de 2024)
addBusinessDays(new Date(2024, 1, 0), 10); // Date, 2024-02-15 00:00 (10º de fevereiro de 2024, Carnaval pulado)

// último dia útil do mês: subtraia 1 a partir do primeiro dia do mês seguinte
subBusinessDays(new Date(2024, 3, 1), 1); // Date, 2024-03-28 00:00 (2024-03-29 é Sexta-feira Santa, seguida de um fim de semana)
subBusinessDays(new Date(2024, 1, 1), 2); // Date, 2024-01-30 00:00 (penúltimo de janeiro de 2024)
```

- Um `n` maior que os dias úteis do mês cai no mês seguinte (`addBusinessDays(new Date(2024, 0, 0), 23)` é 2024-02-01, janeiro de 2024 tem 22); compare `getMonth()` quando isso importar.
- Esta é a contagem bancária (segunda a sexta). O "quinto dia útil" do salário, do art. 459, § 1º, da CLT, é contado de outro jeito pela fiscalização do trabalho.
- O n-ésimo dia útil de janeiro de 1900 e o último dia útil de dezembro de 2099 retornam `null`, porque a receita parte de um dia fora dos anos suportados (31 de dezembro de 1899 e 1º de janeiro de 2100).

### differenceInBusinessDays

Conta os dias úteis entre duas datas. Assinatura: `differenceInBusinessDays(laterDate, earlierDate, options?)`, a mesma do date-fns.
Expand Down
18 changes: 18 additions & 0 deletions docs/utilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -1862,6 +1862,24 @@ subBusinessDays(new Date('not a date'), 1); // null
subBusinessDays(new Date(2024, 0, 2), 1.5); // null (not an integer)
```

To get the n-th business day of a month, or the last one, start from the day just outside the month:

```javascript
import { addBusinessDays, subBusinessDays } from '@brazilian-utils/brazilian-utils';

// n-th business day of the month: add n from the last day of the month before
addBusinessDays(new Date(2024, 0, 0), 5); // Date, 2024-01-08 00:00 (5th business day of January 2024)
addBusinessDays(new Date(2024, 1, 0), 10); // Date, 2024-02-15 00:00 (10th of February 2024, Carnaval skipped)

// last business day of the month: subtract 1 from the first day of the month after
subBusinessDays(new Date(2024, 3, 1), 1); // Date, 2024-03-28 00:00 (2024-03-29 is Sexta-feira Santa, then a weekend)
subBusinessDays(new Date(2024, 1, 1), 2); // Date, 2024-01-30 00:00 (2nd to last of January 2024)
```

- An `n` beyond the business days of the month lands in the next month (`addBusinessDays(new Date(2024, 0, 0), 23)` is 2024-02-01, January 2024 has 22); compare `getMonth()` when that matters.
- This is the banking count (Monday to Friday). The payroll "quinto dia útil" of CLT art. 459 § 1º is counted differently by labour inspection.
- The n-th business day of January 1900 and the last business day of December 2099 return `null`, since the recipe starts from a day outside the supported years (31 December 1899 and 1 January 2100).

### differenceInBusinessDays

Count the Brazilian business days (dias úteis) between two dates. Signature: `differenceInBusinessDays(laterDate, earlierDate, options?)`, the same as date-fns.
Expand Down
70 changes: 70 additions & 0 deletions src/_internals/each-local-day/each-local-day.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, inTimeZone, test } from "../test/runtime";
import { eachLocalDay } from "./each-local-day";

const daysOfMonth = (from: number, until: number): number[] =>
[...eachLocalDay({ from, until })].map((day) => day.getDate());

describe("eachLocalDay", () => {
test("should yield every day of March 2024 forwards, the day it stops at excluded", () => {
expect(daysOfMonth(Date.UTC(2024, 2, 1), Date.UTC(2024, 3, 1))).toEqual([
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
27, 28, 29, 30, 31,
]);
});

test("should yield every day of March 2024 backwards when until comes before from", () => {
expect(daysOfMonth(Date.UTC(2024, 2, 31), Date.UTC(2024, 1, 29))).toEqual([
31, 30, 29, 28, 27, 26, 25, 24, 23, 22, 21, 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8,
7, 6, 5, 4, 3, 2, 1,
]);
});

test("should yield nothing when from and until are the same day", () => {
expect(daysOfMonth(Date.UTC(2024, 2, 1), Date.UTC(2024, 2, 1))).toEqual([]);
});

test("should yield a single day when until is its neighbour", () => {
expect(daysOfMonth(Date.UTC(2024, 2, 1), Date.UTC(2024, 2, 2))).toEqual([1]);
expect(daysOfMonth(Date.UTC(2024, 2, 1), Date.UTC(2024, 1, 29))).toEqual([1]);
});

test("should cross the end of a year, a leap day and a month boundary", () => {
expect(daysOfMonth(Date.UTC(2023, 11, 30), Date.UTC(2024, 0, 3))).toEqual([30, 31, 1, 2]);
expect(daysOfMonth(Date.UTC(2024, 1, 28), Date.UTC(2024, 2, 2))).toEqual([28, 29, 1]);
});

test("should yield every day at noon local time", () => {
const [first] = [...eachLocalDay({ from: Date.UTC(2024, 2, 1), until: Date.UTC(2024, 2, 2) })];

expect(first?.getHours()).toBe(12);
expect(first?.getMinutes()).toBe(0);
expect(first?.getSeconds()).toBe(0);
expect(first?.getMilliseconds()).toBe(0);
});

inTimeZone("Pacific/Apia", () => {
test("should skip 30 December 2011, the day Samoa dropped to cross the date line", () => {
expect(daysOfMonth(Date.UTC(2011, 11, 1), Date.UTC(2012, 0, 1))).toEqual([
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
26, 27, 28, 29, 31,
]);
});
});

inTimeZone("Pacific/Kiritimati", () => {
test("should skip 31 December 1994, walking backwards too", () => {
expect(daysOfMonth(Date.UTC(1994, 11, 28), Date.UTC(1995, 0, 1))).toEqual([28, 29, 30]);
expect(daysOfMonth(Date.UTC(1994, 11, 31), Date.UTC(1994, 11, 27))).toEqual([30, 29, 28]);
});
});

inTimeZone("America/Sao_Paulo", () => {
test("should yield 4 November 2018, whose local midnight does not exist", () => {
expect(daysOfMonth(Date.UTC(2018, 10, 3), Date.UTC(2018, 10, 6))).toEqual([3, 4, 5]);
});

test("should yield the days either side of the backward transition of 18 February 2018", () => {
expect(daysOfMonth(Date.UTC(2018, 1, 16), Date.UTC(2018, 1, 19))).toEqual([16, 17, 18]);
});
});
});
65 changes: 65 additions & 0 deletions src/_internals/each-local-day/each-local-day.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/** The two ends of a walk over local calendar days. */
export type EachLocalDayParams = {
/** The first local calendar day to visit, as `Date.UTC(year, month, day)` maps it to a number. */
from: number;
/** The local calendar day to stop at, the same way, never visited: it only bounds the walk and gives it its direction. */
until: number;
};

const DAY_IN_MS = 86_400_000;

const NOON = 12;

/**
* Walks the local calendar days from `from` to `until`, `until` itself excluded, and yields each
* one as a new `Date` at noon local time.
*
* Both ends are local calendar days written as the number `Date.UTC(year, month, day)` returns
* for them, so the walk is plain integer arithmetic on days: it always advances, it always stops
* after `Math.abs(until - from)` days, and no `Date` is ever mutated. That is what makes it safe
* in every time zone. A walk driven by `date.setDate(date.getDate() + 1)` is not: when the
* neighbouring local day does not exist (`Pacific/Apia` and `Pacific/Fakaofo` skipped 30 December 2011,
* `Pacific/Kiritimati` and `Pacific/Enderbury` 31 December 1994, `Pacific/Kwajalein` 21 August 1993, all of them crossing
* the date line) the runtime re-normalizes onto the same local day, the walk stops advancing and
* the loop never ends.
*
* Each day is yielded at **noon**, not at midnight, because noon is a time of day every existing
* local calendar day has: a transition that moves the clock forward (Brazilian summer time always
* started at local midnight, so there was no `00:00` on 4 November 2018 in São Paulo) leaves
* midnight of that day unrepresentable, and a `Date` built at it silently belongs to the day
* before or carries a shifted hour. A caller that only reads the local year, month, day and
* weekday of the yielded value, which is all `isBusinessDay` reads, therefore always sees the day
* it asked for.
*
* The five local days listed above do not exist in their zones at all, so no `Date` can carry
* them. They are skipped rather than yielded as the neighbouring day the runtime resolves them
* to, which would otherwise be visited twice.
*
* @param {EachLocalDayParams} params - The first local calendar day of the walk and the one to stop at.
* @yields {Date} Each existing local calendar day of the range, in order, at 12:00 local time.
*
* @example
* ```typescript
* // The business days of March 2024, forwards:
* for (const day of eachLocalDay({ from: Date.UTC(2024, 2, 1), until: Date.UTC(2024, 3, 1) })) {
* if (isBusinessDay(day)) console.log(day.getDate());
* }
* ```
*/
export function* eachLocalDay({ from, until }: EachLocalDayParams): Generator<Date> {
const step = Math.sign(until - from) * DAY_IN_MS;
const length = Math.abs(until - from) / DAY_IN_MS;

for (let index = 0; index < length; index += 1) {
const target = new Date(from + index * step);
const candidate = new Date(
target.getUTCFullYear(),
target.getUTCMonth(),
target.getUTCDate(),
NOON,
);

// Stryker disable next-line ConditionalExpression: the five local days listed above are the only input that tells this branch from an unconditional yield, and the tests pinning them need the process time zone set, which the mutation runner's worker threads cannot do; `npm run test -- --run` does kill this mutant
if (candidate.getDate() === target.getUTCDate()) yield candidate;
}
}
20 changes: 20 additions & 0 deletions src/_internals/test/arbitraries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as fc from "fast-check";

import { type GeneratePhoneType } from "../../generate-phone/generate-phone";
import { type LicensePlateFormat } from "../../get-format-license-plate/get-format-license-plate";
import { type BusinessDayOptions, isBusinessDay } from "../../is-business-day/is-business-day";
import { UF_TO_VOTER_ID_CODE } from "../../is-valid-voter-id/constants";
import { assembleBoletoArrecadacao } from "../assemble-boleto-arrecadacao/assemble-boleto-arrecadacao";
import { assembleBoletoBancario } from "../assemble-boleto-bancario/assemble-boleto-bancario";
Expand Down Expand Up @@ -169,6 +170,25 @@ export const businessDayDates: fc.Arbitrary<Date> = fc.date({
noInvalidDate: true,
});

/**
* A month inside the range the business day utils are exercised over, with every business day it
* has at 00:00 local time, found by asking `isBusinessDay` about each day in turn: the brute force
* answer the month recipes of `addBusinessDays`/`subBusinessDays` are checked against.
*/
export const businessDayMonths = (
options?: BusinessDayOptions,
): fc.Arbitrary<{ year: number; month: number; businessDays: Date[] }> =>
fc
.record({ year: fc.integer({ min: 1950, max: 2050 }), month: fc.integer({ min: 0, max: 11 }) })
.map(({ year, month }) => ({
year,
month,
businessDays: Array.from(
{ length: new Date(year, month + 1, 0).getDate() },
(_, index) => new Date(year, month, index + 1),
).filter((day) => isBusinessDay(day, options)),
}));

/**
* `Object.prototype`'s own keys: the ones a lookup must resolve as unknown rather than reach
* through the prototype chain.
Expand Down
4 changes: 4 additions & 0 deletions src/_internals/test/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { type bench as vitestBench, type expectTypeOf as vitestExpectTypeOf } from "vite-plus/test";

import { createTimeZoneSuite } from "./timezones";

type RuntimeModule = {
afterEach: (callback: () => void | Promise<void>) => void;
bench: typeof vitestBench;
Expand All @@ -24,3 +26,5 @@ const runtimeModule = await loadRuntime();

export const { afterEach, bench, beforeEach, describe, expect, expectTypeOf, it, test, vi } =
runtimeModule;

export const inTimeZone = createTimeZoneSuite(runtimeModule);
106 changes: 106 additions & 0 deletions src/_internals/test/timezones.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
type Environment = Record<string, string | undefined>;

type ProcessLike = {
env?: Environment;
};

type Hook = (callback: () => void) => void;

type Describe = ((name: string, callback: () => void) => void) & {
skip: (name: string, callback: () => void) => void;
};

type TimeZoneRuntime = {
afterEach: Hook;
beforeEach: Hook;
describe: Describe;
};

/** Declares a suite whose every test runs with the process time zone pinned to a given zone. */
export type TimeZoneSuite = (timeZone: string, suite: () => void) => void;

const KIRITIMATI_OFFSET_IN_MINUTES = -840;

const getEnvironment = (): Environment | undefined => {
const globalWithProcess = globalThis as typeof globalThis & { process?: ProcessLike };

try {
return globalWithProcess.process?.env;
} catch {
return undefined;
}
};

const environment = getEnvironment();

/**
* The zone to go back to, resolved before anything below changes `TZ`. Restoring means assigning
* this name again, never `delete process.env.TZ`: Bun stops applying any later `TZ` once the
* variable has been deleted once, which would silently run the rest of the suite in the wrong
* zone.
*/
const ambientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;

const setTimeZone = (timeZone: string): void => {
if (environment === undefined) return;

environment["TZ"] = timeZone;
};

/**
* Node, Bun and Deno all apply a new `process.env.TZ` to the `Date` objects built after it, which
* is what lets a test pin a time zone. A browser has no such switch, so the two zones below both
* report the ambient offset and the time zone suites are skipped there.
*/
const canSetTimeZone = (): boolean => {
if (environment === undefined) return false;

try {
setTimeZone("UTC");

const utcOffset = new Date(2024, 0, 1).getTimezoneOffset();

setTimeZone("Pacific/Kiritimati");

const kiritimatiOffset = new Date(2024, 0, 1).getTimezoneOffset();

setTimeZone(ambientTimeZone);

return utcOffset === 0 && kiritimatiOffset === KIRITIMATI_OFFSET_IN_MINUTES;
} catch {
return false;
}
};

/**
* Builds the `inTimeZone` helper `src/_internals/test/runtime` exports, around the `describe` and
* the hooks of whichever runtime the tests run on. It takes them as an argument rather than
* importing them so that the runtime module can export the helper without the two modules
* importing each other.
*
* @param {TimeZoneRuntime} runtime - The `describe`, `beforeEach` and `afterEach` of the runtime in use.
* @returns {TimeZoneSuite} A `describe` that pins the process time zone around every test inside it.
*/
export const createTimeZoneSuite = ({
afterEach,
beforeEach,
describe,
}: TimeZoneRuntime): TimeZoneSuite => {
const supported = canSetTimeZone();

return (timeZone, suite) => {
const describeTimeZone = supported ? describe : describe.skip;

describeTimeZone(`in ${timeZone}`, () => {
beforeEach(() => {
setTimeZone(timeZone);
});

afterEach(() => {
setTimeZone(ambientTimeZone);
});

suite();
});
};
};
Loading
Loading