Skip to content
lingo

Dates & durations

Relative dates against an explicit now, two-way.

Prefer the live demos? Open this section in the interactive docs.

Relative dates parse against an explicit now for reproducible results, and the humanizer output always re-parses within one grain. Import from @pascal-app/lingo/date.

import {
  parseDate,
  parseDateRange,
  humanizeDate,
  humanizeDateRange,
  parseDuration,
  humanizeDuration,
} from "@pascal-app/lingo/date"

parseDate("three days ago", { now }).date       // exact Date, grain "day"
parseDate("17h30", { now })                      // 17:30 today (grain "minute")
parseDate("quarter past 5", { now })             // 05:15
humanizeDate(d, { now })                         // "3 days ago" — re-parseable

// Timezones: exposed by default; opt in to resolve the instant
parseDate("3pm EST", { now }).zone               // { source: "abbrev", offsetMinutes: -300 }
parseDate("3pm EST", { now, applyZone: true })   // the real UTC instant, not host-local

// Time slots, two-way
const slot = parseDateRange("2pm to 4pm", { now }) // { start, end } endpoints
parseDateRange("9-5", { now })                   // workday shift → 09:00–17:00
humanizeDateRange(slot)                          // "2:00 PM to 4:00 PM"

// Calendar ranges: dated spans and whole periods, first day to last
parseDateRange("Aug 3 - Aug 9", { now })         // dated span, grain "day"
parseDateRange("August", { now })                // Aug 1 → Aug 31, not just the 1st

parseDuration("1h30").duration.base              // 5400 (seconds)
humanizeDuration(5400, { style: "natural" })     // "an hour and a half"

Times of day read the way people write them — 17h, 5 o’clock, quarter past 5, 5.30pm, midi/minuit, 0900 hours. A trailing timezone is detected and exposed on .zone while the civil wall-clock is kept; pass applyZone: true to resolve the real UTC instant (offsets, abbreviations, and IANA names resolve DST-correctly via Intl). parseDateRange turns a slot like 2pm to 4pm, between 9am and 5pm, or the 9-5 workday shift into { start, end } endpoints, and humanizeDateRange renders it back.

Reference-dependent input needs an explicit now, so a queued job parses the same date every time. Fully absolute dates never require it. Browse the shorthand it reads under Catalog → Date shorthand and Time slots.

One field, three readings

parseDateRange reads three shapes: a time slot (2pm to 4pm), a date-to-date span (July 1 to July 5, Aug 3 - Aug 9, 2026-08-03 to 2026-08-09), and a whole calendar period (next week, this weekend, next month, August, 2027) expanded to its real first and last day. A coarse endpoint widens on the closing side too, so July to August ends on August 31 and until August does the same, while from August still opens on the 1st. this weekend read on a Saturday or Sunday is the weekend in progress, not the next one. A dated flag on the result says whether the span came from date grammar or clock grammar, which is what lets one input drive a day picker, a two-month range picker, or a slot picker without asking the person to choose a mode first. humanizeDateRange renders each shape back in a form that re-parses, and a descending pair such as 2026-08-09 to 2026-08-03 is swapped with a RANGE_REVERSED warning rather than handed back backwards.

Outside the grammar, and returning UNSUPPORTED_DATE rather than a guess: quarters (Q3, next quarter — they need a fiscal-year anchor to mean anything), elliptical right sides (Aug 3-9), and ISO dates dash-joined with no spaces (2026-08-01-2026-08-05 has four dashes and no way to tell which one splits; 2026-08-01 - 2026-08-05 and 2026-08-01 to 2026-08-05 both parse).