Skip to content

fix(date-fns): keep the month grid intact across midnight DST transitions - #2811

Open
fernandolecarosluna wants to merge 1 commit into
bigcalendar:masterfrom
fernandolecarosluna:fix/date-fns-midnight-dst
Open

fernandolecarosluna wants to merge 1 commit into
bigcalendar:masterfrom
fernandolecarosluna:fix/date-fns-midnight-dst

Conversation

@fernandolecarosluna

Copy link
Copy Markdown

The problem

The date-fns localizer is the only official one that does not supply its own date arithmetic — moment, luxon and dayjs all do — so it falls back on date-arithmetic. That implementation advances a day by adding 24h of elapsed time and then correcting the offset so the wall-clock time is preserved.

In zones where DST starts at midnight, the hour that correction aims for does not exist. Chile jumps from 00:00 straight to 01:00 on the transition day, so the corrected result lands an hour earlier — back on the previous day:

current   : Sat 05 Sep 2026 00:00 GMT-0400
+24h      : Sun 06 Sep 2026 01:00 GMT-0300
DST fix   : -60 min  →  Sat 05 Sep 2026 23:00 GMT-0400   ← same day again

visibleDays then emits that day twice. The month grid draws it in two columns, every later day shifts one column away from its weekday header, and the extra cell spills into a seventh row:

September 2026, TZ=America/Santiago, week starting Monday

before                                   after
Mon Tue Wed Thu Fri Sat Sat              Mon Tue Wed Thu Fri Sat Sun
 31  01  02  03  04  05  05               31  01  02  03  04  05  06
 06  07  08  09  10  11  12               07  08  09  10  11  12  13
 13  14  15  16  17  18  19               14  15  16  17  18  19  20
 20  21  22  23  24  25  26               21  22  23  24  25  26  27
 27  28  29  30  01  02  03               28  29  30  01  02  03  04
 04

Sunday the 6th ends up under the Mon header, and so does every Sunday for the rest of the month.

startOf/endOf reach the week boundary through weekday(), which is built on the same add, so week boundaries land a day early too and the week view renders six or eight columns on the transition week.

Thirteen zones in the IANA database transition at midnight, so the grid breaks in each of them once a year, every year:

America/Santiago · America/Coyhaique · America/Havana · America/Asuncion · America/Scoresbysund · Asia/Beirut · Asia/Damascus · Asia/Amman · Asia/Tehran · Asia/Gaza · Asia/Hebron · Africa/Cairo · Atlantic/Azores

The fix

Add by calendar components rather than elapsed time — "the day after the 5th is the 6th" — which is correct in every zone whether that day ran 23, 24 or 25 hours. The localizer now supplies add, range, startOf, endOf, firstVisibleDay, lastVisibleDay and visibleDays, which is the extension point DateLocalizer already documents and the other three localizers already use.

Deliberately narrow:

  • Time units (ms/seconds/minutes/hours) keep using elapsed time — that is what they mean.
  • month/year/decade/century keep delegating to dates.add, which is already component-based.
  • startOf/endOf delegate every unit except week, the only one routed through the broken add.
  • No new runtime dependency. The localizer receives date-fns functions from the caller, so the arithmetic is written with plain Date.

Outside the affected transitions the output is unchanged — I verified this separately by rendering a month in Europe/Madrid with and without the patch and diffing the markup: byte for byte identical.

Tests

Four tests added to test/localizers/date-fns.test.js, sweeping 2020–2035:

  • visibleDays returns whole weeks of consecutive days
  • visibleDays covers every day of the month exactly once
  • a week always spans seven days
  • add lands on the next calendar day, whatever its length

They assert timezone-independent invariants, so they guard the fix wherever CI runs. process.env.TZ cannot be changed at runtime under Jest — it sandboxes process.env, so the mutation never reaches V8's timezone cache — so to watch them fail against the current behaviour, run the suite in an affected zone:

TZ=America/Santiago yarn jest test/localizers/date-fns.test.js

That is how I confirmed they are meaningful: 4 failed before the change, all green after.

Full suite on this branch: 948 passed, 47 suites. eslint src and prettier --check clean.

Context

Found in production in a Chilean events-management app. Happy to adjust naming, split the commit, or move the tests wherever you prefer.

🤖 Generated with Claude Code

…ions

The date-fns localizer is the only official one that does not supply its own
date arithmetic, so it falls back on `date-arithmetic`. That implementation
advances a day by adding 24h of elapsed time and then correcting the offset to
preserve the wall-clock time.

In zones where DST starts at midnight the corrected target — 00:00 on the
transition day — does not exist, so the result lands an hour earlier, back on
the previous day. `visibleDays` then emits that day twice: the month grid draws
it in two columns, every later day shifts one column away from its weekday
header, and the extra cell spills into a seventh row. `startOf`/`endOf` reach
the week boundary through `weekday()`, which is built on the same `add`, so
week boundaries land a day early too and the week view renders six or eight
columns.

Thirteen zones in the IANA database transition at midnight — America/Santiago,
America/Coyhaique, America/Havana, America/Asuncion, America/Scoresbysund,
Asia/Beirut, Asia/Damascus, Asia/Amman, Asia/Tehran, Asia/Gaza, Asia/Hebron,
Africa/Cairo and Atlantic/Azores — so the grid breaks there once a year, every
year.

Adding by calendar components ("the day after the 5th is the 6th") is correct
in every zone whether that day ran 23, 24 or 25 hours. Time units keep using
elapsed time and month/year keep delegating to `dates.add`, which is already
component-based, so nothing changes outside the affected transitions. No new
runtime dependency: the localizer receives date-fns functions from the caller,
so the arithmetic is written with plain `Date`.

The added tests assert timezone-independent invariants — a grid is whole weeks
of consecutive days, a week spans seven days, adding one day lands on the next
calendar day — so they guard the fix wherever CI runs. Running them under
`TZ=America/Santiago` reproduces the regression against the previous behaviour.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant