Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simple generalizable algorithm for CalendarDateDifference #16

Open
ptomato opened this issue Jan 27, 2024 · 7 comments
Open

Simple generalizable algorithm for CalendarDateDifference #16

ptomato opened this issue Jan 27, 2024 · 7 comments

Comments

@ptomato
Copy link
Collaborator

ptomato commented Jan 27, 2024

In the last Temporal meeting we discussed a possible algorithm for CalendarDateDifference that is generalizable to lunisolar calendars. @sffc suggested that ICU4X might implement this. I don't know whether it's in scope for this proposal to specify it, but I'm copying it here as an FYI. This is written in loose prose, not a formal description.

To take the difference between start date d0 and target date d1:

  1. Add (without constraining) as many years as possible to d0, in the direction from d0 to d1, without surpassing d1. “Surpassing” here (and in all steps below) means to compare year, then month code, then day lexicographically; if they exceed d1 in the direction from d0 to d1, then d1 is surpassed.
  2. Constrain d0 to a real year/month (NOT year/month/day). (Only matters for lunisolar calendars.)
  3. Add (without constraining) as many months as possible to d0 without surpassing d1.
  4. Constrain d0 to a real year/month/day.
  5. If largestUnit=week, add as many weeks as possible to d0 without surpassing d1.
  6. Add as many days as possible to d0 until it is equal to d1.
  7. The difference is the number of years, months, weeks, and days added.

We are adopting a similar algorithm for DifferenceISODate in Temporal. The more formal description is here: https://github.com/tc39/proposal-temporal/pull/2759/files#diff-113bc23f7ddc769c78deac4268f2400a0a8ca75258f4a6a8af8219cf430a0788R828-R863
(with the caveat that step 2 is a no-op for ISO 8601 calendar and so not present, and we compare month numbers instead of month codes lexicographically because they are the same for the ISO 8601 calendar.)

@Manishearth
Copy link
Contributor

Manishearth commented Jan 27, 2024

I like this algorithm and it is close to what I would have implemented if this had not been further specified.

Point 2 is interesting but makes sense.

Will there be a similar algorithm for adding date differences to dates that preserves roundtrip?

@Manishearth
Copy link
Contributor

then month code, then day lexicographically

Does this mean to compare month code lexicographically as well?

This could potentially be tricky: in the chinese calendar, M05L occurs after M05, but in the Hebrew calendar, M05L (Adar I) occurs before M05 (Adar II). Also tricky for Hindu calendars (see #18).

(ICU4X retains a distinction here for formatting months specifically: it uses M06L for Adar II when formatting only to pick up the alternate month name, but Adar II's month identity is still M05, which matches Temporal)

I do not think we will ever have calendars where the month codes do not have a total ordering, I think it would be sufficient to ask each calendar to define a total ordering of month codes (could be done with two lines of prose for the existing calendars).

Constrain d0 to a real year/month

What is actually done here? I assume this is just "remove the L if needed". But it could also be "go down a month" which can amount to removing the L and subtracting 1 in the case of Hebrew. Or going one month up/down based on how close to the end of the month it is. "constrain" says "nearest valid value" but that's harder to reason about here.

@justingrant
Copy link

justingrant commented Jan 29, 2024

M05L (Adar I) occurs before M05 (Adar II).

In current Temporal, Adar II (and regular, non-leap Adar) is M06, not M05L. This is because regardless of the calendar, Temporal currently defines the leap month's month code as the code of the previous month (in this case M05 which is Shevet) with an L appended. There is no expectation that you can remove the L and end up with the normal-month equivalent to the leap month. Especially given that in some calendars there may be two months that could be considered the "normal-month equivalent" if months are merged.

Temporal.PlainDate.from({year: 5785, monthCode: 'M05', day: 1, calendar: "hebrew"}).toLocaleString("en", {calendar: 'hebrew'})
// => '1 Shevat 5785'
Temporal.PlainDate.from({year: 5785, monthCode: 'M05L', day: 1, calendar: "hebrew"}).toLocaleString("en", {calendar: 'hebrew'})
// => '1 Adar 5785'
Temporal.PlainDate.from({year: 5785, monthCode: 'M06', day: 1, calendar: "hebrew"}).toLocaleString("en", {calendar: 'hebrew'})
// => '1 Adar 5785'

Note that the month code should have no impact on rounding or constraining. If you have a date with M05L and you add one year, you get M06.

Temporal.PlainDate.from({year: 5785, monthCode: 'M05L', day: 1, calendar: "hebrew"}).add({years: 1}).monthCode
// => 'M06'

This behavior ensures that months can be compared and sorted lexicographically.

It also sidesteps the problem of which month should be considered the "normal" month corresponding to each leap month, because my understanding is that some calendars may have 2 months that merge into one.

If we want to change this numbering scheme, we should probably figure this out ASAP before Temporal goes to stage 4. Thanks!

@Manishearth
Copy link
Contributor

current Temporal, Adar II (and regular, non-leap Adar) is M06, not M05L

Ah, my bad. This is also what ICU4X does, I miscounted.

In that case this is probably fine

@sffc
Copy link
Collaborator

sffc commented Jan 29, 2024

What do we mean when we say "Constrain d0 to a real year/month" when counting backwards (d1 < d0)? For example:

  1. d0: year=Y, month=M08L, day=10
  2. d1: year=Y-1, month=M08, day=20

By the current algorithm, we are happy to have a "-1 Year" in the duration, but after constraining, we get a day in M08 which is below the target date.

@sffc
Copy link
Collaborator

sffc commented Jan 30, 2024

I guess another question here is, what should the result of the following operation be?

// `x` is a year containing a month M08L
Temporal.PlainDate.from({ calendar: "chinese", year: x, month: "M08L", day: 20 })
    .subtract({ years: 1, months: 1 })

Should the month end up being M08 or M07?

And what should the answer be if the year brings it into another year with an M08L?

@justingrant
Copy link

And what should the answer be if the year brings it into another year with an M08L?

Do you mean if years is not 1, but is large enough to cause the result to be in another leap year with an M08L? If yes, then the result should have M08L.

Should the month end up being M08 or M07?

The spec text is purposefully vague about this, deferring to the calendar:

Clamping an invalid date to the correct range when overflow is "constrain" is a behaviour specific to each built-in calendar, but all built-in calendars follow this guideline:

  • 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 year, 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 after where that month would normally fall in a leap year.
  • Otherwise, pick the closest date that is still in the same year. If there are two equally-close dates in that year, pick the later one.
  • If the entire year doesn't exist, pick the closest date in a different year. If there are two equally-close dates, pick the later one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants