Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { adapter } from 'test/utils/scheduler';
import { adapter, setSystemTimezone } from 'test/utils/scheduler';
import { isJSDOM } from 'test/utils/skipIf';
import { describe, it, expect } from 'vitest';
import { EVENT_TIMELINE_PREMIUM_PRESET_DEFINITIONS, getPresetPxPerDay } from './preset-utils';
import type { EventTimelinePremiumPreset } from '../../models';
Expand All @@ -19,6 +20,161 @@ describe('EVENT_TIMELINE_PREMIUM_PRESET_DEFINITIONS', () => {
expect(count).to.equal(4 * 24);
});
});

describe('getStartDate', () => {
it.skipIf(!isJSDOM)(
'should start the range at the first instant of the day, whatever the timezone of the system',
() => {
setSystemTimezone('Australia/Lord_Howe');
// Santiago leaves summer time at the end of April 4th, 2026, when 24:00 becomes 23:00.
const visibleDate = adapter.date('2026-04-05T12:00:00', 'America/Santiago');
const start = EVENT_TIMELINE_PREMIUM_PRESET_DEFINITIONS.dayAndHour.getStartDate(
adapter,
visibleDate,
1,
);
expect(adapter.toJsDate(start).toISOString()).to.equal('2026-04-05T04:00:00.000Z');
},
);

it.skipIf(!isJSDOM)(
'should start the range at the first instant of a midnight a DST transition repeats',
() => {
setSystemTimezone('America/Los_Angeles');
// Havana leaves summer time on November 1st, 2026, when 01:00 becomes 00:00 again.
const visibleDate = adapter.date('2026-11-15T12:00:00', 'America/Havana');
const start = EVENT_TIMELINE_PREMIUM_PRESET_DEFINITIONS.monthAndYear.getStartDate(
adapter,
visibleDate,
1,
);
expect(adapter.toJsDate(start).toISOString()).to.equal('2026-11-01T04:00:00.000Z');
},
);
});

describe('getEndDate', () => {
it.skipIf(!isJSDOM)(
'should end the range just before the next day starts when its last hour is repeated',
() => {
setSystemTimezone('UTC');
// Santiago repeats the 23:00 hour of April 4th, 2026.
const start = adapter.date('2026-04-01T00:00:00', 'America/Santiago');
const end = EVENT_TIMELINE_PREMIUM_PRESET_DEFINITIONS.dayAndHour.getEndDate(
adapter,
start,
4,
1,
);
expect(adapter.toJsDate(end).toISOString()).to.equal('2026-04-05T03:59:59.999Z');
},
);

it.skipIf(!isJSDOM)(
'should end a week on its last day when the timezone of the system skips a day of it',
() => {
// Apia skipped December 30th, 2011, when it moved across the date line.
setSystemTimezone('Pacific/Apia');
const start = adapter.date('2011-12-24T00:00:00', 'America/New_York');
const end = EVENT_TIMELINE_PREMIUM_PRESET_DEFINITIONS.dayAndWeek.getEndDate(
adapter,
start,
1,
6,
);
expect(adapter.toJsDate(end).toISOString()).to.equal('2011-12-31T04:59:59.999Z');
},
);

it.skipIf(!isJSDOM)(
'should end a month on its last day when the timezone of the system has the same DST transition',
() => {
setSystemTimezone('Europe/Paris');
// Nuuk leaves summer time at the same instant as Paris, when 23:00 on October 30th, 2021
// becomes 22:00 again.
const start = adapter.date('2021-10-01T00:00:00', 'America/Nuuk');
const end = EVENT_TIMELINE_PREMIUM_PRESET_DEFINITIONS.monthAndYear.getEndDate(
adapter,
start,
1,
1,
);
expect(adapter.toJsDate(end).toISOString()).to.equal('2021-11-01T02:59:59.999Z');
},
);

it('should end a year just before the next one starts when its timezone skips its last day', () => {
// Kiritimati skipped December 31st, 1994, when it moved across the date line.
const start = adapter.date('1994-01-01T00:00:00', 'Pacific/Kiritimati');
const end = EVENT_TIMELINE_PREMIUM_PRESET_DEFINITIONS.year.getEndDate(adapter, start, 1, 1);
expect(adapter.toJsDate(end).toISOString()).to.equal('1994-12-31T09:59:59.999Z');
});
});

