My wife's jealousy is getting ridiculous. The other day she looked at my calendar and wanted to know who May was. -- Rodney Dangerfield
Calendars are curious things. For centuries people from all cultures have sought to impose human order on the astronomical movements of the earth and moon. Today, despite much of the world converging on the Proleptic Gregorian calendar, there remain many derivative and alternative ways for humans to organise the passage of time.
Cldr Calendars
builds on Elixir's standard Calendar
module to provide additional calendars and calendar functionality intended to be of practical use. In particular Cldr Calendars
:
-
Provides support for configurable month-based and week-based calendars that are in common use as Fiscal Year calendars for countries and organizations around the world. See
Cldr.Calendar.new/3
-
Supports localisation of common calendar terms such as day of the week and month of the year using the CLDR data that is available for over 500 locales. See
Cldr.Calendar.localize/3
-
Supports locale-specific knowledge of what is a weekend or a workday. See
Cldr.Calendar.weekend/1
,Cldr.Calendar.weekend?/2
,Cldr.Calendar.weekdays/1
andCldr.Calendar.weekday?/2
. -
Provides convenient
Date.Range
calculators for years, quarters, months and weeks for calendars and provides the means to move to thenext
andprevious
period in a calendar where a period may be a year, quarter, month, week or day. -
Supports adding or subtracting periods to dates and date ranges. See
Cldr.Calendar.plus/3
andCldr.Calendar.minus/3
-
Includes pre-defined calendars for Gregorian (compatible with the builtin
Calendar
module),ISOWeek
andNational Retail Federation (NRF)
calendars -
Includes returning a calendar configured to reflect the
first_day_of_week
andmin_days_in_first_week
for a given territory lor locale. SeeCldr.Calendar.calendar_for_locale/2
. -
Includes functions to find the first, last, nearest and
nth
days of the week from a date. For example, find the2nd Tuesday in November
.
See the documentation for Cldr.Calendar
for the main public API.
Add ex_cldr_calendars
to your deps
in mix.exs
. Note that ex_cldr_calendars
is supported on Elixir 1.11 and later only.
def deps do
[
{:ex_cldr_calendars, "~> 1.23"}
...
]
end
Let's say you work for Cisco Systems. Your learn that the financial year ends on the last Saturday of July. To make things easy you'd like to compare the results of this financial year to last finanical year. And you'd like to know how many days are left this quarter in order to achieve your sales targets.
Here's how we do that:
Each calendar is defined as a module that implements both the Calendar
and Cldr.Calendar
behaviours. The details of how that is achieved isn't important at this stage. Its easy to define your own calendar module through some configuration parameters. Here's how we do that for Cisco:
defmodule Cldr.Calendar.CSCO do
use Cldr.Calendar.Base.Week,
first_or_last: :last,
day_of_year_: 6,
month_of_year_: 7
end
This configuration says that the calendar is defined as first_or_last: :last
which means we are defining a calendar in terms of when it ends (you can of course also define a calendar in terms of when it starts by setting this to :first
).
The :day_of_year
day is Saturday, which is in Calendar
speak, the sixth day of the week. Days of the week are numbered from 1
for Monday to 7
to Sunday.
The :month_of_year
is July. Months are numbered from January being 1
to December being 12
.
There we have it, a calendar that is based upon the definition of "ends on the last Saturday of July".
You might be wondering, how to we represent dates in a customised calendar like this? Thanks to the flexibility of Elixir's standard Calendar
module, we can leverage existing functions to build a date. Lets build a date which is the first day of Cisco's financial year for 2019.
{:ok, date} = Date.new(2019, 1, 1, Cldr.Calendar.CSCO)
{:ok, ~d[2019-W01-1 CSCO]}
That was easy. All dates are specified in the context of the specific calendar. We don't need to know what the equivalent Gregorian calendar date is. But we can find out if we want to:
iex> Date.convert date, Calendar.ISO
{:ok, ~D[2018-07-29]}
Which you will see is July 29th, 2018 - a Sunday. Since we specified that the :last
day of the year is a Saturday this makes sense. You will also note that this is a date in 2018 as it should be. The year ends in July so it must start around 12 months earlier - in July of 2018.
This would also mean that the last day of Fiscal Year 2018 must be July 28th, 2018. Lets check:
iex> Cldr.Calendar.last_gregorian_day_of_year(2018, Cldr.Calendar.CSCO)
~d[2018-07-28 Gregorian]
Which you will see is the last Saturday in July for 2018.
A common activity with calendars is selecting data in certain date ranges or iterating over those same ranges. Cldr Calendars
makes that easy.
Want to know what is the first quarter of Cisco's financial year in 2019?
iex> range = Cldr.Calendar.Interval.quarter 2019, 1, Cldr.Calendar.CSCO
#DateRange<~d[2019-W01-1 CSCO], ~d[2019-W13-7 CSCO]>
A Date.Range.t
is returned which can be enumerated with any of Elixir's Enum or Stream functions. The same applies for year
, month
, week
and day
.
Let's list all of the days Cisco's first quarter:
iex> Enum.map range, &Cldr.Calendar.date_to_string/1
["2019-W01-1", "2019-W01-2", "2019-W01-3", "2019-W01-4", "2019-W01-5",
"2019-W01-6", "2019-W01-7", "2019-W02-1", "2019-W02-2", "2019-W02-3",
"2019-W02-4", "2019-W02-5", "2019-W02-6", "2019-W02-7", "2019-W03-1",
"2019-W03-2", "2019-W03-3", "2019-W03-4", "2019-W03-5", "2019-W03-6",
"2019-W03-7", "2019-W04-1", "2019-W04-2", "2019-W04-3", "2019-W04-4",
"2019-W04-5", "2019-W04-6", "2019-W04-7", "2019-W05-1", "2019-W05-2",
"2019-W05-3", "2019-W05-4", "2019-W05-5", "2019-W05-6", "2019-W05-7",
"2019-W06-1", "2019-W06-2", "2019-W06-3", "2019-W06-4", "2019-W06-5",
"2019-W06-6", "2019-W06-7", "2019-W07-1", "2019-W07-2", "2019-W07-3",
"2019-W07-4", "2019-W07-5", "2019-W07-6", "2019-W07-7", "2019-W08-1", ...]
But wait a minute, these don't look like familiar dates! Shouldn't they be formatted as "yyy-mm-dd"? The answer in this case is "no".
If you look carefully at where we asked for the date range for Cisco's first quarter of 2019 you will see %Date{calendar: Cldr.Calendar.CSCO, day: 7, month: 13, year: 2019}
as the last date in the range. There is, of course, no such month as 13
in the Gregorian calendar. What's going on?
Cisco's calendar is an example of a "week-based" calendar. Such week-based calendars are examples of fiscal year calendars. In Cldr Calendar
, any calendar that is defined in terms of "[first | last] [day_of_week] of [month_of_year_]" is a week-based calendar. These calendars have a year of 52 weeks duration except in "leap years" that have 53 weeks.
The most well-known week-based calendar may be the ISO Week Calendar. How would we define that calendar in Cldr Calendars
? Easy!
defmodule Cldr.Calendar.ISOWeek do
use Cldr.Calendar.Base.Week,
day: 1,
min_days_in_first_week: 4
end
This says that the calendar starts on the first Monday in January. How do we know that? It's because
:month_of_year
defaults to 1
(January) and :first_or_last
defaults to :first
.
Lets see what the first day of 2019 is in the ISOWeek
calendar.
iex> date = Cldr.Calendar.first_day_of_year(2019, Cldr.Calendar.ISOWeek)
~d[2019-W01-1 ISOWeek]
As expected, the date is expressed in terms of the calendar Cldr.Calendar.ISOWeek
. What's the equivalent Gregorian day?
iex> Date.convert(date, Calendar.ISO)
{:ok, ~D[2018-12-31]}
That's interesting. The first day of the 2019 year in the ISO Week calendar is actually December 31st, 2018. Why is that?
Week-based calendars can start or end on a given day of the week in a given month. But there is a third option: the given day of the week nearest to the start or end of the given month. This is indicated by the configuration parameter :min_days_in_first_week
. For the ISO Week
calendar we have min_days_in_first_week: 4
. That means that at least 4
days of the first or last week have to be in the specified :month_of_year
and then we select the nearest day of the week. Hence it is possible and even common for the gregorian start of the year for a week-based calendar to be up to 6 days before or after the Gregorian start of the year.
What's the last week of 2019 in the ISO Week
calendar?
iex> date = Cldr.Calendar.Interval.week(2019, 52, Cldr.Calendar.ISOWeek)
#DateRange<~d[2019-W52-1 ISOWeek], ~d[2019-W52-7 ISOWeek]>
You'll see that for week-based calendars the date is actually stored as year, week, day
where the :month
field of the Date.t
is actually the week in the year and :day
is the day in the week.
The Gregorian calendar is the canonical example of a month-based calendar. It starts on January 1st and ends on December 31st each year. But not all calendars start in January and end in December.
- The United States fiscal year starts on October 1st and ends on September 30th
- The United Kingdom fiscal year starts on April 1st and ends on March 31st
- The Australian fiscal year starts on July 1st and ends on June 30th
Cldr Calendars
allows month-based calendars to be defined based upon the first or last gregorian month of the year for that calendar.
Of course sometimes we also want to refer to weeks within a year although this is less common than referring to days within months. Nevertheless, a month-based calendar can also take advantage of :first_day
and :min_days
to determine how to calculate weeks for month-based calendars too.
Here's how we define each of the three example calendars above:
defmodule Cldr.Calendar.US do
use Cldr.Calendar.Base.Month,
month_of_year: 10, # The year starts in October
min_days_in_first_week: 4, # The first week of the year is that with at least 4 days of October in it
day_of_week: 7 # When referring to weeks, Sunday is the first day
end
defmodule Cldr.Calendar.UK do
use Cldr.Calendar.Base.Month,
month_of_year: 4 # The fiscal year starts in April
end
defmodule Cldr.Calendar.AU do
use Cldr.Calendar.Base.Month,
month_of_year: 7, # The fiscal year starts in July
year: :ending # A year refers to the ending Gregorian year.
# In this example, the Australian fiscal
# year 2017 is the year that starts in July
# 2016 and ends in June 2017
end
When we talk about the Gregorian calendar we refer to the 12 months from January to December. However when we consider the various fiscal calendars, the Gregorian starting date and the Gregorian ending date will often be in different years.
In these cases, when we say "the 2019 US Fiscal Year" what does that mean? The US fiscal year starts in October. Now we need to know whether referring to the "the 2019 US Fiscal Year" means the year that ends in September 2019 or the year that starts in October 2019.
Some further examples are:
-
The UK Fiscal Year starts in April. By convention, the Fiscal Year is the year that starts with April.
-
The US Fiscal Year starts in October. By convention , the Fiscal Year is the year that has the ending October in it.
-
The Australian Fiscal Year starts in July. By convention, the Fiscal Year is the year that ends in July.
-
The National Retail Federation has a calendar that starts on the Saturday nearest the end of January. By convention, the Fiscal Year is the year of the starting Saturday.
To cater for these varying definitions of what a Fiscal Year means, a configuration option :year
can be set to :majority
(which is the default), :beginning
and :ending
.
:majority
means that the Fiscal Year is the year that has the most Gregorian months in it. This is the default.:beginning
means that the Fiscal Year is the year in which the first Gregorian month is found.:ending
means that the Fiscal Year is the year in which the last Gregorian month is found.
First lets consider the default :majority
strategy. This strategy says that the Fiscal Year is that year in which the majority of Gregorian months are found.
2018 2019 2020
J F M A M J J A S O N D | J F M A M J J A S O N D | J F M A M J J A S O N D |
. . . . . . . . . . . . | . . . . . . . . . . . . | . . . . . . . . . . . . |
Majority
Starts Jan <--------------------->
Starts Mar <----------------------->
Starts Jun <----------------------->
Starts Jul <----------------------->
Starts Oct <----------------------->
Ends Dec <--------------------->
Ends Feb <----------------------->
Ends May <----------------------->
Ends Jun <----------------------->
Ends Aug <----------------------->
From the diagram above we can define the following rules:
-
For
:starts
calendars, we can say that the starting gregorian year is is the same as the fiscal year if the starting month is January through June inclusive. If the starting month is July through to December then the starting Gregorian year is the year prior to the fiscal year. Similarly, the ending Gregorian year is the next year for calendars that start in February through June and it's the fiscal year for calendars that start in July through December. Years that start in January end in January of the same year. -
For ":ends" calendars the rules are the opposite.
Since calendars defined in Cldr.Calendar
are intended to be compatible and convertible to other Calendars supporting Elixir's Calendar
behaviour, the configuration of calendars needs to be encapsulated.
The simplest way to is to define a module that use
s either Cldr.Calendar.Base.Week
or Cldr.Calendar.Base.Month
. This is how we have been defining calendar modules in the examples so far.
A calendar module can also be created at run time. It is semantically identical to defining a static module but the module is built at run time rather than compile time. New calendars are created with the function Cldr.Calendar.new/3
. For example:
iex> Cldr.Calendar.new :my_new_calendar, :week, first_or_last: :first, day_of_week: 1, min_days_in_first_week: 7
{:ok, :my_new_calendar}
Calendar functions are now available on the module :my_new_calendar
.
iex> :my_new_calendar.
__config__/0
date_from_iso_days/1
date_to_iso_days/3
date_to_string/3
datetime_to_string/11
day_of_era/3
day_of_week/3
day_of_year/3
...
CAUTION Since the runtime creation of new calendars creates a new module and therefore a new atom, this function has the potential to surface an attack vector that could exhaust the atom table and crash the BEAM. It is strongly recommended calendars be defined statically where possible. Never trust unfiltered user input to create a calendar.
Cldr Calendars
can create a fiscal year calendar for many territories (countries) based upon data from the CIA world fact book. To create a fiscal year calendar for a territory use the Cldr.Calendar.FiscalYear.calendar_for/1
function.
iex> Cldr.Calendar.FiscalYear.calendar_for("IS")
{:ok, Cldr.Calendar.FiscalYear.IS}
iex> Cldr.Calendar.FiscalYear.calendar_for("ZZ")
{:error, {Cldr.UnknownTerritoryError, "The territory \"ZZ\" is unknown"}}
iex> Cldr.Calendar.FiscalYear.calendar_for(:AF)
{:error, {Cldr.UnknownCalendarError, "Fiscal calendar is unknown for :AF"}}
Cldr Calendars
provides a convenience sigil for the creation of dates in calendars. Note that it is necessary to import the Cldr.Calendar.Sigils
module before using the ~d
sigil.
iex> import Cldr.Calendar.Sigils
# Create a date in the default Cldr.Calendar.Gregorian
iex> ~d[2019-01-01]
~d[2019-01-01 Gregorian]
# Inbuilt calendars can be referred to by their shortened form
iex> ~d[2019-01-01 NRF]
~d[2019-W01-1 NRF]
# Create a calendar and define a date in it
iex> Cldr.Calendar.new FiscalAU, :month, month_of_year: 7
{:ok, FiscalAU}
iex> ~d[2019-01-01 FiscalAU]
~d[2019-01-01 FiscalAU]
Cldr Calendars
is able to localize parts of a date include the era
,
quarter
, month
and day_of_week
. The CLDR
provides the underlying data. The function Cldr.Calendar.localize/3
provides the required functionality. Some examples are:
iex> Cldr.Calendar.localize ~D[2019-01-01], :era
"AD"
iex> Cldr.Calendar.localize ~D[2019-01-01], :day_of_week
"Tue"
iex> Cldr.Calendar.localize ~D[0001-01-01], :day_of_week
"Mon"
iex> Cldr.Calendar.localize ~D[2019-06-01], :era
"AD"
iex> Cldr.Calendar.localize ~D[2019-06-01], :quarter
"Q2"
iex> Cldr.Calendar.localize ~D[2019-06-01], :month
"Jun"
iex> Cldr.Calendar.localize ~D[2019-06-01], :day_of_week
"Sat"
iex> Cldr.Calendar.localize ~D[2019-06-01], :day_of_week, format: :wide
"Saturday"
iex> Cldr.Calendar.localize ~D[2019-06-01], :day_of_week, format: :narrow
"S"
iex> Cldr.Calendar.localize ~D[2019-06-01], :day_of_week, locale: "ar"
"السبت"
Intervals representing parts of a calendar can be created and compared. Since intervals are represented as a Date.Range
they can also be enumerated with the Map
and Stream
functions.
Intervals can be created for a year, quarter, month, week and day. For example:
iex> Cldr.Calendar.Interval.year(2019)
#DateRange<~d[2019-01-01 Gregorian], ~d[2019-12-31 Gregorian]>
iex> Cldr.Calendar.Interval.month(2019, 3)
#DateRange<~d[2019-03-01 Gregorian], ~d[2019-03-31 Gregorian]>
iex> Cldr.Calendar.Interval.month(2019, 3, Cldr.Calendar.NRF)
#DateRange<~d[2019-W10-1 NRF], ~d[2019-W13-7 NRF]>
iex> Cldr.Calendar.Interval.week(2019, 5, Cldr.Calendar.NRF)
#DateRange<~d[2019-W05-1 NRF], ~d[2019-W05-7 NRF]>
iex> Cldr.Calendar.Interval.quarter(2019, 3)
#DateRange<~d[2019-07-01 Gregorian], ~d[2019-09-30 Gregorian]>
Intervals can also be compared to each other and using the taxonomy of Allen's Interval Algebra a comparison will return one of 13 different relationship types between two calendar intervals:
Relation | Converse |
---|---|
:precedes | :preceded_by |
:meets | :met_by |
:overlaps | :overlapped_by |
:finished_by | :finishes |
:contains | :during |
:starts | :started_by |
:equals | :equals |
Some examples:
iex> Cldr.Calendar.Interval.compare Cldr.Calendar.Interval.day(~D[2019-01-01]),
...> Cldr.Calendar.Interval.day(~D[2019-01-02])
:meets
iex> Cldr.Calendar.Interval.compare Cldr.Calendar.Interval.day(~D[2019-01-01]),
...> Cldr.Calendar.Interval.day(~D[2019-01-03])
:precedes
iex> Cldr.Calendar.Interval.compare Cldr.Calendar.Interval.day(~D[2019-01-03]),
...> Cldr.Calendar.Interval.day(~D[2019-01-01])
:preceded_by
iex> Cldr.Calendar.Interval.compare Cldr.Calendar.Interval.day(~D[2019-01-02]),
...> Cldr.Calendar.Interval.day(~D[2019-01-01])
:met_by
iex> Cldr.Calendar.Interval.compare Cldr.Calendar.Interval.day(~D[2019-01-02]),
...> Cldr.Calendar.Interval.day(~D[2019-01-02])
:equals
A duration is calculated as the difference in time in calendar units: years, months, days, hours, minutes, seconds and microseconds.
This is useful to support formatting a string for users in easy-to-understand terms. For example 11 months, 3 days and 4 minutes
is a lot easier to understand than 28771440
seconds.
The package ex_cldr_units can be optionally configured to provide localized formatting of durations.
If configured, the following providers must be configured in the appropriate CLDR backend module. For example:
defmodule MyApp.Cldr do
use Cldr,
locales: ["en", "ja"],
providers: [Cldr.Calendar, Cldr.Number, Cldr.Unit, Cldr.List]
end
To create a duration, use Cldr.Calendar.Duration.new/2
providing two dates, times or datetimes. The first date must occur before the second date. Datetimes must be in the same time zone. To format a duration into a string use Cldr.Calendar.Duration.to_string/2
.
An example is:
iex> {:ok, duration} = Cldr.Calendar.Duration.new(~D[2019-01-01], ~D[2019-12-31])
iex> Cldr.Calendar.Duration.to_string(duration)
"11 months and 30 days"
A duration can also be created from a Date.Range.t
and CalendarInterval.t
. CalendarInterval.t
is defined by the wonderful calendar_interval library.
iex> Cldr.Calendar.Duration.new Date.range(~D[2020-01-01], ~D[2020-12-31])
{:ok,
%Cldr.Calendar.Duration{
day: 30,
hour: 0,
microsecond: 0,
minute: 0,
month: 11,
second: 0,
year: 0
}}
iex> use CalendarInterval
CalendarInterval
iex> Cldr.Calendar.Duration.new ~I"2020-01/12"
{:ok,
%Cldr.Calendar.Duration{
day: 30,
hour: 0,
microsecond: 0,
minute: 0,
month: 11,
second: 0,
year: 0
}}
A duration can be added to a date. Adding to times and datetimes is not currently supported. An example is:
iex> {:ok, duration} = Cldr.Calendar.Duration.new(~D[2019-01-01], ~D[2019-12-31])
iex> Cldr.Calendar.plus ~D[2019-01-01], duration
~D[2019-12-31]
In order to localize date parts a backend
module must be defined. This
is a module which hosts the CLDR data for a set of locales. The detailed
information for configuring a backend
is documented here.
For a simple configuration the following steps may be used:
- Create a backend module.
defmodule MyApp.Cldr do
use Cldr,
locales: ["en", "fr", "jp", "ar"],
providers: [Cldr.Calendar, Cldr.Number]
end
- Optionally configure this backend as the system default in your
config.exs
.
config :ex_cldr,
default_backend: MyApp.Cldr
- When creating a calendar a default backend may also be defined for this calendar.
defmodule MyCalendar do
use Cldr.Calendar.Base.Month,
month_of_year: 4,
cldr_backend: MyApp.Cldr
end
It is also possible to pass the name of a backend module to the Cldr.Calendar.localize/3
function by specifying the :backend
option with a backend
module name.