ecma402 icon indicating copy to clipboard operation
ecma402 copied to clipboard

Accessing the sequence of eras of a calendar

Open devongovett opened this issue 3 years ago • 24 comments

I'm curious about the decision to use string identifiers for the era field. There are some cases where it might be useful to be able to manipulate the era field similarly to other numeric fields. For example, a date picker UI control might want to allow the user to change the era using the arrow keys. This example is from the macOS native date picker control on the Japanese calendar.

https://user-images.githubusercontent.com/19409/126418252-95f4c73a-6123-4b43-accf-3ab9c1f9c9d6.mov

In ICU and Joda Time (I believe), the era field is a number like other fields, which means it can be incremented and decremented. But this is not possible with Temporal because the era field is a string, and not included in Duration.

I do like that eras are more user-friendly by using identifiers though (an enum would also work). Perhaps an alternative would be for calendars to have a method to retrieve the list of (or at least the previous and next) available era identifiers. This way, UI code could find the current era identifier, and determine the next/previous one from there.

I also think a way to retrieve the number of years in an era would be useful. For example, a date picker UI might wish to make the year field wrap back to the first year when the end of an era is met (so that editing one field does not affect another). This could be a method on calendar similar to the other methods for retrieving limits.

devongovett avatar Jul 21 '21 02:07 devongovett

@sffc @manishearth - what's the latest status (if any) of an enumeration API for calendar eras?

@devongovett - https://github.com/tc39/ecma402/issues/541 is the issue currently tracking which Japanese eras will be supported in Temporal and what their identifiers will be. You should probably watch that issue. Keep in mind that the decision about which eras to support is out of scope of the Temporal spec. This doesn't matter from an end-user standpoint, but it matters somewhat for who in the TC39 world is actually making era-related decisions: the 402 working group, not the Temporal champions.

BTW, @manishearth wrote a document that goes into a lot of detail about the problem of creating era identifiers for Japanese. Manish, are there any updates from what's in that doc that you'd want to share?

As for why we don't use numeric identifiers for eras, the main reason was readability. But also, for Japanese, new historical eras are being discovered from time to time, so any numbering scheme would have to either be sparse (to allow new ones to be injected) or would have to be out-of-order. Neither of those is a great experience, so using alphanumeric strings was considered better because consecutive, sequential-across-time identifiers wouldn't be expected. @sffc or @manishearth can correct or expand on this if I got it wrong.

BTW, if you want to find years of eras, if you have an enumeration then it's trivial to create a Temporal.PlainDate for the first ~~day~~ year of the era:

Temporal.PlainDate.from({era: 'meiji', eraYear: 1, month: 1, day: 1, calendar: 'japanese'});

Note that Japanese eras that start mid-year are interesting. The way Temporal handles this (at least in the polyfill currently) is that era/eraYear are used to calculate the year, and then that year value is used. So that's why the era below is returned as ce (which is a placeholder in the polyfilll until https://github.com/tc39/ecma402/issues/541 is resolved) instead of 'meiji' because that era started midyear.

It's an interesting question of whether an era enumeration API should include the specific dates where the era starts and ends. This seems like a reasonable requirement to add to whatever TC39 proposal targets that enumeration use case.

justingrant avatar Jul 21 '21 23:07 justingrant

Thanks, that's useful information. I didn't realize that new historical eras are still being discovered. In that case, string identifiers with a way to enumerate the valid values definitely seems like a better solution.

BTW, @Manishearth wrote a document that goes into a lot of detail about the problem of creating era identifiers for Japanese.

It seems I don't have access to this.

Note that Japanese eras that start mid-year are interesting.

Indeed. Determining the number of years in an era isn't so simple with this in mind. For the date picker use case, the maximum allowed value for the year field should change depending on both the era as well as the month and day fields. For example, in the heisei era, the maximum value should be 31 for dates before May 1st and 30 for dates after May 1st.

It's an interesting question of whether an era enumeration API should include the specific dates where the era starts and ends.

For my use case, either a specific method to get the number of years in an era, or providing the exact dates and letting me compute that myself would work.

devongovett avatar Jul 22 '21 01:07 devongovett

BTW, @Manishearth wrote a document that goes into a lot of detail about the problem of creating era identifiers for Japanese. Manish, are there any updates from what's in that doc that you'd want to share?

No updates, the doc is as far as we've gotten.

It seems I don't have access to this.

