Repository: moment/luxon (js)

Issue:
DateTime#toISO() of local time can result in invalid format (offset "-00:00")
I noticed a bug in our application only users in Portugal encountered when sending requests to our server containing ISO formatted dates. We were only able to reproduce it by setting our computer's time zones to GMT (e.g. "Europe/London").

The issue is that luxon (v 1.22.0) formats the current date as follows:

```js
> luxon.DateTime.local().toISO()
'2020-02-17T10:02:14.703-00:00'
```

According to the Wikipedia entry on ISO 8601:

> An offset of zero, in addition to having the special representation "Z", can also be stated numerically as "+00:00", "+0000", or "+00". However, **it is not permitted to state it numerically with a negative sign, as "−00:00", "−0000", or "−00"**. The section dictating sign usage (section 3.4.2 in the 2004 edition of the standard) states that a plus sign must be used for a positive or zero value, and a minus sign for a negative value. Contrary to this rule, RFC 3339, which is otherwise a profile of ISO 8601, permits the use of "-00", with the same denotation as "+00" but a differing connotation.[29]

Funny enough, the following works:

```js
> luxon.DateTime.local().setZone(luxon.DateTime.local().zoneName).toISO()
'2020-02-17T10:08:04.416+00:00'
```
(Note: this does not work around the problem if a `Zone` object is given to `setZone`, it only works for the zone name, i.e. `Europe/London`)

As this shows there is a workaround that we will include in our application to fix the issue for our British and Portuguese users.

Since I'm not familiar with the luxon code base, I have not yet started working on a fix/PR. Please tell me whether this seems like an easy fix and you'll be able to do it. Otherwise please point me in some direction and I'll see what I can come up with.

Pull request description:
formatOffset handles signed offsets. Fixes #665
Making a -0 offset display as "+00:00", while keeping the negative sign for other negative values.

