By James Aspinwall, co-written by Alfred Pennyworth (my trusted AI) — March 3, 2026, 07:30
Elixir’s Chronic library handles 18 expression patterns. Ruby’s Chronic gem handles over 150. This article catalogs every category of natural language time expression that Ruby supports and Elixir doesn’t, evaluates what it would take to close the gap, and whether it’s worth it.
The Architecture Gap
Before listing expressions, it helps to understand why the gap exists. Ruby Chronic has a 4-stage pipeline that Elixir Chronic doesn’t replicate:
Ruby Chronic:
- Pre-normalize (37 substitution rules) — “tomorrow” becomes “next day”, “noon” becomes “12:00pm”, “an hour” becomes “1 hour”, word numbers get numerized
-
Multi-tag tokenize (18 tagger classes) — a single token like “morning” gets tagged as both a
Repeaterand aDayPortion - Handler dispatch (~60 handler patterns) — match on semantic tag sequences, not raw strings
-
Span construction — handlers return time ranges,
guess()collapses to a point
Elixir Chronic:
- Preprocess (3 rules) — replace dashes, strip spaces before am/pm, split on whitespace
- Single-type tokenize (6 types) — each token gets exactly one classification
- Pattern match (27 clauses) — match on token type sequences
- Direct return — clauses return a single NaiveDateTime
The key difference: Ruby has semantic concepts (Grabber, Pointer, Repeater, DayPortion) that compose. Elixir has syntactic tokens (month, number, word, time) that don’t. Ruby can handle “next {anything}” with one handler pattern. Elixir needs a separate clause for every combination.
Category-by-Category Gap
1. Relative Durations (“in N units” / “N units from now”)
Ruby handles. Elixir doesn’t.
Ruby pre-normalizes “from now”, “hence”, and “after” to a future pointer, then matches [Scalar, Repeater, Pointer]:
"in 3 hours" → [Pointer:future, Scalar:3, Repeater:hour]
"5 minutes from now" → [Scalar:5, Repeater:minute, Pointer:future]
"2 weeks hence" → [Scalar:2, Repeater:week, Pointer:future]
"20 seconds from now" → [Scalar:20, Repeater:second, Pointer:future]
All four hit the same handler (handle_s_r_p or handle_p_s_r). One handler, infinite combinations.
In Elixir, you’d need a clause per unit — or add a :unit token type and a shift/3 helper (as discussed in our previous article). Roughly 15 lines of tokenizer change + 5 process/2 clauses.
Effort to add: Low. High value — these are the most common expressions people try.
2. Ago Expressions (“N units ago”)
Ruby handles. Elixir doesn’t.
Ruby pre-normalizes “ago” and “before” to a past pointer:
"3 days ago" → [Scalar:3, Repeater:day, Pointer:past]
"an hour ago" → [Scalar:1, Repeater:hour, Pointer:past] (pre-norm: "an" → "1")
"2 fortnights ago" → [Scalar:2, Repeater:fortnight, Pointer:past]
"20 seconds before now"→ [Scalar:20, Repeater:second, Pointer:past]
Same handler as “from now” — the Pointer direction is the only difference. In Elixir, the same shift/3 helper with a negative sign covers both categories.
Effort to add: Near-zero once relative durations exist.
3. Compound Relative (“N and N from now”)
Ruby handles. Elixir doesn’t.
"24 hours and 20 minutes from now"
"25 minutes and 20 seconds from now"
"24 hours 20 minutes from now" (no "and")
Ruby has a dedicated handler handle_s_r_a_s_r_p_a that matches [Scalar, Repeater, And?, Scalar, Repeater, Pointer, At?, Anchor]. This is a niche pattern — most users say “in 1 day” not “in 24 hours and 20 minutes.”
Effort to add: Medium. Low value — rare in practice.
4. Grabber Expressions (“this/next/last” + unit)
Ruby handles. Elixir doesn’t.
This is the biggest category gap. Ruby’s Grabber tag captures directional intent:
"next tuesday" → [Grabber:next, Repeater:tuesday]
"last friday" → [Grabber:last, Repeater:friday]
"this morning" → [Grabber:this, Repeater:morning]
"next month" → [Grabber:next, Repeater:month]
"this week" → [Grabber:this, Repeater:week]
"last november" → [Grabber:last, Repeater:november]
"next year" → [Grabber:next, Repeater:year]
"this spring" → [Grabber:this, Repeater:spring]
"tonight" → pre-normalized to "this night"
"next weekend" → [Grabber:next, Repeater:weekend]
"last quarter" → [Grabber:last, Repeater:quarter]
All handled by 2-3 anchor handler patterns. The combinatorial explosion is absorbed by the semantic tag system — “next” works with any repeater (day, week, month, season, quarter, year, weekend, weekday, fortnight).
In Elixir’s current architecture, you’d need either:
- A clause for every grabber + unit combination (~30 clauses), or
-
A
:grabbertoken type and compositional dispatch (~5 clauses + the token type)
The compositional approach is clearly better, but it means Elixir Chronic starts becoming a different library — one with semantic tokens instead of purely syntactic ones.
Effort to add: Medium-high. High value for “next/last {day}” and “this morning/tonight”. Lower value for seasons and quarters.
5. Relative with Anchor (“N units ago/from now + anchor”)
Ruby handles. Elixir doesn’t.
These combine a duration offset with a reference point:
"3 years ago tomorrow"
"3 months ago saturday at 5:00pm"
"2 days from this second"
"7 hours before tomorrow at midnight"
"september 3 years ago"
Ruby has handle_s_r_p_a and handle_rmn_s_r_p for these. They first resolve the anchor (“tomorrow”, “this friday”, “september”), then apply the offset.
Effort to add: High. Low value — extremely rare in practice.
6. Ordinal Within Range (“3rd Wednesday in November”)
Ruby handles. Elixir doesn’t.
"3rd wednesday in november"
"1st thursday in november"
"2nd monday in january"
"4th day last week"
"3rd month next year"
Ruby’s narrow handlers (handle_o_r_s_r, handle_o_r_g_r) match [Ordinal, Repeater, SeparatorIn, Repeater]. Useful for holidays (Thanksgiving = 4th Thursday in November) and business scheduling.
Effort to add: Medium. Niche value — useful if scheduling recurring events.
7. Seasons
Ruby handles. Elixir doesn’t.
"this spring" → March 20 - June 20
"next winter" → December 22 - March 19
"last summer" → June 21 - September 22
Ruby defines astronomical season boundaries and treats them as repeaters. The guess() function returns the midpoint of the range by default.
Effort to add: Low (just date ranges). Low value for a scheduler — “this spring” is too vague for alarm scheduling.
8. Quarters
Ruby handles. Elixir doesn’t.
"Q1" → January 1 - March 31
"this quarter" → current quarter range
"next quarter" → next quarter range
"1 quarter ago" → previous quarter
"first quarter" → pre-normalized from "first" → "1st" → Q1
Useful for financial/business reporting contexts.
Effort to add: Low. Niche value — depends on use case.
9. Fortnights and Weekdays/Weekends as Units
Ruby handles. Elixir doesn’t.
"this fortnight" → current 14-day period
"2 fortnights ago" → 28 days ago
"next weekend" → Saturday-Sunday range
"2 weekends ago" → the weekend before last
"next weekday" → next Mon-Fri day (skips Sat/Sun)
"3 businessdays hence" → 3 working days from now
The weekday/weekend repeaters skip non-applicable days. Weekday skips Saturday and Sunday. Weekend returns a 48-hour Saturday-Sunday span.
Effort to add: Medium (weekday skip logic is non-trivial). Low-medium value.
10. Day Portions as Standalone Modifiers
Ruby handles. Elixir doesn’t.
"this morning" → today 6:00am - 12:00pm
"tonight" → today 8:00pm - 12:00am
"yesterday afternoon" → yesterday 1:00pm - 5:00pm
"tomorrow evening at 7" → tomorrow 7:00pm
"friday 11 at night" → friday 11:00pm
Ruby defines time ranges for each portion:
| Portion | Range |
|---|---|
| Morning | 6:00 - 12:00 |
| Afternoon | 13:00 - 17:00 |
| Evening | 17:00 - 20:00 |
| Night | 20:00 - 24:00 |
Elixir has "6 in the morning" and "6 in the evening" (adds 12 to the hour), but not “this morning” or “tonight” as standalone expressions.
Effort to add: Low-medium. Medium value — “tonight” and “this morning” are natural expressions.
11. Word Numbers (Numerizer)
Ruby handles. Elixir doesn’t.
Ruby bundles the Numerizer library, which converts English words to digits before tokenization:
"thirty-three days from now" → "33 days from now"
"may tenth" → "may 10th"
"an hour ago" → "1 hour ago"
"a month ago" → "1 month ago"
"second monday in january" → "2nd monday in january"
"half past two" → "30 past 2" (via separate pre-norm)
Numerizer handles units through trillions, ordinals through ninetieth, compound numbers (“twenty-one”), and the article-to-number conversion (“a”/“an” → “1”).
Effort to add: High (Numerizer alone is a significant library). Medium value — most users type digits, not words. The “a/an” → “1” conversion is the highest-value piece.
12. Misspelling Tolerance
Ruby handles. Elixir doesn’t.
Ruby’s day-name recognition uses regex patterns that absorb common misspellings:
"munday" → Monday (regex: /^m[ou]n(day)?$/)
"tusday" → Tuesday (regex: /^t(ue|eu|oo|u)s?(day)?$/)
"wennsday" → Wednesday (regex: /^we(d|dnes|nds|nns)(day)?$/)
"thersday" → Thursday (regex: /^th(u|ur|urs|ers)(day)?$/)
"fryday" → Friday (regex: /^fr[iy](day)?$/)
"satterday" → Saturday (regex: /^sat(t?[ue]rday)?$/)
"sumday" → Sunday (regex: /^su[nm](day)?$/)
Also: “tommorrow”, “tomorow” → “tomorrow” (via pre-normalization).
Effort to add: Low (just regex updates in tokenizer). Low-medium value — depends on input source.
13. Named Times
Ruby handles. Elixir doesn’t.
"noon" → pre-normalized to "12:00pm"
"midday" → pre-normalized to "12:00pm"
"midnight" → pre-normalized to "24:00"
"now" → pre-normalized to "this second"
Four substitutions in the pre-normalizer. Trivial.
Effort to add: Trivial (4 string replacements in preprocessing). High value — “noon” and “midnight” are extremely common.
14. Context Option (Past/Future Bias)
Ruby handles. Elixir doesn’t.
Chronic.parse("saturday", context: :past) # last Saturday
Chronic.parse("saturday", context: :future) # next Saturday (default)
When a day-of-week or time is ambiguous (could be past or future), Ruby uses the :context option to decide. Elixir always resolves forward.
Effort to add: Low (thread an option through the process clauses). Medium value.
15. Span Return Mode
Ruby handles. Elixir doesn’t.
Chronic.parse("this morning", guess: false)
# => Chronic::Span (6:00am..12:00pm)
Instead of guessing a single point, Ruby can return the full time range. Useful when “this afternoon” means “sometime between 1pm and 5pm” and the caller wants to know the boundaries.
Effort to add: Medium (requires changing return types). Low value for alarm scheduling — you need a specific fire time.
16. Timezone Abbreviations
Ruby handles. Elixir doesn’t.
"Mon Apr 02 17:00:00 PDT 2007"
Ruby recognizes PST, PDT, CST, CDT, EST, EDT, MST, MDT, UTC as timezone tags. Elixir only handles ISO8601 numeric offsets (+07:00, Z).
Effort to add: Medium (timezone abbreviation → offset mapping). Low value for a single-user system with a known timezone.
The Integration Question
Could all of this be integrated into Elixir’s Chronic library? Yes, but the answer depends on how far you want to go.
Tier 1: Quick Wins (1-2 hours, ~100 lines)
| Feature | Lines | Value |
|---|---|---|
| Named times (noon, midnight, now) | ~10 | High |
| Relative durations (in N units) | ~25 | High |
| Ago expressions (N units ago) | ~5 | High |
| “tomorrow 9am” without “at” | ~3 | High |
| Misspelling tolerance | ~15 | Medium |
| “a/an” → “1” conversion | ~3 | Medium |
This covers the expressions real users actually type. These are all additive — new preprocessing rules, one new token type, a handful of process/2 clauses. No architectural change.
Tier 2: Grabber System (4-6 hours, ~200 lines)
| Feature | Lines | Value |
|---|---|---|
| next/last/this + day-of-week | ~40 | High |
| this morning / tonight | ~30 | Medium |
| next/last + month | ~30 | Medium |
| next/last + week/month/year | ~40 | Medium |
| Context option (past/future) | ~20 | Medium |
| Day portions (afternoon, evening) | ~30 | Medium |
This requires adding :grabber and :unit token types, plus a resolve_direction/3 helper. It’s still pattern matching on token lists, but the tokens now carry semantic meaning. The library starts looking different from the original.
Tier 3: Full Ruby Parity (~2-3 weeks, ~1500 lines)
| Feature | Lines | Value |
|---|---|---|
| Compound relative (N and N from now) | ~40 | Low |
| Relative with anchor (3 days ago tomorrow) | ~60 | Low |
| Ordinal within range (3rd Wed in Nov) | ~80 | Niche |
| Seasons | ~60 | Low |
| Quarters | ~50 | Niche |
| Fortnights | ~20 | Low |
| Weekday/Weekend units | ~80 | Low |
| Word numbers (Numerizer) | ~400 | Medium |
| Timezone abbreviations | ~50 | Low |
| Span return mode | ~100 | Low |
| DST handling | ~100 | Medium |
At this point you’re rewriting the library from scratch. The pattern-matching-on-flat-token-lists architecture doesn’t compose well enough for 60+ handlers. You’d want Ruby’s multi-tag approach where tokens carry multiple semantic tags and handlers match on tag sequences.
Verdict: Tier 1 Is the Sweet Spot
The cost-benefit curve is steep. Tier 1 covers ~80% of the expressions users actually attempt, with ~100 lines of code and no architectural changes. Tier 2 adds another ~15% of real-world use cases but requires rethinking the token system. Tier 3 is academic completeness that almost nobody needs.
For The Orchestrator — a system where one user schedules Pushover notifications and task reminders — Tier 1 handles every realistic input:
- “in 30 minutes” — schedule a quick reminder
- “2 hours from now” — schedule for later today
- “tomorrow at noon” — named time with relative day
- “midnight” — named time
- “5 days ago” — useful for history queries, not scheduling
Tier 2 becomes relevant if the system grows to handle natural language from multiple users through a chat interface, where “next Monday” and “this evening” are more likely to appear than explicit timestamps.
Tier 3 is for building a general-purpose NLP library. That’s a different project.
Fork Chronic. Add Tier 1. Ship it. Revisit Tier 2 when user input patterns demand it.
What About Replacing Chronic Entirely?
There’s no better option in the Elixir ecosystem. DateTimeParser handles structured formats, not NL. Timex does strftime parsing. Nobody has built a comprehensive NL time parser for Elixir.
Building one from scratch with NimbleParsec would produce a faster tokenizer, but the semantic interpretation layer — the part that understands “next” means “future direction applied to a repeating unit” — is the same work regardless of how you tokenize. You’d end up writing the same process/2 clauses with a fancier front end.
The regex/pattern-matching technique isn’t the bottleneck. The missing clauses are. Add them.