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

How to find first day of year (or month) in a non-ISO calendar? #1220

Closed
justingrant opened this issue Dec 2, 2020 · 13 comments
Closed

How to find first day of year (or month) in a non-ISO calendar? #1220

justingrant opened this issue Dec 2, 2020 · 13 comments
Labels
calendar Part of the effort for Temporal Calendar API

Comments

@justingrant
Copy link
Collaborator

How will a developer find the first day of a year in a non-ISO calendar? In ISO this is easy, e.g. date.with({ month: 1, day: 1 }).

But per #1203, what if month and day can't be guaranteed to be numeric? And what if the first month of the year isn't guaranteed to be 1? The same questions apply to first-day-of-month use cases too.

One possible fix (other than specifying that all calendars must have numeric months and dates that start with 1) would be to add a startOfYear() and startOfMonth() method.

BTW, this issue came up as part of building code examples for #1203.

@sffc
Copy link
Collaborator

sffc commented Dec 3, 2020

The way to support this would be to allow months and years in the round method.

You said in #827:

2.10 For DateTime and LocalDateTime, smallestUnit must be 'days' or smaller. We decided to exclude larger units because the rounding behavior of weeks and months is confusingly calendar-dependent.

I think month and year rounding is a tractable problem to pass through the calendar. The calendar could have a function roundDate(date, options). Alternatively, the calendar could have a function to step backward and forward between the beginning of months and years, and the rounding logic could be implemented in ISO space, just with different endpoints.

@sffc sffc added the calendar Part of the effort for Temporal Calendar API label Dec 3, 2020
@Louis-Aime
Copy link

As pointed at #1203, I would suggest to require the month to be a number, possibly with a supplemental field like monthType to solve the problem of lunisolar calendars. AND it would be required from the author to set the beginning of the year to the first day and the first (common) month ,i.e. (1, 1).

If necessary we could say: authors, please provide a startOfYear method. If this method is not present, Temporal assumes a Temporal.MonthDay of { month : 1, day : 1}. But then Temporal should either provide a default method, or test whether a specific method exists.

@Manishearth
Copy link

Manishearth commented Jan 7, 2021

Preliminary thoughts, going through the range of the various lunisolar calendars:

The traditional Hebrew calendar inserts a second Adar month (called Adar I) right before Adar (which is called Adar II in a leap year). Adar II is when the normal Adar festivals get celebrated. Here, the leap month occurs before the "real" month.

The traditional Chinese and Korean lunisolar calendars will insert leap months when they do not overlap with a "solar term" (and thus can be inserted near any month). These months share the name of the previous month. Here, the leap month occurs after the "real" month. It's unclear to me what happens to festivals, I believe they follow the "real" month. It's also unclear to me if there is a special qualifier.

The Hindu vedic lunisolar calendar (which is not in CLDR, and has multiple variants) is similar, but it goes a step further. If a month occurs without a zodiac transit, it is declared to be a leap month sharing the name of the succeeding month (but with a qualifier, it's basically called "Extra Foo" vs "Foo" or "Regular Foo"). Here, the leap month occurs before the "real" month. Festivals follow the "real" month.

The reason the Hindu calendar is more complex is that if multiple zodiac transits occur in a month (very rare), then the following month will be deleted. So it's crucial that whatever implementation we have has the ability to arbitrarily reject month-year combos if we ever wish to be able to support these calendars.

The Thai Buddhist lunisolar calendar (also not in CLDR) works similarly to the Hebrew calendar where leap months occur at a defined time in the year, though I am unclear about their naming.

I am unaware of any lunisolar calendar which gives the leap month an entirely different name. In the Hindu vedic lunisolar calendar it is common to just call the month "Extra Month", but the month's actual name is "Extra Foo". If such a calendar crops up we can always support it with something like {month: 13, leap: true} where month: 13 is only ever valid with leap: true


This means that the constraints are:

  • Leap months may share the name as a regular month
  • Leap months may share the name of any regular month (so you can't just hardcode "adar-1" and be done with it, which is a valid solution for Hebrew)
  • Leap months typically have a difference in display naming
  • Leap months may occur before or after the regular month they share a name with, depending on the calendar
  • (If supporting the Hindu vedic lunisolar calendar) Regular months can be deleted
  • We can probably assume that leap months can be modeled as "regular month with a leap flag"

Putting all this together, {month: 1, day: 1, leap: false} is insufficient for a "start of year" query because it's possible that the calendar system puts leap months before regular months (e.g. the Hindu lunisolar calendars). Now, given that the Hindu calendars are not in CLDR, we can perhaps ignore this, but ideally the design does not teach people bad patterns that later lock us out of adding more calendars.

A separate startOfYear() may indeed be useful.

@justingrant
Copy link
Collaborator Author

Hi @Manishearth - Welcome to Temporal! It's great to have more Googlers helping out. Also good to see a fellow Berkeleyan here!

Over in #1203 and #1231, I proposed (with a proof-of-concept implementation in #1245) that we solve the lunisolar month-numbering dilemma by offering two ways of describing the month:

  • "month code" - a year-independent, string-valued property that describes the month. For non-lunisolar calendars, the month code would be the month number converted to a string, e.g. April => '4'. For "simple" lunisolar calendars like Hebrew and Chinese, we'd follow the iCalendar standard, e.g. '5L' for Adar I or '8L' for a Chinese leap month after the regular 8th month. For more complex lunisolar calendars like Hindu, the calendar could use a different naming scheme, e.g. '3C4' for a leap month that merges the regular 3rd and regular 4th month. Because the calendar is in charge of interpreting the string, there's a lot of flexibility in case there are other calendars that do unexpected things.
  • "month index" - a 1-based number index of the month in the current year. For, example, in a Chinese leap year the leap month with month code '7L' will be month 8. Unlike month code, in lunisolar calendars the same month index may have different names depending on the year.

@sffc and I have been discussing which of these two fields should be called month. My preference, and what's currently implemented in #1245, is for the index to be month and the code to be monthCode. Having a non-numeric property for month seems unnecessarily confusing for the vast majority of software developers who will never need to work with lunisolar calendars. Making month numeric also makes the "start of year" use case discussed above really easy, because .with({month: 1, day: 1}) will always be sufficient to get the start of year, even in calendars where the first month might be a leap month.

In an earlier design of #1245, in addition to month and monthCode, I was planning to also add a similar regularMonth/monthType pair which sounds similar to what you proposed above. But after implementing the PR I'm now skeptical that regularMonth is a good idea, because of calendars like Hindu where there may be more than one regular month that corresponds to an irregular month. Therefore I'm planning to remove that property from the PR soon. But monthType: 'regular' | 'leap' | string still seems like a good idea.

If you have more feedback or ideas about month properties, feel free to chime in over in #1203.

#1245 also has a list of other non-ISO-calendar issues that you may also be interested in.

@Louis-Aime
Copy link

I am unaware of any lunisolar calendar which gives the leap month an entirely different name.

The only one I know is the Roman antique lunisolar calendar, just before Julius Caesar turned it into the Julian (solar) calendar, the ancestor of the Gregorian calendar. The leap month was called Mercedonius or Intercalaris. It was inserted after Feb. 24th.
It can either be considered as month number 13, but deleted in most years, or be considered as "leap February". The real issue is for IntL.DateTimeFormat, because of its special name.
Anyway, the Roman lunisolar calendar was a real mess before Caesar's reform. In short, there has never been any algorithmic rule for intercalation. IMHO a custom calendar for this calendar would be no more than a research project.

@Manishearth
Copy link

So I just realized that there's another wrinkle which as far as I can tell, has not yet been brought up in this group.

Japanese eras may start mid-year:

> new Date("2019-04-31").toLocaleString('en-US-u-ca-japanese', {era: 'short'})
"4/30/31 Heisei, 5:00:00 PM"
> new Date("2020-05-31").toLocaleString('en-US-u-ca-japanese', {era: 'short'})
"5/30/2 Reiwa, 5:00:00 PM"
> new Date("2020-01-02").toLocaleString('en-US-u-ca-japanese', {era: 'short'})
"1/1/2 Reiwa, 4:00:00 PM"

This means:

  • Heisei 31 was a year with four months in it (we suspect "November 5, Heisei 31" would still be an intelligible string, however)
  • Reiwa 1 was a year with eight months in it, which started from May and ended in December

Furthermore, while Reiwa started on May 1 2019, eras can begin in the middle of a month (and from what I can tell, most do -- e.g. the first day of Jōkyō was February 21).

In this model, {month: 5, era: "reiwa", year: 1} is highly ambiguous -- it's either May 2019, or it's September 2019, and in both models you will have to mark a set of month numbers as invalid (which is fine, we have to do that for supporting lunisolar calendars anyway).

This pushes me closer to the belief that month indexing should either not be exposed, or exposed via .monthIndex or specific month indexing methods like getNthMonth().

This also evinces a subtler problem: day-of-the-month numbering isn't indexing either! "The first day of February Jōkyō 1" is February 21, not "February 1, Jōkyō 1" (an invalid date). I think it's fine to keep treating day-of-the-month numbering as day-of-the-month numbering (i.e. {monthCode: "2" , day: 1, era: "jokyo"} means "February 1st", not "the first day in February"), and not expose day-index numbering. but it's worth highlighting.

(We may also have similar issues if we ever decide to handle hybrid calendars like the julian-gregorian switchover of various countries)

@Louis-Aime
Copy link

Reacting to the issue raised by @Manishearth:

Japanese eras may start mid-year (etc.)

and

(We may also have similar issues if we ever decide to handle hybrid calendars like the julian-gregorian switchover of various countries)

In the case of Japanese calendar (which is the Julian-Gregorian calendar with a specific year numbering system), IMHO we should simply say that the month and the year remain the same when a new era opens. In other words, the first day of Reiwa era falls in month 5. In fact, { era : "he", year : 31 } equals `{ era : "re", year : 1 } for all civil and business activities, except that Japanese name the former until Apr 30, and the latter since May 1. In fact, this is the same issue as indexing a weekday in a year. Year 2021 began on a Friday, but this Friday remains day 5. In the same way, year 1 of era "Reiwa" began in May, index 5. The same principle apply if the era begins in the middle of a month.

I am sure that "the first day of February Jõkyõ 1" would be solved that way: either it did not exist, or it was in the former era. In the same way, the first day of the first week of 2020 was in fact in 30 Dec. 2019, and the last day of the last week of 2020 was last sunday, i.e. 3 Jan. 2021.

I hope a Japanese person can confirm this.

As for hybrid European calendars that have a switchover, let me show you how this works after designing a class of calendar, with the switching date as parameter. A very interesting (and real !) case is this of several German protestant states, who decided to switch to Gregorian on 1700-03-01. On https://louis-aime.github.io/Temporal-experiences/TemporalEnvironment.html, dger1 and dger2 are the plain dates of the last Julian and first Gregorian days, respectively. Here is what comes out:
dger1.toString() //>"1700-02-28[c=german]"
dger1.getFields() //>Object { day: 18, month: 2, year: 1700, era: "as", calendar: {…} }
"as" meaning "Ancient Style" as opposed to "New Style"
dger1.daysInMonth //>18
dger1.daysInYear //> 355
Next day is dger2 = dger1.add('P1D')
dger2.getFields() //>Object { day: 1, month: 3, year: 1700, era: "ns", calendar: {…} }
dger2.daysInYear //>355
dger2.inLeapYear //>false
You can also see that October 1582 of the Vatican calendar has only 21 days.
The only concern is about the "leap year". Should we throw an error ?

We may also discuss weeks, but I do not believe this is a real issue. People did not count weeks the way ISO does.

@justingrant
Copy link
Collaborator Author

justingrant commented Jan 9, 2021

Great discussion! There are (at least) three cases where days are skipped by calendars.

  • a) Valid calendar days that don't exist in a particular year. An obvious example is Feb 29 of a non-leap Gregorian year. Another is @Louis-Aime's example of days skipped by the Julian=>Gregorian switchover.
  • b) Calendar months that don't exist in a particular calendar year, e.g. Adar I in a non-leap Hebrew year.
  • c) Dates where the month and day are before the start or after the end of the provided era, which is the case noted by @Manishearth above. EDIT: also, years that are before the era starts (e.g. -5) or after the era ends (for an era that has another era after it).

Here's a proposal for how to handle these cases. Some of below may already be implemented in the polyfill and/or in #1245. Feedback appreciated!

  1. Calling .from() with the { overflow: 'reject' } option should throw a RangeError in all three cases, because those calendar dates don't exist. This behavior is implemented for ISO, but not yet in Initial implementation of all ICU calendars #1245.

  2. For cases (a) and (b), { overflow: 'constrain' } should return the first valid date before the user-provided date. So for non-leap years, Feb 29 => Feb 28, and 6 Adar I => 30 Shevat. FWIW, this is the same behavior as ISO non-leap dates or times: April 31 => April 30, 10:65 => 10:59. This behavior is implemented for ISO, but broken in Initial implementation of all ICU calendars #1245 for some calendars. I'll fix!

  1. Case (c) should also be left up to the calendar, but the default should NOT be like (a) and (b) because that would constrain the result to the first or last day of the era. Given that (at least for ICU calendars) changing the era doesn't affect the month and day at all, that would be the wrong behavior. So this case is less like Feb 29 and more like @Louis-Aime's example of how changing the month or year doesn't change the progression of weekdays. So I'd suggest that we handle this case like we currently handle negative years in the Gregorian AD era: let the era and eraYear properties determine the year (I'm using names from Initial implementation of all ICU calendars #1245 here, where year is the era-independent signed year) and let the day and month (or monthCode) properties determine the day and month. This would mean that the era of the input may not match the era field read back from the object, but this seems like a reasonable interpretation of user intent. This behavior is implemented in Initial implementation of all ICU calendars #1245:
date = Temporal.PlainDate.from({ eraYear: -5, era: 'ad', month: 1, day: 1, calendar: 'gregory' });
=> -000005-01-01[c=gregory]
({ era, eraYear, year } = date)
// => { era: "bc", eraYear: 6, year: -5 }

date = Temporal.PlainDate.from({ eraYear: 1, era: 'reiwa', month: 1, day: 1, calendar: 'japanese' });
=> 2019-01-01[c=japanese]
({ era, eraYear, year } = date)
// => { era: "heisei", eraYear: 31, year: 152 }

LMK if there are concerns about this proposed behavior. If not, I'll fix the broken parts of (2) and implement (1) in #1245.

@Manishearth
Copy link

I do have some thoughts about month as well which I plan to comment in #1203 about, I suspect we should be handling these issues in tandem. But yeah the proposed behavior for out of range stuff roughly looks good

@Louis-Aime
Copy link

IMO, the rules @justingrant proposes sounds good. I think there should not be valuable opposition.

Exceptional cultural practises should be handled apart, e.g. with additional custom methods. As an example, the Christian feast of Annunciation is on March 25, but may be reported to a much later date if it falls near Eater. Such rules should not "pollute" Temporal's general behaviour. I am sure calendars' authors can understand this.

@ptomato
Copy link
Collaborator

ptomato commented Jan 15, 2021

Assuming we have a month index property, then this is solved, I think?

@justingrant
Copy link
Collaborator Author

Yep, first day of year is trivial if we have a month index field that can be used in from and with.

@ptomato
Copy link
Collaborator

ptomato commented Jan 19, 2021

As per the consensus at the 2021-01-19 meeting, we do have that, so closing this.

@ptomato ptomato closed this as completed Jan 19, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
calendar Part of the effort for Temporal Calendar API
Projects
None yet
Development

No branches or pull requests

5 participants