Skip to content

Commit e7d50e1

Browse files
[scheduler] Support the days of Date in the BaseTemporal copy
The copy follows the Base UI version, which supports the days of `Date`, from -271821-04-20 to +275760-09-13, like Temporal, instead of the years 0 to 9999. It also brings the `with()` checks of the Base UI version. The tests of the last day the recurring series support move to September 13th, 275760. The reworded errors keep their codes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 91a20eb commit e7d50e1

10 files changed

Lines changed: 220 additions & 128 deletions

File tree

‎docs/public/static/error-codes.json‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -314,17 +314,17 @@
314314
"314": "Base UI Plus: `BaseTemporal.%s` received %s, which isn't an object of date fields. `with()` replaces the date fields of an object, and keeps the calendar of the date, so it rejects temporal values and objects with a calendar or a time zone. Pass the fields to replace, such as `{ day: 1 }`, and call `withCalendar()` to change the calendar.",
315315
"315": "Base UI Plus: `BaseTemporal.%s` received no date field the calendar of the date reads. `with()` can't tell which fields to replace. Pass at least one of `year`, `month`, `monthCode` and `day`, or `era` and `eraYear` outside the ISO calendar.",
316316
"316": "Base UI Plus: A calendar received %s where it expects a `PlainDate`. The calendar reads the year, the month and the day of each date, so it can't use this one. Create dates with `BaseTemporal.PlainDate.from()`. A Server Component sends a `PlainDate` to the client as its string: pass that string to `from()` in a Client Component.",
317-
"317": "Base UI Plus: `BaseTemporal.PlainDate.from()` received %s, which isn't an existing date in the `YYYY-MM-DD` format. Other ISO 8601 forms, such as times, offsets, time zone annotations or 6-digit years, are not supported. Pass a date such as `'2024-05-24'`, optionally followed by a calendar annotation such as `'[u-ca=hebrew]'`.",
317+
"317": "Base UI Plus: `BaseTemporal.PlainDate.from()` received %s, which isn't an existing date in the `YYYY-MM-DD` or `±YYYYYY-MM-DD` format. Other ISO 8601 forms, such as times, offsets or time zone annotations, are not supported. Pass a date such as `'2024-05-24'`, optionally followed by a calendar annotation such as `'[u-ca=hebrew]'`.",
318318
"318": "Base UI Plus: `BaseTemporal.PlainDate.from()` received %s, which isn't a date. `from()` can't read a year, a month and a day from it. Pass a `PlainDate`, an object such as `{ year: 2024, month: 5, day: 24 }`, or a string such as `'2024-05-24'`.",
319319
"319": "Base UI Plus: `BaseTemporal.%s` received date fields that %s. The fields don't name a single date of the calendar. Pass a year (or an era and a year of era), a month (or a month code) and a day that exist in the calendar.",
320320
"320": "Base UI Plus: `BaseTemporal.%s` received an object without %s. A date can't be built without its year, month and day. Pass an object such as `{ year: 2024, month: 5, day: 24 }`, with an era and a year of era together in place of the year if needed.",
321-
"321": "Base UI Plus: `PlainDate` received the year %s, the month %s and the day %s, which don't name a supported date. The year must be from 0 to 9999, the month from 1 to 12, and the day must exist in the month. Pass a valid date, or create it with `BaseTemporal.PlainDate.from()` and an object to move a month or a day above its range to the closest valid value.",
321+
"321": "Base UI Plus: `PlainDate` received the year %s, the month %s and the day %s, which don't name a supported date. The month must be from 1 to 12, the day must exist in the month, and the date must be from -271821-04-20 to +275760-09-13, the days of `Date`. Pass a valid date, or create it with `BaseTemporal.PlainDate.from()` and an object to move a month or a day above its range to the closest valid value.",
322322
"322": "Base UI Plus: A temporal value received %s as its calendar, which isn't a string. Calendars are identified by their name. Pass a calendar identifier such as `'iso8601'`, `'gregory'` or `'hebrew'`.",
323323
"323": "Base UI Plus: The \"%s\" calendar isn't supported. Base UI Plus has no arithmetic for it, so it cannot create or display its dates. Use `iso8601`, `gregory`, or one of the calendars listed in the documentation.",
324324
"324": "Base UI Plus: The \"%s\" calendar isn't registered. Only the ISO and Gregorian calendars are built in, so other calendars stay out of the bundles that don't use them. Pass `%sCalendar` to `registerCalendar()`, both from `@base-ui/plus/calendar-systems`, before creating or displaying its dates.",
325325
"325": "Base UI Plus: `registerCalendar()` received %s, which isn't a calendar of `@base-ui/plus/calendar-systems`. Base UI Plus only registers the calendars it implements. Pass a calendar such as `hebrewCalendar` from `'@base-ui/plus/calendar-systems'`.",
326326
"326": "Base UI Plus: `registerCalendar()` received a second implementation of the \"%s\" calendar. Two implementations could give different dates for the same values, so the first registration stays. Make sure a single copy of `@base-ui/plus` is installed, and register each calendar once.",
327-
"327": "Base UI Plus: A date of the \"%s\" calendar is outside the days it supports, from the ISO date %s-%s-%s to %s-%s-%s. Outside these days, `Intl.DateTimeFormat` doesn't label dates as Base UI Plus computes them, or the ISO year is outside 0 to 9999. Use a date inside the range.",
327+
"327": "Base UI Plus: A date of the \"%s\" calendar is outside the days it supports, from the ISO date %s-%s-%s to %s-%s-%s. Outside these days, `Intl.DateTimeFormat` doesn't label dates as Base UI Plus computes them, or `Date` can't represent them. Use a date inside the range.",
328328
"328": "Base UI Plus: A temporal value received %s as the %s, which isn't a finite number. A temporal value cannot represent a non-finite field. Pass finite numbers for its date and time fields.",
329329
"329": "Base UI Plus: The options of `BaseTemporal.%s` are %s, which isn't an object. The method can't read the `overflow` option from them. Pass an object such as `{ overflow: 'reject' }`, or no options.",
330330
"330": "Base UI Plus: The `overflow` option of `BaseTemporal.%s` is %s, which isn't supported. The method can't tell whether to adjust or reject a field above its range. Pass `'constrain'` or `'reject'`.",
@@ -333,6 +333,8 @@
333333
"333": "Base UI Plus: The time string has an unsupported format. Its clock fields cannot be read. Use HH:mm or HH:mm:ss with up to nine fractional digits.",
334334
"334": "Base UI Plus: The time has no clock fields. A clock time cannot be created. Pass a string such as '09:30' or an object such as { hour: 9, minute: 30 }.",
335335
"335": "Base UI Plus: The %s of the time is out of range. The clock time cannot be created. Pass an integer between 0 and %s.",
336-
"336": "Base UI Plus: The zoned date-time is outside the supported years. Fields cannot display it. Use a date-time between years 0 and 9999.",
337-
"337": "Base UI Plus: The zoned date-time could not be created or changed. Its date, clock time, offset or timezone could not be resolved: %s. Pass a valid ISO date-time and timezone, and choose a disambiguation policy for skipped or repeated clock times."
336+
"336": "Base UI Plus: The zoned date-time is outside the supported range. Like in Temporal, its instant must be one of the instants of `Date`, and its date must be from -271821-04-20 to +275760-09-13. Use a date-time inside this range.",
337+
"337": "Base UI Plus: The zoned date-time could not be created or changed. Its date, clock time, offset or timezone could not be resolved: %s. Pass a valid ISO date-time and timezone, and choose a disambiguation policy for skipped or repeated clock times.",
338+
"338": "Base UI Plus: `BaseTemporal.%s` received %s, which isn't an object of fields. `with()` replaces the fields of an object, and keeps the calendar of the value, so it rejects temporal values and objects with a calendar or a time zone. Pass only the fields to replace, such as `{ day: 1 }` for a date or `{ hour: 9 }` for a time.",
339+
"339": "Base UI Plus: `BaseTemporal.%s` received no field the value reads. `with()` can't tell which fields to replace. Pass at least one field of the value, such as `{ day: 1 }` for a date or `{ hour: 9 }` for a time. Only calendars with eras, unlike the ISO calendar, read `era` and `eraYear`."
338340
}

