@kip said:
And then @josevalim said:
Which @kip finally replied:
I will continue the discussion below.
@kip said:
And then @josevalim said:
Which @kip finally replied:
I will continue the discussion below.
Thatās a good point. I can think of cases this would be an issue but I canāt come up with anything concrete. Do you have any? Shall we explore this a bit further?
Here is a slightly more complete proposal. Imagine you have a data-structure called %WeekDay{}
. It has fields such as year, week, and day of week. It also has a āweek_calendarā that knows how to convert it to iso days so you can convert it back to a Date or even a DateTime with timezone (you can embed timezones on the Weekday data structure if you want to or even have NaiveWeekDay without tz and WeekDay with tz).
Does this make your life better or worse? I guess though what you are implementing here is exactly a Date*
, except you are using the month as week and the day as day of week. Although it may still be worth exploring if it makes computations easier or harder.
They all represent the same point in time but they are different representations. In your case, they are the same representation. So I guess my point here is that it is weird to change calendars when they have the exact same underlying representation.
JosĆ©, I appreciate the opportunity to work this through, itās been vexing me for a while.
One of the reasons I came to the idea that each variation is its own calendar is because conversion between calendars becomes straight forward using the existing Calendar
module functions.
As an example, lets say Iām using a 445
calendar. It has exactly the same notion of year
, month
and day
. Except that the start/end of the year is a different Gregorian date and the month numbers donāt align either. Neither do the week days. For financial reporting purposes I would use my 445
calendar. But of course I also want to know what those periods are in the Gregorian/ISO calendar. Using date_to_iso_days/1
and iso_days_to_date
I can convert between the two in a very straight forward manner. I donāt need to use another api to manage representations.
As we discussed earlier, but keeping all the calendar configuration in the relevant Calendar
module, a calendar now easily conforms to the standard Date
, Time
and DateTime
structs which is in line with a developers expectations. Although the date/time/datetime functions only require structural compatibility, i think the developer experience is simplified if the principle of one module == one calendar
is kept.
No doubt that there is plenty of room to optimise implementation for several classes of calendars, including 445
style, and calendars that have different min_days
and first_day_of_week
.
My thinking was something like this:
defmodule MyCalendars.SomeCalendar do
use MyCalendars.Gregorian, min_days: 4, first_day: 1
end
defmodule MyCalendars.RetailCalendar do
use MyCalendars.Calendar445, type: :454, ends: {:last, 7, 7}
end
By no means the exact API but it will be reasonably straight forward to encapsulate the generalised version of these typical calendar types in a concrete fashion.
One additional challenge with this approach is runtime configuration. As one example, a locale_string
can actually specify the desired calendar as defined in BCP47. So assume we have an accept-language
header with a language-tag
it might look like this:
Accept-Language: he-IL-u-ca-hebrew-tz-jeruslm
Represents Hebrew as spoken in Israel, using the traditional Hebrew calendar, and in the āAsia/Jerusalemā time zone as identified in the tz database.
The ca=hebrew
is the calendar configuration and it could be retail
or cisco
or whatever is interpreted by the serving system. In this case, a module needs to exist and it might need to be generated at runtime. I am not sure the optimal way to do this.
Interesting! So the representation that you are keeping in Date/Time is actually the year of week and month of week? The other question is: what would those calendars return for week_of_year
? Just the information in the struct?
EDIT: Meanwhile I have removed week_of_year
from the Calendar callbacks because I am really uncertain it is the best way to go. If using calendars is the best way to model week-based calendars, then having week_of_year
is like having japanese_era
as a Calendar callback.
I do like this.
I havenāt had the time yet to read through this discussion in much detail, but I do want to say that I really like that we are having this discussion !
I will post an in-depth post later today or tomorrow.
Iāve gone backwards and forwards on this and concluded that for any given Calendar
, the year
, month
and day
are that calendars representation. This fits for Gregorian derivatives (like 445
, or US fiscal) since the notion of year
, month
and day
remain but are offset from the Gregorian.
Where it is uncomfortable is for week-based calendars, like ISOWeek
. In this case I use month
to mean week
. year
and day
retail their normal meaning but within the ISOWeek
context. Its not perfect but its workable.
Every calendar we have discussed has the concept of a week
(French Revolutionary has 10 days, but there rest, afair are 7 days). Maybe Iām an outlier on this. I donāt see week_of_year
as valuable to only week-based calendars like ISOWeek.
For Gregorian-based calendars, the first_day
and min_days
determine the start of the weekly cycle. Weeks of year (and also quarter and month) are used quite a lot for scheduling and reporting ("⦠my forecast for week 11 of quarter 3 is ā¦"). I hope there may be a case to return the callback, but clearly it can be implemented outside of the behaviour.
Why not use the week-based month here too? Since that is also specified by ISO? I am mostly curious.
Mostly because the standard for ISOWeek says:
The ISO standard does not define any association of weeks to months. A date is either expressed with a month and day-of-the-month, or with a week and day-of-the-week, never a mix.
I finally had time to catch up with the discussion.
In the earlier discussion we had when talking about implementing the calendar behaviour, I noted that the concept of a week is orthogonal to the concept of days/months ā¦, and arrived at the same reasoning as we did here, that when calculating with weeks, this could be modeled by a separate calendar that uses the month
-field to store the week number.
If I remember correctly, this was not done at that time and we ended up including day_of_week
in the Calendar behaviour; the reasoning being that it was common enough for people to want to look up the week to include it in the calendar directly.
I think this reasoning is still valid, although I do not think that we should make the Calendar
behaviour itself more complex because of it.
Currently, I like the %WeekDay{}
-approach the best; I think it might look as follows:
WeekCalendar
, which mainly works with %WeekDay{}
(or maybe %WeekDate{}
/%WeekDateTime{}
-structs?)WeekCalendar
and Calendar
behaviours (maybe we can extract this to a separate behaviour?)Calendar
and a WeekCalendar
is possible using the date_to_iso_days
-helper in the middle.Calendar
to a compatible WeekCalendar
.day_of_week
, and of course play a role in the new formatter-proposal weāre working on in a similar fashion.I see the main advantage to introducing a new behaviour as twofold:
:month
and :week
, week-based calendars do not have to think about how the months work (and only implement behaviour callbacks that make sense for week-related datastructures).:week
-field rather than a :month
-field as well.⦠Side note: Calendar-weeks are often based on a religious background, although there is research that suggests that there definitely is psychological merit for humans to having a work-leisure cycle of ~7-8 days. In any case, the relation between weeks and āmonthsā for any given calendar is either āvery looseā (when trying to evenly subdivide the synodic lunar month of ~29.5 days), or completely non-existent. @kip 7 day-weeks are the most common, but there are:
Its been a while but Iām getting reasonable close to releasing ex_cldr_calendars. The focus has been on gregorian-based calendars that follow the gregorian month cycle with a variable starting month as well as week-based calendars that have a variable start or end day and a configurable week.
After a number of design experiments I am settled on an approach where a calendar module can be defined statically such as the calendar that is defined by the US National Retail Federation. Its quite simple:
defmodule Cldr.Calendar.NRF do
use Cldr.Calendar.Base.Week,
min_days: 4,
anchor: :last,
day: 6,
month: 1
end
Which basically means the calendar year ends on the last saturday nearest the end of January. An example of a financial year calendar for a US corporation that elects to have its financial year finish on the last Saturday in July would be:
defmodule Cldr.Calendar.CSCO do
use Cldr.Calendar.Base.Week,
min_days: 5,
anchor: :last,
day: 6,
month: 7
end
An example of a month-based calendar would be a financial year calendar for a country. In the US the government financial year starts in October so it is defined by:
defmodule Cldr.Calendar.US do
use Cldr.Calendar.Base.Month,
month: 10
end
(Australia would be month 7, the UK would be month 4).
Calendars can be generated at runtime by passing the configuration to Cldr.Calendar.get_or_create(calendar, config)
or to Cldr.Calendar.get_or_create_for(territory)
which will create a financial year calendar module configured for a given territory (country).
As Iāve gone through this process I have asked myself what I would want to add to the current Calendar
behaviour and the answer is: very little.
I would like to propose that the Inspect
protocol implementation delegate to an inspect/2
function on a calendar. This would be very useful when inspecting week-based calendars, or when using (as I do) a sigil_d/2
to define dates in different calendars. For an ISOWeek calendar for example, it would - in my implementation - return ~d[2019-W12-02]ISOWeek
which is a lot more meaningful that the struct dump - especially since for week based calendars one has to hijack the month
field and use it instead as week
.
I would like to propose revising the Calendar.week_of_year/1
addition to the Calendar
behaviour. Weeks are such a standard part of most modern calendars, including the ISO calendar. The behaviour today nearly every other date facet: era, year, quarter, month, day. Week is the notable exception. Reporting data based upon weeks is an intrinsic part of business activities; they are how we structure formatted calendars.
These are really the only two behaviour additions that after a few months of calendar hacking strike me as additions Iād like to see - which is a testament to the original design and the recent 1.8 additions.
In my own Cldr.Calendar
behaviour I definitely have other requirements that I donāt believe are relevant to the core Calendar
module but may be of interest to other calendarises:
year/1
, quarter/1
, month/1
, week/1
that return a Date.Range
week_in_year/3
that takes a date, :first_day
and :min_days
per the earlier discussioniso_week_in_year/1
that delegates to week_in_year/3
with first_day: 1
, min_days: 4
first_gregorian_day_in_year/2
and last_gregorian_day_in_year/2
to return what it saysI donāt see any of these belonging in the core Calendar
module but they are very useful in the practical use of calendar building and calendar usage.
I still have to add spec, docs and more tests so cldr_calendars
is definitely not ready for anyone but it will be in a couple of weeks or so. Then I can finally finish up version 2.0 of ex_cldr_dates_times
which only has to focus on formatting (think of it as an CLDR-based formatting using the LDML formats in a localised context).
Fantastic work @kip!
Yes, please do send a PR!
We added it to Elixir but removed it before v1.8 was released exactly because the understanding of the āweek of yearā is locale specific. The only other functionality that I believe to be locale specific in the current calendar is day_of_week
. So it felt awkward to add week_of_year
given that it would be limited in many cases.
However, my understanding is that week_of_year would help your CLDR because calendars will now be able to rely on this uniform callback. Is this correct?
Can those be implemented using the existing Calendar callbacks? Those look nice additions to Elixir but I think we would need to add at least days_in_year
and days_in_week
.
Iāll work on the PR for the inspect
part first, that seems the most straight forward.
The week_of_year
is by no means a requirement for CLDR-related work. Itās that to me a calendar that doesnāt have the notion of weeks is incomplete since thats a cycle within which human activity is firmly rooted. I accept that thats hardly a rigorous argument though.
For the date ranges:
year/2
can be defined as {year, 1, 1}..{year, months_in_year(year), days_in_month(year, months_in_year(year))}
quarter/2
can be defined in terms of months_in_year(year) / 4
⦠and so on. If there is interest in a more detailed proposal or PR its not much work and Iām happy to do it.
Ah nice!
I would like to see that, yes, but as Date.year_range/1
, quarter_range/1
, etc. What about week_range/1
though? We would need days_in_week
, wouldnāt we?
JosĆ©, Iāve been prototyping a bit today in the way you suggested: implement in Date
by leveraging the existing callbacks. For month
based calendars thats working well. For week
based calendars it may not be that easy. Work in progress.
Date.week_range/2
would require Date.week_of_year/2
I believe (the earlier conversation).
Iāll summarise my findings here after some more experimentation in a day or so.
Another question: would the approach above work with week-based calendars since we use month to mean week?
Well it was more than a day or so but I can summarise my findings now that ex_cldr_dates_times is published.
The interpretation of a week varies quite a lot, even in Gregorian calendars, based upon usage and expectation. For example:
4-4-5
, 4-5-4
or 4-4-5
weeks.2019-01-01
(meaning July 1st) what is the Gregorian date? Is it 2019-07-01
or is it 2020-07-01
?There are both cultural and definitional differences.
Sunday
or Monday
as common practiselast Saturday in July
.In order to provide configuration for calendars implementing week-based behaviour I found I had to define the following configuration elements:
# Each quarter has three
# 'months` each of 13 weeks
# in either of a 4,4,5; 4,5,4
# of 5,4,4 layout
weeks_in_month: [4, 4, 5],
# Indicates if the anchor
# represents the beginning
# of the year or the end
begins_or_ends: :begins,
# Calendar begins on the
# :first, :last or :nearest
first_or_last: :first,
# First week of the year begins on this day.
# of the week.
# `day: :first` means that the first week starts
# on the first day of the year. `day: :first` only
# applies to month-based calendars and it
# will also result in a short week for the last
# week of a year
day: 1,
# Year begins in this month
month: 1,
# The year of the last_day or first_day
# is either the year with the :majority
# of months or the :beginning year
# or :ending year
year: :majority,
# First week has at least
# this many days in current
# year
min_days: 7
In my implementation I found I required the following callbacks to implement week-based calendars:
@doc """
Returns the `month` for a given `year`, `month` or `week`, and `day`
for a a calendar. Required to provide for returning the notion of a month
for a week-based calendar,
The `month_of_year` is calculated based upon the calendar configuration.
"""
@callback month_of_year(
year :: Calendar.year(),
month :: Calendar.month() | Cldr.Calendar.week(),
day :: Calendar.day()
) ::
Calendar.month()
@doc """
Returns a tuple of `{year, week_in_year}` for a given `year`, `month` or `week`, and `day`
for a a calendar.
The `week_in_year` is calculated based upon the calendar configuration.
"""
@callback week_of_year(
year :: Calendar.year(),
month :: Calendar.month() | Cldr.Calendar.week(),
day :: Calendar.day()
) ::
{Calendar.year(), Calendar.week()} | {:error, :not_defined}
@doc """
Returns a tuple of `{year, week_in_year}` for a given `year`, `month` or `week`, and `day`
for a a calendar.
The `iso_week_of_year` is calculated based on the ISO calendar.
"""
@callback iso_week_of_year(
year :: Calendar.year(),
month :: Calendar.month(),
day :: Calendar.day()
) ::
{Calendar.year(), Calendar.week()} | {:error, :not_defined}
@doc """
Returns a tuple of `{month, week_in_month}` for a given `year`, `month` or `week`, and `day`
for a a calendar.
The `week_in_month` is calculated based upon the calendar configuration.
"""
@callback week_of_month(Calendar.year(), Cldr.Calendar.week(), Calendar.day()) ::
{Cldr.Calendar.month(), Cldr.Calendar.week()}
@doc """
Returns the calendar basis.
Returns either :week or :month
"""
@callback calendar_base() :: :week | :month
@doc """
Returns the number of periods (which are
months in a month calendar and weeks in a
week calendar) in a year
"""
@callback periods_in_year(year :: Calendar.year()) :: week() | Calendar.month()
@doc """
Returns the number of weeks in a year
"""
@callback weeks_in_year(year :: Calendar.year()) :: week()
@doc """
Returns the number of days in a year
"""
@callback days_in_year(year :: Calendar.year()) :: Calendar.day()
I use Interval.year/2
, Interval.quarter/2
and so on. Would be renamed Calendar.year_range/2
based on the earlier conversation.
@doc """
Returns a date range representing the days in a
calendar year.
"""
@callback year(year :: Calendar.year()) :: Date.Range.t()
@doc """
Returns a date range representing the days in a
given quarter for a calendar year.
"""
@callback quarter(year :: Calendar.year(), quarter :: Cldr.Calendar.quarter()) :: Date.Range.t()
@doc """
Returns a date range representing the days in a
given month for a calendar year.
"""
@callback month(year :: Calendar.year(), month :: Calendar.month()) :: Date.Range.t()
@doc """
Returns a date range representing the days in a
given week for a calendar year.
Returns an error tuple if the calendar has no
notion of a week as is the case with the Julian
calendar.
"""
@callback week(year :: Calendar.year(), week :: week()) ::
Date.Range.t() | {:error, :not_defined}
When incrementing or decrementing years, quarters, months, weeks and days - including date ranges - most of the heavy lifting can be done in a base calendar module.
However for incrementing (and decrementing) months and quarters a callback is required since the nature of a quarter and a month is calendar specific.
@doc """
Increments a `Date.t` or `Date.Range.t` by a specified positive
or negative integer number of periods (year, quarter, month,
week or day).
Calendars need only implement this callback for `:months` and `:quarters`
since all other date periods can be derived.
"""
@callback plus(
year :: Calendar.year(),
month :: Calendar.month() | week(),
day :: Calendar.day(),
months_or_quarters :: :months | :quarters,
increment :: integer,
options :: Keyword.t()
) :: {Calendar.year(), Calendar.month(), Calendar.day()}