Feel free to request access; unfortunately my employer's settings don't let me link-share docs.

Manishearth avatar Jul 22 '21 06:07 Manishearth

@devongovett - I'm going to transfer this issue over to the https://github.com/tc39/ecma402/ repo which would be the place where the actual decision would live about how to handle this enumeration use case.

Here's a starting point suggestion for requirements of this API, although as the user of it I'd defer to you to edit/change these suggestions to match your use case.

Use Case Summary

  • A calendar-aware date-time picker wants to display a localized list of eras in a particular calendar. This should be a reasonably fast operation, especially in Japanese with its hundreds of eras.
  • A calendar-aware UI (which might also be a date-time picker!) wants to know the start/end boundaries of an era to avoid showing lists of years/months/days that are invalid for that era.

Proposed API Requirements

402 experts - The list below is a rough initial idea. Feel free to replace with a better solution!

  1. An Intl.* API should exist that allows enumerating eras that are supported in each calendar
  2. The input to the API should be, like other Intl APIs, a locale (which may include a calendar hint e.g. 'fr-FR-u-ca-buddhist') and an options bag which may also contain a calendar property which may be a calendar ID or a Temporal.Calendar instance.
  3. Either a calendar hint in the locale or a calendar option is required. (Or should it default to the user's current locale and calendar?)
  4. The output should be an array (or an iterable?) of objects, each representing one era.
  5. Q: what order should eras be returned in? I'm inclined to suggest returning eras in reverse chronological order so that apps using the Japanese calendar (the only ICU calendar with a large number of eras) can iterate through only the "top N" most-recent eras (often those from Meiji onwards).
  6. Each era object should include the identifier string of that era, as well as begin/end dates of the era. Perhaps like this:
{
  era: string,
  displayName: string,
  startDate: Temporal.PlainDate | null, // or ISO string instead?
  endDate: Temporal.PlainDate | null,  // or ISO string instead?
}
  1. For eras that are unbounded (like 'bc' and 'ad' in the 'gregorian' calendar), the unbounded date should be null.
  2. Calendars like 'iso8601' with no eras should return an empty array/iterable (or should it return null or undefined?)
  3. I assume that the output PlainDate objects should have the calendar set to the calendar of the input. If ISO start/end dates are desired, these can be obtained by calling getISOCalendarFields() or withCalendar on those objects.
  4. Returning a localized name for each era could be considered optional (because DateTimeFormat.p.formatToParts can supply the localized names) but performance of that operation will be abysmal for the Japanese calendar's hundreds of eras, so it probably makes sense to include the localized name too.
  5. Will we ever need to support eras that overlap? For example, assume a pre-modern Japanese era A that scholars later realize is really two eras B and C. Would A continue to show up in the enumeration? Would it be accepted as input but not be enumerated? (if https://github.com/tc39/ecma402/issues/541 is resolved by not supporting historical Japanese eras, then this question is probably moot because other ICU calendars have well-defined, contiguous, and non-overlapping eras.)
  6. Note that this proposal assumes that eras start at the beginning of a particular day and are not time-specific. If we want to support time-specific eras (which presumably means a time at a particular place on Earth), that would introduce possible bugs related to time zones which seems like a bad idea for a time-specific-era use case that we don't know exists.

justingrant avatar Jul 25 '21 01:07 justingrant

Oops, I don't have permission to transfer this issue to the 402 repo. @sffc is this something you can do?

justingrant avatar Jul 25 '21 01:07 justingrant

@justingrant thanks for your detailed thoughts! Overall it seems like your proposal would cover my use cases.

Just confirming: do you think this would be better as a top-level Intl method rather than a method on a Temporal.Calendar instance? I had originally thought it would be something like: new Temporal.Calendar('japanese').getEras(). This way you could also easily get the eras from an existing date object like date.calendar.getEras().

Similarly, calendar.getYearsInEra(date) would also be a bit simpler, and would match with the existing methods to get limits for fields from a calendar. Getting the actual start/end date of the era would probably also be useful though.

devongovett avatar Jul 25 '21 01:07 devongovett

This seems like a good fit for a future addition to the Intl.Enumeration proposal?

ptomato avatar Jul 26 '21 18:07 ptomato

This seems like a good fit for a future addition to the Intl.Enumeration proposal?

What's the difference between Intl.Enumeration and Intl.DisplayNames? When would a use case go into one vs. the other?

justingrant avatar Jul 27 '21 04:07 justingrant

Just confirming: do you think this would be better as a top-level Intl method rather than a method on a Temporal.Calendar instance? I had originally thought it would be something like: new Temporal.Calendar('japanese').getEras(). This way you could also easily get the eras from an existing date object like date.calendar.getEras().

@devongovett I don't have an opinion about the right long-term home of accessing era-related metadata, but I suspect that @sffc and/or @ptomato may have an opinion.

Similarly, calendar.getYearsInEra(date) would also be a bit simpler, and would match with the existing methods to get limits for fields from a calendar. Getting the actual start/end date of the era would probably also be useful though.

I would not support an API that returned a scalar number of years in an era, because it would seem to complicate use cases where eras start or end mid-year (which is most eras, AFAIK). If the era starts in the 8th month of one year and ends in the 3rd month of another year, should it include or exclude the partial years on either end?

Instead, if we do offer an API to fetch metadata about a specific era, then IMHO it should return two PlainDate instances that represent the first and last day of the era. Then the caller can pull out whatever info is needed.

Ideally, the result of a "get metadata for one era" API would have the same shape as the elements returned by the proposed enumeration API, so callers could either ask for metadata about one era or could enumerate through all of them, but could use the same code to process one era's metadata.

Another alternative is not to offer this kind of single-era API at all, and instead require callers to filter the results of the proposed enumeration API. Given that era metadata is somewhat of a niche use case, this may be an OK workaround.

justingrant avatar Jul 27 '21 05:07 justingrant

BTW, instead of thinking of this as an enumeration API, another alternative could be to think of it as a "Get Calendar Info" API which would return additional metadata about the calendar beyond eras, e.g.:

  • Localized name of the calendar
  • Calendar type: solar, lunar, or lunisolar
  • Min/max number of months per year
  • Min/Max number of days per year
  • Eras - aka results of the proposed enumeration API above
  • etc.

I'm not saying this is better or worse than a plain enumeration API, but it may be something to consider.

justingrant avatar Jul 27 '21 05:07 justingrant

I like the idea of using Enumeration to list calendar IDss, and each calendar object describing itself, rather than trying to make an Enumeration API for each property of a calendar.

ljharb avatar Jul 27 '21 05:07 ljharb

What's the difference between Intl.Enumeration and Intl.DisplayNames? When would a use case go into one vs. the other?

Enumeration is for IDs, DisplayNames is for localized, human-readable names.

ptomato avatar Jul 27 '21 15:07 ptomato

+1 to making this a method on Calendar instances.

sffc avatar Jul 27 '21 19:07 sffc

Enumeration is for IDs

Ahh, got it. I had assumed that there was a was a method on the DisplayNames V2 prototype to get all the localized text values in a single call, but I was mistaken.

BTW, given that showing a UI picker is the canonical use case for enumeration, and given that UI pickers require localized text for each option, I opened https://github.com/tc39/proposal-intl-enumeration/issues/36 to understand the reasoning for not also offering a more ergonomic way to fetch localized text for all IDs in one method call.

justingrant avatar Jul 27 '21 20:07 justingrant

2022-02-10 discussion: https://github.com/tc39/ecma402/blob/master/meetings/notes-2022-02-10.md#accessing-the-sequence-of-eras-of-a-calendar-598

Conclusion: Revisit this issue as part of a bigger proposal about calendar display names or datetime picker components.

sffc avatar Feb 10 '22 20:02 sffc

The Temporal polyfill had to build an internal "enumerate all eras for a calendar" API. Based on that experience, below is a suggestion for the metadata required for each era.

My assumption is that an enumeration API would return an array that's ordered chronologically. (Not sure why we'd need an iterator instead of an array here.) FWIW, the most recent era is always used more than older eras, so having the list be sorted in reverse chronological order may make it easier to work with because the current era would always be [0], but the reverse order would be OK too.

interface Era {
  /** 
   * Non-localized string ID of the era, e.g. "reiwa" or "bce".  Only intended for programmer use,
   * not for display to end users. Valid characters are: a-z (lowercase only), 0-9, and `-`. 
   * */
  id: string;

  /** 
   * Earliest day of this era. If undefined, this is the oldest era and has no lower bound (e.g. BC).
   * It should be in the era's calendar. If the user needs the ISO date, it's easy to convert using `withCalendar`.
   *  */
  startDate?: Temporal.PlainDate;

  /** 
   * Latest day of this era, inclusive. If undefined, this era is the most recent era and has no upper bound. 
   * It should be in the era's calendar. If the user needs the ISO date, it's easy to convert using `withCalendar`.
   * */
  endDate?: Temporal.PlainDate;

  /**
   * If true, this era counts years backwards like BC. There can be at most one era with
   * `reverseYears===true` for each calendar, and if present it must be the oldest era.
   * */
  reverseYears: boolean;
}

BTW, there are other metadata that the polyfill uses internally that probably are not needed in a public API because they can be easily derived from the metadata above:

  • "Does this era start with Year 1 or Year 0?" (the buddhist calendar is the only ICU calendar that has a year 0)
const hasYearZero = era => (era.reverseYears ? era.endDate : era.startDate).year === 0
  • "What is the 'anchor era year' that's used to convert between year and eraYear?"
const getAnchorYear = eras => eras.find(e => e.startDate.year === e.startDate.eraYear)?.year;

justingrant avatar Feb 11 '22 00:02 justingrant

New non-array.prototype APIs are generally expected to return an iterator, not an array - that’s why matchAll does.

ljharb avatar Feb 11 '22 02:02 ljharb

New non-array.prototype APIs are generally expected to return an iterator, not an array - that’s why matchAll does.

It makes sense that matchAll uses an iterator because strings can be huge and I assume there are optimizations possible by streaming the results and avoiding keeping a potentially-huge array in RAM. Ditto for any API that returns a large/unbounded set, or APIs that pull data from async or streaming sources.

But could you remind me why arrays are discouraged for cases where the number of results is known to be small and the enumerated data is built-in and immutable? In all ICU calendars except Japanese, the maximum numbers of eras is three. (In Japanese, it will be a few hundred elements, which is also small.) And the underlying era data will almost certainly be a native array before it's wrapped in an iterator to send back to the client.

What's the benefit of requiring implementers and callers to write extra code to deal with an intermediate iterator?

BTW, here's a few era-list use cases that I know about:

  • transform an era list into a data structure needed to populate a dropdown list with an ID/localized-name pair for each era
const eraNames = new Intl.DisplayNames(['en'], { type: 'era', calendar: 'japanese' });
const dropdownData = eras.map(e => ({ value: e.id, label: eraNames.of(e) }));
  • find an era with a particular ID
meiji = eras.find(e => e.id === 'meiji');
  • find the most recent era
eras[0];
  • find all Japanese eras during the 16th century
eras.filter(e => Math.round(e.startDate.withCalendar('iso8601').year / 100) === 15);

For each of these cases, an iterator version of eras could be replaced with [...eras] or Array.from to get back to the more-ergonomic array form. But why is this better?

justingrant avatar Feb 11 '22 06:02 justingrant

That's a fair argument; just be prepared for the pushback.

ljharb avatar Feb 11 '22 06:02 ljharb

At this point I'm mostly just curious: what's the advantage of iterators for cases where the underlying data is synchronous and small? If there's a significant advantage then the extra hassle of [...eras] would be OK, but for now I'm just trying to understand what that advantage is.

justingrant avatar Feb 11 '22 06:02 justingrant

I'm not arguing that there is one; i agree with you.

ljharb avatar Feb 11 '22 06:02 ljharb

Do you know what people who would argue for iterators would say? I'm sure there must be a good reason, just trying to understand what it is.

justingrant avatar Feb 11 '22 06:02 justingrant

Also, unrelated to iterator vs. array, another thing to think about is whether era iteration would be supported for custom calendars. My assumption would be "no" because we don't support localization for custom calendars nor timezones today. Enumerating eras of a custom calendar may have limited value without the ability to also localize the names of those eras, which would be another new API.

justingrant avatar Feb 11 '22 17:02 justingrant

Do you know what people who would argue for iterators would say? I'm sure there must be a good reason, just trying to understand what it is.

I asked delegates about this. Conclusion:

  • Returning arrays is fine as long as the result will be short and fast.
  • If the result can be long or streamed/async, then return an iterable.
  • Input parameters, on the other hand, should generally be iterable to give callers the most flexibility.

TL;DR - returning an Array of eras would be correct because no calendar other than Japanese has >3 eras, and even in Japanese the total number will be <500.

justingrant avatar Feb 12 '22 17:02 justingrant