‎packages/x-scheduler-internals-premium/src/internals/utils/recurring-events/RecurringSeries.test.ts‎

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -105,47 +105,47 @@ describe('recurring-events/RecurringSeries', () => {
105105
});
106106

107107
describe('last supported day', () => {
108-
it('should end every frequency on December 31, 9999', () => {
109-
const lastYear = ['9999-01-01T00:00:00', '9999-12-31T00:00:00'] as const;
108+
it('should end every frequency on September 13, 275760', () => {
109+
const lastYear = ['+275760-01-01T00:00:00', '+275760-09-13T00:00:00'] as const;
110110
expect(
111111
getOccurrenceDays(
112112
{ freq: 'YEARLY' },
113-
'9998-06-01T09:00:00',
114-
'9998-01-01T00:00:00',
113+
'+275759-06-01T09:00:00',
114+
'+275759-01-01T00:00:00',
115115
lastYear[1],
116116
),
117-
).to.deep.equal(['9998-06-01', '9999-06-01']);
117+
).to.deep.equal(['+275759-06-01', '+275760-06-01']);
118118
expect(
119-
getOccurrenceDays({ freq: 'DAILY', interval: 300 }, '9999-06-01T09:00:00', ...lastYear),
120-
).to.deep.equal(['9999-06-01']);
119+
getOccurrenceDays({ freq: 'DAILY', interval: 300 }, '+275760-06-01T09:00:00', ...lastYear),
120+
).to.deep.equal(['+275760-06-01']);
121121
expect(
122-
getOccurrenceDays({ freq: 'MONTHLY', interval: 5 }, '9999-10-15T09:00:00', ...lastYear),
123-
).to.deep.equal(['9999-10-15']);
122+
getOccurrenceDays({ freq: 'MONTHLY', interval: 5 }, '+275760-08-15T09:00:00', ...lastYear),
123+
).to.deep.equal(['+275760-08-15']);
124124
// The month after the first one is too far to compute.
125125
expect(
126126
getOccurrenceDays(
127127
{ freq: 'MONTHLY', interval: Number.MAX_VALUE },
128-
'9999-10-15T09:00:00',
128+
'+275760-08-15T09:00:00',
129129
...lastYear,
130130
),
131-
).to.deep.equal(['9999-10-15']);
131+
).to.deep.equal(['+275760-08-15']);
132132
});
133133

