Repository: moment/luxon (js)

Issue:
Feature request: toRelative multiple units
It would be great to specify multiple units for `toRelative()` to receive a more precise result if the date is less than 24 hours.

`toRelative({ units: ['days', 'hours' ]})`

```
> 24 hours

in 2 days
```

---

```
< 24 hours

in 10 hours
```

Pull request description:
feat: multiple units support in toRelative
This has been requested before in #579, see discussion there for more details.

My use case is that during the first minute, `.toRelative()` will print something like `1 second ago`. I'd like to avoid updating it every second (for performance purposes), and instead I'd like it to print out `0 minutes ago`. This way I can schedule updates once every minute instead of every second.

This PR adds a way to override which units are considered when formatting a relative date.

See the tests I added as well.

Fixes #579.

Patch:
diff --git a/benchmarks/datetime.js b/benchmarks/datetime.js
index 773f3807..68df1517 100644
--- a/benchmarks/datetime.js
+++ b/benchmarks/datetime.js
@@ -4,64 +4,76 @@ import Benchmark from "benchmark";
 import DateTime from "../src/datetime";
 import Settings from "../src/settings";
 
-const suite = new Benchmark.Suite();
+function runDateTimeSuite() {
+  return new Promise((resolve, reject) => {
+    const suite = new Benchmark.Suite();
 
-const dt = DateTime.now();
+    const dt = DateTime.now();
 
-suite
-  .add("DateTime.local", () => {
-    DateTime.now();
-  })
-  .add("DateTime.fromObject with locale", () => {
-    DateTime.fromObject({ locale: "fr" });
-  })
-  .add("DateTime.local with numbers", () => {
-    DateTime.local(2017, 5, 15);
-  })
-  .add("DateTime.fromISO", () => {
-    DateTime.fromISO("1982-05-25T09:10:11.445Z");
-  })
-  .add("DateTime.fromSQL", () => {
-    DateTime.fromSQL("2016-05-14 10:23:54.2346");
-  })
-  .add("DateTime.fromString", () => {
-    DateTime.fromString("1982/05/25 09:10:11.445", "yyyy/MM/dd HH:mm:ss.SSS");
-  })
-  .add("DateTime.fromString with zone", () => {
-    DateTime.fromString("1982/05/25 09:10:11.445", "yyyy/MM/dd HH:mm:ss.SSS", {
-      zone: "America/Los_Angeles"
-    });
-  })
-  .add("DateTime#setZone", () => {
-    dt.setZone("America/Los_Angeles");
-  })
-  .add("DateTime#toFormat", () => {
-    dt.toFormat("yyyy-MM-dd");
-  })
-  .add("DateTime#toFormat with macro", () => {
-    dt.toFormat("T");
-  })
-  .add("DateTime#toFormat with macro no cache", () => {
-    dt.toFormat("T");
-    Settings.resetCaches();
-  })
-  .add("DateTime#add", () => {
-    dt.plus({ milliseconds: 3434 });
-  })
-  .add("DateTime#toISO", () => {
-    dt.toISO();
-  })
-  .add("DateTime#toLocaleString", () => {
-    dt.toLocaleString();
-  })
-  .add("DateTime#toRelativeCalendar", () => {
-    dt.toRelativeCalendar({ base: DateTime.now(), locale: "fi" });
-  })
-  .on("cycle", event => {
-    console.log(String(event.target));
-  })
-  // eslint-disable-next-line func-names
-  .on("complete", function() {
-    console.log("Fastest is " + this.filter("fastest").map("name"));
-  })
-  .run();
+    suite
+      .add("DateTime.local", () => {
+        DateTime.now();
+      })
+      .add("DateTime.fromObject with locale", () => {
+        DateTime.fromObject({ locale: "fr" });
+      })
+      .add("DateTime.local with numbers", () => {
+        DateTime.local(2017, 5, 15);
+      })
+      .add("DateTime.fromISO", () => {
+        DateTime.fromISO("1982-05-25T09:10:11.445Z");
+      })
+      .add("DateTime.fromSQL", () => {
+        DateTime.fromSQL("2016-05-14 10:23:54.2346");
+      })
+      .add("DateTime.fromString", () => {
+        DateTime.fromString("1982/05/25 09:10:11.445", "yyyy/MM/dd HH:mm:ss.SSS");
+      })
+      .add("DateTime.fromString with zone", () => {
+        DateTime.fromString("1982/05/25 09:10:11.445", "yyyy/MM/dd HH:mm:ss.SSS", {
+          zone: "America/Los_Angeles"
+        });
+      })
+      .add("DateTime#setZone", () => {
+        dt.setZone("America/Los_Angeles");
+      })
+      .add("DateTime#toFormat", () => {
+        dt.toFormat("yyyy-MM-dd");
+      })
+      .add("DateTime#toFormat with macro", () => {
+        dt.toFormat("T");
+      })
+      .add("DateTime#toFormat with macro no cache", () => {
+        dt.toFormat("T");
+        Settings.resetCaches();
+      })
+      .add("DateTime#add", () => {
+        dt.plus({ milliseconds: 3434 });
+      })
+      .add("DateTime#toISO", () => {
+        dt.toISO();
+      })
+      .add("DateTime#toLocaleString", () => {
+        dt.toLocaleString();
+      })
+      .add("DateTime#toRelativeCalendar", () => {
+        dt.toRelativeCalendar({ base: DateTime.now(), locale: "fi" });
+      })
+      .on("cycle", event => {
+        console.log(String(event.target));
+      })
+      // eslint-disable-next-line func-names
+      .on("complete", function() {
+        console.log("Fastest is " + this.filter("fastest").map("name"));
+        resolve();
+      })
+      .on("error", function() {
+        reject(this.error);
+      })
+      .run();
+  });
+}
+
+const allSuites = [runDateTimeSuite];
+
+export default allSuites;
diff --git a/benchmarks/index.js b/benchmarks/index.js
new file mode 100644
index 00000000..db58cb32
--- /dev/null
+++ b/benchmarks/index.js
@@ -0,0 +1,12 @@
+import dateTimeSuites from "./datetime";
+import infoSuites from "./info";
+
+const allSuites = [...dateTimeSuites, ...infoSuites];
+
+async function runAllSuites() {
+  for (const runSuite of allSuites) {
+    await runSuite();
+  }
+}
+
+runAllSuites();
diff --git a/benchmarks/info.js b/benchmarks/info.js
new file mode 100644
index 00000000..bced7c91
--- /dev/null
+++ b/benchmarks/info.js
@@ -0,0 +1,112 @@
+/* eslint import/no-extraneous-dependencies: off */
+/* eslint no-console: off */
+import Benchmark from "benchmark";
+import Info from "../src/info";
+import Locale from "../src/impl/locale.js";
+
+function runWeekdaysSuite() {
+  return new Promise((resolve, reject) => {
+    const locale = Locale.create(null, null, null);
+
+    new Benchmark.Suite()
+      .add("Info.weekdays with existing locale", () => {
+        Info.weekdays("long", { locObj: locale });
+      })
+      .add("Info.weekdays", () => {
+        Info.weekdays("long");
+      })
+      .on("cycle", event => {
+        console.log(String(event.target));
+      })
+      // eslint-disable-next-line func-names
+      .on("complete", function() {
+        console.log("Fastest is " + this.filter("fastest").map("name"));
+        resolve();
+      })
+      .on("error", function() {
+        reject(this.error);
+      })
+      .run();
+  });
+}
+
+function runWeekdaysFormatSuite() {
+  return new Promise((resolve, reject) => {
+    const locale = Locale.create(null, null, null);
+
+    new Benchmark.Suite()
+      .add("Info.weekdaysFormat with existing locale", () => {
+        Info.weekdaysFormat("long", { locObj: locale });
+      })
+      .add("Info.weekdaysFormat", () => {
+        Info.weekdaysFormat("long");
+      })
+      .on("cycle", event => {
+        console.log(String(event.target));
+      })
+      // eslint-disable-next-line func-names
+      .on("complete", function() {
+        console.log("Fastest is " + this.filter("fastest").map("name"));
+        resolve();
+      })
+      .on("error", function() {
+        reject(this.error);
+      })
+      .run();
+  });
+}
+
+function runMonthsSuite() {
+  return new Promise((resolve, reject) => {
+    const locale = Locale.create(null, null, null);
+    new Benchmark.Suite()
+      .add("Info.months with existing locale", () => {
+        Info.months("long", { locObj: locale });
+      })
+      .add("Info.months", () => {
+        Info.months("long");
+      })
+      .on("cycle", event => {
+        console.log(String(event.target));
+      })
+      // eslint-disable-next-line func-names
+      .on("complete", function() {
+        console.log("Fastest is " + this.filter("fastest").map("name"));
+        resolve();
+      })
+      .on("error", function() {
+        reject(this.error);
+      })
+      .run();
+  });
+}
+
+function runMonthsFormatSuite() {
+  return new Promise((resolve, reject) => {
+    const locale = Locale.create(null, null, null);
+
+    new Benchmark.Suite()
+      .add("Info.monthsFormat with existing locale", () => {
+        Info.monthsFormat("long", { locObj: locale });
+      })
+      .add("Info.monthsFormat", () => {
+        Info.monthsFormat("long");
+      })
+      .on("cycle", event => {
+        console.log(String(event.target));
+      })
+      // eslint-disable-next-line func-names
+      .on("complete", function() {
+        console.log("Fastest is " + this.filter("fastest").map("name"));
+        resolve();
+      })
+      .on("error", function() {
+        reject(this.error);
+      })
+      .run();
+  });
+}
+
+const allSuites = [runMonthsSuite, runMonthsFormatSuite, runWeekdaysSuite, runWeekdaysFormatSuite];
+
+export default allSuites;
diff --git a/docs/math.md b/docs/math.md
index f2ea27a4..cbaa3f8f 100644
--- a/docs/math.md
+++ b/docs/math.md
@@ -263,7 +263,7 @@ var diffInMonths = end.diff(start, 'months');
 diffInMonths.as('days'); //=> 30
 ```
 
-That's our conversion between months and days (you could also do a longterm-accurate conversion; it wouldn't fix the issue ahead). But this isn't the number of days between February 15 and March 15!
+That's our conversion between months and days (you could also do a longterm-accurate conversion; it wouldn't fix the issue ahead). But this isn't the number of days between February 13 and March 13!
 
 ```js
 var diffInDays = end.diff(start, 'days');
diff --git a/package-lock.json b/package-lock.json
index 55c9bbea..680b000b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8027,9 +8027,9 @@
       }
     },
     "lodash": {
-      "version": "4.17.19",
-      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz",
-      "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==",
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
       "dev": true
     },
     "lodash.assignin": {
@@ -11093,9 +11093,9 @@
       }
     },
     "y18n": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
-      "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==",
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.1.tgz",
+      "integrity": "sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ==",
       "dev": true
     },
     "yaml": {
diff --git a/package.json b/package.json
index cbba9a60..a8167187 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,7 @@
     "lint": "eslint --quiet src test benchmarks",
     "lint!": "npm run format && npm run lint",
     "format": "prettier --write 'src/**/*.js' 'test/**/*.js' 'benchmarks/*.js'",
-    "benchmark": "babel-node benchmarks/datetime.js",
+    "benchmark": "babel-node benchmarks/index.js",
     "codecov": "codecov",
     "check-doc-coverage": "babel-node tasks/docCoverage"
   },
diff --git a/src/datetime.js b/src/datetime.js
index aa563c10..61ef8b42 100644
--- a/src/datetime.js
+++ b/src/datetime.js
@@ -360,7 +360,7 @@ function diffRelative(start, end, opts) {
       return format(count, unit);
     }
   }
