diff --git a/examples/AsiaSaigonTimezone.test.tsx b/examples/AsiaSaigonTimezone.test.tsx index 0bd75082f..de659c936 100644 --- a/examples/AsiaSaigonTimezone.test.tsx +++ b/examples/AsiaSaigonTimezone.test.tsx @@ -7,7 +7,7 @@ beforeEach(() => { render(); }); -test.skip("the first row should display 7 days", () => { +test("the first row should display 7 days", () => { expect( screen.getAllByRole("row")[0].querySelectorAll("[role='gridcell']"), ).toHaveLength(7); diff --git a/examples/AsiaSaigonTimezone.tsx b/examples/AsiaSaigonTimezone.tsx index a8bd7b0e7..622e6fdc5 100644 --- a/examples/AsiaSaigonTimezone.tsx +++ b/examples/AsiaSaigonTimezone.tsx @@ -1,7 +1,17 @@ import React from "react"; -import { DayPicker } from "react-day-picker"; +import { DayPicker, TZDate } from "react-day-picker"; export function AsiaSaigonTimezone() { - return ; + const timeZone = "Asia/Saigon"; + + return ( + + ); } diff --git a/examples/PersianNoonSafe.test.tsx b/examples/PersianNoonSafe.test.tsx new file mode 100644 index 000000000..4d0602029 --- /dev/null +++ b/examples/PersianNoonSafe.test.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { DayPicker, enUS, faIR } from "react-day-picker/persian"; + +import { render, screen, within } from "@/test/render"; + +test("Persian noonSafe keeps full weeks in historical time zones", () => { + render( + , + ); + + const grid = screen.getByRole("grid"); + const rows = within(grid).getAllByRole("row"); + const dayRows = rows.filter( + (row) => within(row).queryAllByRole("gridcell").length > 0, + ); + + expect(within(dayRows[0]).getAllByRole("gridcell")).toHaveLength(7); + expect( + within(dayRows[dayRows.length - 1]).getAllByRole("gridcell"), + ).toHaveLength(7); +}); + +test("Persian noonSafe renders full first week for historical Saigon month", () => { + render( + , + ); + + const grid = screen.getByRole("grid"); + const rows = within(grid).getAllByRole("row"); + const dayRows = rows.filter( + (row) => within(row).queryAllByRole("gridcell").length > 0, + ); + + expect(within(dayRows[0]).getAllByRole("gridcell")).toHaveLength(7); +}); + +test("month dropdown does not repeat month labels when noonSafe is set", () => { + render( + , + ); + + const monthSelect = screen.getAllByRole("combobox")[0]; + const monthLabels = within(monthSelect) + .getAllByRole("option") + .map((option) => option.textContent); + + expect(new Set(monthLabels).size).toBe(monthLabels.length); +}); diff --git a/examples/TimeZoneNoonSafe.test.tsx b/examples/TimeZoneNoonSafe.test.tsx new file mode 100644 index 000000000..b0d3e739e --- /dev/null +++ b/examples/TimeZoneNoonSafe.test.tsx @@ -0,0 +1,157 @@ +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { DayPicker } from "react-day-picker"; +import { dateButton, grid } from "@/test/elements"; +import { render, screen, within } from "@/test/render"; +import { TimeZoneNoonSafe } from "./TimeZoneNoonSafe"; + +test("the first row should display 7 days", () => { + render(); + const grid = screen.getByRole("grid"); + const rows = within(grid).getAllByRole("row"); + const firstDayRow = rows.find((row) => + row.querySelector("[role='gridcell']"), + ); + expect(firstDayRow?.querySelectorAll("[role='gridcell']")).toHaveLength(7); +}); + +test("week numbers remain valid when using noonSafe", () => { + render(); + const weekNumbers = screen + .getAllByRole("rowheader") + .map((cell) => Number(cell.textContent)) + .filter((value) => Number.isFinite(value)); + + expect(weekNumbers.length).toBeGreaterThan(0); + weekNumbers.forEach((value) => { + expect(value).toBeGreaterThan(0); + expect(value).toBeLessThan(60); + }); +}); + +test("the last row should display 7 days", () => { + render(); + const grid = screen.getByRole("grid"); + const rows = within(grid).getAllByRole("row"); + const lastDayRow = [...rows] + .reverse() + .find((row) => row.querySelector("[role='gridcell']")); + expect(lastDayRow?.querySelectorAll("[role='gridcell']")).toHaveLength(7); +}); + +describe("TimeZoneNoonSafe navigation", () => { + test("previous and next month buttons render full weeks", async () => { + render(); + const user = userEvent.setup(); + + const assertFirstAndLastRowHave7Cells = () => { + const [grid] = screen.getAllByRole("grid"); + const rows = within(grid).getAllByRole("row"); + const dayRows = rows.filter( + (row) => within(row).queryAllByRole("gridcell").length > 0, + ); + const firstCells = within(dayRows[0]).getAllByRole("gridcell"); + const lastCells = within(dayRows[dayRows.length - 1]).getAllByRole( + "gridcell", + ); + expect(firstCells.length).toBe(7); + expect(lastCells.length).toBe(7); + }; + + // Current month + assertFirstAndLastRowHave7Cells(); + + // Move to previous month + await user.click(screen.getByRole("button", { name: /previous month/i })); + assertFirstAndLastRowHave7Cells(); + + // Move to next month twice (back to original and forward one) + await user.click(screen.getByRole("button", { name: /next month/i })); + await user.click(screen.getByRole("button", { name: /next month/i })); + assertFirstAndLastRowHave7Cells(); + }); +}); + +test("year dropdown starts at the fromMonth year", () => { + const timeZone = "Asia/Dubai"; + const fromMonth = new Date(1880, 0, 1); + render( + , + ); + const selectYear = screen.getAllByRole("combobox")[1]; + const firstYearOption = within(selectYear).getAllByRole("option")[0]; + + expect(Number(firstYearOption.getAttribute("value"))).toBe(1880); +}); + +describe("when props are midnight UTC dates with noonSafe and a time zone", () => { + const originalTz = process.env.TZ; + const isoDate = new Date("2024-03-01T00:00:00.000Z"); + + beforeAll(() => { + process.env.TZ = "America/Los_Angeles"; + }); + + afterAll(() => { + process.env.TZ = originalTz; + }); + + test("the month prop is interpreted in the target zone", () => { + render( + , + ); + + expect(grid("March 2024")).toBeInTheDocument(); + }); + + test("selected/disabled dates are interpreted in the target zone", () => { + render( + , + ); + + const marchFirst = dateButton(new Date(2024, 2, 1)); + expect(marchFirst).toBeInTheDocument(); + expect(marchFirst).toBeDisabled(); + }); +}); + +describe("when the system zone is Honolulu and the target zone is historical Auckland", () => { + const originalTz = process.env.TZ; + + beforeAll(() => { + process.env.TZ = "Pacific/Honolulu"; + }); + + afterAll(() => { + process.env.TZ = originalTz; + }); + + test("noonSafe keeps the full month grid", () => { + render( + , + ); + + expect( + document.querySelector('[data-day="1900-11-01"]'), + ).toBeInTheDocument(); + expect( + document.querySelector('[data-day="1900-11-30"]'), + ).toBeInTheDocument(); + }); +}); diff --git a/examples/TimeZoneNoonSafe.tsx b/examples/TimeZoneNoonSafe.tsx new file mode 100644 index 000000000..3640648fb --- /dev/null +++ b/examples/TimeZoneNoonSafe.tsx @@ -0,0 +1,64 @@ +import React, { useState } from "react"; + +import { + DayPicker, + type DayPickerProps, + type PropsSingle, + TZDate, +} from "react-day-picker"; + +type TimeZoneNoonSafeProps = Partial & { + selected?: Date; + onSelect?: PropsSingle["onSelect"]; +}; + +export function TimeZoneNoonSafe(props: TimeZoneNoonSafeProps = {}) { + const { + timeZone: timeZoneProp, + weekStartsOn: weekStartsOnProp, + selected: selectedProp, + onSelect: onSelectProp, + defaultMonth, + startMonth, + footer, + mode: _mode, + ...rest + } = props; + + const timeZone = timeZoneProp ?? "Asia/Dubai"; + const weekStartsOn = (weekStartsOnProp ?? + 1) as DayPickerProps["weekStartsOn"]; + const [selected, setSelected] = useState( + selectedProp ?? new TZDate(1900, 11, 1, timeZone), + ); + const onSelect: PropsSingle["onSelect"] = + onSelectProp ?? + ((nextSelected) => { + setSelected(nextSelected ?? undefined); + }); + const selectedValue = selectedProp ?? selected; + + return ( + + ); +} diff --git a/examples/TimeZoneNoonSafeSimple.tsx b/examples/TimeZoneNoonSafeSimple.tsx new file mode 100644 index 000000000..9145a2f30 --- /dev/null +++ b/examples/TimeZoneNoonSafeSimple.tsx @@ -0,0 +1,41 @@ +import React, { useState } from "react"; + +import { DayPicker, TZDate } from "react-day-picker"; + +export function TimeZoneNoonSafeSimple() { + const timeZone = "Asia/Dubai"; + const [selected, setSelected] = useState( + new TZDate(1900, 11, 1, timeZone), + ); + const [noonSafeEnabled, setNoonSafeEnabled] = useState(true); + const formatter = new Intl.DateTimeFormat("en-US", { + dateStyle: "full", + timeStyle: "short", + timeZone, + }); + + return ( +
+ + +
+ ); +} diff --git a/examples/index.ts b/examples/index.ts index 97923afe8..efa70b3ef 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -101,6 +101,8 @@ export * from "./TestCase2843"; export * from "./TestCase2864"; export * from "./Testcase1567"; export * from "./TimeZone"; +export * from "./TimeZoneNoonSafe"; +export * from "./TimeZoneNoonSafeSimple"; export * from "./timezone/TestCase2833"; export * from "./Utc"; export * from "./WeekIso"; diff --git a/src/DayPicker.tsx b/src/DayPicker.tsx index 58eaa954b..725dbf254 100644 --- a/src/DayPicker.tsx +++ b/src/DayPicker.tsx @@ -13,6 +13,7 @@ import { getMonthOptions } from "./helpers/getMonthOptions.js"; import { getStyleForModifiers } from "./helpers/getStyleForModifiers.js"; import { getWeekdays } from "./helpers/getWeekdays.js"; import { getYearOptions } from "./helpers/getYearOptions.js"; +import { createNoonOverrides } from "./noonDateLib.js"; import type { DayPickerProps, Modifiers, @@ -100,18 +101,31 @@ export function DayPicker(initialProps: DayPickerProps) { const { components, formatters, labels, dateLib, locale, classNames } = useMemo(() => { const locale = { ...defaultLocale, ...props.locale }; + const weekStartsOn = props.broadcastCalendar ? 1 : props.weekStartsOn; + + const noonOverrides = + props.noonSafe && props.timeZone + ? createNoonOverrides(props.timeZone, { + weekStartsOn, + locale, + }) + : undefined; + const overrides = + props.dateLib && noonOverrides + ? { ...noonOverrides, ...props.dateLib } + : (props.dateLib ?? noonOverrides); const dateLib = new DateLib( { locale, - weekStartsOn: props.broadcastCalendar ? 1 : props.weekStartsOn, + weekStartsOn, firstWeekContainsDate: props.firstWeekContainsDate, useAdditionalWeekYearTokens: props.useAdditionalWeekYearTokens, useAdditionalDayOfYearTokens: props.useAdditionalDayOfYearTokens, timeZone: props.timeZone, numerals: props.numerals, }, - props.dateLib, + overrides, ); return { @@ -132,6 +146,7 @@ export function DayPicker(initialProps: DayPickerProps) { props.timeZone, props.numerals, props.dateLib, + props.noonSafe, props.components, props.formatters, props.labels, diff --git a/src/noonDateLib.ts b/src/noonDateLib.ts new file mode 100644 index 000000000..f3e351913 --- /dev/null +++ b/src/noonDateLib.ts @@ -0,0 +1,224 @@ +import { TZDate } from "@date-fns/tz"; +import type { EndOfWeekOptions, Locale, StartOfWeekOptions } from "date-fns"; +import { + differenceInCalendarDays as differenceInCalendarDaysFn, + differenceInCalendarMonths as differenceInCalendarMonthsFn, + getISOWeek as getISOWeekFn, + getWeek as getWeekFn, +} from "date-fns"; +import type { DateLib } from "./classes/DateLib.js"; + +export interface CreateNoonOverridesOptions { + weekStartsOn?: number; + locale?: Locale; +} + +type SupportedDate = Date | number | string | TZDate; +type WeekStartsOn = NonNullable; + +/** + * Creates `dateLib` overrides that keep all calendar math at noon in the target + * time zone. This avoids second-level offset changes (e.g., historical zones + * with +03:41:12) from pushing dates backward across midnight. + */ +export function createNoonOverrides( + timeZone: string, + options: CreateNoonOverridesOptions = {}, +): Partial { + const { weekStartsOn, locale } = options; + const fallbackWeekStartsOn: WeekStartsOn = (weekStartsOn ?? + locale?.options?.weekStartsOn ?? + 0) as WeekStartsOn; + + // Keep all internal math anchored at noon in the target zone to avoid + // historical second-level offsets from crossing midnight. + const toNoonTZDate = (date: SupportedDate): TZDate => { + const normalizedDate = + typeof date === "number" || typeof date === "string" + ? new Date(date) + : date; + return new TZDate( + normalizedDate.getFullYear(), + normalizedDate.getMonth(), + normalizedDate.getDate(), + 12, + 0, + 0, + timeZone, + ); + }; + + // Convert a value into a host `Date` that represents the same calendar day + // as the target-zone noon. This is useful for helpers (e.g., date-fns week + // utilities) that expect local `Date` instances rather than `TZDate`s. + const toCalendarDate = (date: SupportedDate): Date => { + const zoned = toNoonTZDate(date); + return new Date( + zoned.getFullYear(), + zoned.getMonth(), + zoned.getDate(), + 0, + 0, + 0, + 0, + ); + }; + + return { + today: () => { + return toNoonTZDate(TZDate.tz(timeZone)); + }, + + newDate: (year: number, monthIndex: number, date: number) => { + return new TZDate(year, monthIndex, date, 12, 0, 0, timeZone); + }, + + startOfDay: (date) => { + return toNoonTZDate(date); + }, + + startOfWeek: (date, options?: StartOfWeekOptions) => { + const base = toNoonTZDate(date); + const weekStartsOnValue = (options?.weekStartsOn ?? + fallbackWeekStartsOn) as WeekStartsOn; + const diff = (base.getDay() - weekStartsOnValue + 7) % 7; + base.setDate(base.getDate() - diff); + return base; + }, + + startOfISOWeek: (date) => { + const base = toNoonTZDate(date); + const diff = (base.getDay() - 1 + 7) % 7; + base.setDate(base.getDate() - diff); + return base; + }, + + startOfMonth: (date) => { + const base = toNoonTZDate(date); + base.setDate(1); + return base; + }, + + startOfYear: (date) => { + const base = toNoonTZDate(date); + base.setMonth(0, 1); + return base; + }, + + endOfWeek: (date, options?: EndOfWeekOptions) => { + const base = toNoonTZDate(date); + const weekStartsOnValue = (options?.weekStartsOn ?? + fallbackWeekStartsOn) as WeekStartsOn; + const endDow = (weekStartsOnValue + 6) % 7; + const diff = (endDow - base.getDay() + 7) % 7; + base.setDate(base.getDate() + diff); + return base; + }, + + endOfISOWeek: (date) => { + const base = toNoonTZDate(date); + const diff = (7 - base.getDay()) % 7; + base.setDate(base.getDate() + diff); + return base; + }, + + endOfMonth: (date) => { + const base = toNoonTZDate(date); + base.setMonth(base.getMonth() + 1, 0); + return base; + }, + + endOfYear: (date) => { + const base = toNoonTZDate(date); + base.setMonth(11, 31); + return base; + }, + + eachMonthOfInterval: (interval) => { + const start = toNoonTZDate(interval.start); + const end = toNoonTZDate(interval.end); + const result: Date[] = []; + const cursor = new TZDate( + start.getFullYear(), + start.getMonth(), + 1, + 12, + 0, + 0, + timeZone, + ); + const endKey = end.getFullYear() * 12 + end.getMonth(); + while (cursor.getFullYear() * 12 + cursor.getMonth() <= endKey) { + result.push(new TZDate(cursor, timeZone)); + cursor.setMonth(cursor.getMonth() + 1, 1); + } + return result; + }, + + // Normalize to noon once before arithmetic (avoid DST/midnight edge cases), + // mutate the same TZDate, and return it. + addDays: (date, amount) => { + const base = toNoonTZDate(date); + base.setDate(base.getDate() + amount); + return base; + }, + + addWeeks: (date, amount) => { + const base = toNoonTZDate(date); + base.setDate(base.getDate() + amount * 7); + return base; + }, + + addMonths: (date, amount) => { + const base = toNoonTZDate(date); + base.setMonth(base.getMonth() + amount); + return base; + }, + + addYears: (date, amount) => { + const base = toNoonTZDate(date); + base.setFullYear(base.getFullYear() + amount); + return base; + }, + + eachYearOfInterval: (interval) => { + const start = toNoonTZDate(interval.start); + const end = toNoonTZDate(interval.end); + const years: Date[] = []; + const cursor = new TZDate(start.getFullYear(), 0, 1, 12, 0, 0, timeZone); + while (cursor.getFullYear() <= end.getFullYear()) { + years.push(new TZDate(cursor, timeZone)); + cursor.setFullYear(cursor.getFullYear() + 1, 0, 1); + } + return years; + }, + + getWeek: (date, options) => { + const base = toCalendarDate(date); + return getWeekFn(base, { + weekStartsOn: options?.weekStartsOn ?? fallbackWeekStartsOn, + firstWeekContainsDate: + options?.firstWeekContainsDate ?? + locale?.options?.firstWeekContainsDate ?? + 1, + }); + }, + + getISOWeek: (date) => { + const base = toCalendarDate(date); + return getISOWeekFn(base); + }, + + differenceInCalendarDays: (dateLeft, dateRight) => { + const left = toCalendarDate(dateLeft); + const right = toCalendarDate(dateRight); + return differenceInCalendarDaysFn(left, right); + }, + + differenceInCalendarMonths: (dateLeft, dateRight) => { + const left = toCalendarDate(dateLeft); + const right = toCalendarDate(dateRight); + return differenceInCalendarMonthsFn(left, right); + }, + }; +} diff --git a/src/noonJalaliDateLib.ts b/src/noonJalaliDateLib.ts new file mode 100644 index 000000000..eb334c6a3 --- /dev/null +++ b/src/noonJalaliDateLib.ts @@ -0,0 +1,173 @@ +import { TZDate } from "@date-fns/tz"; +import type { EndOfWeekOptions, StartOfWeekOptions } from "date-fns"; +import { + addDays as addDaysJalali, + addMonths as addMonthsJalali, + addWeeks as addWeeksJalali, + addYears as addYearsJalali, + differenceInCalendarDays as differenceInCalendarDaysJalali, + differenceInCalendarMonths as differenceInCalendarMonthsJalali, + eachMonthOfInterval as eachMonthOfIntervalJalali, + eachYearOfInterval as eachYearOfIntervalJalali, + endOfISOWeek as endOfISOWeekJalali, + endOfMonth as endOfMonthJalali, + endOfWeek as endOfWeekJalali, + endOfYear as endOfYearJalali, + getWeek as getWeekJalali, + startOfISOWeek as startOfISOWeekJalali, + startOfMonth as startOfMonthJalali, + startOfWeek as startOfWeekJalali, + startOfYear as startOfYearJalali, +} from "date-fns-jalali"; + +import type { DateLib } from "./classes/DateLib.js"; +import type { CreateNoonOverridesOptions } from "./noonDateLib.js"; + +type SupportedDate = Date | number | string | TZDate; +type WeekStartsOn = NonNullable; + +/** + * Jalali-aware version of {@link createNoonDateLibOverrides}. + * + * Keeps all calendar math at noon in the target time zone while deferring to + * `date-fns-jalali` for calendar logic. + */ +export function createJalaliNoonOverrides( + timeZone: string, + options: CreateNoonOverridesOptions = {}, +): Partial { + const { weekStartsOn, locale } = options; + const fallbackWeekStartsOn: WeekStartsOn = (weekStartsOn ?? + locale?.options?.weekStartsOn ?? + 6) as WeekStartsOn; + + // Keep all internal math anchored at noon in the target zone to avoid + // historical second-level offsets from crossing midnight. + const toNoonTZDate = (date: SupportedDate): TZDate => { + const normalizedDate = + typeof date === "number" || typeof date === "string" + ? new Date(date) + : date; + return new TZDate( + normalizedDate.getFullYear(), + normalizedDate.getMonth(), + normalizedDate.getDate(), + 12, + 0, + 0, + timeZone, + ); + }; + + // Represent the target-zone calendar date in the host zone so date-fns-jalali + // (which is not time-zone aware) can operate on stable wall times. + const toCalendarDate = (date: SupportedDate): Date => { + const zoned = toNoonTZDate(date); + return new Date( + zoned.getFullYear(), + zoned.getMonth(), + zoned.getDate(), + 12, + 0, + 0, + 0, + ); + }; + + return { + today: () => toNoonTZDate(TZDate.tz(timeZone)), + newDate: (year: number, monthIndex: number, date: number) => + new TZDate(year, monthIndex, date, 12, 0, 0, timeZone), + + startOfDay: (date) => { + return toNoonTZDate(date); + }, + startOfWeek: (date, options?: StartOfWeekOptions) => { + const weekStartsOnValue = (options?.weekStartsOn ?? + fallbackWeekStartsOn) as WeekStartsOn; + const start = startOfWeekJalali(toCalendarDate(date), { + weekStartsOn: weekStartsOnValue, + }); + return toNoonTZDate(start); + }, + startOfISOWeek: (date) => { + const start = startOfISOWeekJalali(toCalendarDate(date)); + return toNoonTZDate(start); + }, + startOfMonth: (date) => { + const start = startOfMonthJalali(toCalendarDate(date)); + return toNoonTZDate(start); + }, + startOfYear: (date) => { + const start = startOfYearJalali(toCalendarDate(date)); + return toNoonTZDate(start); + }, + + endOfWeek: (date, options?: EndOfWeekOptions) => { + const weekStartsOnValue = (options?.weekStartsOn ?? + fallbackWeekStartsOn) as WeekStartsOn; + const end = endOfWeekJalali(toCalendarDate(date), { + weekStartsOn: weekStartsOnValue, + }); + return toNoonTZDate(end); + }, + endOfISOWeek: (date) => { + const end = endOfISOWeekJalali(toCalendarDate(date)); + return toNoonTZDate(end); + }, + endOfMonth: (date) => { + const end = endOfMonthJalali(toCalendarDate(date)); + return toNoonTZDate(end); + }, + endOfYear: (date) => { + const end = endOfYearJalali(toCalendarDate(date)); + return toNoonTZDate(end); + }, + + eachMonthOfInterval: (interval) => { + return eachMonthOfIntervalJalali({ + start: toCalendarDate(interval.start), + end: toCalendarDate(interval.end), + }).map((date) => toNoonTZDate(date)); + }, + + addDays: (date, amount) => + toNoonTZDate(addDaysJalali(toCalendarDate(date), amount)), + addWeeks: (date, amount) => + toNoonTZDate(addWeeksJalali(toCalendarDate(date), amount)), + addMonths: (date, amount) => + toNoonTZDate(addMonthsJalali(toCalendarDate(date), amount)), + addYears: (date, amount) => + toNoonTZDate(addYearsJalali(toCalendarDate(date), amount)), + + eachYearOfInterval: (interval) => { + return eachYearOfIntervalJalali({ + start: toCalendarDate(interval.start), + end: toCalendarDate(interval.end), + }).map((date) => toNoonTZDate(date)); + }, + + getWeek: (date, options) => { + const base = toCalendarDate(date); + return getWeekJalali(base, { + weekStartsOn: options?.weekStartsOn ?? fallbackWeekStartsOn, + firstWeekContainsDate: + options?.firstWeekContainsDate ?? + locale?.options?.firstWeekContainsDate ?? + 1, + }); + }, + + differenceInCalendarDays: (dateLeft, dateRight) => { + const left = toCalendarDate(dateLeft); + const right = toCalendarDate(dateRight); + return differenceInCalendarDaysJalali(left, right); + }, + + differenceInCalendarMonths: (dateLeft, dateRight) => { + const left = toCalendarDate(dateLeft); + const right = toCalendarDate(dateRight); + return differenceInCalendarMonthsJalali(left, right); + }, + }; +} diff --git a/src/persian.tsx b/src/persian.tsx index 7f182b2b4..604c806ef 100644 --- a/src/persian.tsx +++ b/src/persian.tsx @@ -8,6 +8,7 @@ import { } from "./index.js"; import { enUSJalali } from "./locale/en-US-jalali.js"; import { faIRJalali } from "./locale/fa-IR-jalali.js"; +import { createJalaliNoonOverrides } from "./noonJalaliDateLib.js"; import type { DayPickerProps } from "./types/props.js"; /** Persian (Iran) Jalali locale with DayPicker labels. */ @@ -70,20 +71,32 @@ export function DayPicker( numerals?: DayPickerProps["numerals"]; }, ) { + const { + locale: localeProp, + dir, + dateLib: dateLibProp, + numerals, + noonSafe, + ...restProps + } = props; + const dateLib = getDateLib({ - locale: props.locale, + locale: localeProp, weekStartsOn: props.broadcastCalendar ? 1 : props.weekStartsOn, firstWeekContainsDate: props.firstWeekContainsDate, useAdditionalWeekYearTokens: props.useAdditionalWeekYearTokens, useAdditionalDayOfYearTokens: props.useAdditionalDayOfYearTokens, timeZone: props.timeZone, + numerals: numerals ?? "arabext", + noonSafe, + overrides: dateLibProp, }); return ( ); @@ -95,6 +108,23 @@ export function DayPicker( * @param options - Optional configuration for the date library. * @returns The date library instance. */ -export const getDateLib = (options?: DateLibOptions) => { - return new DateLib(options, dateFnsJalali); +export const getDateLib = ( + options?: DateLibOptions & { + noonSafe?: boolean; + overrides?: DayPickerProps["dateLib"]; + }, +) => { + const { noonSafe, overrides, ...dateLibOptions } = options ?? {}; + const baseOverrides = + noonSafe && dateLibOptions.timeZone + ? { + ...dateFnsJalali, + ...createJalaliNoonOverrides(dateLibOptions.timeZone, { + weekStartsOn: dateLibOptions.weekStartsOn, + locale: dateLibOptions.locale, + }), + } + : dateFnsJalali; + + return new DateLib(dateLibOptions, { ...baseOverrides, ...overrides }); }; diff --git a/src/types/props.ts b/src/types/props.ts index 5787138f5..099d2b291 100644 --- a/src/types/props.ts +++ b/src/types/props.ts @@ -304,6 +304,14 @@ export interface PropsBase { * @see https://daypicker.dev/docs/time-zone */ timeZone?: string | undefined; + /** + * Keep calendar math at noon in the configured {@link timeZone} to avoid + * historical second-level offsets drifting days across midnight. + * + * @since 9.13.0 + * @experimental + */ + noonSafe?: boolean | undefined; /** * Change the components used for rendering the calendar elements. * diff --git a/src/utils/convertMatchersToTimeZone.ts b/src/utils/convertMatchersToTimeZone.ts index ce7007531..164429d4d 100644 --- a/src/utils/convertMatchersToTimeZone.ts +++ b/src/utils/convertMatchersToTimeZone.ts @@ -1,3 +1,4 @@ +import { TZDate } from "@date-fns/tz"; import type { Matcher } from "../types/index.js"; import { toTimeZone } from "./toTimeZone.js"; import { @@ -7,18 +8,37 @@ import { isDateRange, } from "./typeguards.js"; -function convertMatcher(matcher: Matcher, timeZone: string): Matcher { +function toZoneNoon(date: Date, timeZone: string, noonSafe?: boolean) { + if (!noonSafe) return toTimeZone(date, timeZone); + const zoned = toTimeZone(date, timeZone); + const noonZoned = new TZDate( + zoned.getFullYear(), + zoned.getMonth(), + zoned.getDate(), + 12, + 0, + 0, + timeZone, + ); + return new Date(noonZoned.getTime()); +} + +function convertMatcher( + matcher: Matcher, + timeZone: string, + noonSafe?: boolean, +): Matcher { if (typeof matcher === "boolean" || typeof matcher === "function") { return matcher; } if (matcher instanceof Date) { - return toTimeZone(matcher, timeZone); + return toZoneNoon(matcher, timeZone, noonSafe); } if (Array.isArray(matcher)) { return matcher.map((value) => - value instanceof Date ? toTimeZone(value, timeZone) : value, + value instanceof Date ? toZoneNoon(value, timeZone, noonSafe) : value, ); } @@ -32,20 +52,20 @@ function convertMatcher(matcher: Matcher, timeZone: string): Matcher { if (isDateInterval(matcher)) { return { - before: toTimeZone(matcher.before, timeZone), - after: toTimeZone(matcher.after, timeZone), + before: toZoneNoon(matcher.before, timeZone, noonSafe), + after: toZoneNoon(matcher.after, timeZone, noonSafe), }; } if (isDateAfterType(matcher)) { return { - after: toTimeZone(matcher.after, timeZone), + after: toZoneNoon(matcher.after, timeZone, noonSafe), }; } if (isDateBeforeType(matcher)) { return { - before: toTimeZone(matcher.before, timeZone), + before: toZoneNoon(matcher.before, timeZone, noonSafe), }; } @@ -63,14 +83,17 @@ function convertMatcher(matcher: Matcher, timeZone: string): Matcher { export function convertMatchersToTimeZone( matchers: Matcher | Matcher[] | undefined, timeZone: string, + noonSafe?: boolean, ): Matcher | Matcher[] | undefined { if (!matchers) { return matchers; } if (Array.isArray(matchers)) { - return matchers.map((matcher) => convertMatcher(matcher, timeZone)); + return matchers.map((matcher) => + convertMatcher(matcher, timeZone, noonSafe), + ); } - return convertMatcher(matchers, timeZone); + return convertMatcher(matchers, timeZone, noonSafe); } diff --git a/website/docs/localization/setting-time-zone.mdx b/website/docs/localization/setting-time-zone.mdx index 772721f05..3e3929cd0 100644 --- a/website/docs/localization/setting-time-zone.mdx +++ b/website/docs/localization/setting-time-zone.mdx @@ -9,9 +9,10 @@ By default, DayPicker uses the browser’s local time zone. You can override thi The time zone can be specified as either an [IANA time zone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) identifier or a UTC offset. -| Prop Name | Type | Description | -| ---------- | -------- | ------------------------------------------------------ | -| `timeZone` | `string` | The IANA time zone to use when rendering the calendar. | +| Prop Name | Type | Description | +| ---------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `timeZone` | `string` | The IANA time zone to use when rendering the calendar. | +| `noonSafe` | `boolean` | Experimental (see below). Keep calendar math at noon in the time zone to avoid historical drift and use when your time zone shows duplicated/missing days. | ```tsx // Coordinated Universal Time @@ -53,3 +54,25 @@ export function TimeZone() { + +## Historical time zones with second offsets + +Some historical time zones use offsets with seconds (for example `Asia/Dubai` in +1900 is `+03:41:12`). JavaScript `Date` and `date-fns` round offsets to minutes, +which can push midnight calculations into the previous day. Set `noonSafe` to +keep calendar math at 12:00 in the chosen time zone so the date never drifts. + +```ts +const timeZone = "Asia/Dubai"; + +; +``` + +This override is optional and only needed when you render months that include +offsets with seconds and want to avoid duplicate or missing days in the grid. Use +it when you notice time-zone related glitches such as missing days, duplicate +days, or unexpected week padding. + + + + diff --git a/website/src/components/Playground/LocalizationFieldset.tsx b/website/src/components/Playground/LocalizationFieldset.tsx index 1e6faf87a..e712f3844 100644 --- a/website/src/components/Playground/LocalizationFieldset.tsx +++ b/website/src/components/Playground/LocalizationFieldset.tsx @@ -271,6 +271,7 @@ export function LocalizationFieldset({ calendar: undefined, locale: undefined, timeZone: undefined, + noonSafe: undefined, numerals: undefined, weekStartsOn: undefined, firstWeekContainsDate: undefined, @@ -320,6 +321,22 @@ export function LocalizationFieldset({ ))} + {props.timeZone ? ( + + ) : null}