Repository: moment/luxon (2025-07-29T21:55:04Z)

Issue:
Interval.fromISO not following standard for start/end notation
Actual behavior:
`Interval.fromISO("1988-04-15T09/15").toString()`
> '[1988-04-15T09:00:00.000+01:00 – 2021-03-23T15:00:00.000+01:00)'

Expected behavior:
`Interval.fromISO("1988-04-15T09/15").toString()`
> '[1988-04-15T09:00:00.000+01:00 – 1988-04-15T15:00:00.000+01:00)'

as described in:
https://en.wikipedia.org/wiki/ISO_8601#Time_intervals
> For <start>/<end> expressions, if any elements are missing from the end value, they are assumed to be the same as for the start value including the time zone.

In node 12.21.0 with luxon 1.25.0, 1.26.0.


Discussion:
I've taken a look into this and it is a genuine bug/limitation of our implementation. We currently require that the start/end be fully formed datetimes (or else a duration and a datetime for ISO interval formats 'b' and 'c', which are not relevant to this bug), but the ISO spec dictates that in "a" format intervals, the end date is only required to include the differing last digits, with an additional exceptional rule for offset/zone carrying over from the start date.

The examples from ISO 8601-1:2019 (section 5.1.1 - 'Means of specifying time intervals') explain what I'm trying to say more clearly:

> EXAMPLE 1 —‘2018-01-15/02-20’ represents ‘2018-01-15/2018-02-20’' as the expression ‘02-20’ is unambiguously the last two time components, i.e. a calendar month and a calendar day.

> EXAMPLE 2 = ‘2018-01-15+05:00/2018-02-20’ is equivalent to ‘2018-01-15+05:00/2018-02-20+05:00' as the ‘+05:00’ time shift also applies to the expression after the separator.

Currently we allow neither of these examples! The fix I have in mind is to do some string manipulation on the end string to prefix it with the substring from the start date covering the missing datetime parts, and suffix with the offset from the start date if it is present and another offset isn't already present on the end date. In pseudocode:
```
s = <starting datetime string>
[sDT, sOffset] = splitDTStringFromOffsetString(s)
e = <ending datetime string - partial>
[eDT, eOffset] = splitDTStringFromOffsetString(e)
eFull = sDT[0..(-eDT.length)] + eDT + (eOffset || sOffset)
```

@diesieben07 I have run into a problem with this approach though: we do not currently support "2018-01-15+05:00" as a valid ISO DateTime (ie: including extended offset/zone, but not including time is invalid). Is it OK for me to add this support for time-less offsets? Or do we deliberately not allow this? It seems to me that one of the really annoying parts of supporting this format is that "2018-01-01" (ie: jan 1, 2018) can be easily misparsed as "2018-01-01:00" (ie: january 2018, with offset -1h), so it is a bit of a regex nightmare to distinguish between trailing 2 digit negative offsets and trailing 2 digit time units. Nothing insurmountable, but I don't want to write code that will definitely be rejected.

(Also, a meta-question: is there a better way for me to reach out to leadership over questions like this? I keep tagging you on PR and discussion threads because I know you are an admin on the repo, but I don't want to be a burden or going through the wrong channels. Please let me know if there's a better way to reach out, or someone else I should be tagging.)
Hi @dobon and thank you for looking into this!

Several points:

> The fix I have in mind is to do some string manipulation on the end string to prefix it with the substring from the start date covering the missing datetime parts, and suffix with the offset from the start date if it is present and another offset isn't already present on the end date.

Without having looked into this a ton, using string manipulation for this task seems brittle. 

> I have run into a problem with this approach though: we do not currently support "2018-01-15+05:00" as a valid ISO DateTime (ie: including extended offset/zone, but not including time is invalid). Is it OK for me to add this support for time-less offsets? Or do we deliberately not allow this?

Is this actually allowed though? Unfortunately I do not have access to the actual ISO 8601 specification, but I have found no evidence of this being allowed. For example:
- https://stackoverflow.com/a/52229618 states: "Officially, the ISO 8601 spec does not allow for a date with a time zone unless a time is provided."
- Wikipedia states: "If a time zone designator is required, it follows the combined date and time.", implying a time is required.
- RFC3339, which is based on ISO 8601 but much simpler, does not allow it.

> is there a better way for me to reach out to leadership over questions like this?

Github issues are the correct place for this. You're doing everything right, I am just being slow to respond due to time constraints. And again, thank you for looking in to and fixing things, it is always highly appreciated!

I've dug into this a big more and annoyingly, the "Date with shift and no time" rules seem to be poorly defined by the ISO Spec and is one of the things that actually changed between the 2016 and 2019 versions. The following are verbatim quotes from the current spec ([2019-1 5.1.1](https://archive.org/details/iso-8601-2019/bs-iso-8601-1-2019/page/27/mode/1up)):
>EXAMPLE 1 —‘2018-01-15/02-20’ represents ‘2018-01-15/2018-02-20’' as the expression ‘**02-08**’ is unambiguously the last two time components, i.e. a calendar month and a calendar day.

_NB: a typo error in the ISO spec where they wrote "02-08" but meant to write "02-20". Clearly their examples are NOT rigorously reliable._

>EXAMPLE 2 = ‘2018-01-15+05:00/2018-02-20’ is equivalent to ‘2018-01-15+05:00/2018-02-20+05:00' as the ‘+05:00’ time shift also applies to the expression after the separator

_NB: ISO spec for intervals specifically uses "date with shift and no time" as an example._

This problematic example 2 can be further tracked to the extended spec ([2019-2 7.14.3](https://archive.org/details/iso-8601-2019/bs-iso-8601-2-2019/page/5/mode/1up?q=dsE))
>EXAMPLE '2018Y1M15D**Z5Y0M**/2018Y2M20D' is equivalent to '2018Y1M15DZ5Y0M/2018Y2M20DZ5Y0M' as the '**_Z5H0M_**' time shift also applies to the expression after the separator.

_NB: it is the exact same example as in 2019-1, but the offset is even more messed up and clearly a typo where the offset is malformed as "Z5Y0M" and later referred to as "Z5H0M"._ 

"Date with shift representation in explicit form" is given a special named symbol, `dsE`, and then never referred to again in [2019-2 3.2.3](https://archive.org/details/iso-8601-2019/bs-iso-8601-2-2019/page/5/mode/1up?q=dsE)

So it is really a hot mess. There are strong indications that they want "date with shift" to be a valid format: they assign it a specific symbol and use it in examples; but on the other hand they seem to have scant, contradictory and erroneous information about the actual details, and it just so happens that one of those errors was in the section pertaining to intervals so I bumped into it while researching this bug.

As amusing/concerning as the official spec being riddled with errors is, I think the best course of action is for Luxon to disallow "Date with shift and no time" when parsing ISO until there is clear guidance on the matter in a future ISO revision. What do you think?
@dobon Thank you very much for looking into this.

> As amusing/concerning as the official spec being riddled with errors is, I think the best course of action is for Luxon to disallow "Date with shift and no time" when parsing ISO until there is clear guidance on the matter in a future ISO revision. What do you think?

100% agreed. 
We should not allow parsing of `DateTime.fromISO("2025-07-09+01:00")`.
However we should allow parsing of intervals 
We should find a way to make this work _during parsing of Intervals_ (i.e. allowing to re-use the parts from the start DT in the end DT), 



Patch:
diff --git a/src/datetime.js b/src/datetime.js
index 40a3f5511..e948032e7 100644
--- a/src/datetime.js
+++ b/src/datetime.js
@@ -186,7 +186,7 @@ function adjustTime(inst, dur) {
 
 // helper useful in turning the results of parsing into real dates
 // by handling the zone options
-function parseDataToDateTime(parsed, parsedZone, opts, format, text, specificOffset) {
+export function parseDataToDateTime(parsed, parsedZone, opts, format, text, specificOffset) {
   const { setZone, zone } = opts;
   if ((parsed && Object.keys(parsed).length !== 0) || parsedZone) {
     const interpretationZone = parsedZone || zone,
@@ -797,7 +797,7 @@ export default class DateTime {
     const normalized = normalizeObject(obj, normalizeUnitWithLocalWeeks);
     const { minDaysInFirstWeek, startOfWeek } = usesLocalWeekValues(normalized, loc);
 
-    const tsNow = Settings.now(),
+    const tsNow = opts.overrideNow ?? Settings.now(),
       offsetProvis = !isUndefined(opts.specificOffset)
         ? opts.specificOffset
         : zoneToUse.offset(tsNow),
diff --git a/src/impl/regexParser.js b/src/impl/regexParser.js
index 6710d64ea..c39dd2082 100644
--- a/src/impl/regexParser.js
+++ b/src/impl/regexParser.js
@@ -294,6 +294,39 @@ export function parseISODate(s) {
   );
 }
 
+// ISO Interval parsing
+
+// Note: Do not optimize the outer non-capturing group, it is necessary, because the
+// regex is combined with other regexes and contains |
+const partialIsoIntervalEndDate = /(?:(?:(\d\d)-)?(\d\d)?|(?:W(\d\d)-)?(\d)|(\d{3}))/;
+const isoIntervalEndDateTime = combineRegexes(partialIsoIntervalEndDate, isoTimeExtensionRegex);
+
+const extractPartialIsoIntervalEndDate = simpleParse(
+  "month",
+  "day",
+  "weekNumber",
+  "weekDay",
+  "ordinal"
+);
+
+const extractISOIntervalPartialDateAndTime = combineExtractors(
+  extractPartialIsoIntervalEndDate,
+  extractISOTime,
+  extractISOOffset,
+  extractIANAZone
+);
+
+export function parseISOIntervalEnd(s) {
+  return parse(
+    s,
+    [isoIntervalEndDateTime, extractISOIntervalPartialDateAndTime],
+    [isoYmdWithTimeExtensionRegex, extractISOYmdTimeAndOffset],
+    [isoWeekWithTimeExtensionRegex, extractISOWeekTimeAndOffset],
+    [isoOrdinalWithTimeExtensionRegex, extractISOOrdinalDateAndTime],
+    [isoTimeCombinedRegex, extractISOTimeAndOffset]
+  );
+}
+
 export function parseRFC2822Date(s) {
   return parse(preprocessRFC2822(s), [rfc2822, extractRFC2822]);
 }
diff --git a/src/interval.js b/src/interval.js
index 48f9ba496..1cdb6b118 100644
--- a/src/interval.js
+++ b/src/interval.js
@@ -1,10 +1,11 @@
-import DateTime, { friendlyDateTime } from "./datetime.js";
+import DateTime, { friendlyDateTime, parseDataToDateTime } from "./datetime.js";
 import Duration from "./duration.js";
 import Settings from "./settings.js";
 import { InvalidArgumentError, InvalidIntervalError } from "./errors.js";
 import Invalid from "./impl/invalid.js";
 import Formatter from "./impl/formatter.js";
 import * as Formats from "./impl/formats.js";
+import { parseISOIntervalEnd } from "./impl/regexParser.js";
 
 const INVALID = "Invalid Interval";
 
@@ -134,11 +135,14 @@ export default class Interval {
    * @return {Interval}
    */
   static fromISO(text, opts) {
+    const { zone, setZone, ...restOpts } = opts || {};
     const [s, e] = (text || "").split("/", 2);
     if (s && e) {
       let start, startIsValid;
       try {
-        start = DateTime.fromISO(s, opts);
+        // we need to know the zone that was used in the string, so that we can
+        // default to it when parsing end, therefor use setZone: true
+        start = DateTime.fromISO(s, { ...restOpts, zone, setZone: true });
         startIsValid = start.isValid;
       } catch (e) {
         startIsValid = false;
@@ -146,12 +150,27 @@ export default class Interval {
 
       let end, endIsValid;
       try {
-        end = DateTime.fromISO(e, opts);
+        const [vals, parsedZone] = parseISOIntervalEnd(e);
+        const endParseOpts = {
+          ...restOpts,
+          overrideNow: startIsValid ? start.valueOf() : null,
+          zone: startIsValid ? start.zone : zone,
+          setZone: true,
+        };
+        end = parseDataToDateTime(vals, parsedZone, endParseOpts, "ISO 8601 Interval end", e);
         endIsValid = end.isValid;
       } catch (e) {
         endIsValid = false;
       }
 
+      // if we overrode the user's choice for setZone earlier, make up for it now
+      if (startIsValid && !setZone) {
+        start = start.setZone(zone);
+      }
+      if (endIsValid && !setZone) {
+        end = end.setZone(zone);
+      }
+
       if (startIsValid && endIsValid) {
         return Interval.fromDateTimes(start, end);
       }


Test patch:
diff --git a/test/interval/parse.test.js b/test/interval/parse.test.js
index fb70fb681..2512ac14f 100644
--- a/test/interval/parse.test.js
+++ b/test/interval/parse.test.js
@@ -144,3 +144,182 @@ test.each(badInputs)("Interval.fromISO will return invalid for [%s]", (s) => {
   expect(i.isValid).toBe(false);
   expect(i.invalidReason).toBe("unparsable");
 });
+
+describe("Interval.fromISO defaults missing values in end to start", () => {
+  test("Gregorian, end just time", () => {
+    const i = Interval.fromISO("1988-04-15T09/15:30");
+    expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("1988-04-15T15:30:00.000-04:00");
+  });
+  test("Gregorian, end just time and zone", () => {
+    const i = Interval.fromISO("1988-04-15T09/15:30-07:00");
+    expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("1988-04-15T18:30:00.000-04:00");
+  });
+  test("Gregorian, end just day", () => {
+    const i = Interval.fromISO("1988-04-15T09/17");
+    expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("1988-04-17T00:00:00.000-04:00");
+  });
+  test("Gregorian, end day and time", () => {
+    const i = Interval.fromISO("1988-04-15T09/17T15:30");
+    expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("1988-04-17T15:30:00.000-04:00");
+  });
+  test("Gregorian, end month and day", () => {
+    const i = Interval.fromISO("1988-04-15T09/05-17");
+    expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("1988-05-17T00:00:00.000-04:00");
+  });
+  test("Gregorian, end month, day and time", () => {
+    const i = Interval.fromISO("1988-04-15T09/05-17T15:30");
+    expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("1988-05-17T15:30:00.000-04:00");
+  });
+  test("Gregorian with zone in options and partial date", () => {
+    const i = Interval.fromISO("1988-04-15T09/19", { zone: "UTC-06:00" });
+    expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-06:00");
+    expect(i.end.toISO()).toBe("1988-04-19T00:00:00.000-06:00");
+  });
+  test("Gregorian with zone in options and partial date and time", () => {
+    const i = Interval.fromISO("1988-04-15T09/19T13:00", { zone: "UTC-06:00" });
+    expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-06:00");
+    expect(i.end.toISO()).toBe("1988-04-19T13:00:00.000-06:00");
+  });
+  test("Gregorian with zone in options and full date and time", () => {
+    const i = Interval.fromISO("1988-04-15T09/1989-03-01T13:00", { zone: "UTC-06:00" });
+    expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-06:00");
+    expect(i.end.toISO()).toBe("1989-03-01T13:00:00.000-06:00");
+  });
+  test("Gregorian with zone in options and end zone", () => {
+    const i = Interval.fromISO("1988-04-15T09/16T15:00-07:00", { zone: "UTC-06:00" });
+    expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-06:00");
+    expect(i.end.toISO()).toBe("1988-04-16T16:00:00.000-06:00");
+  });
+  test("Gregorian with zone in options, setZone and end zone", () => {
+    const i = Interval.fromISO("1988-04-15T09/16T15:00-07:00", {
+      zone: "UTC-06:00",
+      setZone: true,
+    });
+    expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-06:00");
+    expect(i.end.toISO()).toBe("1988-04-16T15:00:00.000-07:00");
+  });
+  test("Gregorian with start zone", () => {
+    const i = Interval.fromISO("1988-04-15T09:00:00+01:00/17T15:30");
+    expect(i.start.toISO()).toBe("1988-04-15T04:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("1988-04-17T10:30:00.000-04:00");
+  });
+  test("Gregorian with start zone and zone in options", () => {
+    const i = Interval.fromISO("1988-04-15T09:00:00+01:00/15T15:00", { zone: "UTC-06:00" });
+    expect(i.start.toISO()).toBe("1988-04-15T02:00:00.000-06:00");
+    expect(i.end.toISO()).toBe("1988-04-15T08:00:00.000-06:00");
+  });
+  test("Gregorian with start zone and setZone", () => {
+    const i = Interval.fromISO("1988-04-15T09:00:00+01:00/15T15:00", { setZone: true });
+    expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000+01:00");
+    expect(i.end.toISO()).toBe("1988-04-15T15:00:00.000+01:00");
+  });
+  test("Gregorian with two zones", () => {
+    const i = Interval.fromISO("1988-04-15T09:00:00+01:00/15T16:00+02:00");
+    expect(i.start.toISO()).toBe("1988-04-15T04:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("1988-04-15T10:00:00.000-04:00");
+  });
+  test("Gregorian with two zones and setZone", () => {
+    const i = Interval.fromISO("1988-04-15T09:00:00+01:00/15T16:00+02:00", { setZone: true });
+    expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000+01:00");
+    expect(i.end.toISO()).toBe("1988-04-15T16:00:00.000+02:00");
+  });
+
+  // Week dates
+  test("Week, end just time", () => {
+    const i = Interval.fromISO("2025-W20-1T09/15:30");
+    expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("2025-05-12T15:30:00.000-04:00");
+  });
+  test("Week, end just time and zone", () => {
+    const i = Interval.fromISO("2025-W20-1T09/15:30-07:00");
+    expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("2025-05-12T18:30:00.000-04:00");
+  });
+  test("Week, end week day", () => {
+    const i = Interval.fromISO("2025-W20-1T09/3T15:30");
+    expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("2025-05-14T15:30:00.000-04:00");
+  });
+  test("Week, end week number and week day", () => {
+    const i = Interval.fromISO("2025-W20-1T09/W21-3T15:30");
+    expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("2025-05-21T15:30:00.000-04:00");
+  });
+
+  // Ordinal dates
+  test("Ordinal, end just time", () => {
+    const i = Interval.fromISO("2025-132T09/15:30");
+    expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("2025-05-12T15:30:00.000-04:00");
+  });
+  test("Ordinal, end just time and zone", () => {
+    const i = Interval.fromISO("2025-132T09/15:30-07:00");
+    expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("2025-05-12T18:30:00.000-04:00");
+  });
+  test("Ordinal, end with ordinal", () => {
+    const i = Interval.fromISO("2025-132T09/135T15:30");
+    expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("2025-05-15T15:30:00.000-04:00");
+  });
+
+  // Mixed
+  test("Gregorian, end just weekday", () => {
+    const i = Interval.fromISO("2025-05-12T09/4T15:30");
+    expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("2025-05-15T15:30:00.000-04:00");
+  });
+  test("Gregorian, end weekNumber and weekday", () => {
+    const i = Interval.fromISO("2025-05-12T09/W21-1T15:30");
+    expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("2025-05-19T15:30:00.000-04:00");
+  });
+  test("Gregorian, end just ordinal", () => {
+    const i = Interval.fromISO("2025-05-12T09/135T15:30");
+    expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("2025-05-15T15:30:00.000-04:00");
+  });
+
+  test("Week date, end just day", () => {
+    const i = Interval.fromISO("2025-W20-1T09/15T15:30");
+    expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("2025-05-15T15:30:00.000-04:00");
+  });
+  test("Week date, end month and day", () => {
+    const i = Interval.fromISO("2025-W20-1T09/06-15T15:30");
+    expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("2025-06-15T15:30:00.000-04:00");
+  });
+  test("Week date, end just ordinal", () => {
+    const i = Interval.fromISO("2025-W20-1T09/135T15:30");
+    expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("2025-05-15T15:30:00.000-04:00");
+  });
+
+  test("Ordinal, end just day", () => {
+    const i = Interval.fromISO("2025-132T09/15T15:30");
+    expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("2025-05-15T15:30:00.000-04:00");
+  });
+  test("Ordinal, end month and day", () => {
+    const i = Interval.fromISO("2025-132T09/06-15T15:30");
+    expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("2025-06-15T15:30:00.000-04:00");
+  });
+  test("Ordinal, end just weekday", () => {
+    const i = Interval.fromISO("2025-132T09/4T15:30");
+    expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("2025-05-15T15:30:00.000-04:00");
+  });
+  test("Ordinal, end weekNumber and weekday", () => {
+    const i = Interval.fromISO("2025-132T09/W21-1T15:30");
+    expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00");
+    expect(i.end.toISO()).toBe("2025-05-19T15:30:00.000-04:00");
+  });
+});