Chronix

Chronix is a natural language date parser. It is heavily inspired by Chronic.

Installation

Until a proper Hex package is available, you can add Chronix as a dependency in your mix.exs:

def deps do
[
{:chronix, github: "mylanconnolly/chronix"}
]
end

Usage

The main entry points are Chronix.parse/2, Chronix.parse!/2, Chronix.parse_range/2, Chronix.parse_range!/2, and Chronix.expression?/1. All of them share the same definition of "a valid Chronix expression."

# Current dates (two equivalent formats)
iex> Chronix.parse("today")
{:ok, ~U[2025-01-27 11:59:03Z]}
iex> Chronix.parse("now")
{:ok, ~U[2025-01-27 11:59:03Z]}
# Future dates (two equivalent formats)
iex> Chronix.parse("in 2 minutes")
{:ok, ~U[2025-01-27 12:01:03Z]} # 2 minutes from now
iex> Chronix.parse("2 minutes from now")
{:ok, ~U[2025-01-27 12:01:03Z]} # same as above
iex> Chronix.parse("in 3 days")
{:ok, ~U[2025-01-30 11:59:03Z]} # 3 days from now
# Past dates
iex> Chronix.parse("2 hours ago")
{:ok, ~U[2025-01-27 09:59:03Z]} # 2 hours before now
# Weekday-based parsing
iex> Chronix.parse("next monday")
{:ok, ~U[2025-02-03 11:59:03Z]} # Next Monday
iex> Chronix.parse("last friday")
{:ok, ~U[2025-01-24 11:59:03Z]} # Previous Friday
# Using a reference date (applies to ALL relative expressions, including "today" / "now")
iex> reference = ~U[2025-01-27 00:00:00Z]
iex> Chronix.parse("in 1 day", reference_date: reference)
{:ok, ~U[2025-01-28 00:00:00Z]}
iex> Chronix.parse("today", reference_date: reference)
{:ok, ~U[2025-01-27 00:00:00Z]}
# Raising variant
iex> Chronix.parse!("in 1 day", reference_date: reference)
~U[2025-01-28 00:00:00Z]
# Validity check
iex> Chronix.expression?("in 3 days")
true
iex> Chronix.expression?("tomorrow")
false

Beginning and End of Durations

Chronix can parse expressions that refer to the beginning or end of a duration:

# Beginning of durations
iex> Chronix.parse("beginning of 2 days from now")
{:ok, ~U[2025-01-29 00:00:00.000000Z]} # Start of the day, 2 days from now
iex> Chronix.parse("beginning of 1 week from now")
{:ok, ~U[2025-02-03 00:00:00.000000Z]} # Monday 00:00:00, start of next week
iex> Chronix.parse("beginning of 2 months from now")
{:ok, ~U[2025-03-01 00:00:00.000000Z]} # First day of the month, 2 months ahead
# End of durations
iex> Chronix.parse("end of 2 days from now")
{:ok, ~U[2025-01-29 23:59:59.999999Z]} # Last microsecond of the day
iex> Chronix.parse("end of 1 week from now")
{:ok, ~U[2025-02-09 23:59:59.999999Z]} # Sunday 23:59:59, end of next week
iex> Chronix.parse("end of 1 month from now")
{:ok, ~U[2025-02-28 23:59:59.999999Z]} # Last microsecond of the last day of next month
# With reference date
iex> reference = ~U[2025-01-01 12:30:45Z]
iex> Chronix.parse("beginning of 1 year from now", reference_date: reference)
{:ok, ~U[2026-01-01 00:00:00.000000Z]} # Start of next year
iex> Chronix.parse("end of 1 year from now", reference_date: reference)
{:ok, ~U[2026-12-31 23:59:59.999999Z]} # End of next year

Ranges

Chronix.parse_range/2 resolves an expression to the inclusive {start, finish} interval it denotes. It accepts exactly the same expressions and options as parse/2; the finish bound is the last microsecond of the period, matching the "end of ..." semantics above.

iex> ref = ~U[2025-01-15 10:30:45Z] # a Wednesday
iex> Chronix.parse_range("last week", reference_date: ref)
{:ok, {~U[2025-01-06 00:00:00.000000Z], ~U[2025-01-12 23:59:59.999999Z]}}
iex> Chronix.parse_range("this month", reference_date: ref)
{:ok, {~U[2025-01-01 00:00:00.000000Z], ~U[2025-01-31 23:59:59.999999Z]}}
iex> Chronix.parse_range("today", reference_date: ref)
{:ok, {~U[2025-01-15 00:00:00.000000Z], ~U[2025-01-15 23:59:59.999999Z]}}
iex> Chronix.parse_range("7/1/2026", reference_date: ref)
{:ok, {~U[2026-07-01 00:00:00.000000Z], ~U[2026-07-01 23:59:59.999999Z]}}
iex> Chronix.parse_range("3 hours ago", reference_date: ref)
{:ok, {~U[2025-01-15 07:30:45Z], ~U[2025-01-15 07:30:45Z]}}
iex> Chronix.parse_range!("tomorrow at 3pm", reference_date: ref)
{~U[2025-01-16 15:00:00.000000Z], ~U[2025-01-16 15:00:00.000000Z]}

last/this/next quarter is not supported (quarters exist only as a duration unit — "in 2 quarters" — not as a calendar period). All arithmetic happens in whatever time zone the reference date carries; normalize zones before calling if you need a specific one.

Supported formats

Supported units: second, minute, hour, day, week, fortnight (= 14 days), month, quarter (= 3 months), year, decade (= 10 years), century (= 100 years). Each accepts the plural form as well.

Numbers may include commas for readability ("in 1,000 seconds") and can be fractional for fixed-duration units ("in 1.5 hours", "0.5 days ago"). Fractional months and years are rejected (no unambiguous conversion); "beginning of" / "end of" require integer durations. The words "a" and "an" are accepted as synonyms for 1 ("in a week", "an hour ago"). Numeric words work too: zero through twelve, teens, and tens (with compounds like "twenty one" or "thirty-five") — "in five days", "twenty years ago", "ninety nine seconds from now".

Parsing is case-insensitive and whitespace-tolerant. Contradictory phrases like "in 2 seconds ago" are rejected with {:error, _} rather than silently normalized.

Reference date

All relative expressions — including "today" and "now" — are resolved against the :reference_date option. If omitted, Chronix uses DateTime.utc_now/0. Pinning the reference date is the right way to make tests deterministic:

Chronix.parse("next monday", reference_date: ~U[2025-01-27 00:00:00Z])