describe('navigate', () => {
it.skipIf(!isJSDOM).each([
{ preset: 'dayAndHour', visibleDate: '2026-10-24T02:00:00' },
{ preset: 'dayAndWeek', visibleDate: '2026-10-18T02:00:00' },
{ preset: 'monthAndYear', visibleDate: '2026-09-25T02:00:00' },
{ preset: 'year', visibleDate: '2025-10-25T02:00:00' },
] as const)(
'should keep the time of day of the visible date of $preset, whatever the timezone of the system',
({ preset, visibleDate }) => {
setSystemTimezone('Europe/Paris');
// London leaves summer time on October 25th, 2026, when 02:00 becomes 01:00 again.
const nextVisibleDate = EVENT_TIMELINE_PREMIUM_PRESET_DEFINITIONS[preset].navigate(
adapter,
adapter.date(visibleDate, 'Europe/London'),
1,
);
expect(adapter.toJsDate(nextVisibleDate).toISOString()).to.equal(
'2026-10-25T02:00:00.000Z',
);
},
);

it.skipIf(!isJSDOM).each([
{ preset: 'dayAndHour', visibleDate: '2026-10-24T02:30:00' },
{ preset: 'dayAndWeek', visibleDate: '2026-10-18T02:30:00' },
{ preset: 'monthAndYear', visibleDate: '2026-09-25T02:30:00' },
{ preset: 'year', visibleDate: '2025-10-25T02:30:00' },
] as const)(
'should move the visible date of $preset to the first instant of a time a DST transition repeats',
({ preset, visibleDate }) => {
setSystemTimezone('UTC');
// Paris leaves summer time on October 25th, 2026, when 03:00 becomes 02:00 again.
const nextVisibleDate = EVENT_TIMELINE_PREMIUM_PRESET_DEFINITIONS[preset].navigate(
adapter,
adapter.date(visibleDate, 'Europe/Paris'),
1,
);
expect(adapter.toJsDate(nextVisibleDate).toISOString()).to.equal(
'2026-10-25T00:30:00.000Z',
);
},
);

it.skipIf(!isJSDOM).each([
{ preset: 'dayAndHour', visibleDate: '2027-03-27T02:30:00' },
{ preset: 'dayAndWeek', visibleDate: '2027-03-21T02:30:00' },
{ preset: 'monthAndYear', visibleDate: '2027-02-28T02:30:00' },
{ preset: 'year', visibleDate: '2026-03-28T02:30:00' },
] as const)(
'should move the visible date of $preset forward by the length of the gap when a DST transition skips its time',
({ preset, visibleDate }) => {
setSystemTimezone('Europe/London');
// Paris enters summer time on March 28th, 2027, when 02:00 becomes 03:00.
const nextVisibleDate = EVENT_TIMELINE_PREMIUM_PRESET_DEFINITIONS[preset].navigate(
adapter,
adapter.date(visibleDate, 'Europe/Paris'),
1,
);
expect(adapter.toJsDate(nextVisibleDate).toISOString()).to.equal(
'2027-03-28T01:30:00.000Z',
);
},
);
});
});

