From f361071885e8fdbd059d458ab216687e6ec8eedd Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:27:47 -0300 Subject: [PATCH 1/4] fix(business-days): walk calendar days by integer offsets, never by mutating a Date addBusinessDays, subBusinessDays and differenceInBusinessDays advanced their walk with result.setDate(result.getDate() + step). That call is not guaranteed to change the local calendar day: when the neighbouring day does not exist in the zone, because it was skipped to cross the date line, the runtime re-normalizes onto the same day and the loop has a fixed point. Under TZ=Pacific/Apia, subBusinessDays(new Date(2012, 0, 5, 12), 4) and differenceInBusinessDays(new Date(2011, 11, 1), new Date(2011, 11, 31)) never return: they freeze the calling thread, and a browser tab with it, on input that is perfectly valid. Pacific/Fakaofo (30 December 2011), Pacific/Kiritimati and Pacific/Enderbury (31 December 1994) and Pacific/Kwajalein (21 August 1993) have the same five-day set of local days that no Date can carry, and the backward walks reach every one of them. The same normalization also dragged a shifted hour through the rest of the walk, which addBusinessDays masked with a final setHours: the hour came back but the minutes did not, so a walk crossing the half hour transition of Australia/Lord_Howe moved 02:15 to 02:45. The new src/_internals/each-local-day walks the days between two local calendar days, given as the Date.UTC numbers of those days, with an integer counter: it always advances, it stops after a fixed number of steps whatever the zone does, and it never mutates a Date. Each day is yielded at noon, the one time of day every existing local day has, so a caller reading the local year, month, day and weekday, which is all isBusinessDay reads, always sees the day it asked for. The five local days no zone ever had are skipped rather than yielded twice as the day the runtime resolves them to. All three utils now drive their walk with it, so there is one implementation to reason about instead of three. Behaviour is unchanged for every input that did not hang or fall on a daylight saving boundary, including the sign convention, the boundary treatment and the 1900-2099 refusal, all still pinned by the existing tests. The new time zone suites run on Node, Bun and Deno through the inTimeZone helper of the test runtime and are skipped where the process time zone cannot be changed. --- .../each-local-day/each-local-day.test.ts | 70 ++++++++++++ .../each-local-day/each-local-day.ts | 65 +++++++++++ src/_internals/test/runtime.ts | 4 + src/_internals/test/timezones.ts | 106 ++++++++++++++++++ .../add-business-days.test.ts | 26 ++++- src/add-business-days/add-business-days.ts | 40 +++++-- .../difference-in-business-days.test.ts | 20 +++- .../difference-in-business-days.ts | 15 +-- .../sub-business-days.test.ts | 16 ++- 9 files changed, 336 insertions(+), 26 deletions(-) create mode 100644 src/_internals/each-local-day/each-local-day.test.ts create mode 100644 src/_internals/each-local-day/each-local-day.ts create mode 100644 src/_internals/test/timezones.ts diff --git a/src/_internals/each-local-day/each-local-day.test.ts b/src/_internals/each-local-day/each-local-day.test.ts new file mode 100644 index 000000000..3310131cb --- /dev/null +++ b/src/_internals/each-local-day/each-local-day.test.ts @@ -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]); + }); + }); +}); diff --git a/src/_internals/each-local-day/each-local-day.ts b/src/_internals/each-local-day/each-local-day.ts new file mode 100644 index 000000000..e9fa08bb3 --- /dev/null +++ b/src/_internals/each-local-day/each-local-day.ts @@ -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` 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 { + 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; + } +} diff --git a/src/_internals/test/runtime.ts b/src/_internals/test/runtime.ts index 8e3717516..90de61d01 100644 --- a/src/_internals/test/runtime.ts +++ b/src/_internals/test/runtime.ts @@ -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; bench: typeof vitestBench; @@ -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); diff --git a/src/_internals/test/timezones.ts b/src/_internals/test/timezones.ts new file mode 100644 index 000000000..01d550a89 --- /dev/null +++ b/src/_internals/test/timezones.ts @@ -0,0 +1,106 @@ +type Environment = Record; + +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(); + }); + }; +}; diff --git a/src/add-business-days/add-business-days.test.ts b/src/add-business-days/add-business-days.test.ts index 65513c766..691c22505 100644 --- a/src/add-business-days/add-business-days.test.ts +++ b/src/add-business-days/add-business-days.test.ts @@ -8,7 +8,7 @@ import { PROTOTYPE_KEYS, } from "../_internals/test/arbitraries"; import { expectNeverThrowsWithArguments } from "../_internals/test/properties"; -import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { describe, expect, expectTypeOf, inTimeZone, it, test } from "../_internals/test/runtime"; import { type BusinessDayOptions, isBusinessDay } from "../is-business-day/is-business-day"; import { addBusinessDays } from "./add-business-days"; @@ -215,6 +215,30 @@ describe("addBusinessDays", () => { expect(result?.getMilliseconds()).toBe(500); }); + inTimeZone("Pacific/Apia", () => { + it("should walk back over 30 December 2011, the local day Samoa skipped to cross the date line", () => { + expect(addBusinessDays(new Date(2012, 0, 5, 12), -4)).toEqual(new Date(2011, 11, 29, 12)); + }); + }); + + inTimeZone("America/Sao_Paulo", () => { + it("should keep the time-of-day across the summer time start of 4 November 2018", () => { + const result = addBusinessDays(new Date(2018, 10, 1, 9, 30, 15, 500), 5); + + expect(result).toEqual(new Date(2018, 10, 9, 9, 30, 15, 500)); + expect(result?.getHours()).toBe(9); + }); + }); + + inTimeZone("Australia/Lord_Howe", () => { + it("should keep the minutes across the half hour transition of 6 October 2024", () => { + const result = addBusinessDays(new Date(2024, 9, 3, 2, 15), 2); + + expect(result).toEqual(new Date(2024, 9, 7, 2, 15)); + expect(result?.getMinutes()).toBe(15); + }); + }); + describe("properties", () => { const amounts = fc.integer({ min: -200, max: 200 }); diff --git a/src/add-business-days/add-business-days.ts b/src/add-business-days/add-business-days.ts index 8696a6c51..5939161ca 100644 --- a/src/add-business-days/add-business-days.ts +++ b/src/add-business-days/add-business-days.ts @@ -1,3 +1,5 @@ +import { HOLIDAYS_MAX_YEAR, HOLIDAYS_MIN_YEAR } from "../_internals/constants/holidays"; +import { eachLocalDay } from "../_internals/each-local-day/each-local-day"; import { isSupportedHolidayYear } from "../_internals/is-supported-holiday-year/is-supported-holiday-year"; import { isValidDate } from "../_internals/is-valid-date/is-valid-date"; import { type BusinessDayOptions, isBusinessDay } from "../is-business-day/is-business-day"; @@ -21,7 +23,10 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * positively. * * The time-of-day (hours, minutes, seconds, milliseconds) of `date` is preserved in the - * result, and `date` itself is never mutated. + * result, daylight saving transitions along the way included, and `date` itself is never + * mutated. The one case that cannot be honoured is a time of day the resulting local day does + * not have, such as `00:30` on a day whose clocks jump from `00:00` to `01:00`: the result is + * then the nearest instant of that day, `01:30`. * * If `options.stateCode` is provided but is not a valid/known state code, it is ignored and * only national holidays are considered (same behavior as `getHolidays`/`isBusinessDay`), so a @@ -74,24 +79,35 @@ export const addBusinessDays = ( if (!isSupportedHolidayYear(date.getFullYear())) return null; - const result = new Date(date); + if (amount === 0) return new Date(date); - const hours = result.getHours(); - // Stryker disable next-line EqualityOperator: when amount is 0, remaining is 0 below and the loop never reads step, so > vs >= here is unobservable + // Stryker disable next-line EqualityOperator: amount is never 0 here, so > vs >= is unobservable const step = amount > 0 ? 1 : -1; let remaining = Math.abs(amount); - while (remaining > 0) { - result.setDate(result.getDate() + step); + const walk = eachLocalDay({ + from: Date.UTC(date.getFullYear(), date.getMonth(), date.getDate() + step), + until: + step === 1 ? Date.UTC(HOLIDAYS_MAX_YEAR + 1, 0, 1) : Date.UTC(HOLIDAYS_MIN_YEAR - 1, 11, 31), + }); - if (!isSupportedHolidayYear(result.getFullYear())) return null; - - if (isBusinessDay(result, options)) { + for (const candidate of walk) { + if (isBusinessDay(candidate, options)) { remaining -= 1; + + if (remaining === 0) { + return new Date( + candidate.getFullYear(), + candidate.getMonth(), + candidate.getDate(), + date.getHours(), + date.getMinutes(), + date.getSeconds(), + date.getMilliseconds(), + ); + } } } - result.setHours(hours); - - return result; + return null; }; diff --git a/src/difference-in-business-days/difference-in-business-days.test.ts b/src/difference-in-business-days/difference-in-business-days.test.ts index 5669113d6..aef047991 100644 --- a/src/difference-in-business-days/difference-in-business-days.test.ts +++ b/src/difference-in-business-days/difference-in-business-days.test.ts @@ -6,7 +6,7 @@ import { PROTOTYPE_KEYS, } from "../_internals/test/arbitraries"; import { expectNeverThrowsWithArguments } from "../_internals/test/properties"; -import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { describe, expect, expectTypeOf, inTimeZone, it, test } from "../_internals/test/runtime"; import { addBusinessDays } from "../add-business-days/add-business-days"; import { type BusinessDayOptions, isBusinessDay } from "../is-business-day/is-business-day"; import { differenceInBusinessDays } from "./difference-in-business-days"; @@ -164,6 +164,24 @@ describe("differenceInBusinessDays", () => { }); }); + inTimeZone("Pacific/Apia", () => { + it("should count the 20 business days December 2011 has there, the missing 30th excluded", () => { + expect(differenceInBusinessDays(new Date(2011, 11, 1), new Date(2011, 11, 31))).toBe(-20); + }); + }); + + inTimeZone("UTC", () => { + it("should count 21 for the same December, where the 30th is an ordinary Friday", () => { + expect(differenceInBusinessDays(new Date(2011, 11, 1), new Date(2011, 11, 31))).toBe(-21); + }); + }); + + inTimeZone("America/Sao_Paulo", () => { + it("should count November 2018 across the summer time start of the 4th", () => { + expect(differenceInBusinessDays(new Date(2018, 10, 30), new Date(2018, 10, 1))).toBe(19); + }); + }); + describe("properties", () => { const amounts = fc.integer({ min: -100, max: 100 }); diff --git a/src/difference-in-business-days/difference-in-business-days.ts b/src/difference-in-business-days/difference-in-business-days.ts index 6b24db617..f0f412b95 100644 --- a/src/difference-in-business-days/difference-in-business-days.ts +++ b/src/difference-in-business-days/difference-in-business-days.ts @@ -1,3 +1,4 @@ +import { eachLocalDay } from "../_internals/each-local-day/each-local-day"; import { isSupportedHolidayYear } from "../_internals/is-supported-holiday-year/is-supported-holiday-year"; import { isValidDate } from "../_internals/is-valid-date/is-valid-date"; import { type BusinessDayOptions, isBusinessDay } from "../is-business-day/is-business-day"; @@ -78,20 +79,12 @@ export const differenceInBusinessDays = ( const laterDay = toLocalDayTimestamp(laterDate); const earlierDay = toLocalDayTimestamp(earlierDate); - - // Stryker disable next-line EqualityOperator: when the two days are equal, the loop below never runs (movingDate already equals laterDay), so < vs <= here is unobservable - const step = earlierDay < laterDay ? 1 : -1; - const movingDate = new Date( - earlierDate.getFullYear(), - earlierDate.getMonth(), - earlierDate.getDate(), - ); + const step = Math.sign(laterDay - earlierDay); let result = 0; - while (toLocalDayTimestamp(movingDate) !== laterDay) { - if (isBusinessDay(movingDate, options)) result += step; - movingDate.setDate(movingDate.getDate() + step); + for (const candidate of eachLocalDay({ from: earlierDay, until: laterDay })) { + if (isBusinessDay(candidate, options)) result += step; } return result; diff --git a/src/sub-business-days/sub-business-days.test.ts b/src/sub-business-days/sub-business-days.test.ts index f428e7a84..8de347ae4 100644 --- a/src/sub-business-days/sub-business-days.test.ts +++ b/src/sub-business-days/sub-business-days.test.ts @@ -8,7 +8,7 @@ import { PROTOTYPE_KEYS, } from "../_internals/test/arbitraries"; import { expectNeverThrowsWithArguments } from "../_internals/test/properties"; -import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { describe, expect, expectTypeOf, inTimeZone, it, test } from "../_internals/test/runtime"; import { addBusinessDays } from "../add-business-days/add-business-days"; import { type BusinessDayOptions } from "../is-business-day/is-business-day"; import { subBusinessDays } from "./sub-business-days"; @@ -121,6 +121,20 @@ describe("subBusinessDays", () => { } }); + inTimeZone("Pacific/Apia", () => { + it("should walk back over 30 December 2011, the local day Samoa skipped to cross the date line", () => { + expect(subBusinessDays(new Date(2012, 0, 5, 12), 4)).toEqual(new Date(2011, 11, 29, 12)); + }); + }); + + inTimeZone("America/Sao_Paulo", () => { + it("should keep the time-of-day across the summer time start of 4 November 2018", () => { + expect(subBusinessDays(new Date(2018, 10, 9, 9, 30), 5)).toEqual( + new Date(2018, 10, 1, 9, 30), + ); + }); + }); + describe("properties", () => { test("should never throw, regardless of the input, prototype chain state codes included", () => { expectNeverThrowsWithArguments( From 8ee63b8c2cdc29f0bf51679fe719329533a419c7 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:07:20 -0300 Subject: [PATCH 2/4] fix(get-holidays): memoize the local day, not the instant it was built at getHolidays memoizes a year and returns copies of the Date objects it built the first time. A Date is an instant, and the holidays of a year are local calendar days, so once the process time zone changes the memoized answer moves with it: under TZ=America/Sao_Paulo, a 2018 entry first computed under UTC turns 2 November 2018 00:00 into 1 November 21:00, and Finados stops being a holiday for the day it falls on. isBusinessDay and the walks built on it then count that day as a business day, which is how addBusinessDays(new Date(2018, 10, 1, 9, 30), 5) answered 8 November instead of 9. The memo now keeps the year, month and day of each holiday and builds the Date on the way out, in the zone the caller is in. It costs nothing: the copy on the way out already allocated one Date per holiday. The new time zone suite pins it, and the time zone suites of the business day utils stop depending on which test file warmed the memo first, which is why they passed on Node, where each test file gets its own module registry, and failed on Bun, where they share one. --- src/get-holidays/get-holidays.test.ts | 20 ++++++++++++++- src/get-holidays/get-holidays.ts | 36 ++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/get-holidays/get-holidays.test.ts b/src/get-holidays/get-holidays.test.ts index 5a8b7a0ad..59ad8266a 100644 --- a/src/get-holidays/get-holidays.test.ts +++ b/src/get-holidays/get-holidays.test.ts @@ -2,7 +2,14 @@ import * as fc from "fast-check"; import { HOLIDAYS_MAX_YEAR, HOLIDAYS_MIN_YEAR } from "../_internals/constants/holidays"; import { DATA as STATES, type StateCode } from "../_internals/constants/states"; -import { bench, describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; +import { + bench, + describe, + expect, + expectTypeOf, + inTimeZone, + test, +} from "../_internals/test/runtime"; import { isBusinessDay } from "../is-business-day/is-business-day"; import { STATE_HOLIDAYS } from "./constants"; import { getHolidays, type GetHolidaysParams, type Holiday } from "./get-holidays"; @@ -663,6 +670,17 @@ describe("getHolidays", () => { expect(rjHolidays.some((h) => h.name === "São Sebastião")).toBe(false); }); + inTimeZone("America/Sao_Paulo", () => { + test("should answer for the local days of the current time zone, not of the memoized one", () => { + const finados = getHolidays(2018).find((holiday) => holiday.name === "Finados"); + + expect(finados?.date.getMonth()).toBe(10); + expect(finados?.date.getDate()).toBe(2); + expect(finados?.date.getHours()).toBe(0); + expect(isBusinessDay(new Date(2018, 10, 2))).toBe(false); + }); + }); + describe("properties", () => { const yearArbitrary = fc.integer({ min: HOLIDAYS_MIN_YEAR, max: HOLIDAYS_MAX_YEAR }); const stateCodeArbitrary = fc.constantFrom(...STATES.map((state) => state.code)); diff --git a/src/get-holidays/get-holidays.ts b/src/get-holidays/get-holidays.ts index 32a51cb13..60a2ae5c1 100644 --- a/src/get-holidays/get-holidays.ts +++ b/src/get-holidays/get-holidays.ts @@ -39,10 +39,34 @@ export type GetHolidaysParams = { */ export type GetHolidaysOptions = GetHolidaysParams; -const cache = new Map(); +// A holiday is a local calendar day, so the memo keeps the year, month and day rather than the +// `Date` built from them: a `Date` is an instant, and the same instant falls on another local day +// once the process time zone changes, which would make a memoized year answer for the wrong days. +type MemoizedHoliday = { + name: string; + type: HolidayType; + year: number; + month: number; + day: number; +}; -const cloneHolidays = (holidays: Holiday[]): Holiday[] => - holidays.map((holiday) => ({ ...holiday, date: new Date(holiday.date) })); +const cache = new Map(); + +const memoizeHolidays = (holidays: Holiday[]): MemoizedHoliday[] => + holidays.map(({ name, type, date }) => ({ + name, + type, + year: date.getFullYear(), + month: date.getMonth(), + day: date.getDate(), + })); + +const buildHolidays = (holidays: MemoizedHoliday[]): Holiday[] => + holidays.map(({ name, type, year, month, day }) => ({ + name, + date: new Date(year, month, day), + type, + })); const computeHolidays = (year: number, stateCode: StateCode | undefined): Holiday[] => { const holidays: Holiday[] = []; @@ -247,11 +271,11 @@ export function getHolidays(yearOrOptions: number | GetHolidaysParams): Holiday[ const cached = cache.get(cacheKey); if (cached) { - return cloneHolidays(cached); + return buildHolidays(cached); } const holidays = computeHolidays(year, normalizedStateCode); - cache.set(cacheKey, holidays); + cache.set(cacheKey, memoizeHolidays(holidays)); - return cloneHolidays(holidays); + return holidays; } From 12fad5d08289e83e0b53f2d13ac6bfaabee46535 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:46:58 +0000 Subject: [PATCH 3/4] docs(business-days): show the n-th and last business day of a month with add/subBusinessDays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The month questions ("quinto dia útil", "último dia útil do mês") need no utility of their own: adding n business days to the last day of the month before gives the n-th business day of the month, and subtracting one from the first day of the month after gives the last one. Both docs now show that recipe under subBusinessDays, with the two ways it differs from a dedicated function spelled out: an n beyond the business days of the month lands in the next month, and January 1900 and December 2099 return null because the starting day is outside the supported years. They also say this is the banking count, not the payroll one of CLT art. 459 § 1º. The recipe is pinned by tests: hand counted examples (Ano novo, Carnaval, Sexta-feira Santa, Corpus Christi with and without includeOptional, a state holiday, the month spill, the year edges and December 2011 in Pacific/Apia, the case that used to hang) and two fast-check properties that compare it with a brute force walk of the month through isBusinessDay, generated by the new businessDayMonths arbitrary. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RLkm9YrtAifc6XCLFVEsdH --- docs/pt-br/utilities.md | 18 ++++++ docs/utilities.md | 18 ++++++ src/_internals/test/arbitraries.ts | 20 +++++++ .../add-business-days.test.ts | 59 +++++++++++++++++++ .../sub-business-days.test.ts | 37 ++++++++++++ 5 files changed, 152 insertions(+) diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index b7aef9e45..b8e1c341a 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -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. +- Janeiro de 1900 e dezembro de 2099 retornam `null`, porque o dia de partida sai dos anos suportados. + ### differenceInBusinessDays Conta os dias úteis entre duas datas. Assinatura: `differenceInBusinessDays(laterDate, earlierDate, options?)`, a mesma do date-fns. diff --git a/docs/utilities.md b/docs/utilities.md index 59a9ef9d2..3579920a6 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -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. +- January 1900 and December 2099 return `null`, since the starting day is outside the supported years. + ### differenceInBusinessDays Count the Brazilian business days (dias úteis) between two dates. Signature: `differenceInBusinessDays(laterDate, earlierDate, options?)`, the same as date-fns. diff --git a/src/_internals/test/arbitraries.ts b/src/_internals/test/arbitraries.ts index b5c9a94aa..032b56561 100644 --- a/src/_internals/test/arbitraries.ts +++ b/src/_internals/test/arbitraries.ts @@ -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"; @@ -169,6 +170,25 @@ export const businessDayDates: fc.Arbitrary = 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. diff --git a/src/add-business-days/add-business-days.test.ts b/src/add-business-days/add-business-days.test.ts index 691c22505..aeb61c4d9 100644 --- a/src/add-business-days/add-business-days.test.ts +++ b/src/add-business-days/add-business-days.test.ts @@ -5,6 +5,7 @@ import { anyBusinessDayDate, anyBusinessDayOptions, businessDayDates, + businessDayMonths, PROTOTYPE_KEYS, } from "../_internals/test/arbitraries"; import { expectNeverThrowsWithArguments } from "../_internals/test/properties"; @@ -239,9 +240,67 @@ describe("addBusinessDays", () => { }); }); + describe("the n-th business day of a month, from the last day of the month before", () => { + it("should give the 5th business day of January 2024 (Jan 1 is Ano novo): Mon 2024-01-08", () => { + expect(addBusinessDays(new Date(2024, 0, 0), 5)).toEqual(new Date(2024, 0, 8)); + }); + + it("should give the 1st business day when the 1st of the month is a holiday: Tue 2024-01-02", () => { + expect(addBusinessDays(new Date(2024, 0, 0), 1)).toEqual(new Date(2024, 0, 2)); + }); + + it("should skip Carnaval for the 10th business day of February 2024 (Thu 2024-02-15), and count it when includeOptional is false (Wed 2024-02-14)", () => { + expect(addBusinessDays(new Date(2024, 1, 0), 10)).toEqual(new Date(2024, 1, 15)); + expect(addBusinessDays(new Date(2024, 1, 0), 10, { includeOptional: false })).toEqual( + new Date(2024, 1, 14), + ); + }); + + it("should skip a state holiday for the 7th business day of July 2024 in SP (Wed 2024-07-10)", () => { + expect(addBusinessDays(new Date(2024, 6, 0), 7, { stateCode: "SP" })).toEqual( + new Date(2024, 6, 10), + ); + }); + + it("should spill into the next month when the month has fewer business days (January 2024 has 22, the 23rd is Thu 2024-02-01)", () => { + expect(addBusinessDays(new Date(2024, 0, 0), 22)).toEqual(new Date(2024, 0, 31)); + expect(addBusinessDays(new Date(2024, 0, 0), 23)).toEqual(new Date(2024, 1, 1)); + }); + + it("should return null for January 1900, whose day before is in 1899, outside the supported years", () => { + expect(addBusinessDays(new Date(1900, 0, 0), 1)).toBeNull(); + }); + }); + describe("properties", () => { const amounts = fc.integer({ min: -200, max: 200 }); + test("should give the n-th business day of the month from the last day of the month before", () => { + fc.assert( + fc.property( + businessDayMonths(), + fc.integer({ min: 1, max: 23 }), + ({ year, month, businessDays }, n) => { + const result = addBusinessDays(new Date(year, month, 0), n); + + if (n <= businessDays.length) { + expect(result).toEqual(businessDays[n - 1]); + } else { + expect(result?.getMonth()).toBe((month + 1) % 12); + } + }, + ), + ); + }); + + test("should give the last business day of the month, walking back 1 from the first day of the month after", () => { + fc.assert( + fc.property(businessDayMonths(), ({ year, month, businessDays }) => { + expect(addBusinessDays(new Date(year, month + 1, 1), -1)).toEqual(businessDays.at(-1)); + }), + ); + }); + test("should never throw, regardless of the input, prototype chain state codes included", () => { expectNeverThrowsWithArguments( addBusinessDays, diff --git a/src/sub-business-days/sub-business-days.test.ts b/src/sub-business-days/sub-business-days.test.ts index 8de347ae4..3d4dc4cc2 100644 --- a/src/sub-business-days/sub-business-days.test.ts +++ b/src/sub-business-days/sub-business-days.test.ts @@ -121,6 +121,43 @@ describe("subBusinessDays", () => { } }); + describe("the last business day of a month, from the first day of the month after", () => { + it("should give Thu 2024-03-28 for March 2024 (Sexta-feira Santa, then a weekend)", () => { + expect(subBusinessDays(new Date(2024, 3, 1), 1)).toEqual(new Date(2024, 2, 28)); + }); + + it("should give Fri 2024-08-30 for August 2024 (the 31st is a Saturday)", () => { + expect(subBusinessDays(new Date(2024, 8, 1), 1)).toEqual(new Date(2024, 7, 30)); + }); + + it("should skip Corpus Christi on 2018-05-31 by default, and count it when includeOptional is false", () => { + expect(subBusinessDays(new Date(2018, 5, 1), 1)).toEqual(new Date(2018, 4, 30)); + expect(subBusinessDays(new Date(2018, 5, 1), 1, { includeOptional: false })).toEqual( + new Date(2018, 4, 31), + ); + }); + + it("should skip a state holiday (Dia do Evangélico, 2023-11-30 in DF): Wed 2023-11-29", () => { + expect(subBusinessDays(new Date(2023, 11, 1), 1, { stateCode: "DF" })).toEqual( + new Date(2023, 10, 29), + ); + }); + + it("should count from the end with a larger amount (the 2nd to last of January 2024 is Tue 2024-01-30)", () => { + expect(subBusinessDays(new Date(2024, 1, 1), 2)).toEqual(new Date(2024, 0, 30)); + }); + + it("should return null for December 2099, whose day after is in 2100, outside the supported years", () => { + expect(subBusinessDays(new Date(2100, 0, 1), 1)).toBeNull(); + }); + }); + + inTimeZone("Pacific/Apia", () => { + it("should give Thu 2011-12-29 as the last business day of December 2011, skipping the 30th Samoa never had", () => { + expect(subBusinessDays(new Date(2012, 0, 1), 1)).toEqual(new Date(2011, 11, 29)); + }); + }); + inTimeZone("Pacific/Apia", () => { it("should walk back over 30 December 2011, the local day Samoa skipped to cross the date line", () => { expect(subBusinessDays(new Date(2012, 0, 5, 12), 4)).toEqual(new Date(2011, 11, 29, 12)); From d31ea33e9752b216ebdfb813f0854e36870b69d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 18:47:43 +0000 Subject: [PATCH 4/4] docs(business-days): name every skipped day, the recipe's year edges and sub's time caveat The day walk comment said five local days do not exist but listed four, leaving out Pacific/Fakaofo. The recipe bullet said January 1900 and December 2099 return null, when only the n-th business day of the first and the last business day of the second do. subBusinessDays said the time of day is always kept, without the caveat addBusinessDays, which it delegates to, documents. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RLkm9YrtAifc6XCLFVEsdH --- docs/pt-br/utilities.md | 2 +- docs/utilities.md | 2 +- src/_internals/each-local-day/each-local-day.ts | 4 ++-- src/sub-business-days/sub-business-days.ts | 5 ++++- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index b8e1c341a..4cb8a8127 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -1878,7 +1878,7 @@ subBusinessDays(new Date(2024, 1, 1), 2); // Date, 2024-01-30 00:00 (penúltimo - 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. -- Janeiro de 1900 e dezembro de 2099 retornam `null`, porque o dia de partida sai dos anos suportados. +- 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 diff --git a/docs/utilities.md b/docs/utilities.md index 3579920a6..d6abd35d3 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -1878,7 +1878,7 @@ subBusinessDays(new Date(2024, 1, 1), 2); // Date, 2024-01-30 00:00 (2nd to last - 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. -- January 1900 and December 2099 return `null`, since the starting day is outside the supported years. +- 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 diff --git a/src/_internals/each-local-day/each-local-day.ts b/src/_internals/each-local-day/each-local-day.ts index e9fa08bb3..b352b8f74 100644 --- a/src/_internals/each-local-day/each-local-day.ts +++ b/src/_internals/each-local-day/each-local-day.ts @@ -18,8 +18,8 @@ const NOON = 12; * 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` skipped 30 December 2011, `Pacific/Kiritimati` - * and `Pacific/Enderbury` 31 December 1994, `Pacific/Kwajalein` 21 August 1993, all of them crossing + * 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. * diff --git a/src/sub-business-days/sub-business-days.ts b/src/sub-business-days/sub-business-days.ts index 664c6fbde..4396cc24e 100644 --- a/src/sub-business-days/sub-business-days.ts +++ b/src/sub-business-days/sub-business-days.ts @@ -17,7 +17,10 @@ export type { BusinessDayOptions } from "../is-business-day/is-business-day"; * `subBusinessDays`. * * The time-of-day (hours, minutes, seconds, milliseconds) of `date` is preserved in the result, - * and `date` itself is never mutated. + * daylight saving transitions along the way included, and `date` itself is never mutated. The + * one case that cannot be honoured is a time of day the resulting local day does not have, such + * as `00:30` on a day whose clocks jump from `00:00` to `01:00`: the result is then the nearest + * instant of that day, `01:30`, as in `addBusinessDays`. * * If `options.stateCode` is provided but is not a valid/known state code, it is ignored and only * national holidays are considered (same behavior as `getHolidays`/`isBusinessDay`), so a