Skip to content

Releases: elixir-tempo/tempo

Tempo version 1.6.4

Choose a tag to compare

@kipcole9 kipcole9 released this 03 Sep 06:17

[v1.6.4] — 2026-09-03

Fixed

  • Tempo.select/2 no longer projects a date that cannot exist. Selecting ~o"2M29D" across ~o"{2026..2029}Y" yields only 2028, where before it fabricated four February 29ths.

  • Tempo.explain/1 describes every representation. An audit over all 1777 ISO literals in the repository found 140 values it crashed on, described generically, or described wrongly; all of them now explain.

  • A yearless date (~o"4M3D", a birthday) is no longer reported as "an anchored Tempo value" with a [?, ?) span. It reads as "April 3, in any year" spanning --04-03.

  • Tempo.to_interval/1 no longer raises on a yearless week (1W), a clock-only value (T-1S), a bare offset (Z), or a grouped component (2018-{1,3,5}G2MU). Grouped components report Tempo.MaterialisationError rather than crashing inside Keyword.

  • Tempo.resolution/1 handles a value with no components, and grouped components resolve to their group size instead of raising CaseClauseError.

Tempo version 1.6.3

Choose a tag to compare

@kipcole9 kipcole9 released this 03 Sep 04:25

[v1.6.3] — 2026-09-03

Fixed

  • An interval's [u-ca=…] calendar now propagates backward from its end onto an endpoint carrying no tag of its own, as the zone already did. 1448Y9M24D/25D[u-ca=islamic-civil] was parsing as a mixed Gregorian/Islamic pair, which its abbreviated end cannot mean.

  • An interval whose endpoints share a calendar writes [u-ca=…] once, at the end. A deliberately mixed pair still names the calendar on each endpoint.

Tempo version 1.6.2

Choose a tag to compare

@kipcole9 kipcole9 released this 03 Sep 03:15

[v1.6.2] — 2026-09-03

Fixed

  • An interval whose endpoints share a zone writes the IXDTF suffix once, at the end (2026Y9M2DT18H/T20H[Australia/Melbourne]), where before it was repeated on both. A per-endpoint [u-ca=…] is still written on each, because it decides how that endpoint is read.

  • 2026-09-02T18:00/2026-09-02T20:00[Australia/Melbourne] — the suffix written once, as IXDTF defines it — no longer fails endpoint-order validation. The endpoint frame now propagates before the order check rather than after.

Tempo version 1.6.1

Choose a tag to compare

@kipcole9 kipcole9 released this 03 Sep 02:27

[v1.6.1] — 2026-09-03

Fixed

  • Un-anchored arithmetic reports a missing anchor by return value rather than throw, so it can no longer escape to a caller as an uncaught {:tempo_math, :requires_anchor}. lib/ now contains no throw, catch or explicit try.

  • Tempo.to_interval/1 materialises an unspecified year (X*Y12M28D) instead of raising a FunctionClauseError. Where the answer depends on the missing year (X*Y2M28D) it returns Tempo.RequiresAnchorError.

  • Tempo.to_interval/1 resolves a yearless masked month (XX-15, the 15th of any month) instead of raising a KeyError. Calendars whose month count varies by year return Tempo.RequiresAnchorError.

  • Enumerating a masked value that needs an anchor (XX-15 in a calendar whose month count varies by year) raises Tempo.RequiresAnchorError rather than a bare KeyError. Enumerable has no error channel; Tempo.to_interval/1 returns an error tuple for the same value.

  • Tempo.explain/1 describes a start-and-duration interval (2026-06-15T09:00/PT8H) rather than reporting "an unusual shape".

  • Tempo.explain/1 no longer calls a duration-and-end interval (P1D/2026-06-15) open-lower. The duration implies the lower bound, so the value is bounded.

  • Tempo.explain/1 explains an unanchored recurrence (R/../P1Y/FL11M4I4KN) instead of reporting "an unusual shape". Its selection, cadence and missing anchor are now named in prose.

Tempo version 1.6.0

Choose a tag to compare

@kipcole9 kipcole9 released this 02 Sep 07:58

[v1.6.0] — 2026-09-02

Added

  • Tempo.parse_date/2, parse_datetime/2, parse_time/2, parse_interval/2 and parse_duration/1, with bang variants, parse a string that must be one ISO 8601 shape. A date, a datetime and a time are all %Tempo{}, so from_iso8601/1 cannot report a time of day arriving in a date field; declaring the profile makes it an error.

  • Profile parsing is 4–28× faster than from_iso8601/1, being a narrower grammar. Correctness, not speed, is the reason to prefer it.

  • parse_time/2 reads "2026" as 20:26 where from_iso8601/1 reads the year 2026 — the declared profile resolves the ISO 8601 basic-format ambiguity.