134-
it('should end the series when its next day of the week or month is in the year 10000', () => {
135-
// The Sunday of the last week of 9999 is January 2nd, 10000.
134+
it('should end the series when its next day of the week or month is after September 13, 275760', () => {
135+
// September 13th, 275760 is a Saturday, so the Sunday of its week is after it.
136136
expect(
137137
getOccurrenceDays(
138138
{ freq: 'WEEKLY', byDay: ['SU'] },
139-
'9999-12-26T09:00:00',
140-
'9999-12-27T00:00:00',
141-
'9999-12-31T00:00:00',
139+
'+275760-09-07T09:00:00',
140+
'+275760-09-08T00:00:00',
141+
'+275760-09-13T00:00:00',
142142
),
143143
).to.deep.equal([]);
144-
// December 9999 has no 5th Sunday, which would be January 2nd, 10000.
144+
// September 275760 has no 5th Sunday before the 13th, and August 31st is the last one.
145145
const series = new RecurringSeries(adapter, {
146146
rule: { freq: 'MONTHLY', byDay: ['5SU'], count: 3 },
147-
...seriesStart(adapter.date('9999-10-31T09:00:00', 'UTC')),
148-
exDates: [adapter.date('9999-10-31T09:00:00', 'UTC')],
147+
...seriesStart(adapter.date('+275760-08-31T09:00:00', 'UTC')),
148+
exDates: [adapter.date('+275760-08-31T09:00:00', 'UTC')],
149149
});
150150
expect(series.hasOccurrence()).to.equal(false);
151151
});

‎packages/x-scheduler-internals/src/internals/utils/base-temporal/PlainDate.ts‎

Lines changed: 24 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export declare namespace BaseTemporal {
5353
readonly eraYear: number | undefined;
5454
/**
5555
* The year in the calendar, which keeps counting down before its first era: the ISO year 0 is
56-
* 1 BC. ISO years go from 0 to 9999.
56+
* 1 BC.
5757
*/
5858
readonly year: number;
5959
/**
@@ -81,6 +81,11 @@ export declare namespace BaseTemporal {
8181
* The number of months in the year.
8282
*/
8383
readonly monthsInYear: number;
84+
/**
85+
* A date has no time. Date-times have every other member of a date, so this keeps them out of
86+
* the APIs that take a date, such as the date field.
87+
*/
88+
readonly hour?: undefined;
8489
/**
8590
* Returns the date moved by a duration in its calendar, like Temporal. Years keep the month
8691
* code, or move to the month that replaces it in a year without it, and months count the
@@ -208,7 +213,8 @@ export function getWeekDayIndex(dayOfWeek: number, firstDayOfWeek: number): numb
208213
*/
209214
const UNVALIDATED = Symbol('unvalidated');
210215

211-
const ISO_DATE_REGEXP = /^(\d{4})-(\d{2})-(\d{2})$/;
216+
// Like in Temporal, a year has four digits, or a sign and six digits, but `-000000` isn't a year.
217+
const ISO_DATE_REGEXP = /^(\d{4}|\+\d{6}|-(?!0{6})\d{6})-(\d{2})-(\d{2})$/;
212218

213219
/**
214220
* The units of a Temporal duration, in the order Temporal reads them.
@@ -226,12 +232,10 @@ const DURATION_UNITS = [
226232
'years',
227233
] as const;
228234

229-
const WITH_FIELDS = ['year', 'era', 'eraYear', 'month', 'monthCode', 'day'] as const;
230-
231235
/**
232236
* More years than the supported days span in any calendar.
233237
*/
234-
const MAX_YEARS = 20_000;
238+
const MAX_YEARS = 600_000;
235239

236240
/**
237241
* Reads a duration like Temporal. Its time units must be 0.
@@ -488,34 +492,9 @@ export class PlainDate implements BaseTemporal.PlainDate {
488492
*/
489493
public with(fields: PlainDateWithFields, options?: PlainDateFromOptions): PlainDate {
490494
const apiName = 'PlainDate.prototype.with()';
491-
if (
492-
typeof fields !== 'object' ||
493-
fields === null ||
494-
['calendar', 'calendarId', 'timeZone'].some(
495-
(key) => (fields as Record<string, unknown>)[key] !== undefined,
496-
)
497-
) {
498-
throw new TypeError(
499-
`Base UI Plus: \`BaseTemporal.${apiName}\` received ${String(fields)}, which isn't an object of date fields. ` +
500-
'`with()` replaces the date fields of an object, and keeps the calendar of the date, so it rejects temporal values and objects with a calendar or a time zone. ' +
501-
'Pass the fields to replace, such as `{ day: 1 }`, and call `withCalendar()` to change the calendar.',
502-
);
503-
}
504-
// Like Temporal, a calendar without eras ignores the era fields.
505-
const partial: PlainDateWithFields = { ...fields };
506-
if (getCalendar(this.calendarId).getYearFromEra == null) {
507-
partial.era = undefined;
508-
partial.eraYear = undefined;
509-
}
510-
if (WITH_FIELDS.every((field) => partial[field] === undefined)) {
511-
throw new TypeError(
512-
`Base UI Plus: \`BaseTemporal.${apiName}\` received no date field the calendar of the date reads. ` +
513-
"`with()` can't tell which fields to replace. " +
514-
'Pass at least one of `year`, `month`, `monthCode` and `day`, or `era` and `eraYear` outside the ISO calendar.',
515-
);
516-
}
517495
return createPlainDate(
518-
readDate(mergeDateFields(this, partial), readOverflow(options, apiName), apiName).dayNumber,
496+
readDate(mergeDateFields(this, fields, apiName), readOverflow(options, apiName), apiName)
497+
.dayNumber,
519498
this.calendarId,
520499
);
521500
}
@@ -611,8 +590,8 @@ export function readDate(
611590
const day = Number(match?.[3]);
612591
if (match == null || !isValidIsoDate(year, month, day)) {
613592
throw new Error(
614-
`Base UI Plus: \`BaseTemporal.PlainDate.from()\` received ${JSON.stringify(item)}, which isn't an existing date in the \`YYYY-MM-DD\` format. ` +
615-
'Other ISO 8601 forms, such as times, offsets, time zone annotations or 6-digit years, are not supported. ' +
593+
`Base UI Plus: \`BaseTemporal.PlainDate.from()\` received ${JSON.stringify(item)}, which isn't an existing date in the \`YYYY-MM-DD\` or \`±YYYYYY-MM-DD\` format. ` +
594+
'Other ISO 8601 forms, such as times, offsets or time zone annotations, are not supported. ' +
616595
"Pass a date such as `'2024-05-24'`, optionally followed by a calendar annotation such as `'[u-ca=hebrew]'`.",
617596
);
618597
}
@@ -629,9 +608,17 @@ export function readDate(
629608
);
630609
}
631610
const calendar = getItemCalendar(item);
632-
const dayNumber =
633-
calendar.resolveFields?.(item, overflow, apiName) ??
634-
resolveIsoFields(calendar, item, overflow, apiName);
611+
let dayNumber: number;
612+
if (typeof (item as Partial<BaseTemporal.PlainDate>).withCalendar === 'function') {
613+
// A temporal value, such as a `Temporal` one, whose era codes can differ from the calendar's,
614+
// such as `gregory-inverse`: its ISO fields name the same day.
615+
const isoDate = (item as BaseTemporal.PlainDate).withCalendar(ISO_CALENDAR_ID);
616+
dayNumber = toDayNumber(isoDate.year, isoDate.month, isoDate.day);
617+
} else {
618+
dayNumber =
619+
calendar.resolveFields?.(item, overflow, apiName) ??
620+
resolveIsoFields(calendar, item, overflow, apiName);
621+
}
635622
checkCalendarRange(calendar, dayNumber);
636623
return { dayNumber, calendar };
637624
}

‎packages/x-scheduler-internals/src/internals/utils/base-temporal/PlainDateTime.ts‎

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ export interface PlainDateTimeFields extends PlainTimeFields, PlainDateFields {}
3030

3131
export type PlainDateTimeInput = PlainDateTimeFields | string;
3232

33+
/**
34+
* The fields `with()` replaces, read in the calendar of the value.
35+
*/
36+
export type PlainDateTimeWithFields = Partial<Omit<PlainDateTimeFields, 'calendar'>>;
37+
3338
// Declared apart from the class, like `BaseTemporal.PlainDate`.
3439
export declare namespace BaseTemporal {
3540
/**
@@ -61,7 +66,7 @@ export declare namespace BaseTemporal {
6166
* Returns the date-time with some fields replaced, with the rules of `BaseTemporal.PlainDate`
6267
* for the date fields.
6368
*/
64-
with(fields: Partial<PlainDateTimeFields>, options?: PlainDateFromOptions): PlainDateTime;
69+
with(fields: PlainDateTimeWithFields, options?: PlainDateFromOptions): PlainDateTime;
6570
/**
6671
* Returns the same date at another time, midnight by default.
6772
*/
@@ -196,7 +201,7 @@ export class PlainDateTime implements BaseTemporal.PlainDateTime {
196201
let time: Required<PlainTimeFields>;
197202
if (typeof item === 'string') {
198203
const annotated = readCalendarAnnotation(item);
199-
const match = /^(\d{4}-\d{2}-\d{2})T(.+)$/.exec(annotated.text);
204+
const match = /^((?:\d{4}|[+-]\d{6})-\d{2}-\d{2})T(.+)$/.exec(annotated.text);
200205
if (match == null) {
201206
throw new Error(
202207
'Base UI Plus: The date-time string has an unsupported format. ' +
@@ -233,12 +238,8 @@ export class PlainDateTime implements BaseTemporal.PlainDateTime {
233238
const value = other instanceof PlainDateTime ? other : PlainDateTime.from(other);
234239
return PlainDateTime.compare(this, value) === 0 && this.calendarId === value.calendarId;
235240
}
236-
with(fields: Partial<PlainDateTimeFields>, options?: PlainDateFromOptions): PlainDateTime {
237-
const time: PlainTimeFields = {};
238-
for (const key of TIME_FIELDS) {
239-
time[key] = fields[key] ?? this[key];
240-
}
241-
return PlainDateTime.from({ ...mergeDateFields(this.date, fields), ...time }, options);
241+
with(fields: PlainDateTimeWithFields, options?: PlainDateFromOptions): PlainDateTime {
242+
return withFields(this, fields, options, 'PlainDateTime.prototype.with()', TIME_FIELDS);
242243
}
243244
withPlainTime(time: PlainTimeInput = new PlainTime()): PlainDateTime {
244245
return createPlainDateTime(this.date, readTime(time));
@@ -263,6 +264,25 @@ export class PlainDateTime implements BaseTemporal.PlainDateTime {
263264
}
264265
}
265266

267+
/**
268+
* Returns `dateTime` with the fields `with()` received replaced. `fieldNames` are the fields of the
269+
* value besides its date fields, at least one of which `fields` must set without a date field.
270+
*/
271+
export function withFields(
272+
dateTime: PlainDateTime,
273+
fields: PlainDateTimeWithFields,
274+
options: PlainDateFromOptions | undefined,
275+
apiName: string,
276+
fieldNames: readonly string[],
277+
): PlainDateTime {
278+
const dateFields = mergeDateFields(dateTime.toPlainDate(), fields, apiName, fieldNames);
279+
const time: PlainTimeFields = {};
280+
for (const key of TIME_FIELDS) {
281+
time[key] = fields[key] ?? dateTime[key];
282+
}
283+
return PlainDateTime.from({ ...dateFields, ...time }, options);
284+
}
285+
266286
/**
267287
* Creates a date-time from a date and a time read by `readTime()`, without checking them again.
268288
*/

0 commit comments

Comments
 (0)