Proposal-temporal: After `PlainTime` has a calendar, which calendar wins in `plainTime.toPlainDateTime(plainDate)` ?

Created on 1 Nov 2020  Â·  25Comments  Â·  Source: tc39/proposal-temporal

Previous behavior of PlainDate.prototype.toPlainDateTime and PlainDate.prototype.toPlainDateTime was unambiguous: the calendar of the result would come from the date, because times had no calendars.

Now that times have calendars, how should we determine the calendar of the result when combining a date and a time?

Most helpful comment

Making the date calendar win means that you could throw away a time calendar. Here's an example:

// The bank in Addis Ababa opens at the 4th hour in the first period (ISO time 10:00)
const ethiopicTime = Temporal.PlainTime.from("10:00[c=ethiopic]");

// What is the DateTime on 2020-10-01 when the bank opens?
const ethiopicDateTime = ethiopicTime.toPlainDateTime("2020-10-01");

// Expected result: 2020-10-01T10:00[c=ethiopic]
// As locale string: "Hedar 1, 2013 ERA1 at 4th hour East Africa Time"

In this case, the date could come from an ISO source, but the time is the piece that carries the programmer's intent.

All 25 comments

I guess there are a few options for what the result's calendar will be:

  1. Calendar of the receiver

  2. Calendar of the input

  3. Calendar of the date (assuming date calendars will be much more common)

  4. If only one is non-ISO, then the calendar is that one. If both are non-ISO, then throw.

I have a weak preference for (3) because I think that behavior might be the most predictable, but honestly I'm not sure. This is a difficult ambiguity.

I’d assume that if they’re not both the same calendar, and no compatible overriding calendar option is provided to the with call, that it would throw.