Fixed

  • Count-from-the-end bounds on fixed-extent units now resolve at validation, so a set-valued clock component expands instead of yielding nothing. T{-4..-1}H is the last four hours of the day.

  • Week-and-weekday and ordinal-day values with ranges ({1..3}W{1..-1}K, {1..-1}O) expand to real calendar dates. {1..-1}O previously raised a KeyError.

  • A set resolving to a single member collapses to that member ({12..-1}M is 12M), fixing a FunctionClauseError in days_in_month/3.

  • Ranges in any combination of component positions now expand: {2000..2010}Y{1..-1}M{1..-1}D yields its 4018 days instead of looping forever. Each component resolves against its already-concrete coarser units, so a month range follows the year's own month count and a day range the month's own length.

  • A day set under a month of unknown length is bounded by that month's maximum across years rather than refused, so {2020,2021}Y2M{1..-1}D and yearless 2M{1..-1}D resolve. Days that exist in no year (2M30D) and day sets overflowing a concrete month (2026Y9M{28..31}D) are still rejected.

Tempo version 1.5.4

Choose a tag to compare

@kipcole9 kipcole9 released this 01 Sep 04:55

[v1.5.4] — 2026-09-01

Fixed

  • A negative component under a set-valued container (~o"2026Y{1..12}M-1D", the last day of each month) resolves against each expanded member's own context — leap-aware, per ISO 8601-2 §4.4.1 — instead of leaking an unresolved -1 into the materialised intervals. The literal, the recurrence (R12/2026-01-01/P1M/FL-1DN), and Tempo.select/2 now agree.

  • RRULE BYMONTH expansion no longer lets DTSTART's day-of-month filter occurrences: when BYMONTHDAY/BYDAY determine the day, results are identical from any DTSTART (a rule anchored on the 31st no longer returns leap-years-only or empty sets), and when nothing later sets the day, the anchor's original day clamps to each occurrence's month — per occurrence, leap-aware, in every calendar (Feb 29 anchors restore the 29th in leap years; Hebrew day-30 anchors track each year's month length; leap months drop in common years).

Tempo version 1.5.3

Choose a tag to compare

@kipcole9 kipcole9 released this 30 Aug 06:16

[v1.5.3] — 2026-08-30