From the [ISO 8601 web page](https://en.wikipedia.org/wiki/ISO_8601) : _"+00:00" (but not "-00:00") for London (UTC±00:00)_

Patch:
diff --git a/docs/formatting.md b/docs/formatting.md
index 414f38ba..1e59a884 100644
--- a/docs/formatting.md
+++ b/docs/formatting.md
@@ -133,7 +133,7 @@ The supported tokens are described in the table below.
 
 ### Intl
 
-All of the strings (e.g. month names and weekday names) are internationalized by introspecting strings generated by the Intl API. Thus they exact strings you get are implementation-specific.
+All of the strings (e.g. month names and weekday names) are internationalized by introspecting strings generated by the Intl API. Thus the exact strings you get are implementation-specific.
 
 ```js
 DateTime.fromISO('2014-08-06T13:07:04.054')
@@ -239,4 +239,4 @@ The macro options available correspond one-to-one with the preset formats define
 | FFF              |              | verbose localized date and time with seconds                   | August 6, 2014, 1:07:04 PM EDT                              |
 | FFFF             |              | extra verbose localized date and time with seconds             | Wednesday, August 6, 2014, 1:07:04 PM Eastern Daylight Time |
 | X                |              | unix timestamp in seconds                                      | 1407287224                                                  |
-| x                |              | unix timestamp in milliseconds                                 | 1407287224054                                              |
+| x                |              | unix timestamp in milliseconds                                 | 1407287224054                                               |
diff --git a/src/impl/util.js b/src/impl/util.js
index 19d0c1e7..767f35d1 100644
--- a/src/impl/util.js
+++ b/src/impl/util.js
@@ -265,7 +265,7 @@ export function normalizeObject(obj, normalizer, nonUnitKeys) {
 export function formatOffset(offset, format) {
   const hours = Math.trunc(offset / 60),
     minutes = Math.abs(offset % 60),
-    sign = hours >= 0 && !Object.is(hours, -0) ? "+" : "-",
+    sign = offset >= 0 ? "+" : "-",
     base = `${sign}${Math.abs(hours)}`;
 
   switch (format) {
diff --git a/src/interval.js b/src/interval.js
index 51cd6b07..0fd901ad 100644
--- a/src/interval.js
+++ b/src/interval.js
@@ -134,19 +134,32 @@ export default class Interval {
   static fromISO(text, opts) {
     const [s, e] = (text || "").split("/", 2);
     if (s && e) {
-      const start = DateTime.fromISO(s, opts),
+      let start, startIsValid;
+      try {
+        start = DateTime.fromISO(s, opts);
+        startIsValid = start.isValid;
+      } catch (e) {
+        startIsValid = false;
+      }
+
+      let end, endIsValid;
+      try {
         end = DateTime.fromISO(e, opts);
+        endIsValid = end.isValid;
+      } catch (e) {
+        endIsValid = false;
+      }
 
-      if (start.isValid && end.isValid) {
+      if (startIsValid && endIsValid) {
         return Interval.fromDateTimes(start, end);
       }
 
-      if (start.isValid) {
+      if (startIsValid) {
         const dur = Duration.fromISO(e, opts);
         if (dur.isValid) {
           return Interval.after(start, dur);
         }
-      } else if (end.isValid) {
+      } else if (endIsValid) {
         const dur = Duration.fromISO(s, opts);
         if (dur.isValid) {
           return Interval.before(end, dur);


Test patch:
diff --git a/test/interval/parse.test.js b/test/interval/parse.test.js
index b7bad590..d2bd58cf 100644
--- a/test/interval/parse.test.js
+++ b/test/interval/parse.test.js
@@ -1,5 +1,8 @@
 /* global test expect */
 import { Interval } from "../../src/luxon";
+import Helpers from "../helpers";
+
+const withThrowOnInvalid = Helpers.setUnset("throwOnInvalid");
 
 //------
 // .fromISO()
@@ -117,6 +120,17 @@ test("Interval.fromISO accepts a zone argument", () => {
   expect(durDate.start.zoneName).toBe("Europe/Paris");
 });
 
+// #728
+test("Interval.fromISO works with Settings.throwOnInvalid", () => {
+  withThrowOnInvalid(true, () => {
+    const dateDur = Interval.fromISO("2020-06-22T17:30:00.000+02:00/PT5H30M");
+    expect(dateDur.isValid).toBe(true);
+
+    const durDate = Interval.fromISO("PT5H30M/2020-06-22T17:30:00.000+02:00");
+    expect(durDate.isValid).toBe(true);
+  });
+});
+
 const badInputs = [
   null,
   "",
diff --git a/test/zones/fixedOffset.test.js b/test/zones/fixedOffset.test.js
index 8ae5b631..82df958d 100644
--- a/test/zones/fixedOffset.test.js
+++ b/test/zones/fixedOffset.test.js
@@ -53,6 +53,16 @@ test("FixedOffsetZone.formatOffset is consistent despite the provided timestamp"
   expect(zone.formatOffset(1552280400, "techie")).toBe("-0500");
 });
 
+test("FixedOffsetZone.formatOffset prints the correct sign before the offset", () => {
+  expect(FixedOffsetZone.instance(-300).formatOffset(0, "short")).toBe("-05:00");
+  expect(FixedOffsetZone.instance(-30).formatOffset(0, "short")).toBe("-00:30");
+  // Note the negative zero results in a + sign
+  expect(FixedOffsetZone.instance(-0).formatOffset(0, "short")).toBe("+00:00");
+  expect(FixedOffsetZone.instance(0).formatOffset(0, "short")).toBe("+00:00");
+  expect(FixedOffsetZone.instance(30).formatOffset(0, "short")).toBe("+00:30");
+  expect(FixedOffsetZone.instance(300).formatOffset(0, "short")).toBe("+05:00");
+});
+
 test("FixedOffsetZone.equals requires both zones to be fixed", () => {
   expect(FixedOffsetZone.utcInstance.equals(IANAZone.create("UTC"))).toBe(false);
 });