describe('getPresetPxPerDay', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import * as React from 'react';
import type { TemporalAdapter } from '@base-ui/react/internals/temporal';
import {
addToAdapterDate,
buildTemporalPlainDate,
createAdapterDate,
getEndOfWeek,
getStartOfWeek,
MS_PER_DAY,
toEpochMs,
} from '@mui/x-scheduler-internals/internals';
import type { PlainDate } from '@mui/x-scheduler-internals/internals';
import type { PlainDate, PlainDateDuration } from '@mui/x-scheduler-internals/internals';
import type {
TemporalSupportedObject,
EventTimelinePremiumPreset,
Expand Down Expand Up @@ -70,6 +72,64 @@ function formatHourLabel(adapter: TemporalAdapter, hour: number, ampm: boolean)
return adapter.formatByString(adapter.setHours(getDstFreeTemplateDay(adapter), hour), pattern);
}

// The presets read and move their ranges on the plain dates of their timezone, resolved like
// `toEpochMs()`. The first week of `dayAndWeek` comes from `getStartOfWeek()`.

/**
* The first instant of the day `getPlainDate()` returns for the day of `date`, or with an
* `offsetMs` of `-1`, the last instant before that day.
*/
function createDateFromPlainDate(
adapter: TemporalAdapter,
date: TemporalSupportedObject,
getPlainDate: (plainDate: PlainDate) => PlainDate,
offsetMs = 0,
) {
const timezone = adapter.getTimezone(date);
const plainDate = getPlainDate(buildTemporalPlainDate(adapter, date));
return createAdapterDate(adapter, toEpochMs(plainDate, 0, timezone) + offsetMs, timezone);
}

/**
* The range and the navigation of a preset made of `unitCount` periods of a `unit`, like days or
* months. The range starts on the first day of the period of the visible date, `getPeriodStart()`,
* and ends on the last instant before the period that follows the last one.
*/
function createPlainDateRange(
unit: keyof PlainDateDuration,
getPeriodStart: (plainDate: PlainDate) => PlainDate = (plainDate) => plainDate,
): Pick<PresetDefinition, 'getStartDate' | 'getEndDate' | 'navigate'> {
const toDuration = (amount: number) => ({ [unit]: amount }) as PlainDateDuration;
return {
getStartDate: (adapter, visibleDate) =>
createDateFromPlainDate(adapter, visibleDate, getPeriodStart),
// `start` is the start of the first period, as `getStartDate()` returns it.
getEndDate: (adapter, start, unitCount) =>
createDateFromPlainDate(
adapter,
start,
(plainDate) => plainDate.add(toDuration(unitCount)),
-1,
),
navigate: (adapter, date, amount) => addToAdapterDate(adapter, date, toDuration(amount)),
};
}

/**
* The number of days from the day of `start` to the day of `end`, both included.
*/
function getDayCount(
adapter: TemporalAdapter,
start: TemporalSupportedObject,
end: TemporalSupportedObject,
) {
return (
buildTemporalPlainDate(adapter, end).dayNumber -
buildTemporalPlainDate(adapter, start).dayNumber +
1
);
}

export const EVENT_TIMELINE_PREMIUM_PRESET_DEFINITIONS: Readonly<
Record<EventTimelinePremiumPreset, PresetDefinition>
> = {
Expand All @@ -89,14 +149,11 @@ export const EVENT_TIMELINE_PREMIUM_PRESET_DEFINITIONS: Readonly<
},
],
unitCount: DAY_AND_HOUR_DAYS,
getStartDate: (adapter, visibleDate) => adapter.startOfDay(visibleDate),
getEndDate: (adapter, start, unitCount) =>
adapter.endOfDay(adapter.addDays(start, unitCount - 1)),
...createPlainDateRange('days'),
// `unitCount` is in days (the navigation step), but the grid ticks in hours. Pin
// the CSS tick count to `days × 24`: the hour row is a wall-clock grid, so it emits
// the same cell count on every day, DST transitions included.
getCssUnitCount: () => DAY_AND_HOUR_DAYS * 24,
navigate: (adapter, date, amount) => adapter.addDays(date, amount),
},
dayAndMonth: {
timeResolution: 'day',
Expand All @@ -117,10 +174,7 @@ export const EVENT_TIMELINE_PREMIUM_PRESET_DEFINITIONS: Readonly<
},
],
unitCount: 8 * 7, // 8 weeks
getStartDate: (adapter, visibleDate) => adapter.startOfDay(visibleDate),
getEndDate: (adapter, start, unitCount) =>
adapter.endOfDay(adapter.addDays(start, unitCount - 1)),
navigate: (adapter, date, amount) => adapter.addDays(date, amount),
...createPlainDateRange('days'),
},
dayAndWeek: {
timeResolution: 'day',
Expand All @@ -138,12 +192,10 @@ export const EVENT_TIMELINE_PREMIUM_PRESET_DEFINITIONS: Readonly<
},
],
unitCount: 16, // 16 weeks
...createPlainDateRange('weeks'),
getStartDate: (adapter, visibleDate, weekStartsOn) =>
getStartOfWeek(adapter, visibleDate, weekStartsOn),
getEndDate: (adapter, start, unitCount, weekStartsOn) =>
getEndOfWeek(adapter, adapter.addWeeks(start, unitCount - 1), weekStartsOn),
getCssUnitCount: (adapter, start, end) => adapter.differenceInDays(end, start) + 1,
navigate: (adapter, date, amount) => adapter.addWeeks(date, amount),
getCssUnitCount: getDayCount,
},
monthAndYear: {
timeResolution: 'day',
Expand All @@ -153,21 +205,15 @@ export const EVENT_TIMELINE_PREMIUM_PRESET_DEFINITIONS: Readonly<
{ unit: 'month', formatDate: formatMonth3Letters },
],
unitCount: 3 * 12, // 3 years
getStartDate: (adapter, visibleDate) => adapter.startOfMonth(visibleDate),
getEndDate: (adapter, start, unitCount) =>
adapter.endOfMonth(adapter.addMonths(start, unitCount - 1)),
getCssUnitCount: (adapter, start, end) => adapter.differenceInDays(end, start) + 1,
navigate: (adapter, date, amount) => adapter.addMonths(date, amount),
...createPlainDateRange('months', (plainDate) => plainDate.with({ day: 1 })),
getCssUnitCount: getDayCount,
},
year: {
timeResolution: 'year',
tickWidth: 200,
headers: [{ unit: 'year', formatDate: formatYear }],
unitCount: 30, // 30 years
getStartDate: (adapter, visibleDate) => adapter.startOfYear(visibleDate),
getEndDate: (adapter, start, unitCount) =>
adapter.endOfYear(adapter.addYears(start, unitCount - 1)),
navigate: (adapter, date, amount) => adapter.addYears(date, amount),
...createPlainDateRange('years', (plainDate) => plainDate.with({ month: 1, day: 1 })),
},
};

