Skip to content

[pickers] AdapterDayjs startOf/endOf and getDaysInMonth are wrong on dates predating the timezone standardization #23301

Description

@JCQuintas

Steps to reproduce

Split out from #23163, which fixed setYear/setMonth/addYears/addMonths in #23296 and deliberately scoped out the sibling methods. This is the remaining part.

AdapterDayjs returns wrong results for startOfYear, startOfMonth, startOfDay, endOfYear, endOfMonth, endOfDay and getDaysInMonth on dates predating the standardization of the timezone, where the IANA database falls back on the Local Mean Time of the location (Asia/Kolkata is GMT+05:53:28).

import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';

const adapter = new AdapterDayjs();
// A plausible birthdate, in a timezone whose Local Mean Time is not a round number of minutes.
const value = adapter.setYear(adapter.date('2026-08-15T12:30:00', 'Asia/Kolkata'), 1883);

adapter.getDaysInMonth(value); // 1        (expected 31)
adapter.startOfMonth(value);   // 1883-07-31 23:59  (expected 1883-08-01 00:00)
adapter.startOfYear(value);    // 1882-12-31 23:59  (expected 1883-01-01 00:00)
adapter.endOfMonth(value);     // 1883-09-01 00:00  (expected 1883-08-31 23:59)
adapter.endOfYear(value);      // 1884-01-01 00:00  (expected 1883-12-31 23:59)
adapter.startOfDay(value);     // 1883-08-14 23:59  (expected 1883-08-15 00:00)
adapter.endOfDay(value);       // 1883-08-16 00:00  (expected 1883-08-15 23:59)

Note that startOfYear and endOfYear are off by a whole year, not just by a day.

User-visible consequence. getDaysInMonth feeds the day section boundaries, so arrow editing on the day section collapses the day to 1 and can never leave it:

<DateField
  defaultValue={adapter.setYear(adapter.date('2026-08-15T12:30:00', 'Asia/Kolkata'), 1883)}
  format="MM/DD/YYYY"
  timezone="Asia/Kolkata"
/>

Select the day section and press ArrowUp:

case getDaysInMonth day section on ArrowUp
1883, Asia/Kolkata 1 250101 (stuck)
2020, Asia/Kolkata 31 252627
1883, America/Detroit 31 252627
1883, UTC 31 252627

Typing a day works (numeric editing computes its boundaries with currentDate: null, so it does not consult getDaysInMonth), and DateCalendar renders correctly for those dates (getWeekArray is unaffected: correct header, 31 day cells, correct selection). Arrow editing is the path that breaks.

Current behavior

Measured over 8 timezones × 6 years (202, 1582, 1883, 1900, 1950, 2020), with the system timezone set to UTC:

method wrong results
startOfYear 14/48
startOfMonth 14/48
startOfDay 14/48
endOfYear 15/48
endOfMonth 15/48
endOfDay 15/48
getDaysInMonth 11/48
getWeekArray 0/48

Which timezones are affected depends on the system timezone as well as the timezone prop, because dayjs derives the offset by reparsing a locale string in the system zone. With TZ=UTC it is Asia/Kolkata, Australia/Adelaide and Asia/Kathmandu; with TZ=America/Detroit it is America/New_York and others. So the reproduction above may need a different timezone on your machine.

Expected behavior

startOf* should return the first instant of the year/month/day and endOf* the last one, and getDaysInMonth should return the real number of days in the month, on these dates as on any other.

Context

The cause is upstream. dayjs's timezone plugin overrides startOf by formatting the value to a wall clock, applying startOf in the system timezone, then calling .tz(zone, true):

proto.startOf = function (units, startOf) {
  if (!this.$x || !this.$x.$timezone) return oldStartOf.call(this, units, startOf)
  const withoutTz = dayjs(this.format('YYYY-MM-DD HH:mm:ss:SSS'), { locale: this.$L })
  return oldStartOf.call(withoutTz, units, startOf).tz(this.$x.$timezone, true)
}

That last .tz(zone, true) is the broken step on Local Mean Time offsets, and endOf and daysInMonth both derive from it. Two dayjs behaviours are involved:

  • The offset is computed as new Date(date.toLocaleString('en-US', { timeZone })), parsed in the system zone, which skews once the system zone also has a sub-minute offset for the date.
  • utcOffset() treats a magnitude below 16 as hours (Math.abs(s) < 16 ? 60 * s : s in plugin/utc.js), so Europe/Paris at GMT+00:09:21 is read as +09:00.

The fix used in #23296 (restoring the day of the month after the operation) does not transfer here: these methods need the whole wall clock rebuilt, not just the day, so it needs its own approach. Repairing them by composing the already-safe primitives is one option, since setDate/setHours/... are unaffected.

Search keywords: AdapterDayjs startOf endOf getDaysInMonth timezone Local Mean Time

Metadata

Metadata

Assignees

No one assigned

    Labels

    external dependencyBlocked by external dependency, we can’t do anything about it.scope: pickersChanges related to the date/time pickers.type: bugIt doesn't behave as expected.

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions