Skip to content

Commit

Permalink
Normative: Remove calendars and time zones
Browse files Browse the repository at this point in the history
This is a very large change, as it not only removes Temporal.Calendar and
Temporal.TimeZone, but also tries to eliminate any extra complexity due to
no longer having to deal with user code calls for calendar and time zone
calculations.

Some of the things that are removed or simplified include:

- No more Calendar Method Records and Time Zone Method Records

- In many places, no need to pass around the user's original options bag

- In many places, no need to pass around the user's original PlainDate or
  Instant; use epoch nanoseconds, ISO Date Records, and ISO Date-Time
  Records instead

- No more copying the own properties of options bags

- Most of the calendar and time zone operations are now infallible

- The set of extra calendar fields that used to be returned by
  Temporal.Calendar.prototype.fields() is now static; so no need to have
  the complicated PrepareTemporalFields operation that returns a null-
  prototype object with own data properties that correspond to arbitrary
  user fields. Dates in calendar space can be represented by a Calendar
  Fields Record with known fields.

- Much of the special-casing to avoid user calls that was added in #2519
  and similar PRs is now unobservable and is removed.

Closes: #2836
Closes: #2853
Closes: #2854
  • Loading branch information
ptomato committed Aug 22, 2024
1 parent eff8b62 commit 2d20426
Show file tree
Hide file tree
Showing 49 changed files with 3,113 additions and 7,583 deletions.
37 changes: 4 additions & 33 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,43 +192,14 @@ Unlike the other Temporal types, the units in `Temporal.Duration` don't naturall

See [Duration balancing](./balancing.md) for more on this topic.

### **Temporal.TimeZone**
### Calendars

A `Temporal.TimeZone` represents an IANA time zone, a specific UTC offset, or UTC itself.
Time zones translate from a date/time in UTC to a local date/time.
Because of this `Temporal.TimeZone` can be used to convert between `Temporal.Instant` and `Temporal.PlainDateTime` as well as finding out the offset at a specific `Temporal.Instant`.

It is also possible to implement your own time zones.

```js
const timeZone = Temporal.TimeZone.from('Africa/Cairo');
timeZone.getInstantFor('2000-01-01T00:00'); // => 1999-12-31T22:00:00Z
timeZone.getPlainDateTimeFor('2000-01-01T00:00Z'); // => 2000-01-01T02:00:00
timeZone.getPreviousTransition(Temporal.Now.instant()); // => 2014-09-25T21:00:00Z
timeZone.getNextTransition(Temporal.Now.instant()); // => null
```

See [Temporal.TimeZone Documentation](./timezone.md) for detailed documentation.
A conceptual explanation of handling [time zones, DST, and ambiguity in Temporal](./ambiguity.md) is also available.

### **Temporal.Calendar**

A `Temporal.Calendar` represents a calendar system.
Temporal supports multiple calendar systems.
Most code will use the ISO 8601 calendar, but other calendar systems are available.

Dates have associated `Temporal.Calendar` objects, to perform calendar-related math.
Under the hood, this math is done by methods on the calendars.

It is also possible to implement your own calendars.

```js
const cal = Temporal.Calendar.from('iso8601');
const date = cal.dateFromFields({ year: 1999, month: 12, day: 31 }, {});
date.monthsInYear; // => 12
date.daysInYear; // => 365
```
Dates have associated calendar IDs, to perform calendar-related math.

See [Temporal.Calendar Documentation](./calendar.md) for detailed documentation.
See [Calendars in Temporal](./calendars.md) for detailed documentation.

## Object relationship

Expand Down
542 changes: 0 additions & 542 deletions docs/calendar.md

This file was deleted.

128 changes: 128 additions & 0 deletions docs/calendars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Calendars in Temporal

<details>
<summary><strong>Table of Contents</strong></summary>
<!-- toc -->
</details>