Expand Down
15 changes: 3 additions & 12 deletions packages/x-scheduler-internals/src/internals/utils/drag-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
} from '../../models';
import type { Adapter } from '../../use-adapter/useAdapter.types';
import type { PlainDate } from './base-temporal/PlainDate';
import { createAdapterDate } from './plain-date-utils/adapter';
import { addToAdapterDate, createAdapterDate } from './plain-date-utils/adapter';
import { toEpochMs, toPlainDateTime } from './plain-date-utils/timeZone';

const INTERNAL_DRAG_OR_RESIZE_PLACEHOLDER_TYPES = new Set(['internal-drag', 'internal-resize']);
Expand All @@ -31,21 +31,12 @@ export function moveToPlainDate(
}

/**
* Moves a date by whole days, keeping its time of day on the wall clock of its timezone.
* Moves a date by whole days like `addToAdapterDate()`, and returns it unchanged for 0 days.
*/
export function addDaysOnWallClock(
adapter: Adapter,
date: TemporalSupportedObject,
dayCount: number,
): TemporalSupportedObject {
if (dayCount === 0) {
return date;
}
const timezone = adapter.getTimezone(date);
const plainDateTime = toPlainDateTime(adapter.getTime(date), timezone);
return createAdapterDate(
adapter,
toEpochMs(plainDateTime.date.add({ days: dayCount }), plainDateTime.msInDay, timezone),
timezone,
);
return dayCount === 0 ? date : addToAdapterDate(adapter, date, { days: dayCount });
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export {
getStartOfWeek as getStartOfWeekDayNumber,
LAST_SUPPORTED_DAY,
PlainDate,
type PlainDateDuration,
} from './base-temporal/PlainDate';
export { ISO_CALENDAR_ID } from './base-temporal/calendar';
export { MS_PER_DAY } from './base-temporal/iso';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { TemporalTimezone } from '@base-ui/react/internals/temporal';
import type { SchedulerProcessedDate, TemporalSupportedObject } from '../../../models';
import type { Adapter } from '../../../use-adapter/useAdapter.types';
import type { PlainDate } from '../base-temporal/PlainDate';
import type { PlainDate, PlainDateDuration } from '../base-temporal/PlainDate';
import { MS_PER_DAY } from '../base-temporal/iso';
import {
getEndOfDayMs,
Expand Down Expand Up @@ -203,3 +203,22 @@ export function createProcessedDate(
timezone,
);
}

/**
* Moves an adapter value by a duration of the calendar, keeping its time of day on the wall clock of
* its timezone. A time that a DST transition skips moves forward by the length of the gap, and a
* repeated one resolves to its first instant, like `toEpochMs()`.
*/
export function addToAdapterDate(
adapter: Adapter,
date: TemporalSupportedObject,
duration: PlainDateDuration,
): TemporalSupportedObject {
const timezone = adapter.getTimezone(date);
const plainDateTime = toPlainDateTime(adapter.getTime(date), timezone);
return createAdapterDate(
adapter,
toEpochMs(plainDateTime.date.add(duration), plainDateTime.msInDay, timezone),
timezone,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
} from './timeZone';
export type { PlainDateTime } from './timeZone';
export {
addToAdapterDate,
buildTemporalPlainDate,
createAdapterDate,
createAdapterDateOnPlainDate,
Expand Down
Loading