Fixed

  • Tempo.to_elixir/1 and to_date_time/1 convert offset-grounded values: a Z or +10:00 value becomes the instant as an Etc/UTC DateTime (matching DateTime.from_iso8601/1's normalisation), a zoned value keeps its zone instead of degrading to a NaiveDateTime, and a floating value's refusal now says why and points at Tempo.in_zone/2.

  • A negative UTC offset with minutes (-03:30) projects as −(3 h 30 m), not −3 h + 30 m — comparisons and conversions for half-hour zones west of Greenwich were off by an hour.

  • Tempo.equal?/2 compares member extents by instant, so the same moment expressed at different offsets (09:00+05:30 vs 03:30Z) is equal, agreeing with relation/2.

Added

  • Tempo.shift/2,3 accepts an ISO 8601 duration string (Tempo.shift(t, "-PT30M")) — iCalendar DURATION/TRIGGER/REPEAT values shift without a parse at every call site; a string that parses to a non-duration is refused with direction.

Tempo version 1.5.2

Choose a tag to compare

@kipcole9 kipcole9 released this 27 Aug 00:52

[v1.5.2] — 2026-08-26

Fixed

  • Tempo.equal?/2,3 now ignores per-member metadata, honouring its contract: two extent-equal values differing only in metadata (a summary, PRODID) compare equal and agree with Tempo.compare/3's :eq. The internal unit-strip was leaving metadata on the struct comparison.

Tempo version 1.5.1

Choose a tag to compare

@kipcole9 kipcole9 released this 26 Aug 17:35

[v1.5.1] — 2026-08-26

Fixed

  • An interval whose end omits the higher order components it shares with its start — 2018-01-15/02-20, ISO 8601-1 §5.5.1's own example — now takes those components from the start, as the clause requires.

Changed

  • inspect/1 and Tempo.to_iso8601/1 render an interval's end without the components it shares with its start, so 2026-06-15/2026-06-16 prints as ~o"2026Y6M15D/16D". This is the form §5.5.1 defines and the one people write; it round-trips unchanged now that the parser expands it.

Tempo version 1.5.0

Choose a tag to compare

@kipcole9 kipcole9 released this 26 Aug 09:01

[v1.5.0] — 2026-08-26

Changed

  • Calendrical 1.3 and Astro 2.5 in the lock: every calendar-touching example in the cookbook, guides, and livebooks re-validated by execution; Hebrew and Islamic calendar weeks now parse and materialise through Tempo (Tempo.from_iso8601("5786-W03", Calendrical.Hebrew)), and beginning_of_week/1 on those calendars lands on their own week start.

Added

  • Tempo.Duration.subtract/2 (add/2 of the negation) and the missing bang variants Tempo.to_calendar!/2, to_interval_set!/1, and select!/2, completing the ! convention across the conversion and selection API.

  • Tempo.week/1 completes the component-accessor family for week-axis values (~o"2026Y32W", Hebrew/Islamic calendar weeks, retail weeks): the week number, nil off the week axis, and an ArgumentError for an interval spanning more than one week.

  • Tempo.RRule.parse/2 accepts :duration, :base_to and :metadata — the occurrence-span controls Tempo.RRule.Expander.to_ast/3 already had — so a parsed RRULE can carry a per-occurrence span rather than a granule at its resolution. parse("FREQ=MONTHLY;BYDAY=1WE", from: dtstart, duration: ~o"PT2H") emits two-hour occurrences, the RRULE echo of iCalendar's DTSTART + DURATION.

  • Tempo.IntervalSet.last/1 — the latest member interval, or nil when empty, pairing with first/1. Unlike first/1's constant-time peek, it is O(n) and bounded-only: an unbounded set raises Tempo.UnboundedSetError rather than walking to find an end that isn't there.

  • Tempo.to_calendar/2 converts a day-resolution value from its calendar into another via Date.convert/2Tempo.to_calendar(~o"2026-06-15", Calendrical.Hebrew) is 30 Tevet 5786, and it round-trips. Tempo.to_calendar/1 is now a deprecated alias for Tempo.to_elixir/1, the outbound mirror of from_elixir/2 that also converts durations. Tempo.to_calendar/2 also takes a t:Tempo.Interval.t/0 — converting both endpoints and carrying duration, recurrence, repeat rule, unit and metadata across untouched, with an unbounded end left unbounded — and a t:Tempo.IntervalSet.t/0, converting each member. Taking the whole span saves the caller pulling it apart and rebuilding it, which silently drops whatever it was carrying.

Fixed

  • Shifting a week-axis value by weeks (Tempo.shift(~o"2026Y32W", week: 1)) steps weeks natively with year-rollover carry instead of raising KeyError; component accessors (Tempo.year/1) on single-week intervals no longer crash for the same reason. Month-axis values still take a week as seven days.

  • A day duration on a week-axis value computes as a day_of_week step (~o"2026Y32W" + P2D is ~o"2026Y32W3K", carrying across week and year boundaries), and a sub-day duration (PT1H) returns {:error, %Tempo.ResolutionError{}} instead of raising KeyError — the week axis has no path to an hour slot.

  • The tour livebook called Tempo.precedes?/2 (not a Tempo function — the predicate is before?/2) and compared a zoned time against a floating window; both cells now run. The weekend?/2 doc describes the actual mechanism: the date converts to Calendar.ISO before the weekday is read, so Calendrical 1.3's culturally-native weekday numbering (Hebrew/Islamic weeks from Sunday, Persian from Saturday) cannot misclassify a weekend.

  • Tempo.trunc/2 with :week names the day that value's week begins on, at day resolution, instead of silently answering with the month. trunc(~o"2026-08-16", :week) returned ~o"2026Y8M" — it walked past :day, found :month still coarser than :week, and gave back the month. It now answers ~o"2026Y8M10D", taking the week boundary from the value's own calendar so a Sunday-start calendar gives a different day from an ISO one. Units on another axis that name nothing expressible — :day_of_week, :day_of_year against a Gregorian value — return a Tempo.ResolutionError rather than an unrelated coarser unit. Coarsening within an axis is unchanged.

  • Component accessors (Tempo.year/1, month/1, day/1, hour/1, …) on an interval exactly one granule wide now read the component instead of raising "ambiguous" when the half-open upper bound rolls into a coarser unit — so Tempo.month/1 of [2026Y12M, 2027Y1M) (December 2026, e.g. from Tempo.select(~o"2026", ~o"-1M")) is 12. A genuinely multi-unit span still raises.

  • Materialised recurrence occurrences no longer carry the internal occurrence_duration / occurrence_base_to span directives in their metadata: they are consumed to size each occurrence and then dropped, so occurrences from a DURATION-bearing rule inspect cleanly instead of showing a spurious metadata key.

  • Tempo.to_elixir/1, to_date/1 and to_naive_date_time/1 now preserve a non-Gregorian value's calendar at the native boundary, mapping only Tempo's internal Calendrical.Gregorian to Elixir's Calendar.ISO. Previously a Hebrew value converted to a Date mislabelled Calendar.ISO carrying the Hebrew numbers — a corrupt value; Gregorian conversions are unchanged.