Localize.Interval (Localize v1.4.0)

Copy Markdown View Source

Formats date and time intervals as localized strings.

Interval formats produce strings like "Jan 10 – 12, 2008" from two dates, rather than repeating "Jan 10, 2008 – Jan 12, 2008". The format is selected based on the greatest calendar field difference between the start and end values.

An interval is formatted with the formats of its endpoints' calendar: its interval patterns, its date and time formats, and the date-time pattern joining a date to a time range. Both endpoints must therefore be in the same calendar.

An interval writes the fields its format names and no others, as TR35's interval algorithm has it. Two values alike in every field the format writes are written once; two that differ in a field it writes take CLDR's interval pattern for their greatest difference; and where there is no such pattern, as where they differ in a year, a month or an era the format does not write, each is written with the format around the locale's fallback pattern. An era is a field like any other: format: :GyMMMd writes "Dec 31, 1 BC – Jan 1, 1 AD", and a format without an era writes none.

The two values are written in the order the locale's interval fallback pattern states, which TR35 makes the order of every interval pattern: the earlier first in every locale but one of CLDR 49. kek's Gregorian pattern is "{1} – {0}", so it writes the later value first.

Summary

Functions

Returns the greatest calendar field difference between two dates or datetimes.

Returns the locale-independent skeletons for the :fields option of to_string/3.

Parses a localized date interval.

Splits an interval format string into [left, right] halves at the point where a format character repeats.

Formats a date, time, or datetime interval into typed parts, mirroring ECMA-402's formatRangeToParts.

Same as to_parts/3 but raises on error.

Formats an interval between two dates, times or datetimes as a localized string.

Same as to_string/3 but raises on error.

Functions

greatest_difference(from, to)

@spec greatest_difference(map(), map()) ::
  {:ok, :y | :M | :d | :H | :m} | {:error, Exception.t()}

Returns the greatest calendar field difference between two dates or datetimes.

Arguments

  • from is a date or datetime map.

  • to is a date or datetime map.

Returns

  • {:ok, field} where field is :y, :M, :d, :H, or :m.

  • {:error, %Localize.NoPracticalDifferenceError{}} if the values are equal at every field considered.

Examples

iex> Localize.Interval.greatest_difference(~D[2022-04-22], ~D[2022-04-27])
{:ok, :d}

iex> Localize.Interval.greatest_difference(~D[2021-12-31], ~D[2022-01-01])
{:ok, :y}

known_fields()

@spec known_fields() :: %{
  month: %{short: :M, medium: :MMM, long: :MMM, full: :MMM},
  month_and_day: %{short: :Md, medium: :MMMd, long: :MMMEd, full: :MMMEd},
  year_and_month: %{short: :yM, medium: :yMMM, long: :yMMMM, full: :yMMMM}
}

Returns the locale-independent skeletons for the :fields option of to_string/3.

Only the non-default :fields selections (:month, :month_and_day, :year_and_month) appear here, because only those are locale-independent. The default :date selection is resolved per-locale, mirroring Localize.Date.to_string/2's :format → skeleton mapping for that locale, so it has no fixed entry to list.

Returns

  • A map keyed by field selection (:month, :month_and_day, :year_and_month), each value a map of :format (:short, :medium, :long, :full) to the CLDR skeleton atom used for that combination.

Examples

iex> Localize.Interval.known_fields()
%{
  month: %{short: :M, full: :MMM, long: :MMM, medium: :MMM},
  month_and_day: %{short: :Md, full: :MMMEd, long: :MMMEd, medium: :MMMd},
  year_and_month: %{short: :yM, full: :yMMMM, long: :yMMMM, medium: :yMMM}
}

parse(input, options \\ [])

@spec parse(String.t() | {String.t(), String.t()}, Keyword.t()) ::
  {:ok, Date.Range.t() | {map(), map()}} | {:error, Exception.t()}

Parses a localized date interval.

The inverse of to_string/3. Accepts either a single string, such as "May 5 – 10, 2026", or a 2-tuple {from_string, to_string} for two-input UIs that already have the endpoints split.