Note that whatever decision we make here probably also applies to withTime / withDate (if we adopt the proposal in https://github.com/tc39/proposal-temporal/issues/906#issuecomment-720143706) or with (if we don't adopt that proposal).

Here's a few test cases. What should be the calendar of the result in each of these cases? @sffc @ryzokuken

// Japanese calendar uses ISO time; Ethiopic does not.
// Strings without a calendar annotation are implicitly ISO
time = '10:00';
date = '2020-01-01';

// ISO merging into Non-ISO
Temporal.PlainDate.from(date).withCalendar('japanese').toPlainDateTime(time);
Temporal.PlainDate.from(date).withCalendar('ethiopic').toPlainDateTime(time);
Temporal.PlainTime.from(time).withCalendar('japanese').toPlainDateTime(date);
Temporal.PlainTime.from(time).withCalendar('ethiopic').toPlainDateTime(date);

// Non-ISO merging into ISO
Temporal.PlainDate.from(date).toPlainDateTime(time+'[ca=ethiopic]');
Temporal.PlainDate.from(date).toPlainDateTime(time+'[ca=japanese]');
Temporal.PlainTime.from(time).toPlainDateTime(date+'[ca=ethiopic]');
Temporal.PlainTime.from(time).toPlainDateTime(date+'[ca=japanese]');

// Cross-calendar
Temporal.PlainDate.from(date).withCalendar('japanese').toPlainDateTime(time+'[ca=ethiopic]');
Temporal.PlainDate.from(date).withCalendar('ethiopic').toPlainDateTime(time+'[ca=japanese]');
Temporal.PlainTime.from(time).withCalendar('japanese').toPlainDateTime(date+'[ca=ethiopic]');
Temporal.PlainTime.from(time).withCalendar('ethiopic').toPlainDateTime(date+'[ca=japanese]');

I’d assume that if they’re not both the same calendar, and no compatible overriding calendar option is provided to the with call, that it would throw.

This is a reasonable assumption but non-ISO time is extraordinarily unusual. AFAIK non-ISO time is used only in Ethiopia (not sure about Eritrea) and in specialized userland custom calendars like a custom calendar to track NYSE market days.

IMHO, it may be easier for all developers (including those targeting Ethiopian users!) to have a predictable model where date calendar always trumps the time calendar. The alternative would be for userland code to start unexpectedly throwing exceptions when confronted with dates in non-ISO calendars, because patterns like this would thrrow for non-ISO dates because times by default are ISO:

result = date.with('12:00');
result = date.toPlainDateTime({hour: 0});

I guess one way to handle this would be to let a calendar declare itself as a "uses ISO time" calendar or not. This would let us throw exceptions only in cases where the calendars' conception of time is incompatible.

As of right now, in case of dissimilar calendars, if either calendar is iso8601, the other calendar takes precedence. Otherwise, it fails.

Check out ES.ConsolidateCalendars.

I think a good principle to adopt when you could choose between two calendars is the same one we adopted in #262 for toLocaleString, which I believe is what @ryzokuken is referencing above. If one calendar is ISO and the other is not, favor the non-ISO calendar. Otherwise, throw if the calendars differ.

I'll call the "non-ISO wins" (https://github.com/tc39/proposal-temporal/issues/262#issuecomment-687281748) behavior "consolidate", according to the name of the spec function.

What are the other instances in Temporal when two calendars come into conflict with one another?

  1. toLocaleString: consolidate or throw
  2. Plain[Date/Time].prototype.toPlainDateTime: consolidate or throw (proposed above)
  3. [Zoned/Plain]Date.prototype.withDate: receiver wins (proposed in #1085)
  4. [Zoned/Plain]Date[Time].prototype.[since/until]: throw

Are there any others?

Should we consider changing cases 3 and 4 to also adopt the consolidate behavior?

It might be worth checking with @pipobscure who didn't seem to like the consolidate behavior as it related to #906.

There's formatRange() and formatRangeToParts() as well.

Also technically equals() and compare() can have two Temporal objects with conflicting calendars, but of course there is no need to either consolidate or throw there.

The main use case for consolidate is that you are working with a non-ISO date/time, and you want to perform some type of operation on it. Having the ISO date/time consolidated leads to briefer and more readable call sites, like:

// With consolidation
jpnDateTime.withDate("2020-05-01");

// Without consolidation, calendar known at compile time
jpnDateTime.withDate("2020-05-01[u-ca=japanese]");
jpnDateTime.withDate(Temporal.Date.from("2020-05-01").withCalendar("japanese"));

// Without consolidation, calendar of receiver unknown at compile time
dateTime.withDate(Temporal.Date.from("2020-05-01").withCalendar(dateTime.calendar));

I can see consolidation being useful in formatRange. For example:

const dtf = new Intl.DateTimeFormat();
dtf.formatRange(Temporal.Date.from("2020-05-01"), jpnDate);
// Potential future improvement: coerce the argument:
dtf.formatRange("2020-05-01", jpnDate);

I haven't yet found an example where the consolidate behavior is wrong or unexpected. I therefore favor using that behavior consistently whenever two calendars come into conflict.

FWIW, I think there are a few subtly different cases here:

  1. peer consolidation - two arguments of the same type (e.g. until/since)
  2. self consolidation - e.g. toLocaleString
  3. merge consolidation - merging into a "larger" type like PlainDateTime or ZonedDateTime, e.g. withDate
  4. date/time consolidation - date combined with time, where neither is inherently "larger" unless we consider date as always "larger"

I think a consistent pattern should be used in each of the 4 types above, but IMHO we should consider each case separately. The answer might be to use the same pattern in all 4 cases, but each one probably should be considered on its own merits.

The particular case I'm thinking of is an obscure one, but imagine a locale-sensitive time picker is used by a browser user in Ethiopia. The time is then merged with an ISO date. Would the app's author expect month, day, and year of that date to be in the Ethiopian calendar? It will be hard enough teaching developers about locale-sensitive dates... getting them to anticipate locale-sensitive times that are only used by 1-2 countries (still not sure about Eritrea) seems to be likely to make the web really crappy for Ethiopian users.

So I'd prefer to see a pattern that avoids the "unexpected infectious time" case. Something like this:

  1. peer consolidation - ???
  2. self consolidation - non-ISO-wins, throw if both are non-ISO
  3. merge consolidation - date calendar wins?
  4. date/time consolidation - date calendar wins?

CC @Louis-Aime - Do you have intuition for the correct behavior when resolving calendars from two different sources?

I would like to avoid bringing this back to the Temporal meeting for discussion, since any decision we make there will likely be too late to do anything about it. I believe throwing in cases 1, 3, and 4 is consistent with what we have discussed so far about avoiding surprises where one object's calendar "contaminates" another object.

Case 1 — we already had consensus on throwing in since()/until().
Case 2 — we already had consensus on consolidating the ISO calendar here, and it's different from case 1 because of the expectation that many Temporal objects will have the ISO calendar, and very few locales will; because in the most common case (temporalObj.toLocaleString() with no arguments) it's not at all clear to the caller that there's potentially a second calendar being introduced; and because the result is a string, so the consolidated calendar cannot "contaminate" anything else. For these reasons I think deviation from case 1 is justified here, and I don't see any need to rethink this. (I'd call this case "locale consolidation" instead of "self consolidation", because you're consolidating the calendar of a Temporal object with that of a locale)
Case 3 — moot because we no longer have this in Temporal, and I suggest throwing in any future cases where it appears, for the same reason as in case 4.
Case 4 — PlainDate.withPlainTime() and PlainTime.withPlainDate() are under discussion here. I suggest throwing because I think Justin's scenario of the objects coming from two different sources is likely enough, and would mean that programmers would likely be surprised for the non-ISO calendar to override the ISO calendar and "contaminate" the result. On a more pragmatic note, throwing here is future-proof in case someone really does think this should be changed in a future version.

Counting formatRange()/formatRangeToParts() as instances of case 1 ("peer consolidation"), it would be consistent to throw there as well despite "contamination" not being possible.

I'm OK with @ptomato's proposed solution for 1 and 2, and 3 won't be in V1 so I agree that it's now moot.

For 4, I assume that the canonical use case will be date.toZonedDateTime(tz, time) where time came from a date picker run in the context of an Ethiopian user's browser. Is this correct? (And also time.toZonedDateTime(tz, date), as well as the toPlainDateTime variations of those other two.)

In practice, my concern about @ptomato's proposal to throw in case 4 is that apps would run great in every country except Ethiopia when exceptions would crop up and apps would fail. This seems like sub-par experience for Ethiopian end-users. And unlike, say, non-ISO calendars where at least some developers will understand them, it seems unlikely that most developers would ever anticipate the possibility of a non-ISO time.

One alternative would be to consider date calendars as trumping time calendars, which IMHO seems to make sense intuitively because dates are "larger" than times. Also, for the Ethiopian local market it seems really unlikely that a developer specifically targeting Ethiopian users would be using Ethiopian times without also wanting Ethiopian dates, where no contamination would happen. So the use case to focus on is a non-Ethiopian app trying to run in an Ethiopian browser without unexpected failures.

So my proposal would be "date wins" in case 4. Thoughts?

I don't think that code failing only in Ethiopia would be the a problem, because if time comes from any date picker at all in anyone's browser, it would have a non-ISO calendar. (gregory in my case.)

There are then three possibilities:

  1. The date has the same calendar as the time. The programmer is combining a date and time from the same human-calendar source (e.g. a date picker and time picker in the same browser). This is expected.
  2. The date has the ISO calendar. The programmer is combining a time from a human-calendar source (picker in browser) with a date that they generated themselves or parsed from an ISO string with no calendar annotation. This will always throw, which is in line with our previous decisions. If dealing with a human-calendar source, the programmer should create their dates using that calendar.
  3. The date has a different non-ISO calendar from the time. The programmer is combining dates and times from two different human-calendar sources and needs to be careful anyway. It would actually be worse to let the date's calendar win here, because you could call toPlainTime() on the result to go back to the PlainTime domain and then you'd have times with two different calendars.

if time comes from any date picker at all in anyone's browser, it would have a non-ISO calendar. (gregory in my case.)

Interesting. At least this case will fail in development, which is probably better than just failing in Ethiopia.

But it still seems like this case could be simplified by having date calendars always win. For until/since, the case for throwing is much stronger because the result is a duration that will be expressed in calendar units but which doesn't have any indication of the source calendar. So if we didn't throw, you'd end up with a Duration with units expressed in an unknown calendar. Also, in the until/since case, there's no hierarchy between this and other that we could use as a proxy for the caller's intent.

But in the case of date.toZonedDateTime(tz, time), neither of those are true:

  • The result is a PDT or ZDT which has an observable calendar and it could easily be converted to another calendar if that's what the caller wants to do.
  • Times are smaller than dates, so deciding that a date calendar overrides the time calendar seems like an intuitive default.

My concern is that if we don't let dates win, then there will be pressure for time picker libraries to return ISO times to avoid triggering exceptions for the 99%+ of the non-Ethiopian world. That seems like a bad outcome for localization.

I guess I don't understand the case where letting dates win makes anything worse. The case where a user has a non-ISO time and an ISO date, but wants to end up with a PDT or ZDT in the time's calendar (but not the date's) seems rather far-fetched. Either you want both to be ISO or both to be Ethiopian, and using the date's calendar to determine the outcome seems like a reasonable default that will improve ergonomics and localization.

I prefer the consolidate behavior and strongly believe that date calendars should not override.

My mental model is that human calendars are intentional, as opposed to the ISO calendar, which you get when no calendar annotation is present. Having two different human calendars come into conflict is not intended to generally occur.

My main concern is when the "intentional" code is someone else's, e.g. a library for a time picker component.

@sffc - what type (and what calendar) would you expect a UI time-picker component to return to the caller?

@sffc - what type (and what calendar) would you expect a UI time-picker component to return to the caller?

It should either return an ISO Temporal.PlainTime, or a Temporal.PlainTime with the correct human calendar if the picker supports time calendars. If the picker supports time calendars, then any corresponding date picker should support date calendars, and there should not be a calendar conflict when the two pieces are merged, because both the date and the time should both have the same human calendar.

Note: Given that all CLDR calendars only use ISO time, I don't consider ISO time to be bad for l10n, at least not in 2020.

Making the date calendar win means that you could throw away a time calendar. Here's an example:

// The bank in Addis Ababa opens at the 4th hour in the first period (ISO time 10:00)
const ethiopicTime = Temporal.PlainTime.from("10:00[c=ethiopic]");

// What is the DateTime on 2020-10-01 when the bank opens?
const ethiopicDateTime = ethiopicTime.toPlainDateTime("2020-10-01");

// Expected result: 2020-10-01T10:00[c=ethiopic]
// As locale string: "Hedar 1, 2013 ERA1 at 4th hour East Africa Time"

In this case, the date could come from an ISO source, but the time is the piece that carries the programmer's intent.

I'm OK with https://github.com/tc39/proposal-temporal/issues/1087#issuecomment-724508353 as a reasonable alternative to throwing.

@ptomato I'm OK with #1087 (comment) as a reasonable alternative to throwing.

What is the specific proposed alternative behavior for plainDate.toPlainDateTime(plainTime) and plainTime.toPlainDateTime(plainDate) ?

@sffc Note: Given that all CLDR calendars only use ISO time, I don't consider ISO time to be bad for l10n, at least not in 2020.

Wait, I thought the Ethiopian calendar used non-ISO time. Is ethiopic not a calendar that 402 will support? Or am I misunderstanding something?

@ptomato I'm OK with #1087 (comment) as a reasonable alternative to throwing.

What is the specific proposed alternative behavior for plainDate.toPlainDateTime(plainTime) and plainTime.toPlainDateTime(plainDate) ?

My understanding is that it is consolidation behavior. https://github.com/tc39/proposal-temporal/issues/1087#issuecomment-720416763

@sffc Note: Given that all CLDR calendars only use ISO time, I don't consider ISO time to be bad for l10n, at least not in 2020.

Wait, I thought the Ethiopian calendar used non-ISO time. Is ethiopic not a calendar that 402 will support? Or am I misunderstanding something?

CLDR and ICU support Ethiopic date calendars, but not Ethiopic time calendars. 402 won't add support for Ethiopic time calendars until they are added in CLDR/ICU. Here is a bug tracking this feature request that has been open since 2016:

https://unicode-org.atlassian.net/browse/CLDR-9716

Closing in light of the decision on #522.

Was this page helpful?
0 / 5 - 0 ratings