From 27d283fdf4ecb857eb18f2ef6ff1a585e39d19ce Mon Sep 17 00:00:00 2001 From: gpbl Date: Tue, 9 Dec 2025 10:22:05 +0100 Subject: [PATCH 01/24] docs: update time zone docs, add Asia/Saigon examples Signed-off-by: gpbl --- examples/AsiaSaigonTimezone.test.tsx | 14 ++++++++++++++ examples/AsiaSaigonTimezone.tsx | 7 +++++++ website/docs/localization/setting-time-zone.mdx | 15 +++++++++++---- .../Playground/LocalizationFieldset.tsx | 1 + 4 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 examples/AsiaSaigonTimezone.test.tsx create mode 100644 examples/AsiaSaigonTimezone.tsx diff --git a/examples/AsiaSaigonTimezone.test.tsx b/examples/AsiaSaigonTimezone.test.tsx new file mode 100644 index 000000000..171699cdc --- /dev/null +++ b/examples/AsiaSaigonTimezone.test.tsx @@ -0,0 +1,14 @@ +import React from "react"; + +import { render, screen } from "@/test/render"; +import { AsiaSaigonTimezone } from "./AsiaSaigonTimezone"; + +beforeEach(() => { + render(); +}); + +test.skip('should have the "id" attribute', () => { + expect( + screen.getAllByRole("row")[0].querySelectorAll("[role='gridcell']"), + ).toHaveLength(7); +}); diff --git a/examples/AsiaSaigonTimezone.tsx b/examples/AsiaSaigonTimezone.tsx new file mode 100644 index 000000000..a8bd7b0e7 --- /dev/null +++ b/examples/AsiaSaigonTimezone.tsx @@ -0,0 +1,7 @@ +import React from "react"; + +import { DayPicker } from "react-day-picker"; + +export function AsiaSaigonTimezone() { + return ; +} diff --git a/website/docs/localization/setting-time-zone.mdx b/website/docs/localization/setting-time-zone.mdx index add638228..772721f05 100644 --- a/website/docs/localization/setting-time-zone.mdx +++ b/website/docs/localization/setting-time-zone.mdx @@ -9,19 +9,21 @@ 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 time zone to use when rendering the calendar. | +| Prop Name | Type | Description | +| ---------- | -------- | ------------------------------------------------------ | +| `timeZone` | `string` | The IANA time zone to use when rendering the calendar. | ```tsx // Coordinated Universal Time // US Hawaii Time // Central European Time + // UTC plus 2 hours + // UTC minus 5 hours ``` ## Working with time-zoned dates -When working with time zones, use the `TZDate` object exported by `react-day-picker` instead of the native `Date` object. `TZDate` ensures the calendar reads and writes dates in the time zone you specify. +When working with time zones, use the `TZDate` object exported by `react-day-picker` instead of the native `Date` object. `TZDate` ensures your user interface always reads and writes dates in the time zone you specify. ```tsx import React, { useState } from "react"; @@ -38,6 +40,11 @@ export function TimeZone() { timeZone={timeZone} selected={selected} onSelect={setSelected} + footer={ + selected + ? selected.toString() + : `Pick a day to see it is in ${timeZone} time zone.` + } /> ); } diff --git a/website/src/components/Playground/LocalizationFieldset.tsx b/website/src/components/Playground/LocalizationFieldset.tsx index 678255d52..1e6faf87a 100644 --- a/website/src/components/Playground/LocalizationFieldset.tsx +++ b/website/src/components/Playground/LocalizationFieldset.tsx @@ -42,6 +42,7 @@ const timeZones = [ "Asia/Dubai", "Asia/Hong_Kong", "Asia/Kolkata", + "Asia/Saigon", "Asia/Seoul", "Asia/Shanghai", "Asia/Singapore", From 8a4ad1a627672392b57bf0e7ec4026319425eba4 Mon Sep 17 00:00:00 2001 From: gpbl Date: Tue, 9 Dec 2025 10:22:13 +0100 Subject: [PATCH 02/24] Export example Signed-off-by: gpbl --- examples/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/index.ts b/examples/index.ts index 2a80f7547..97923afe8 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -3,6 +3,7 @@ export * from "./Animate"; export * from "./AnimateCSSVars"; export * from "./AnimateRange"; export * from "./AnimateRtl"; +export * from "./AsiaSaigonTimezone"; export * from "./AutoFocus"; export * from "./BroadcastCalendar"; export * from "./Buddhist"; From d8b821be6867a9820993ae57ff96f6775357f2b7 Mon Sep 17 00:00:00 2001 From: gpbl Date: Tue, 9 Dec 2025 10:34:24 +0100 Subject: [PATCH 03/24] Update test description Signed-off-by: gpbl --- examples/AsiaSaigonTimezone.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/AsiaSaigonTimezone.test.tsx b/examples/AsiaSaigonTimezone.test.tsx index 171699cdc..0bd75082f 100644 --- a/examples/AsiaSaigonTimezone.test.tsx +++ b/examples/AsiaSaigonTimezone.test.tsx @@ -7,7 +7,7 @@ beforeEach(() => { render(); }); -test.skip('should have the "id" attribute', () => { +test.skip("the first row should display 7 days", () => { expect( screen.getAllByRole("row")[0].querySelectorAll("[role='gridcell']"), ).toHaveLength(7); From 8232d02301fc10f814e46e64d75fae16c3b3704a Mon Sep 17 00:00:00 2001 From: gpbl Date: Tue, 9 Dec 2025 11:11:28 +0100 Subject: [PATCH 04/24] Make test fail Signed-off-by: gpbl --- examples/AsiaSaigonTimezone.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); From 4937869361a4fafb343ca45985f9a6f0a37e6f07 Mon Sep 17 00:00:00 2001 From: gpbl Date: Tue, 9 Dec 2025 11:26:31 +0100 Subject: [PATCH 05/24] Detect duplicated dates Signed-off-by: gpbl --- src/helpers/getDates.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/helpers/getDates.ts b/src/helpers/getDates.ts index cdb1e91a3..89377f767 100644 --- a/src/helpers/getDates.ts +++ b/src/helpers/getDates.ts @@ -83,5 +83,30 @@ export function getDates( dates.push(date); } } + + if (hasDuplicateDay(dates, dateLib)) { + // Useful for diagnosing timezone/date-lib edge cases without changing behavior. + console.warn("DayPicker: duplicate calendar days detected in getDates", { + dates, + }); + } + return dates; } + +/** + * Returns `true` when the list contains at least two dates falling on the same + * calendar day (after normalizing to the start of the day with `dateLib`). + */ +export function hasDuplicateDay(dates: Date[], dateLib: DateLib): boolean { + const seen = new Set(); + for (const day of dates) { + const start = dateLib.startOfDay(day); + const key = `${dateLib.getYear(start)}-${dateLib.getMonth(start)}-${start.getDate()}`; + if (seen.has(key)) { + return true; + } + seen.add(key); + } + return false; +} From 560d781c52564b191fd99a7ba419e114eabf415d Mon Sep 17 00:00:00 2001 From: gpbl Date: Tue, 9 Dec 2025 11:48:15 +0100 Subject: [PATCH 06/24] Log duplicated days Signed-off-by: gpbl --- src/helpers/getDates.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/helpers/getDates.ts b/src/helpers/getDates.ts index 89377f767..7492fd872 100644 --- a/src/helpers/getDates.ts +++ b/src/helpers/getDates.ts @@ -101,8 +101,9 @@ export function getDates( export function hasDuplicateDay(dates: Date[], dateLib: DateLib): boolean { const seen = new Set(); for (const day of dates) { - const start = dateLib.startOfDay(day); - const key = `${dateLib.getYear(start)}-${dateLib.getMonth(start)}-${start.getDate()}`; + // Key by the calendar components of the original date to avoid timezone + // and sub-hour offsets shifting us into the previous/next day. + const key = `${dateLib.getYear(day)}-${dateLib.getMonth(day)}-${day.getDate()}`; if (seen.has(key)) { return true; } From 7b2f8c39cda5371437da3d6a59f0ab9fde685121 Mon Sep 17 00:00:00 2001 From: gpbl Date: Fri, 12 Dec 2025 10:15:05 +0100 Subject: [PATCH 07/24] feat: add noon date lib submodule --- examples/AsiaSaigonTimezone.tsx | 16 +- examples/TimeZoneNoonSafe.test.tsx | 82 +++++++ examples/TimeZoneNoonSafe.tsx | 67 ++++++ examples/index.ts | 1 + jest.config.ts | 4 + package.json | 10 + src/noonDateLib.ts | 202 ++++++++++++++++++ tsconfig.json | 1 + .../docs/localization/setting-time-zone.mdx | 26 +++ 9 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 examples/TimeZoneNoonSafe.test.tsx create mode 100644 examples/TimeZoneNoonSafe.tsx create mode 100644 src/noonDateLib.ts diff --git a/examples/AsiaSaigonTimezone.tsx b/examples/AsiaSaigonTimezone.tsx index a8bd7b0e7..93d76624b 100644 --- a/examples/AsiaSaigonTimezone.tsx +++ b/examples/AsiaSaigonTimezone.tsx @@ -1,7 +1,19 @@ import React from "react"; -import { DayPicker } from "react-day-picker"; +import { DayPicker, TZDate } from "react-day-picker"; +import { createNoonDateLibOverrides } from "react-day-picker/noon-date-lib"; export function AsiaSaigonTimezone() { - return ; + const timeZone = "Asia/Saigon"; + const dateLib = createNoonDateLibOverrides({ timeZone }); + + return ( + + ); } diff --git a/examples/TimeZoneNoonSafe.test.tsx b/examples/TimeZoneNoonSafe.test.tsx new file mode 100644 index 000000000..8f0c0945e --- /dev/null +++ b/examples/TimeZoneNoonSafe.test.tsx @@ -0,0 +1,82 @@ +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { DateLib } from "react-day-picker"; +import { createNoonDateLibOverrides } from "react-day-picker/noon-date-lib"; +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("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]; + + const dateLib = new DateLib( + { timeZone }, + createNoonDateLibOverrides({ timeZone, weekStartsOn: 1 }), + ); + const expectedFirstYear = dateLib.getYear(dateLib.startOfMonth(fromMonth)); + + expect(Number(firstYearOption.getAttribute("value"))).toBe(expectedFirstYear); +}); diff --git a/examples/TimeZoneNoonSafe.tsx b/examples/TimeZoneNoonSafe.tsx new file mode 100644 index 000000000..6010d37e8 --- /dev/null +++ b/examples/TimeZoneNoonSafe.tsx @@ -0,0 +1,67 @@ +import React, { useState } from "react"; + +import { + DayPicker, + type DayPickerProps, + type PropsSingle, + TZDate, +} from "react-day-picker"; +import { createNoonDateLibOverrides } from "react-day-picker/noon-date-lib"; + +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 dateLib = createNoonDateLibOverrides({ + timeZone, + 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/index.ts b/examples/index.ts index 97923afe8..350d9f5a7 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -101,6 +101,7 @@ export * from "./TestCase2843"; export * from "./TestCase2864"; export * from "./Testcase1567"; export * from "./TimeZone"; +export * from "./TimeZoneNoonSafe"; export * from "./timezone/TestCase2833"; export * from "./Utc"; export * from "./WeekIso"; diff --git a/jest.config.ts b/jest.config.ts index 8febd3624..7504b318b 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -28,6 +28,7 @@ const config: Config.InitialOptions = { "react-day-picker/locale/(.*)\\.js": ["/src/locale/$1.ts"], "react-day-picker/locale/(.*)": ["/src/locale/$1"], "react-day-picker/locale": ["/src/locale.ts"], + "react-day-picker/noon-date-lib": ["/src/noonDateLib.ts"], "^(\\.\\.?\\/.+)\\.jsx?$": "$1", // see https://github.com/kulshekhar/ts-jest/issues/1057 }, }, @@ -45,6 +46,7 @@ const config: Config.InitialOptions = { "react-day-picker/locale/(.*)\\.js": ["/src/locale/$1.ts"], "react-day-picker/locale/(.*)": ["/src/locale/$1"], "react-day-picker/locale": ["/src/locale.ts"], + "react-day-picker/noon-date-lib": ["/src/noonDateLib.ts"], "react-day-picker": ["/src/index.ts"], "^(\\.\\.?\\/.+)\\.jsx?$": "$1", // see https://github.com/kulshekhar/ts-jest/issues/1057 }, @@ -60,6 +62,7 @@ const config: Config.InitialOptions = { "react-day-picker/locale/(.*)\\.js": ["/src/locale/$1.ts"], "react-day-picker/locale/(.*)": ["/src/locale/$1"], "react-day-picker/locale": ["/src/locale.ts"], + "react-day-picker/noon-date-lib": ["/src/noonDateLib.ts"], "react-day-picker": ["/src/index.ts"], "^(\\.\\.?\\/.+)\\.jsx?$": "$1", // see https://github.com/kulshekhar/ts-jest/issues/1057 }, @@ -80,6 +83,7 @@ const config: Config.InitialOptions = { ], "react-day-picker/locale/(.*)": ["/dist/cjs/locale/$1"], "react-day-picker/locale": ["/dist/cjs/locale.js"], + "react-day-picker/noon-date-lib": ["/dist/cjs/noonDateLib.js"], "react-day-picker": ["/dist/cjs/index.js"], "../src": ["/dist/cjs"], // allow using same @/test/elements in both env "^(\\.\\.?\\/.+)\\.jsx?$": "$1", // see https://github.com/kulshekhar/ts-jest/issues/1057 diff --git a/package.json b/package.json index 607495f8d..a29e14a91 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,16 @@ "default": "./dist/cjs/index.js" } }, + "./noon-date-lib": { + "import": { + "types": "./dist/esm/noonDateLib.d.ts", + "default": "./dist/esm/noonDateLib.js" + }, + "require": { + "types": "./dist/cjs/noonDateLib.d.ts", + "default": "./dist/cjs/noonDateLib.js" + } + }, "./jalali": { "import": { "types": "./dist/esm/jalali.d.ts", diff --git a/src/noonDateLib.ts b/src/noonDateLib.ts new file mode 100644 index 000000000..43eb780eb --- /dev/null +++ b/src/noonDateLib.ts @@ -0,0 +1,202 @@ +import { TZDate } from "@date-fns/tz"; +import type { EndOfWeekOptions, Locale, StartOfWeekOptions } from "date-fns"; +import { + addDays, + addMonths, + addWeeks, + addYears, + startOfDay as startOfDayFn, +} from "date-fns"; + +import type { DateLib } from "./classes/DateLib.js"; + +export interface CreateNoonDateLibOverridesOptions { + timeZone?: string; + weekStartsOn?: number; + locale?: Locale; +} + +/** + * 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 createNoonDateLibOverrides( + options: CreateNoonDateLibOverridesOptions = {}, +): Partial { + const { timeZone, weekStartsOn, locale } = options; + const fallbackWeekStartsOn = + weekStartsOn ?? locale?.options?.weekStartsOn ?? 0; + + const normalize = (date: Date | number | string) => { + const normalizedDate = + typeof date === "number" || typeof date === "string" + ? new Date(date) + : date; + if (!timeZone || Number.isNaN(+normalizedDate)) return normalizedDate; + return new TZDate( + normalizedDate.getFullYear(), + normalizedDate.getMonth(), + normalizedDate.getDate(), + 12, + 0, + 0, + timeZone, + ); + }; + + return { + today: () => normalize(timeZone ? TZDate.tz(timeZone) : new Date()), + newDate: (year: number, monthIndex: number, date: number) => + timeZone + ? new TZDate(year, monthIndex, date, 12, 0, 0, timeZone) + : new Date(year, monthIndex, date), + + startOfDay: (date) => { + if (!timeZone) return startOfDayFn(date); + const base = normalize(date); + return new TZDate( + base.getFullYear(), + base.getMonth(), + base.getDate(), + 12, + 0, + 0, + timeZone, + ); + }, + startOfWeek: (date, options?: StartOfWeekOptions) => { + const base = normalize(date); + const weekStartsOnValue = options?.weekStartsOn ?? fallbackWeekStartsOn; + const diff = (base.getDay() - weekStartsOnValue + 7) % 7; + return normalize( + new TZDate( + base.getFullYear(), + base.getMonth(), + base.getDate() - diff, + 12, + 0, + 0, + timeZone, + ), + ); + }, + startOfISOWeek: (date) => { + const base = normalize(date); + const diff = (base.getDay() - 1 + 7) % 7; + return normalize( + new TZDate( + base.getFullYear(), + base.getMonth(), + base.getDate() - diff, + 12, + 0, + 0, + timeZone, + ), + ); + }, + startOfMonth: (date) => + normalize( + new TZDate(date.getFullYear(), date.getMonth(), 1, 12, 0, 0, timeZone), + ), + startOfYear: (date) => + normalize(new TZDate(date.getFullYear(), 0, 1, 12, 0, 0, timeZone)), + + endOfWeek: (date, options?: EndOfWeekOptions) => { + const base = normalize(date); + const weekStartsOnValue = options?.weekStartsOn ?? fallbackWeekStartsOn; + const endDow = (weekStartsOnValue + 6) % 7; + const diff = (endDow - base.getDay() + 7) % 7; + return normalize( + new TZDate( + base.getFullYear(), + base.getMonth(), + base.getDate() + diff, + 12, + 0, + 0, + timeZone, + ), + ); + }, + endOfISOWeek: (date) => { + const base = normalize(date); + const diff = (7 - base.getDay()) % 7; + return normalize( + new TZDate( + base.getFullYear(), + base.getMonth(), + base.getDate() + diff, + 12, + 0, + 0, + timeZone, + ), + ); + }, + endOfMonth: (date) => + normalize( + new TZDate( + date.getFullYear(), + date.getMonth() + 1, + 0, + 12, + 0, + 0, + timeZone, + ), + ), + endOfYear: (date) => + normalize(new TZDate(date.getFullYear(), 11, 31, 12, 0, 0, timeZone)), + + eachMonthOfInterval: (interval) => { + const start = normalize(interval.start); + const end = normalize(interval.end); + const result: Date[] = []; + let y = start.getFullYear(); + let m = start.getMonth(); + const endKey = end.getFullYear() * 12 + end.getMonth(); + while (y * 12 + m <= endKey) { + result.push(new TZDate(y, m, 1, 12, 0, 0, timeZone)); + m++; + if (m > 11) { + m = 0; + y++; + } + } + return result; + }, + + addDays: (date, amount) => normalize(addDays(normalize(date), amount)), + addWeeks: (date, amount) => normalize(addWeeks(normalize(date), amount)), + addMonths: (date, amount) => normalize(addMonths(normalize(date), amount)), + addYears: (date, amount) => normalize(addYears(normalize(date), amount)), + + eachYearOfInterval: (interval) => { + const start = normalize(interval.start); + const end = normalize(interval.end); + const years: Date[] = []; + for (let y = start.getFullYear(); y <= end.getFullYear(); y++) { + years.push(new TZDate(y, 0, 1, 12, 0, 0, timeZone)); + } + return years; + }, + + getWeek: (date) => { + const base = normalize(date); + const weekStartsOnValue = fallbackWeekStartsOn; + const diff = (base.getDay() - weekStartsOnValue + 7) % 7; + const start = new TZDate( + base.getFullYear(), + base.getMonth(), + base.getDate() - diff, + 12, + 0, + 0, + timeZone, + ); + return start.getTime(); + }, + }; +} diff --git a/tsconfig.json b/tsconfig.json index d1a62f994..21e8435b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "@/test": ["./test"], "@/test/*": ["./test/*"], "react-day-picker": ["./src"], + "react-day-picker/noon-date-lib": ["./src/noonDateLib"], "react-day-picker/*": ["./src/*"] }, "noEmit": true, diff --git a/website/docs/localization/setting-time-zone.mdx b/website/docs/localization/setting-time-zone.mdx index 772721f05..4c9c1be30 100644 --- a/website/docs/localization/setting-time-zone.mdx +++ b/website/docs/localization/setting-time-zone.mdx @@ -53,3 +53,29 @@ 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. You can opt into a +“noon-safe” date library that keeps all calendar math at 12:00 in the chosen +time zone so the date never drifts. + +Import the helper from the optional submodule (kept out of the default bundle): + +```ts +import { createNoonDateLibOverrides } from "react-day-picker/noon-date-lib"; + +const timeZone = "Asia/Dubai"; +const noonSafe = createNoonDateLibOverrides({ timeZone, weekStartsOn: 1 }); + +; +``` + +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. + + + + From 212ab5014148184ab2edc781089ed8d8fb2b2c49 Mon Sep 17 00:00:00 2001 From: gpbl Date: Fri, 12 Dec 2025 11:37:12 +0100 Subject: [PATCH 08/24] fix: stabilize noonSafe for timezone calendars --- examples/AsiaSaigonTimezone.tsx | 4 +- examples/PersianNoonSafe.test.tsx | 50 +++++++ examples/TimeZoneNoonSafe.test.tsx | 10 +- examples/TimeZoneNoonSafe.tsx | 7 +- jest.config.ts | 4 - package.json | 10 -- src/DayPicker.tsx | 20 ++- src/noonDateLib.ts | 1 - src/noonJalaliDateLib.ts | 136 ++++++++++++++++++ src/persian.tsx | 44 +++++- src/types/props.ts | 8 ++ tsconfig.json | 1 - .../docs/localization/setting-time-zone.mdx | 19 +-- .../Playground/LocalizationFieldset.tsx | 17 +++ 14 files changed, 276 insertions(+), 55 deletions(-) create mode 100644 examples/PersianNoonSafe.test.tsx create mode 100644 src/noonJalaliDateLib.ts diff --git a/examples/AsiaSaigonTimezone.tsx b/examples/AsiaSaigonTimezone.tsx index 93d76624b..622e6fdc5 100644 --- a/examples/AsiaSaigonTimezone.tsx +++ b/examples/AsiaSaigonTimezone.tsx @@ -1,11 +1,9 @@ import React from "react"; import { DayPicker, TZDate } from "react-day-picker"; -import { createNoonDateLibOverrides } from "react-day-picker/noon-date-lib"; export function AsiaSaigonTimezone() { const timeZone = "Asia/Saigon"; - const dateLib = createNoonDateLibOverrides({ timeZone }); return ( ); } diff --git a/examples/PersianNoonSafe.test.tsx b/examples/PersianNoonSafe.test.tsx new file mode 100644 index 000000000..a0ff28bc5 --- /dev/null +++ b/examples/PersianNoonSafe.test.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { DayPicker, 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); +}); diff --git a/examples/TimeZoneNoonSafe.test.tsx b/examples/TimeZoneNoonSafe.test.tsx index 8f0c0945e..a7488a922 100644 --- a/examples/TimeZoneNoonSafe.test.tsx +++ b/examples/TimeZoneNoonSafe.test.tsx @@ -1,7 +1,5 @@ import userEvent from "@testing-library/user-event"; import React from "react"; -import { DateLib } from "react-day-picker"; -import { createNoonDateLibOverrides } from "react-day-picker/noon-date-lib"; import { render, screen, within } from "@/test/render"; import { TimeZoneNoonSafe } from "./TimeZoneNoonSafe"; @@ -72,11 +70,5 @@ test("year dropdown starts at the fromMonth year", () => { const selectYear = screen.getAllByRole("combobox")[1]; const firstYearOption = within(selectYear).getAllByRole("option")[0]; - const dateLib = new DateLib( - { timeZone }, - createNoonDateLibOverrides({ timeZone, weekStartsOn: 1 }), - ); - const expectedFirstYear = dateLib.getYear(dateLib.startOfMonth(fromMonth)); - - expect(Number(firstYearOption.getAttribute("value"))).toBe(expectedFirstYear); + expect(Number(firstYearOption.getAttribute("value"))).toBe(1880); }); diff --git a/examples/TimeZoneNoonSafe.tsx b/examples/TimeZoneNoonSafe.tsx index 6010d37e8..8d1e078ae 100644 --- a/examples/TimeZoneNoonSafe.tsx +++ b/examples/TimeZoneNoonSafe.tsx @@ -6,7 +6,6 @@ import { type PropsSingle, TZDate, } from "react-day-picker"; -import { createNoonDateLibOverrides } from "react-day-picker/noon-date-lib"; type TimeZoneNoonSafeProps = Partial & { selected?: Date; @@ -29,10 +28,6 @@ export function TimeZoneNoonSafe(props: TimeZoneNoonSafeProps = {}) { const timeZone = timeZoneProp ?? "Asia/Dubai"; const weekStartsOn = (weekStartsOnProp ?? 1) as DayPickerProps["weekStartsOn"]; - const dateLib = createNoonDateLibOverrides({ - timeZone, - weekStartsOn, - }); const [selected, setSelected] = useState( selectedProp ?? new TZDate(1900, 11, 1, timeZone), ); @@ -49,10 +44,10 @@ export function TimeZoneNoonSafe(props: TimeZoneNoonSafeProps = {}) { captionLayout="dropdown" defaultMonth={defaultMonth ?? new TZDate(1900, 11, 1, timeZone)} timeZone={timeZone} + noonSafe weekStartsOn={weekStartsOn} selected={selectedValue} onSelect={onSelect} - dateLib={dateLib} startMonth={startMonth ?? new Date(1880, 0, 1)} toYear={2025} footer={ diff --git a/jest.config.ts b/jest.config.ts index 7504b318b..8febd3624 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -28,7 +28,6 @@ const config: Config.InitialOptions = { "react-day-picker/locale/(.*)\\.js": ["/src/locale/$1.ts"], "react-day-picker/locale/(.*)": ["/src/locale/$1"], "react-day-picker/locale": ["/src/locale.ts"], - "react-day-picker/noon-date-lib": ["/src/noonDateLib.ts"], "^(\\.\\.?\\/.+)\\.jsx?$": "$1", // see https://github.com/kulshekhar/ts-jest/issues/1057 }, }, @@ -46,7 +45,6 @@ const config: Config.InitialOptions = { "react-day-picker/locale/(.*)\\.js": ["/src/locale/$1.ts"], "react-day-picker/locale/(.*)": ["/src/locale/$1"], "react-day-picker/locale": ["/src/locale.ts"], - "react-day-picker/noon-date-lib": ["/src/noonDateLib.ts"], "react-day-picker": ["/src/index.ts"], "^(\\.\\.?\\/.+)\\.jsx?$": "$1", // see https://github.com/kulshekhar/ts-jest/issues/1057 }, @@ -62,7 +60,6 @@ const config: Config.InitialOptions = { "react-day-picker/locale/(.*)\\.js": ["/src/locale/$1.ts"], "react-day-picker/locale/(.*)": ["/src/locale/$1"], "react-day-picker/locale": ["/src/locale.ts"], - "react-day-picker/noon-date-lib": ["/src/noonDateLib.ts"], "react-day-picker": ["/src/index.ts"], "^(\\.\\.?\\/.+)\\.jsx?$": "$1", // see https://github.com/kulshekhar/ts-jest/issues/1057 }, @@ -83,7 +80,6 @@ const config: Config.InitialOptions = { ], "react-day-picker/locale/(.*)": ["/dist/cjs/locale/$1"], "react-day-picker/locale": ["/dist/cjs/locale.js"], - "react-day-picker/noon-date-lib": ["/dist/cjs/noonDateLib.js"], "react-day-picker": ["/dist/cjs/index.js"], "../src": ["/dist/cjs"], // allow using same @/test/elements in both env "^(\\.\\.?\\/.+)\\.jsx?$": "$1", // see https://github.com/kulshekhar/ts-jest/issues/1057 diff --git a/package.json b/package.json index a29e14a91..607495f8d 100644 --- a/package.json +++ b/package.json @@ -35,16 +35,6 @@ "default": "./dist/cjs/index.js" } }, - "./noon-date-lib": { - "import": { - "types": "./dist/esm/noonDateLib.d.ts", - "default": "./dist/esm/noonDateLib.js" - }, - "require": { - "types": "./dist/cjs/noonDateLib.d.ts", - "default": "./dist/cjs/noonDateLib.js" - } - }, "./jalali": { "import": { "types": "./dist/esm/jalali.d.ts", diff --git a/src/DayPicker.tsx b/src/DayPicker.tsx index 58eaa954b..f7f7e49f0 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 { createNoonDateLibOverrides } from "./noonDateLib.js"; import type { DayPickerProps, Modifiers, @@ -100,18 +101,32 @@ 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 + ? createNoonDateLibOverrides({ + timeZone: 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 +147,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 index 43eb780eb..d120aba21 100644 --- a/src/noonDateLib.ts +++ b/src/noonDateLib.ts @@ -7,7 +7,6 @@ import { addYears, startOfDay as startOfDayFn, } from "date-fns"; - import type { DateLib } from "./classes/DateLib.js"; export interface CreateNoonDateLibOverridesOptions { diff --git a/src/noonJalaliDateLib.ts b/src/noonJalaliDateLib.ts new file mode 100644 index 000000000..d2009851a --- /dev/null +++ b/src/noonJalaliDateLib.ts @@ -0,0 +1,136 @@ +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, + eachMonthOfInterval as eachMonthOfIntervalJalali, + eachYearOfInterval as eachYearOfIntervalJalali, + endOfISOWeek as endOfISOWeekJalali, + endOfMonth as endOfMonthJalali, + endOfWeek as endOfWeekJalali, + endOfYear as endOfYearJalali, + getWeek as getWeekJalali, + startOfDay as startOfDayJalali, + 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 { CreateNoonDateLibOverridesOptions } from "./noonDateLib.js"; + +/** + * 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 createJalaliNoonDateLibOverrides( + options: CreateNoonDateLibOverridesOptions = {}, +): Partial { + const { timeZone, weekStartsOn, locale } = options; + const fallbackWeekStartsOn = + weekStartsOn ?? locale?.options?.weekStartsOn ?? 0; + + const normalize = (date: Date | number | string) => { + const normalizedDate = + typeof date === "number" || typeof date === "string" + ? new Date(date) + : date; + if (!timeZone || Number.isNaN(+normalizedDate)) return normalizedDate; + const tzDate = new TZDate( + normalizedDate.getFullYear(), + normalizedDate.getMonth(), + normalizedDate.getDate(), + 12, + 0, + 0, + timeZone, + ); + return new Date(tzDate.getTime()); + }; + + return { + today: () => normalize(timeZone ? TZDate.tz(timeZone) : new Date()), + newDate: (year: number, monthIndex: number, date: number) => + timeZone + ? new TZDate(year, monthIndex, date, 12, 0, 0, timeZone) + : new Date(year, monthIndex, date), + + startOfDay: (date) => { + if (!timeZone) return startOfDayJalali(date); + const base = normalize(date); + return normalize(startOfDayJalali(base)); + }, + startOfWeek: (date, options?: StartOfWeekOptions) => { + const base = normalize(date); + const weekStartsOnValue = options?.weekStartsOn ?? fallbackWeekStartsOn; + return normalize( + startOfWeekJalali(base, { + weekStartsOn: weekStartsOnValue, + }), + ); + }, + startOfISOWeek: (date) => { + const base = normalize(date); + return normalize(startOfISOWeekJalali(base)); + }, + startOfMonth: (date) => + normalize(startOfMonthJalali(normalize(date))), + startOfYear: (date) => + normalize(startOfYearJalali(normalize(date))), + + endOfWeek: (date, options?: EndOfWeekOptions) => { + const base = normalize(date); + const weekStartsOnValue = options?.weekStartsOn ?? fallbackWeekStartsOn; + return normalize( + endOfWeekJalali(base, { + weekStartsOn: weekStartsOnValue, + }), + ); + }, + endOfISOWeek: (date) => { + const base = normalize(date); + return normalize(endOfISOWeekJalali(base)); + }, + endOfMonth: (date) => + normalize(endOfMonthJalali(normalize(date))), + endOfYear: (date) => + normalize(endOfYearJalali(normalize(date))), + + eachMonthOfInterval: (interval) => { + return eachMonthOfIntervalJalali( + { + start: normalize(interval.start), + end: normalize(interval.end), + }, + { weekStartsOn: fallbackWeekStartsOn }, + ).map((date) => normalize(date)); + }, + + addDays: (date, amount) => + normalize(addDaysJalali(normalize(date), amount)), + addWeeks: (date, amount) => + normalize(addWeeksJalali(normalize(date), amount)), + addMonths: (date, amount) => + normalize(addMonthsJalali(normalize(date), amount)), + addYears: (date, amount) => + normalize(addYearsJalali(normalize(date), amount)), + + eachYearOfInterval: (interval) => { + return eachYearOfIntervalJalali({ + start: normalize(interval.start), + end: normalize(interval.end), + }).map((date) => normalize(date)); + }, + + getWeek: (date) => { + return getWeekJalali(normalize(date), { + weekStartsOn: fallbackWeekStartsOn, + }); + }, + }; +} diff --git a/src/persian.tsx b/src/persian.tsx index 7f182b2b4..c116c27d5 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 { createJalaliNoonDateLibOverrides } 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, + ...createJalaliNoonDateLibOverrides({ + timeZone: 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/tsconfig.json b/tsconfig.json index 21e8435b6..d1a62f994 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,6 @@ "@/test": ["./test"], "@/test/*": ["./test/*"], "react-day-picker": ["./src"], - "react-day-picker/noon-date-lib": ["./src/noonDateLib"], "react-day-picker/*": ["./src/*"] }, "noEmit": true, diff --git a/website/docs/localization/setting-time-zone.mdx b/website/docs/localization/setting-time-zone.mdx index 4c9c1be30..700111241 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` | Keep calendar math at noon in the time zone to avoid historical drift. | ```tsx // Coordinated Universal Time @@ -58,19 +59,13 @@ export function TimeZone() { 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. You can opt into a -“noon-safe” date library that keeps all calendar math at 12:00 in the chosen -time zone so the date never drifts. - -Import the helper from the optional submodule (kept out of the default bundle): +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 -import { createNoonDateLibOverrides } from "react-day-picker/noon-date-lib"; - const timeZone = "Asia/Dubai"; -const noonSafe = createNoonDateLibOverrides({ timeZone, weekStartsOn: 1 }); -; +; ``` This override is optional and only needed when you render months that include 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}