A single string is read with the locale's interval formats, in which the two dates share the fields written once, and otherwise cut where the locale's fallback pattern or a separator people write, a dash or "to", has a date on each side, each read as Localize.Date.parse/2 reads it. The spaces about an interval format's dash may be left out or put in. Two dates written without a year are of the reference date's year, as a date alone is, and the later is of the year after where it would otherwise come before the earlier: "Dec 28 – Jan 3" ends in the January that follows. Two weeks written without a year are read the same way by their week-based years: "Sun (week: 53) – Mon (week: 1)" ends the day after it begins.

The result is a Date.Range.t/0 whose endpoints share the calendar named by the :calendar option, which defaults to Calendar.ISO.

Arguments

  • input is either a binary or a {from_binary, to_binary} tuple.

  • options is a keyword list of options.

Options

Same as Localize.Date.parse/2 — :locale, :calendar, :format, :reference_date, :as. As there, :calendar is a calendar module and the endpoints are returned in it. :format is the interval's own format, as to_string/3 takes it, and the text is read as two dates are written at it: with the patterns of the format's interval format, in which the two share the fields written once ("Jun 16 – 20, 2026" at :medium); as two dates each written whole with the format, either side of the locale's separator; and as one date, which two dates alike in every field the format writes are written as, read as the range of that date alone. No pattern of another format is tried. Plus:

  • :allow_inverted is a boolean. When true, an end-before-start interval is returned as-is, since Date.range/3 builds a descending range. When false, the default, an inverted interval is rejected with a Localize.DateRangeParseError.t/0. Applies only when as: :struct, the default; as: :map skips the comparison because partial maps may not carry enough fields to compare.

Returns

  • {:ok, range}, a Date.Range.t/0, when as: :struct, or

  • {:ok, {from_map, to_map}} when as: :map. Each endpoint is a field map; missing fields are inherited from the other endpoint per the CLDR interval convention, so "May 5 – May 10, 2026" yields two maps both carrying :year. A field given a value no date has is an error, never inherited, or

  • {:error, exception}, a Localize.DateParseError.t/0 or Localize.DateRangeParseError.t/0, on failure, or a Localize.InvalidValueError.t/0 if an endpoint is not a string or an option is malformed.

Examples

iex> {:ok, range} = Localize.Interval.parse({"2026-05-05", "2026-05-10"})
iex> {range.first, range.last}
{~D[2026-05-05], ~D[2026-05-10]}

iex> Localize.Interval.parse("May 5 – May 10, 2026", locale: :en, as: :map)
{:ok,
 {%{calendar: Calendar.ISO, year: 2026, month: 5, day: 5},
  %{calendar: Calendar.ISO, year: 2026, month: 5, day: 10}}}

iex> {:ok, range} = Localize.Interval.parse("May 5, 2026 – May 10, 2026", locale: :en)
iex> {range.first, range.last}
{~D[2026-05-05], ~D[2026-05-10]}

iex> Localize.Interval.parse("May 5–10, 2026", locale: :en)
{:ok, Date.range(~D[2026-05-05], ~D[2026-05-10])}

iex> Localize.Interval.parse("May 5 – 10", locale: :en, reference_date: ~D[2026-01-01])
{:ok, Date.range(~D[2026-05-05], ~D[2026-05-10])}

split_interval(interval)

@spec split_interval(term()) :: {:ok, [String.t()]} | {:error, Exception.t()}

Splits an interval format string into [left, right] halves at the point where a format character repeats.

Arguments

  • interval is an interval format pattern string.

Returns

  • {:ok, [left, right]} where left and right are the two halves of the interval pattern.

  • {:error, exception} if the pattern is malformed, has no repeating field, or is not a string.

Examples

iex> Localize.Interval.split_interval("MMM d – d")
{:ok, ["MMM d – ", "d"]}

to_parts(from, to, options \\ [])

@spec to_parts(map(), map(), Keyword.t()) ::
  {:ok, [%{type: atom(), value: String.t(), source: atom()}]}
  | {:error, Exception.t()}

Formats a date, time, or datetime interval into typed parts, mirroring ECMA-402's formatRangeToParts.

The parts concatenate to exactly the string to_string/3 produces with the same options. Every part carries a :source key: :start_range for parts of the interval start, :end_range for parts of the interval end, and :shared for the separators between them. When the endpoints have no practical difference the single formatted value carries source :shared throughout.

Unlike to_string/3, open intervals (a nil endpoint) are not supported — both endpoints are required, matching the JS API.

Arguments

Returns

  • {:ok, parts} where parts is a list of %{type: atom(), value: String.t(), source: atom()} maps.

  • {:error, exception} on failure or when an endpoint is nil.

Examples

iex> Localize.Interval.to_parts(~D[2022-04-22], ~D[2022-04-25], locale: :en)
{:ok,
 [
   %{type: :month, value: "Apr", source: :start_range},
   %{type: :literal, value: " ", source: :start_range},
   %{type: :day, value: "22", source: :start_range},
   %{type: :literal, value: " – ", source: :shared},
   %{type: :day, value: "25", source: :end_range},
   %{type: :literal, value: ", ", source: :end_range},
   %{type: :year, value: "2022", source: :end_range}
 ]}

to_parts!(from, to, options \\ [])

@spec to_parts!(map(), map(), Keyword.t()) :: [
  %{type: atom(), value: String.t(), source: atom()}
]

Same as to_parts/3 but raises on error.

Arguments

  • from is the interval start.

  • to is the interval end.

  • options is a keyword list of options. See to_parts/3.

Returns

  • A list of %{type: atom(), value: String.t(), source: atom()} maps.

Raises

  • Raises an exception if the interval cannot be decomposed into parts.

Examples

iex> Localize.Interval.to_parts!(~D[2022-04-22], ~D[2022-04-25], locale: :en) |> length()
7

to_string(from, to, options \\ [])

@spec to_string(map() | nil, map() | nil, Keyword.t()) ::
  {:ok, String.t()} | {:error, Exception.t()}

Formats an interval between two dates, times or datetimes as a localized string.

Arguments

  • from is the start of the interval: a Date.t/0, Time.t/0, NaiveDateTime.t/0 or DateTime.t/0, or a map with their fields, or nil for an interval open at its start. A date's map may hold only some of its :year, :month and :day.

  • to is the end of the interval, of the same kind as from, or nil for an interval open at its end.

  • options is a keyword list of options.

Options

  • :locale is a locale identifier. The default is :en.

  • :format is a standard format, :short, :medium, :long or :full, a skeleton such as :yMMMEd or :yMMMdHm, or a pattern such as "d MMM y". The default is :medium. A skeleton selects CLDR's interval format for its fields, as CLDR keys them, so it can name a format no standard format reaches. A pattern names no interval format, so both endpoints are formatted with it around the locale's interval fallback pattern. A datetime interval's skeleton is split into its date and time fields, as TR35's interval algorithm separates them: on one day the date is written once and the times as a range where CLDR has an interval format for the time fields, a skeleton of time fields alone writes only the times, and a skeleton of date fields alone formats the dates as a date interval does. CLDR has no interval format with seconds, so at a format that writes them, :medium, :long and :full among them, two times of one day are each written whole around the fallback pattern, as the algorithm's last step has it: "Jun 15, 2026, 10:00:00 – Jun 15, 2026, 14:30:00". A date in a calendar of weeks, such as Calendrical.ISOWeek, is written at a standard format in the calendar's own notation, so an interval of its dates is both dates around the fallback pattern: "2026-W25-2 – 2026-W27-1". A skeleton's month is such a date's week and its day the weekday, which no interval format is keyed by, so its interval is both dates in full too: "Tue, week 25 of 2026 – Mon, week 26 of 2026".

  • :date_format and :time_format choose the date and the time half of a datetime interval separately, each a standard format, a skeleton or a pattern. :date_format is a date interval's format too, and :time_format a time interval's.

  • :fields selects which date fields a date interval shows with a standard :format: :date (the whole date, the default), :month, :month_and_day, or :year_and_month. See known_fields/0.

  • :style is the pattern joining a datetime interval's date and time: :default, the locale's standard date-time pattern, which TR35 says an interval takes ("June 15, 2026, 10:00 – 14:30"), or :at, the "at time" pattern a single date and time takes by default in Localize.DateTime.to_string/2. The default is :default.

  • :numeric_date_separator and :numeric_time_separator are strings replacing the locale's own separators in the rendered pattern, as they do on Localize.Date.to_string/2. A separator the pattern merges with neighbouring literal text is left alone — fi's day-differing interval pattern is "d.–d.M.y", whose first separator carries the range dash — so an interval may substitute one endpoint and not the other.

With a standard format the two are independent axes: :fields chooses which fields appear, :format chooses how wide they are rendered. So fields: :year_and_month renders the two months against a single year either way — numerically for format: :short ("1/2022" … "3/2022") and spelled out for format: :long ("January" … "March 2022").

At a standard format an interval's dates and times have the fields of the pattern the single value is written with, at the pattern's widths, so they read as Localize.Date.to_string/2 and Localize.Time.to_string/2 write one alone: vi's short date is "1/4/23" and its interval "1/4/23 – 10/4/23", and a locale whose short time is 24-hour writes a 24-hour range. The order and the text between the fields are those of CLDR's interval format for those fields.

Endpoints that differ in no field the interval shows are formatted once: whole dates in the requested standard format, exactly as Localize.Date.to_string/2 renders them. A date interval's fields are compared as its format writes them, so a week (:yw) or a quarter (:yQQQ) is one field whatever months and days it spans: 15 June to 20 July 2026 is "week 25 of 2026 – week 30 of 2026", and 15 to 17 June "week 25 of 2026". Nothing is added to a skeleton: two dates that differ in a month or a year it does not write are each written with it around the fallback pattern, format: :MMMd from 28 December to 3 January being "Dec 28 – Jan 3", and the same day of two years is that day once, "Jun 15". A date and a time are compared the same way: two days of June at 10:00 are "Jun 2026, 10:00" at :yMMMHm, and at two times of day both are written in full.

Dates that hold only some of their fields, two months or a month and a day each, take at a standard format CLDR's interval format for the fields they hold, as Localize.Date.to_string/2 writes each alone: %{month: 6} to %{month: 8} is "Jun – Aug". One format writes both endpoints, so they must hold the same fields. A skeleton, a :fields selection or a pattern is used as it is given.

Returns

  • {:ok, formatted_string} on success.

  • {:error, exception} on failure, including endpoints in different calendars and dates that hold different fields.

Examples

iex> Localize.Interval.to_string(~D[2022-04-22], ~D[2022-04-25], locale: :en)
{:ok, "Apr 22 – 25, 2022"}

iex> Localize.Interval.to_string(~D[2026-06-15], ~D[2026-06-18], format: :yMMMEd, locale: :en)
{:ok, "Mon, Jun 15 – Thu, Jun 18, 2026"}

iex> Localize.Interval.to_string(%{month: 6, day: 15}, %{month: 6, day: 20}, locale: :en)
{:ok, "Jun 15 – 20"}

iex> Localize.Interval.to_string(~N[2026-06-15 10:00:00], ~N[2026-06-15 14:30:00],
...>   format: :yMMMdHm,
...>   locale: :en
...> )
{:ok, "Jun 15, 2026, 10:00 – 14:30"}

iex> Localize.Interval.to_string(~N[2026-06-15 10:00:00], ~N[2026-06-15 14:30:00],
...>   format: :Hm,
...>   locale: :en
...> )
{:ok, "10:00 – 14:30"}

iex> Localize.Interval.to_string(~N[2026-06-15 10:00:00], ~N[2026-06-15 14:30:00],
...>   format: :yMMMdHms,
...>   locale: :en
...> )
{:ok, "Jun 15, 2026, 10:00:00 – Jun 15, 2026, 14:30:00"}

to_string!(from, to, options \\ [])

@spec to_string!(map(), map(), Keyword.t()) :: String.t()

Same as to_string/3 but raises on error.

Arguments

Options

See to_string/3 for the supported options.

Returns

  • The formatted interval as a string.

  • Raises an exception if the interval cannot be formatted.

Examples

iex> Localize.Interval.to_string!(~D[2022-04-22], ~D[2022-04-25], locale: :en)
"Apr 22 – 25, 2022"

iex> Localize.Interval.to_string!(~D[2022-01-15], ~D[2022-03-20], locale: :en)
"Jan 15 – Mar 20, 2022"