-  return format(0, opts.units[opts.units.length - 1]);
+  return format(start > end ? -0 : 0, opts.units[opts.units.length - 1]);
 }
 
 /**
@@ -479,7 +479,7 @@ export default class DateTime {
    */
   static local(year, month, day, hour, minute, second, millisecond) {
     if (isUndefined(year)) {
-      return new DateTime({});
+      return DateTime.now();
     } else {
       return quickDT(
         {
@@ -751,8 +751,8 @@ export default class DateTime {
    * @param {string|Zone} [opts.zone='local'] - use this zone if no offset is specified in the input string itself. Will also convert the time to this zone
    * @param {boolean} [opts.setZone=false] - override the zone with a fixed-offset zone specified in the string itself, if it specifies one
    * @param {string} [opts.locale='system's locale'] - a locale to set on the resulting DateTime instance
-   * @param {string} opts.outputCalendar - the output calendar to set on the resulting DateTime instance
-   * @param {string} opts.numberingSystem - the numbering system to set on the resulting DateTime instance
+   * @param {string} [opts.outputCalendar] - the output calendar to set on the resulting DateTime instance
+   * @param {string} [opts.numberingSystem] - the numbering system to set on the resulting DateTime instance
    * @example DateTime.fromISO('2016-05-25T09:08:34.123')
    * @example DateTime.fromISO('2016-05-25T09:08:34.123+06:00')
    * @example DateTime.fromISO('2016-05-25T09:08:34.123+06:00', {setZone: true})
@@ -1099,7 +1099,7 @@ export default class DateTime {
    * @type {string}
    */
   get monthShort() {
-    return this.isValid ? Info.months("short", { locale: this.locale })[this.month - 1] : null;
+    return this.isValid ? Info.months("short", { locObj: this.loc })[this.month - 1] : null;
   }
 
   /**
@@ -1109,7 +1109,7 @@ export default class DateTime {
    * @type {string}
    */
   get monthLong() {
-    return this.isValid ? Info.months("long", { locale: this.locale })[this.month - 1] : null;
+    return this.isValid ? Info.months("long", { locObj: this.loc })[this.month - 1] : null;
   }
 
   /**
@@ -1119,7 +1119,7 @@ export default class DateTime {
    * @type {string}
    */
   get weekdayShort() {
-    return this.isValid ? Info.weekdays("short", { locale: this.locale })[this.weekday - 1] : null;
+    return this.isValid ? Info.weekdays("short", { locObj: this.loc })[this.weekday - 1] : null;
   }
 
   /**
@@ -1129,7 +1129,7 @@ export default class DateTime {
    * @type {string}
    */
   get weekdayLong() {
-    return this.isValid ? Info.weekdays("long", { locale: this.locale })[this.weekday - 1] : null;
+    return this.isValid ? Info.weekdays("long", { locObj: this.loc })[this.weekday - 1] : null;
   }
 
   /**
@@ -1339,7 +1339,22 @@ export default class DateTime {
       settingWeekStuff =
         !isUndefined(normalized.weekYear) ||
         !isUndefined(normalized.weekNumber) ||
-        !isUndefined(normalized.weekday);
+        !isUndefined(normalized.weekday),
+      containsOrdinal = !isUndefined(normalized.ordinal),
+      containsGregorYear = !isUndefined(normalized.year),
+      containsGregorMD = !isUndefined(normalized.month) || !isUndefined(normalized.day),
+      containsGregor = containsGregorYear || containsGregorMD,
+      definiteWeekDef = normalized.weekYear || normalized.weekNumber;
+
+    if ((containsGregor || containsOrdinal) && definiteWeekDef) {
+      throw new ConflictingSpecificationError(
+        "Can't mix weekYear/weekNumber units with year/month/day or ordinals"
+      );
+    }
+
+    if (containsGregorMD && containsOrdinal) {
+      throw new ConflictingSpecificationError("Can't mix ordinal dates with month/day");
+    }
 
     let mixed;
     if (settingWeekStuff) {
@@ -1847,7 +1862,7 @@ export default class DateTime {
    * @param {Object} options - options that affect the output
    * @param {DateTime} [options.base=DateTime.now()] - the DateTime to use as the basis to which this time is compared. Defaults to now.
    * @param {string} [options.style="long"] - the style of units, must be "long", "short", or "narrow"
-   * @param {string} options.unit - use a specific unit; if omitted, the method will pick the unit. Use one of "years", "quarters", "months", "weeks", "days", "hours", "minutes", or "seconds"
+   * @param {string|string[]} options.unit - use a specific unit or array of units; if omitted, or an array, the method will pick the best unit. Use an array or one of "years", "quarters", "months", "weeks", "days", "hours", "minutes", or "seconds"
    * @param {boolean} [options.round=true] - whether to round the numbers in the output.
    * @param {number} [options.padding=0] - padding in milliseconds. This allows you to round up the result if it fits inside the threshold. Don't use in combination with {round: false} because the decimal output will include the padding.
    * @param {string} options.locale - override the locale of this DateTime
@@ -1863,12 +1878,19 @@ export default class DateTime {
     if (!this.isValid) return null;
     const base = options.base || DateTime.fromObject({ zone: this.zone }),
       padding = options.padding ? (this < base ? -options.padding : options.padding) : 0;
+    let units = ["years", "months", "days", "hours", "minutes", "seconds"];
+    let unit = options.unit;
+    if (Array.isArray(options.unit)) {
+      units = options.unit;
+      unit = undefined;
+    }
     return diffRelative(
       base,
       this.plus(padding),
       Object.assign(options, {
         numeric: "always",
-        units: ["years", "months", "days", "hours", "minutes", "seconds"]
+        units,
+        unit
       })
     );
   }
diff --git a/src/duration.js b/src/duration.js
index b2a6679c..14c4e692 100644
--- a/src/duration.js
+++ b/src/duration.js
@@ -593,9 +593,9 @@ export default class Duration {
   /**
    * Get the value of unit.
    * @param {string} unit - a unit such as 'minute' or 'day'
-   * @example Duration.fromObject({years: 2, days: 3}).years //=> 2
-   * @example Duration.fromObject({years: 2, days: 3}).months //=> 0
-   * @example Duration.fromObject({years: 2, days: 3}).days //=> 3
+   * @example Duration.fromObject({years: 2, days: 3}).get('years') //=> 2
+   * @example Duration.fromObject({years: 2, days: 3}).get('months') //=> 0
+   * @example Duration.fromObject({years: 2, days: 3}).get('days') //=> 3
    * @return {number}
    */
   get(unit) {
diff --git a/src/impl/locale.js b/src/impl/locale.js
index fe7a146d..44a92660 100644
--- a/src/impl/locale.js
+++ b/src/impl/locale.js
@@ -3,6 +3,7 @@ import * as English from "./english.js";
 import Settings from "../settings.js";
 import DateTime from "../datetime.js";
 import Formatter from "./formatter.js";
+import IANAZone from "../zones/IANAZone.js";
 
 let intlDTCache = {};
 function getCachedDTF(locString, opts = {}) {
@@ -185,11 +186,15 @@ class PolyDateFormatter {
     if (dt.zone.universal && this.hasIntl) {
       // UTC-8 or Etc/UTC-8 are not part of tzdata, only Etc/GMT+8 and the like.
       // That is why fixed-offset TZ is set to that unless it is:
-      // 1. Outside of the supported range Etc/GMT-14 to Etc/GMT+12.
-      // 2. Not a whole hour, e.g. UTC+4:30.
+      // 1. Representing offset 0 when UTC is used to maintain previous behavior and does not become GMT.
+      // 2. Unsupported by the browser:
+      //    - some do not support Etc/
+      //    - < Etc/GMT-14, > Etc/GMT+12, and 30-minute or 45-minute offsets are not part of tzdata
       const gmtOffset = -1 * (dt.offset / 60);
-      if (gmtOffset >= -14 && gmtOffset <= 12 && gmtOffset % 1 === 0) {
-        z = gmtOffset >= 0 ? `Etc/GMT+${gmtOffset}` : `Etc/GMT${gmtOffset}`;
+      const offsetZ = gmtOffset >= 0 ? `Etc/GMT+${gmtOffset}` : `Etc/GMT${gmtOffset}`;
+      const isOffsetZoneSupported = IANAZone.isValidZone(offsetZ);
+      if (dt.offset !== 0 && isOffsetZoneSupported) {
+        z = offsetZ;
         this.dt = dt;
       } else {
         // Not all fixed-offset zones like Etc/+4:30 are present in tzdata.
diff --git a/src/impl/regexParser.js b/src/impl/regexParser.js
index 8a000430..8e4308b6 100644
--- a/src/impl/regexParser.js
+++ b/src/impl/regexParser.js
@@ -140,8 +140,10 @@ function extractISODuration(match) {
   ] = match;
 
   const hasNegativePrefix = s[0] === "-";
+  const negativeSeconds = secondStr && secondStr[0] === "-";
 
-  const maybeNegate = num => (num && hasNegativePrefix ? -num : num);
+  const maybeNegate = (num, force = false) =>
+    num !== undefined && (force || (num && hasNegativePrefix)) ? -num : num;
 
   return [
     {
@@ -151,8 +153,8 @@ function extractISODuration(match) {
       days: maybeNegate(parseInteger(dayStr)),
       hours: maybeNegate(parseInteger(hourStr)),
       minutes: maybeNegate(parseInteger(minuteStr)),
-      seconds: maybeNegate(parseInteger(secondStr)),
-      milliseconds: maybeNegate(parseMillis(millisecondsStr))
+      seconds: maybeNegate(parseInteger(secondStr), secondStr === "-0"),
+      milliseconds: maybeNegate(parseMillis(millisecondsStr), negativeSeconds)
     }
   ];
 }
diff --git a/src/info.js b/src/info.js
index 0a8bf123..5197da2a 100644
--- a/src/info.js
+++ b/src/info.js
@@ -57,6 +57,7 @@ export default class Info {
    * @param {Object} opts - options
    * @param {string} [opts.locale] - the locale code
    * @param {string} [opts.numberingSystem=null] - the numbering system
+   * @param {string} [opts.locObj=null] - an existing locale object to use
    * @param {string} [opts.outputCalendar='gregory'] - the calendar
    * @example Info.months()[0] //=> 'January'
    * @example Info.months('short')[0] //=> 'Jan'
@@ -68,9 +69,9 @@ export default class Info {
    */
   static months(
     length = "long",
-    { locale = null, numberingSystem = null, outputCalendar = "gregory" } = {}
+    { locale = null, numberingSystem = null, locObj = null, outputCalendar = "gregory" } = {}
   ) {
-    return Locale.create(locale, numberingSystem, outputCalendar).months(length);
+    return (locObj || Locale.create(locale, numberingSystem, outputCalendar)).months(length);
   }
 
   /**
@@ -82,14 +83,15 @@ export default class Info {
    * @param {Object} opts - options
    * @param {string} [opts.locale] - the locale code
    * @param {string} [opts.numberingSystem=null] - the numbering system
+   * @param {string} [opts.locObj=null] - an existing locale object to use
    * @param {string} [opts.outputCalendar='gregory'] - the calendar
    * @return {[string]}
    */
   static monthsFormat(
     length = "long",
-    { locale = null, numberingSystem = null, outputCalendar = "gregory" } = {}
+    { locale = null, numberingSystem = null, locObj = null, outputCalendar = "gregory" } = {}
   ) {
-    return Locale.create(locale, numberingSystem, outputCalendar).months(length, true);
+    return (locObj || Locale.create(locale, numberingSystem, outputCalendar)).months(length, true);
   }
 
   /**
@@ -99,14 +101,15 @@ export default class Info {
    * @param {Object} opts - options
    * @param {string} [opts.locale] - the locale code
    * @param {string} [opts.numberingSystem=null] - the numbering system
+   * @param {string} [opts.locObj=null] - an existing locale object to use
    * @example Info.weekdays()[0] //=> 'Monday'
    * @example Info.weekdays('short')[0] //=> 'Mon'
    * @example Info.weekdays('short', { locale: 'fr-CA' })[0] //=> 'lun.'
    * @example Info.weekdays('short', { locale: 'ar' })[0] //=> 'الاثنين'
    * @return {[string]}
    */
-  static weekdays(length = "long", { locale = null, numberingSystem = null } = {}) {
-    return Locale.create(locale, numberingSystem, null).weekdays(length);
+  static weekdays(length = "long", { locale = null, numberingSystem = null, locObj = null } = {}) {
+    return (locObj || Locale.create(locale, numberingSystem, null)).weekdays(length);
   }
 
   /**
@@ -118,10 +121,14 @@ export default class Info {
    * @param {Object} opts - options
    * @param {string} [opts.locale=null] - the locale code
    * @param {string} [opts.numberingSystem=null] - the numbering system
+   * @param {string} [opts.locObj=null] - an existing locale object to use
    * @return {[string]}
    */
-  static weekdaysFormat(length = "long", { locale = null, numberingSystem = null } = {}) {
-    return Locale.create(locale, numberingSystem, null).weekdays(length, true);
+  static weekdaysFormat(
+    length = "long",
+    { locale = null, numberingSystem = null, locObj = null } = {}
+  ) {
+    return (locObj || Locale.create(locale, numberingSystem, null)).weekdays(length, true);
   }
 
   /**
diff --git a/src/interval.js b/src/interval.js
index e8a419bc..71273fd1 100644
--- a/src/interval.js
+++ b/src/interval.js
@@ -429,7 +429,7 @@ export default class Interval {
     const s = this.s > other.s ? this.s : other.s,
       e = this.e < other.e ? this.e : other.e;
 
-    if (s > e) {
+    if (s >= e) {
       return null;
     } else {
       return Interval.fromDateTimes(s, e);
diff --git a/src/zones/IANAZone.js b/src/zones/IANAZone.js
index e2ad296e..777958df 100644
--- a/src/zones/IANAZone.js
+++ b/src/zones/IANAZone.js
@@ -109,7 +109,7 @@ export default class IANAZone extends Zone {
   /** @ignore */
   static parseGMTOffset(specifier) {
     if (specifier) {
-      const match = specifier.match(/^Etc\/GMT([+-]\d{1,2})$/i);
+      const match = specifier.match(/^Etc\/GMT(0|[+-]\d{1,2})$/i);
       if (match) {
         return -60 * parseInt(match[1]);
       }
@@ -152,8 +152,11 @@ export default class IANAZone extends Zone {
 
   /** @override **/
   offset(ts) {
-    const date = new Date(ts),
-      dtf = makeDTF(this.name),
+    const date = new Date(ts);
+
+    if (isNaN(date)) return NaN;
+
+    const dtf = makeDTF(this.name),
       [year, month, day, hour, minute, second] = dtf.formatToParts
         ? partsOffset(dtf, date)
         : hackyOffset(dtf, date),


Test patch:
diff --git a/test/datetime/format.test.js b/test/datetime/format.test.js
index eb04f9d3..b3383d78 100644
--- a/test/datetime/format.test.js
+++ b/test/datetime/format.test.js
@@ -333,6 +333,12 @@ test("DateTime#toLocaleString() shows things in the right fixed-offset zone when
   );
 });
 
+test("DateTime#toLocaleString() shows things with UTC if fixed-offset zone with 0 offset is used", () => {
+  expect(dt.setZone("UTC").toLocaleString(DateTime.DATETIME_FULL)).toBe(
+    "May 25, 1982, 9:23 AM UTC"
+  );
+});
+
 test("DateTime#toLocaleString() does the best it can with unsupported fixed-offset zone when showing the zone", () => {
   expect(dt.setZone("UTC+4:30").toLocaleString(DateTime.DATETIME_FULL)).toBe(
     "May 25, 1982, 9:23 AM UTC"
diff --git a/test/datetime/math.test.js b/test/datetime/math.test.js
index 8c84bfc0..e1b5475a 100644
--- a/test/datetime/math.test.js
+++ b/test/datetime/math.test.js
@@ -116,6 +116,13 @@ test("DateTime#plus renders invalid when out of max. datetime range using second
   expect(d.isValid).toBe(false);
 });
 
+test("DateTime#plus renders invalid when out of max. datetime range using IANAZone", () => {
+  const d = DateTime.utc(1970, 1, 1, 0, 0, 0, 0)
+    .setZone("America/Los_Angeles")
+    .plus({ second: 1e8 * 24 * 60 * 60 + 1 });
+  expect(d.isValid).toBe(false);
+});
+
 test("DateTime#plus handles fractional days", () => {
   const d = DateTime.fromISO("2016-01-31T10:00");
   expect(d.plus({ days: 0.8 })).toEqual(d.plus({ hours: (24 * 4) / 5 }));
@@ -205,6 +212,13 @@ test("DateTime#minus renders invalid when out of max. datetime range using secon
   expect(d.isValid).toBe(false);
 });
 
+test("DateTime#plus renders invalid when out of max. datetime range using IANAZone", () => {
+  const d = DateTime.utc(1970, 1, 1, 0, 0, 0, 0)
+    .setZone("America/Los_Angeles")
+    .minus({ second: 1e8 * 24 * 60 * 60 + 1 });
+  expect(d.isValid).toBe(false);
+});
+
 test("DateTime#minus handles fractional days", () => {
   const d = DateTime.fromISO("2016-01-31T10:00");
   expect(d.minus({ days: 0.8 })).toEqual(d.minus({ hours: (24 * 4) / 5 }));
diff --git a/test/datetime/relative.test.js b/test/datetime/relative.test.js
index 8fdba2df..4a157c43 100644
--- a/test/datetime/relative.test.js
+++ b/test/datetime/relative.test.js
@@ -53,6 +53,23 @@ test("DateTime#toRelative takes a unit argument", () => {
   expect(base.minus({ months: 3 }).toRelative({ base, unit: "years", round: false })).toBe(
     "0.25 years ago"
   );
+  expect(base.minus({ seconds: 30 }).toRelative({ base, unit: ["days", "hours", "minutes"] })).toBe(
+    "0 minutes ago"
+  );
+  expect(base.minus({ seconds: 1 }).toRelative({ base, unit: "minutes" })).toBe("0 minutes ago");
+  expect(base.plus({ seconds: 1 }).toRelative({ base, unit: "minutes" })).toBe("in 0 minutes");
+  expect(
+    base.plus({ seconds: 30 }).toRelative({
+      base,
+      unit: ["days", "hours", "minutes"]
+    })
+  ).toBe("in 0 minutes");
+  expect(
+    base.plus({ years: 2 }).toRelative({
+      base,
+      unit: ["days", "hours", "minutes"]
+    })
+  ).toBe("in 731 days");
 });
 
 test("DateTime#toRelative always rounds toward 0", () => {
diff --git a/test/datetime/set.test.js b/test/datetime/set.test.js
index aecd7807..9da2c52c 100644
--- a/test/datetime/set.test.js
+++ b/test/datetime/set.test.js
@@ -123,6 +123,12 @@ test("DateTime#set throws for metadata", () => {
   expect(() => dt.set({ invalid: true })).toThrow();
 });
 
+test("DateTime#set throws for mixing incompatible units", () => {
+  expect(() => dt.set({ year: 2020, weekNumber: 22 })).toThrow();
+  expect(() => dt.set({ ordinal: 200, weekNumber: 22 })).toThrow();
+  expect(() => dt.set({ ordinal: 200, month: 8 })).toThrow();
+});
+
 test("DateTime#set maintains invalidity", () => {
   expect(DateTime.invalid("because").set({ ordinal: 200 }).isValid).toBe(false);
 });
diff --git a/test/duration/parse.test.js b/test/duration/parse.test.js
index cbabbf2d..8704b958 100644
--- a/test/duration/parse.test.js
+++ b/test/duration/parse.test.js
@@ -31,6 +31,10 @@ test("Duration.fromISO can parse mixed or negative durations", () => {
   check("-P5Y3M", { years: -5, months: -3 });
   check("-P-5Y-3M", { years: 5, months: 3 });
   check("-P-1W1DT13H-23M34S", { weeks: 1, days: -1, hours: -13, minutes: 23, seconds: -34 });
+  check("PT-1.5S", { seconds: -1, milliseconds: -500 });
+  check("PT-0.5S", { seconds: 0, milliseconds: -500 });
+  check("PT1.5S", { seconds: 1, milliseconds: 500 });
+  check("PT0.5S", { seconds: 0, milliseconds: 500 });
 });
 
 test("Duration.fromISO can parse fractions of seconds", () => {
diff --git a/test/interval/many.test.js b/test/interval/many.test.js
index 002bd97b..69d3aec4 100644
--- a/test/interval/many.test.js
+++ b/test/interval/many.test.js
@@ -111,12 +111,8 @@ test("Interval#intersection returns the intersection for overlapping intervals",
   ).toBeTruthy();
 });
 
-test("Interval#intersection returns empty for adjacent intervals", () => {
-  expect(
-    todayFrom(5, 8)
-      .intersection(todayFrom(8, 10))
-      .isEmpty()
-  ).toBeTruthy();
+test("Interval#intersection returns null for adjacent intervals", () => {
+  expect(todayFrom(5, 8).intersection(todayFrom(8, 10))).toBeNull();
 });
 
 test("Interval#intersection returns invalid for invalid intervals", () => {
diff --git a/test/zones/IANA.test.js b/test/zones/IANA.test.js
index f5f4a671..15e101b4 100644
--- a/test/zones/IANA.test.js
+++ b/test/zones/IANA.test.js
@@ -33,6 +33,9 @@ test("IANAZone.isValidZone", () => {
 
 test("IANAZone.parseGMTOffset returns a number for a valid input", () => {
   expect(IANAZone.parseGMTOffset("Etc/GMT+8")).toBe(-480);
+  expect(IANAZone.parseGMTOffset("Etc/GMT+0")).toBe(-0);
+  expect(IANAZone.parseGMTOffset("Etc/GMT-0")).toBe(+0);
+  expect(IANAZone.parseGMTOffset("Etc/GMT0")).toBe(-0);
 });
 
 test("IANAZone.parseGMTOffset returns null for invalid input", () => {