Much of the world uses the [Gregorian calendar](https://en.wikipedia.org/wiki/Gregorian_calendar), which was invented in 1582 C.E.
The ISO 8601 standard extends the Gregorian date reckoning backwards ("proleptically") to cover the period of history before its invention, to allow designating dates before 1582.
The ISO 8601 calendar is the system most often used in computing, on the modern Internet.

A significant number of places in the world use another calendar system as the main calendar, or use the Gregorian calendar alongside another calendar system as a commonly-used civil or religious calendar.
Even places that use almost exclusively the Gregorian calendar today, often use a different calendar to denote dates before the invention or adoption of the Gregorian calendar.

### When to use calendars in Temporal

It is best practice to specify a calendar system when performing calendar-sensitive operations, which are those involving arithmetic or other calculation in months or years.

For example, to add a month to a date in the Hebrew calendar:

```javascript
date.withCalendar('hebrew').add({ months: 1 });
```

Temporal types' `toLocaleString()` methods use the user's preferred calendar, without needing to call `withCalendar()`.
To perform arithmetic consistently with the `toLocaleString()` calendar system:

```javascript
const calendar = new Intl.DateTimeFormat().resolvedOptions().calendar;
date.withCalendar(calendar).add({ months: 1 });
```

### Invariants Across Calendars

The following "invariants" (statements that are always true) hold for all built-in calendars:

- Any date can be serialized to an object using only four properties: `{ year, month, day, calendar }`
- `year` is always an integer (which may be zero or negative) that increases as time goes forward
- `month` and `day` are always positive integers that increase as time goes forward, except they reset at the boundary of a year or month, respectively
- `month` is always continuous (no gaps)
- `date.month === 1` during the first month of any year, because `month` always represents the order of months in that year.
- `obj.with({ day: 1 })` will always return the first day of the object's month, even if the resulting `day` is not 1.
- `obj.with({ day: Number.MAX_VALUE })` will always return the last day of the object's month.
- `obj.with({ month: 1, day: 1 })` will always return the first day of the object's year.
- `obj.with({ month: obj.monthsInYear, day: Number.MAX_VALUE })` will always return the last day of the object's year.
- `obj.month === obj.monthsInYear` during the last month of any year
- `dayOfWeek`, `dayOfYear`, and `weekOfYear` are 1-based positive integers, that increase consecutively as time goes forward, except they reset at the boundary of a week or year, respectively

### Writing Cross-Calendar Code

Here are best practices for writing code that will work regardless of the calendar used:

- Validate or coerce the calendar of all external input.
If your code receives a Temporal object from an external source, you should check that its calendar is what you expect, and if you are not prepared to handle other calendars, convert it to the ISO 8601 calendar using `obj.withCalendar('iso8601')`.
Otherwise, you may end up with unexpected behavior in your app or introduce security or performance issues by introducing an unexpected calendar.
- Use `compare` methods (e.g. `Temporal.PlainDate.compare(date1, '2000-01-01')`) instead of manually comparing individual properties (e.g. `date.year > 2000`) whose meaning may vary across calendars.
- Never compare field values in different calendars.
A `month` or `year` in one calendar is unrelated to the same property values in another calendar.
To compare dates across calendars, use the `compare` method.
- When comparing dates for equality that might be in different calendars, convert them both to the same calendar using `withCalendar`.
The same ISO date in different calendars will return `false` from the `equals` method because the calendars are not equal.
- When looping through all months in a year, use `monthsInYear` as the upper bound instead of assuming that every year has 12 months.
- Don't assume that `date.month === 12` is the last month of the year.
Instead, use `date.month === date.monthsInYear`.
- Use `until` or `since` to count years, months, or days between dates.
Manually calculating differences (e.g. `Math.floor(months / 12)`) will fail for some calendars.
- Use `daysInMonth` instead of assuming that each month has the same number of days in every year.
- Days in a month are not always continuous.
There can be gaps due to political changes in calendars.
For this reason, instead of looping through a month from 1 to `date.daysInMonth`, it's better to start a loop with the first day of the month (`date.with({day: 1})`) and `add` one day at a time until the `month` property returns a different value.
- Use `daysInYear` instead of assuming that every year has 365 days (366 in a leap year).
- Don't assume that `inLeapYear === true` implies that the year is one day longer than a regular year.
Some calendars add leap months, making the year 29 or 30 days longer than a normal year!
- Use `toLocaleString` to format dates to users.
DO NOT localize manually with code like `${month}/${day}/${year}`.
- Don't assume that `month` has the same name in every year.
Some calendars like Hebrew or Chinese have leap months that cause months to vary across years.
- Use the correct property to refer to months.
If you care about the order of the month in a particular year (e.g. when looping through all the months in a year) use `month`.
If you care about the name of the month regardless of what year it is (e.g. storing a birthday), use the `monthCode` string property.
- When using the `Temporal.PlainMonthDay` type (e.g. for birthdays or holidays), use its `monthCode` property only.
The `month` property is not present on this type because some calendars' month indexes vary from year to year.
- When calling `Temporal.PlainMonthDay.prototype.toPlainDate(year)`, be prepared for the resulting date to have a different day of the month and/or a different month, because leap days and leap months are not present in every year.
- Use `toLocaleString` to fetch month names instead of caching an array of names.
Example: `date.toLocaleString('en-US', { calendar: date.calendar, month: 'long' })`.
If you absolutely must cache month names, a string key like `${date.calendar.id}|{date.monthCode}|{date.inLeapYear}` will work for all built-in calendars.
- Don't assume that `era` or `eraYear` properties are always present.
They are not present in some calendars.
- `era` and `eraYear` should always be used as a pair.
Don't use one property without also using the other.
- Don't combine `month` and `monthCode` in the same property bag.
Pick one month representation and use it consistently.
- Don't combine `year` and `era`/`eraYear` in the same property bag.
Pick one year representation and use it consistently.
- Read the documentation of your calendar to determine the meaning of `monthCode` and `era`.
- Don't show `monthCode` and `era` values in a UI.
Instead, use `toLocaleString` to convert these values into localized strings.
- Don't assume that the year before `{ eraYear: 1 }` is the last year of the previous era.
Some calendars have a "year zero", and the oldest era in era-using calendars typically allows negative `eraYear` values.

### Handling unusual dates: leap days, leap months, and skipped or repeated periods

Calendars can vary from year to year.
[Solar calendars](https://en.wikipedia.org/wiki/Solar_calendar) like `'gregory'` use leap days.
[Lunar calendars](https://en.wikipedia.org/wiki/Lunar_calendar) like `'islamic'` adjust month lengths to lunar cycles.
[Lunisolar calendars](https://en.wikipedia.org/wiki/Lunisolar_calendar) like `'hebrew'` or `'chinese'` have "leap months": extra months added every few years.

Calendars may also have one-time changes.
The built-in `'gregory'` calendar in ECMAScript doesn't skip days because it's a [proleptic Gregorian calendar](https://en.wikipedia.org/wiki/Proleptic_Gregorian_calendar), but other calendars may skip days, months, or even years.
For example, a non-proleptic custom calendar for France would have 4 October 1582 (the last day of the [Julian calendar](https://en.wikipedia.org/wiki/Julian_calendar)) directly followed by 15 October 1582 (the first day of the [Gregorian calendar](https://en.wikipedia.org/wiki/Gregorian_calendar)), skipping 10 calendar days.

Calendar variation across years means that programs may encounter historical dates that are valid in one year but invalid in another.
A common example is calling `toPlainDate` on a `Temporal.PlainMonthDay` object to convert a birthday or anniversary that originally fell on a leap day, leap month, or other skipped period.
Temporal types' `with` or `from` methods can run into the same issue.

When Temporal encounters inputs representing a month and/or day that doesn't exist in the desired calendar year, by default (overridable in `with` or `from` via the `overflow` option) the inputs will be adjusted using the following algorithm:

- First, pick the closest `day` in the same month.
If there are two equally-close dates in that month, pick the later one.
- If the month is a leap month that doesn't exist in the desired year, then pick another date according to the cultural conventions of that calendar's users.
Usually this will result in the same `day` in the month before or the month after where that month would normally fall in a leap year.
- Otherwise, pick the closest date to the provided date that is still in the same year.
If there are two equally-close dates, pick the later one.
- If the entire year doesn't exist, then pick the closest date to the provided date.
If there are two equally-close dates, pick the later one.

Finally, just like calendars can sometimes skip days or months, it is possible for real-world calendars to repeat dates, for example when a country transitions from one calendar system to another.
No current built-in calendar repeats dates, but may in the future.
11 changes: 0 additions & 11 deletions docs/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,6 @@ Legacy `Date` represents an exact time, so it's straightforward to convert a `Te

## Construction

### Time zone object from name

`Temporal.TimeZone.from()` can convert an IANA time zone name into a `Temporal.TimeZone` object, if you need to call `Temporal.TimeZone` methods.
Usually this is not necessary.

<!-- prettier-ignore-start -->
```javascript
{{cookbook/getTimeZoneObjectFromIanaName.mjs}}
```
<!-- prettier-ignore-end -->

### Calendar input element

You can use `Temporal` objects to set properties on a calendar control.
Expand Down
1 change: 0 additions & 1 deletion docs/cookbook/all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import './getNextOffsetTransitionFromExactTime.mjs';
import './getParseableZonedStringAtInstant.mjs';
import './getSortedLocalDateTimes.mjs';
import './getTimeStamp.mjs';
import './getTimeZoneObjectFromIanaName.mjs';
import './getTripDurationInHrMinSec.mjs';
import './getUtcOffsetDifferenceSecondsAtInstant.mjs';
import './getUtcOffsetSecondsAtInstant.mjs';
Expand Down
12 changes: 6 additions & 6 deletions docs/cookbook/getInstantWithLocalTimeInZone.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
*
* @param {Temporal.PlainDateTime} dateTime - Calendar date and wall-clock time to
* convert
* @param {Temporal.TimeZone} timeZone - Time zone in which to consider the
* @param {string} timeZone - IANA identifier of time zone in which to consider the
* wall-clock time
* @param {string} [disambiguation='earlier'] - Disambiguation mode, see description.
* @returns {Temporal.Instant} Absolute time in timeZone at the time of the
Expand Down Expand Up @@ -47,7 +47,6 @@ function getInstantWithLocalTimeInZone(dateTime, timeZone, disambiguation = 'ear
throw new RangeError(`invalid disambiguation ${disambiguation}`);
}

const germany = Temporal.TimeZone.from('Europe/Berlin');
const nonexistentGermanWallTime = Temporal.PlainDateTime.from('2019-03-31T02:45');

const germanResults = {
Expand All @@ -59,12 +58,13 @@ const germanResults = {
};
for (const [disambiguation, result] of Object.entries(germanResults)) {
assert.equal(
getInstantWithLocalTimeInZone(nonexistentGermanWallTime, germany, disambiguation).toString({ timeZone: germany }),
getInstantWithLocalTimeInZone(nonexistentGermanWallTime, 'Europe/Berlin', disambiguation).toString({
timeZone: 'Europe/Berlin'
}),
result
);
}

const brazilEast = Temporal.TimeZone.from('America/Sao_Paulo');
const doubleEasternBrazilianWallTime = Temporal.PlainDateTime.from('2019-02-16T23:45');

const brazilianResults = {
Expand All @@ -76,8 +76,8 @@ const brazilianResults = {
};
for (const [disambiguation, result] of Object.entries(brazilianResults)) {
assert.equal(
getInstantWithLocalTimeInZone(doubleEasternBrazilianWallTime, brazilEast, disambiguation).toString({
timeZone: brazilEast
getInstantWithLocalTimeInZone(doubleEasternBrazilianWallTime, 'America/Sao_Paulo', disambiguation).toString({
timeZone: 'America/Sao_Paulo'
}),
result
);
Expand Down
6 changes: 0 additions & 6 deletions docs/cookbook/getTimeZoneObjectFromIanaName.mjs

This file was deleted.

12 changes: 5 additions & 7 deletions docs/cookbook/getUtcOffsetDifferenceSecondsAtInstant.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,18 @@
* time zones, at an exact time
*
* @param {Temporal.Instant} instant - An exact time
* @param {Temporal.TimeZone} sourceTimeZone - A time zone to examine
* @param {Temporal.TimeZone} targetTimeZone - A second time zone to examine
* @param {string} sourceTimeZone - IANA ID of a time zone to examine
* @param {string} targetTimeZone - IANA ID of a second time zone to examine
* @returns {number} The number of seconds difference between the time zones'
* UTC offsets
*/
function getUtcOffsetDifferenceSecondsAtInstant(instant, sourceTimeZone, targetTimeZone) {
const sourceOffsetNs = sourceTimeZone.getOffsetNanosecondsFor(instant);
const targetOffsetNs = targetTimeZone.getOffsetNanosecondsFor(instant);
const sourceOffsetNs = instant.toZonedDateTimeISO(sourceTimeZone).offsetNanoseconds;
const targetOffsetNs = instant.toZonedDateTimeISO(targetTimeZone).offsetNanoseconds;
return (targetOffsetNs - sourceOffsetNs) / 1e9;
}

const instant = Temporal.Instant.from('2020-01-09T00:00Z');
const nyc = Temporal.TimeZone.from('America/New_York');
const chicago = Temporal.TimeZone.from('America/Chicago');

// At this exact time, Chicago is 3600 seconds earlier than New York
assert.equal(getUtcOffsetDifferenceSecondsAtInstant(instant, nyc, chicago), -3600);
assert.equal(getUtcOffsetDifferenceSecondsAtInstant(instant, 'America/New_York', 'America/Chicago'), -3600);
3 changes: 1 addition & 2 deletions docs/cookbook/getUtcOffsetSecondsAtInstant.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
const instant = Temporal.Instant.from('2020-01-09T00:00Z');
const nyc = Temporal.TimeZone.from('America/New_York');

nyc.getOffsetNanosecondsFor(instant) / 1e9; // => -18000
instant.toZonedDateTimeISO('America/New_York').offsetNanoseconds / 1e9; // => -18000
6 changes: 1 addition & 5 deletions docs/cookbook/getUtcOffsetStringAtInstant.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
const instant = Temporal.Instant.from('2020-01-09T00:00Z');
const nyc = Temporal.TimeZone.from('America/New_York');

nyc.getOffsetStringFor(instant); // => '-05:00'

// Can also be done with ZonedDateTime.offset:
const source = instant.toZonedDateTimeISO(nyc);
const source = instant.toZonedDateTimeISO('America/New_York');
source.offset; // => '-05:00'
9 changes: 4 additions & 5 deletions docs/plaindate.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ It can also be combined with a `Temporal.PlainTime` to yield a "zoneless" `Tempo

## Constructor

### **new Temporal.PlainDate**(_isoYear_: number, _isoMonth_: number, _isoDay_: number, _calendar_: string | object = "iso8601") : Temporal.PlainDate
### **new Temporal.PlainDate**(_isoYear_: number, _isoMonth_: number, _isoDay_: number, _calendar_: string = "iso8601") : Temporal.PlainDate

**Parameters:**

- `isoYear` (number): A year.
- `isoMonth` (number): A month, ranging between 1 and 12 inclusive.
- `isoDay` (number): A day of the month, ranging between 1 and 31 inclusive.
- `calendar` (optional string, `Temporal.Calendar` instance, or plain object): A calendar to project the date into.
- `calendar` (optional string): A calendar to project the date into.

**Returns:** a new `Temporal.PlainDate` object.

Expand All @@ -37,8 +37,7 @@ Together, `isoYear`, `isoMonth`, and `isoDay` must represent a valid date in tha
The range of allowed values for this type is exactly enough that calling [`toPlainDate()`](./plaindatetime.md#toPlainDate) on any valid `Temporal.PlainDateTime` will succeed.
If `isoYear`, `isoMonth`, and `isoDay` form a date outside of this range, then this function will throw a `RangeError`.

Usually `calendar` will be a string containing the identifier of a built-in calendar, such as `'islamic'` or `'gregory'`.
Use an object if you need to supply [custom calendar behaviour](./calendar.md#custom-calendars).
`calendar` is a string containing the identifier of a built-in calendar, such as `'islamic'` or `'gregory'`.

> **NOTE**: The `isoMonth` argument ranges from 1 to 12, which is different from legacy `Date` where months are represented by zero-based indices (0 to 11).
Expand Down Expand Up @@ -397,7 +396,7 @@ nextMonthDate.with({ day: nextMonthDate.daysInMonth }); // => 2006-02-28

**Parameters:**

- `calendar` (`Temporal.Calendar` or plain object or string): The calendar into which to project `date`.
- `calendar` (object or string): The calendar into which to project `date`.

**Returns:** a new `Temporal.PlainDate` object which is the date indicated by `date`, projected into `calendar`.

Expand Down
Loading

0 comments on commit 2d20426

Please sign in to comment.