Repository: moment/luxon
Pull request #803 (2020-10-06 14:12:53)

Add fromISOTime, toISOTime and toMillis to Duration
This resolves https://github.com/moment/luxon/issues/625, by adding a `fromISOTime` and `toISOTime` method to the `Duration` class.
The design of the methods is closely aligned with the design of similar, existing methods, such as `fromISO` and `toISO`.

Additionally, since the `Duration` class has a `fromMillis` method, I've also added a corresponding `toMillis` method.
The justification for this is, that having corresponding pairs of from/to methods, makes it easy to roundtrip a value in the same format, and that it aligns with the `DateTime` class, which already has both method.

jsf-clabot: [![CLA assistant check](https://cla.js.foundation/pull/badge/not_signed)](https://cla.js.foundation/moment/luxon?pullRequest=803) <br/>Thank you for your submission, we really appreciate it. Like many open source projects, we ask that you sign our [Contributor License Agreement](https://cla.js.foundation/moment/luxon?pullRequest=803) before we can accept your contribution.<br/>

Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 00000000..dca9e456
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,24 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ""
+labels: bug
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Please share a minimal code example that triggers the problem:
+
+**Actual vs Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Desktop (please complete the following information):**
+
+- OS: [e.g. iOS]
+- Browser [e.g. Chrome 84, safari 14.0]
+- Luxon version [e.g. 1.25.0]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 00000000..34981977
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,18 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ""
+labels: enhancement
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/README.md b/README.md
index 44e59ba6..84bda4f3 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
 
 [![MIT License][license-image]][license] [![Build Status][travis-image]][travis-url] [![NPM version][npm-version-image]][npm-url] [![Coverage Status][test-coverage-image]][test-coverage-url] [![Doc coverage][doc-coverage-image]][doc-url] [![PRs welcome][contributing-image]][contributing-url]
 
-Luxon is a library for working with dates and times in Javascript.
+Luxon is a library for working with dates and times in JavaScript.
 
 ```js
 DateTime.now().setZone("America/New_York").minus({ weeks: 1 }).endOf("day").toISO();
diff --git a/docs/math.md b/docs/math.md
index 1a08c9a6..ee932767 100644
--- a/docs/math.md
+++ b/docs/math.md
@@ -35,7 +35,7 @@ These units use time math:
  * **Minutes** are always 60 seconds
  * **Seconds** are always 1000 milliseconds
 
-Don't worry about leap seconds. Javascript and most other programming environments don't account for them; they just happen as abrupt, invisible changes to the underlying system's time.
+Don't worry about leap seconds. JavaScript and most other programming environments don't account for them; they just happen as abrupt, invisible changes to the underlying system's time.
 
 ### How to think about calendar math
 
@@ -108,9 +108,8 @@ d1.toMillis() === d2.toMillis() // are d1 and d2 the same instant in time?
 You may also use `#hasSame` to make more subtle comparisons:
 
 ```js
-d1.hasSame(d2, 'millisecond'); // equivalent to `+d1 === +d2`
-d1.hasSame(d2, 'minute');       // both DateTimes are in the same minute (and hour, day, month, etc)
-d1.hasSame(d2, 'year');         // etc
+d1.hasSame(d2, 'year');   // both DateTimes have the same calendar year
+d1.hasSame(d2, 'day');    // both DateTimes have the same calendar day (which implies they also have the same calendar year and month)
 ```
 
 Note that these are checking against the calendar. For example, if `d1` is in 2017, calling `hasSame` with "year" asks if d2 is also in 2017, not whether the DateTimes within a year of each other. For that, you'd need `diff` (see below).
diff --git a/docs/tour.md b/docs/tour.md
index d1328822..98d73bb7 100644
--- a/docs/tour.md
+++ b/docs/tour.md
@@ -1,6 +1,6 @@
 # A quick tour
 
-Luxon is a library that makes it easier to work with dates and times in Javascript. If you want, add and subtract them, format and parse them, ask them hard questions, and so on, Luxon provides a much easier and comprehensive interface than the native types it wraps. We're going to talk about the most immediately useful subset of that interface.
+Luxon is a library that makes it easier to work with dates and times in JavaScript. If you want, add and subtract them, format and parse them, ask them hard questions, and so on, Luxon provides a much easier and comprehensive interface than the native types it wraps. We're going to talk about the most immediately useful subset of that interface.
 
 This is going to be a bit brisk, but keep in mind that the API docs are comprehensive, so if you want to know more, feel free to [dive into them](../identifiers.html).
 
@@ -12,7 +12,7 @@ The most important class in Luxon is [DateTime](../class/src/datetime.js~DateTim
 var dt = DateTime.local(2017, 5, 15, 8, 30);
 ```
 
-[DateTime.local](../class/src/datetime.js~DateTime.html#static-method-local) takes any number of arguments, all the way out to milliseconds. Underneath, this is similar to a Javascript Date object. But we've decorated it with lots of useful methods.
+[DateTime.local](../class/src/datetime.js~DateTime.html#static-method-local) takes any number of arguments, all the way out to milliseconds. Underneath, this is similar to a JavaScript Date object. But we've decorated it with lots of useful methods.
 
 ## Creating a DateTime
 
diff --git a/package-lock.json b/package-lock.json
index ad3d762d..14de6a9a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "luxon",
-  "version": "1.24.1",
+  "version": "1.25.0",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
@@ -8398,9 +8398,9 @@
       }
     },
     "node-fetch": {
-      "version": "2.6.0",
-      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
-      "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==",
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
+      "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==",
       "dev": true
     },
     "node-int64": {
@@ -10613,19 +10613,26 @@
         "is-typedarray": "^1.0.0"
       }
     },
-    "uglify-js": {
-      "version": "3.9.0",
-      "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.9.0.tgz",
-      "integrity": "sha512-j5wNQBWaql8gr06dOUrfaohHlscboQZ9B8sNsoK5o4sBjm7Ht9dxSbrMXyktQpA16Acaij8AcoozteaPYZON0g==",
+    "uglify-es": {
+      "version": "3.3.9",
+      "resolved": "https://registry.npmjs.org/uglify-es/-/uglify-es-3.3.9.tgz",
+      "integrity": "sha512-r+MU0rfv4L/0eeW3xZrd16t4NZfK8Ld4SWVglYBb7ez5uXFWHuVRs6xCTrf1yirs9a4j4Y27nn7SRfO6v67XsQ==",
       "dev": true,
       "requires": {
-        "commander": "~2.20.3"
+        "commander": "~2.13.0",
+        "source-map": "~0.6.1"
       },
       "dependencies": {
         "commander": {
-          "version": "2.20.3",
-          "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
-          "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+          "version": "2.13.0",
+          "resolved": "https://registry.npmjs.org/commander/-/commander-2.13.0.tgz",
+          "integrity": "sha512-MVuS359B+YzaWqjCL/c+22gfryv+mCBPHAv3zyVI2GN8EY6IRP8VwtasXn8jyyhvvq84R4ImN1OKRtcbIasjYA==",
+          "dev": true
+        },
+        "source-map": {
+          "version": "0.6.1",
+          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+          "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
           "dev": true
         }
       }
diff --git a/package.json b/package.json
index 73d994bb..e55d9086 100644
--- a/package.json
+++ b/package.json
@@ -68,7 +68,7 @@
     "rollup": "^1.32.1",
     "rollup-plugin-babel": "^4.4.0",
     "rollup-plugin-babel-minify": "^6.2.0",
-    "uglify-js": "^3.9.0"
+    "uglify-es": "^3.3.9"
   },
   "main": "build/node/luxon.js",
   "module": "src/luxon.js",
diff --git a/scripts/release b/scripts/release
index 43bd6d88..df80bcc3 100755
--- a/scripts/release
+++ b/scripts/release
@@ -1,4 +1,6 @@
 #!/usr/bin/env bash
+./scripts/version || exit $?
+
 npm run build
 npm run docs
 npm run site
diff --git a/scripts/tag b/scripts/tag
index 724baae2..9f19d5f0 100755
--- a/scripts/tag
+++ b/scripts/tag
@@ -1,10 +1,7 @@
 #!/usr/bin/env bash
+./scripts/version || exit $?
 
 PACKAGE_VERSION=$(node -p "require('./package.json').version")
 
-if [ $(git tag -l "$PACKAGE_VERSION") ]; then
-  echo "tag already exists"
-else
-  git tag $PACKAGE_VERSION
-  git push origin master $PACKAGE_VERSION 
-fi
+git tag $PACKAGE_VERSION
+git push origin master $PACKAGE_VERSION
diff --git a/scripts/version b/scripts/version
new file mode 100755
index 00000000..1dd81a94
--- /dev/null
+++ b/scripts/version
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+
+abort() {
+  echo "ERROR: $1"
+  exit 1
+}
+
+PACKAGE_VERSION=$(node -p "require('./package.json').version")
+PACKAGE_LOCK_VERSION=$(node -p "require('./package-lock.json').version")
+EXPORTED_VERSION=$(grep -oP "(?<=const VERSION = \").*(?=\";)" ./src/luxon.js)
+
+if [ $PACKAGE_VERSION != $PACKAGE_LOCK_VERSION ]; then
+  abort "package-lock.json's version differs from package.json's"
+elif [ $PACKAGE_VERSION != $EXPORTED_VERSION ]; then
+  abort "exported version differs from package.json's"
+elif [ $(git tag -l "$PACKAGE_VERSION") ]; then
+  abort "tag already exists"
+fi
diff --git a/site/index.html b/site/index.html
index 93042b8d..6890ecf6 100644
--- a/site/index.html
+++ b/site/index.html
@@ -40,7 +40,7 @@
       <h1 class="display-1">Luxon</h1>
 
       <p class="lead">
-        A powerful, modern, and friendly wrapper for Javascript dates and times.
+        A powerful, modern, and friendly wrapper for JavaScript dates and times.
       </p>
 
       <pre>
diff --git a/src/datetime.js b/src/datetime.js
index 856f8c73..04b01ec7 100644
--- a/src/datetime.js
+++ b/src/datetime.js
@@ -197,6 +197,7 @@ function toTechTimeFormat(
     suppressSeconds = false,
     suppressMilliseconds = false,
     includeOffset,
+    includePrefix = false,
     includeZone = false,
     spaceZone = false,
     format = "extended"
@@ -221,7 +222,13 @@ function toTechTimeFormat(
     fmt += format === "basic" ? "ZZZ" : "ZZ";
   }
 
-  return toTechFormat(dt, fmt);
+  let str = toTechFormat(dt, fmt);
+
+  if (includePrefix) {
+    str = "T" + str;
+  }
+
+  return str;
 }
 
 // defaults for unspecified units in the supported calendars
@@ -531,8 +538,8 @@ export default class DateTime {
   }
 
   /**
-   * Create a DateTime from a Javascript Date object. Uses the default zone.
-   * @param {Date} date - a Javascript Date object
+   * Create a DateTime from a JavaScript Date object. Uses the default zone.
+   * @param {Date} date - a JavaScript Date object
    * @param {Object} options - configuration options for the DateTime
    * @param {string|Zone} [options.zone='local'] - the zone to place the DateTime into
    * @return {DateTime}
@@ -605,7 +612,7 @@ export default class DateTime {
   }
 
   /**
-   * Create a DateTime from a Javascript object with keys like 'year' and 'hour' with reasonable defaults.
+   * Create a DateTime from a JavaScript object with keys like 'year' and 'hour' with reasonable defaults.
    * @param {Object} obj - the object to create the DateTime from
    * @param {number} obj.year - a year, such as 1987
    * @param {number} obj.month - a month, 1-12
@@ -1389,6 +1396,7 @@ export default class DateTime {
    * @param {string} unit - The unit to go to the beginning of. Can be 'year', 'quarter', 'month', 'week', 'day', 'hour', 'minute', 'second', or 'millisecond'.
    * @example DateTime.local(2014, 3, 3).startOf('month').toISODate(); //=> '2014-03-01'
    * @example DateTime.local(2014, 3, 3).startOf('year').toISODate(); //=> '2014-01-01'
+   * @example DateTime.local(2014, 3, 3).startOf('week').toISODate(); //=> '2014-03-03', weeks always start on Mondays
    * @example DateTime.local(2014, 3, 3, 5, 30).startOf('day').toISOTime(); //=> '00:00.000-05:00'
    * @example DateTime.local(2014, 3, 3, 5, 30).startOf('hour').toISOTime(); //=> '05:00:00.000-05:00'
    * @return {DateTime}
@@ -1440,6 +1448,7 @@ export default class DateTime {
    * @param {string} unit - The unit to go to the end of. Can be 'year', 'quarter', 'month', 'week', 'day', 'hour', 'minute', 'second', or 'millisecond'.
    * @example DateTime.local(2014, 3, 3).endOf('month').toISO(); //=> '2014-03-31T23:59:59.999-05:00'
    * @example DateTime.local(2014, 3, 3).endOf('year').toISO(); //=> '2014-12-31T23:59:59.999-05:00'
+   * @example DateTime.local(2014, 3, 3).endOf('week').toISO(); // => '2014-03-09T23:59:59.999-05:00', weeks start on Mondays
    * @example DateTime.local(2014, 3, 3, 5, 30).endOf('day').toISO(); //=> '2014-03-03T23:59:59.999-05:00'
    * @example DateTime.local(2014, 3, 3, 5, 30).endOf('hour').toISO(); //=> '2014-03-03T05:59:59.999-05:00'
    * @return {DateTime}
@@ -1569,22 +1578,26 @@ export default class DateTime {
    * @param {boolean} [opts.suppressMilliseconds=false] - exclude milliseconds from the format if they're 0
    * @param {boolean} [opts.suppressSeconds=false] - exclude seconds from the format if they're 0
    * @param {boolean} [opts.includeOffset=true] - include the offset, such as 'Z' or '-04:00'
+   * @param {boolean} [opts.includePrefix=false] - include the `T` prefix
    * @param {string} [opts.format='extended'] - choose between the basic and extended format
    * @example DateTime.utc().set({ hour: 7, minute: 34 }).toISOTime() //=> '07:34:19.361Z'
    * @example DateTime.utc().set({ hour: 7, minute: 34, seconds: 0, milliseconds: 0 }).toISOTime({ suppressSeconds: true }) //=> '07:34Z'
    * @example DateTime.utc().set({ hour: 7, minute: 34 }).toISOTime({ format: 'basic' }) //=> '073419.361Z'
+   * @example DateTime.utc().set({ hour: 7, minute: 34 }).toISOTime({ includePrefix: true }) //=> 'T07:34:19.361Z'
    * @return {string}
    */
   toISOTime({
     suppressMilliseconds = false,
     suppressSeconds = false,
     includeOffset = true,
+    includePrefix = false,
     format = "extended"
   } = {}) {
     return toTechTimeFormat(this, {
       suppressSeconds,
       suppressMilliseconds,
       includeOffset,
+      includePrefix,
       format
     });
   }
@@ -1707,7 +1720,7 @@ export default class DateTime {
   }
 
   /**
-   * Returns a Javascript object with this DateTime's year, month, day, and so on.
+   * Returns a JavaScript object with this DateTime's year, month, day, and so on.
    * @param opts - options for generating the object
    * @param {boolean} [opts.includeConfig=false] - include configuration attributes in the output
    * @example DateTime.now().toObject() //=> { year: 2017, month: 4, day: 22, hour: 20, minute: 49, second: 42, millisecond: 268 }
@@ -1727,7 +1740,7 @@ export default class DateTime {
   }
 
   /**
-   * Returns a Javascript Date equivalent to this DateTime.
+   * Returns a JavaScript Date equivalent to this DateTime.
    * @return {Date}
    */
   toJSDate() {
@@ -1795,20 +1808,20 @@ export default class DateTime {
   }
 
   /**
-   * Return whether this DateTime is in the same unit of time as another DateTime
+   * Return whether this DateTime is in the same unit of time as another DateTime.
+   * Higher-order units must also be identical for this function to return `true`.
+   * Note that time zones are **ignored** in this comparison, which compares the **local** calendar time. Use {@link setZone} to convert one of the dates if needed.
    * @param {DateTime} otherDateTime - the other DateTime
    * @param {string} unit - the unit of time to check sameness on
-   * @example DateTime.now().hasSame(otherDT, 'day'); //~> true if both the same calendar day
+   * @example DateTime.now().hasSame(otherDT, 'day'); //~> true if otherDT is in the same current calendar day
    * @return {boolean}
    */
   hasSame(otherDateTime, unit) {
     if (!this.isValid) return false;
-    if (unit === "millisecond") {
-      return this.valueOf() === otherDateTime.valueOf();
-    } else {
-      const inputMs = otherDateTime.valueOf();
-      return this.startOf(unit) <= inputMs && inputMs <= this.endOf(unit);
-    }
+
+    const inputMs = otherDateTime.valueOf();
+    const otherZoneDateTime = this.setZone(otherDateTime.zone, { keepLocalTime: true });
+    return otherZoneDateTime.startOf(unit) <= inputMs && inputMs <= otherZoneDateTime.endOf(unit);
   }
 
   /**
diff --git a/src/duration.js b/src/duration.js
index 3c6889a6..b2a6679c 100644
--- a/src/duration.js
+++ b/src/duration.js
@@ -2,7 +2,7 @@ import { InvalidArgumentError, InvalidDurationError, InvalidUnitError } from "./
 import Formatter from "./impl/formatter.js";
 import Invalid from "./impl/invalid.js";
 import Locale from "./impl/locale.js";
-import { parseISODuration } from "./impl/regexParser.js";
+import { parseISODuration, parseISOTimeOnly } from "./impl/regexParser.js";
 import {
   asNumber,
   hasOwnProperty,
@@ -216,7 +216,7 @@ export default class Duration {
   }
 
   /**
-   * Create a Duration from a Javascript object with keys like 'years' and 'hours.
+   * Create a Duration from a JavaScript object with keys like 'years' and 'hours.
    * If this object is empty then a zero milliseconds duration is returned.
    * @param {Object} obj - the object to create the DateTime from
    * @param {number} obj.years
@@ -276,6 +276,31 @@ export default class Duration {
     }
   }
 
+  /**
+   * Create a Duration from an ISO 8601 time string.
+   * @param {string} text - text to parse
+   * @param {Object} opts - options for parsing
+   * @param {string} [opts.locale='en-US'] - the locale to use
+   * @param {string} opts.numberingSystem - the numbering system to use
+   * @param {string} [opts.conversionAccuracy='casual'] - the conversion system to use
+   * @see https://en.wikipedia.org/wiki/ISO_8601#Times
+   * @example Duration.fromISOTime('11:22:33.444').toObject() //=> { hours: 11, minutes: 22, seconds: 33, milliseconds: 444 }
+   * @example Duration.fromISOTime('11:00').toObject() //=> { hours: 11, minutes: 0, seconds: 0 }
+   * @example Duration.fromISOTime('T11:00').toObject() //=> { hours: 11, minutes: 0, seconds: 0 }
+   * @example Duration.fromISOTime('1100').toObject() //=> { hours: 11, minutes: 0, seconds: 0 }
+   * @example Duration.fromISOTime('T1100').toObject() //=> { hours: 11, minutes: 0, seconds: 0 }
+   * @return {Duration}
+   */
+  static fromISOTime(text, opts) {
+    const [parsed] = parseISOTimeOnly(text);
+    if (parsed) {
+      const obj = Object.assign(parsed, opts);
+      return Duration.fromObject(obj);
+    } else {
+      return Duration.invalid("unparsable", `the input "${text}" can't be parsed as ISO 8601`);
+    }
+  }
+
   /**
    * Create an invalid Duration.
    * @param {string} reason - simple string of why this datetime is invalid. Should not contain parameters or anything else data-dependent
@@ -383,7 +408,7 @@ export default class Duration {
   }
 
   /**
-   * Returns a Javascript object with this Duration's values.
+   * Returns a JavaScript object with this Duration's values.
    * @param opts - options for generating the object
    * @param {boolean} [opts.includeConfig=false] - include configuration attributes in the output
    * @example Duration.fromObject({ years: 1, days: 6, seconds: 2 }).toObject() //=> { years: 1, days: 6, seconds: 2 }
@@ -433,6 +458,58 @@ export default class Duration {
     return s;
   }
 
+  /**
+   * Returns an ISO 8601-compliant string representation of this Duration, formatted as a time of day.
+   * Note that this will return null if the duration is invalid, negative, or equal to or greater than 24 hours.
+   * @see https://en.wikipedia.org/wiki/ISO_8601#Times
+   * @param {Object} opts - options
+   * @param {boolean} [opts.suppressMilliseconds=false] - exclude milliseconds from the format if they're 0
+   * @param {boolean} [opts.suppressSeconds=false] - exclude seconds from the format if they're 0
+   * @param {boolean} [opts.includePrefix=false] - include the `T` prefix
+   * @param {string} [opts.format='extended'] - choose between the basic and extended format
+   * @example Duration.fromObject({ hours: 11 }).toISOTime() //=> '11:00:00.000'
+   * @example Duration.fromObject({ hours: 11 }).toISOTime({ suppressMilliseconds: true }) //=> '11:00:00'
+   * @example Duration.fromObject({ hours: 11 }).toISOTime({ suppressSeconds: true }) //=> '11:00'
+   * @example Duration.fromObject({ hours: 11 }).toISOTime({ includePrefix: true }) //=> 'T11:00:00.000'
+   * @example Duration.fromObject({ hours: 11 }).toISOTime({ format: 'basic' }) //=> '110000.000'
+   * @return {string}
+   */
+  toISOTime(opts = {}) {
+    if (!this.isValid) return null;
+
+    const millis = this.toMillis();
+    if (millis < 0 || millis >= 86400000) return null;
+
+    opts = Object.assign(
+      {
+        suppressMilliseconds: false,
+        suppressSeconds: false,
+        includePrefix: false,
+        format: "extended"
+      },
+      opts
+    );
+
+    const value = this.shiftTo("hours", "minutes", "seconds", "milliseconds");
+
+    let fmt = opts.format === "basic" ? "hhmm" : "hh:mm";
+
+    if (!opts.suppressSeconds || value.seconds !== 0 || value.milliseconds !== 0) {
+      fmt += opts.format === "basic" ? "ss" : ":ss";
+      if (!opts.suppressMilliseconds || value.milliseconds !== 0) {
+        fmt += ".SSS";
+      }
+    }
+
+    let str = value.toFormat(fmt);
+
+    if (opts.includePrefix) {
+      str = "T" + str;
+    }
+
+    return str;
+  }
+
   /**
    * Returns an ISO 8601 representation of this Duration appropriate for use in JSON.
    * @return {string}
@@ -453,10 +530,18 @@ export default class Duration {
    * Returns an milliseconds value of this Duration.
    * @return {number}
    */
-  valueOf() {
+  toMillis() {
     return this.as("milliseconds");
   }
 
+  /**
+   * Returns an milliseconds value of this Duration. Alias of {@link toMillis}
+   * @return {number}
+   */
+  valueOf() {
+    return this.toMillis();
+  }
+
   /**
    * Make this Duration longer by the specified amount. Return a newly-constructed Duration.
    * @param {Duration|Object|number} duration - The amount to add. Either a Luxon Duration, a number of milliseconds, the object argument to Duration.fromObject()
@@ -762,8 +847,14 @@ export default class Duration {
       return false;
     }
 
+    function eq(v1, v2) {
+      // Consider 0 and undefined as equal
+      if (v1 === undefined || v1 === 0) return v2 === undefined || v2 === 0;
+      return v1 === v2;
+    }
+
     for (const u of orderedUnits) {
-      if (this.values[u] !== other.values[u]) {
+      if (!eq(this.values[u], other.values[u])) {
         return false;
       }
     }
diff --git a/src/impl/diff.js b/src/impl/diff.js
index 57246dc9..1b8afb36 100644
--- a/src/impl/diff.js
+++ b/src/impl/diff.js
@@ -13,6 +13,7 @@ function dayDiff(earlier, later) {
 function highOrderDiffs(cursor, later, units) {
   const differs = [
     ["years", (a, b) => b.year - a.year],
+    ["quarters", (a, b) => b.quarter - a.quarter],
     ["months", (a, b) => b.month - a.month + (b.year - a.year) * 12],
     [
       "weeks",
diff --git a/src/impl/formatter.js b/src/impl/formatter.js
index 98872f94..87b8a2f2 100644
--- a/src/impl/formatter.js
+++ b/src/impl/formatter.js
@@ -166,7 +166,7 @@ export default class Formatter {
       era = length =>
         knownEnglish ? English.eraForDateTime(dt, length) : string({ era: length }, "era"),
       tokenToString = token => {
-        // Where possible: http://cldr.unicode.org/translation/date-time#TOC-Stand-Alone-vs.-Format-Styles
+        // Where possible: http://cldr.unicode.org/translation/date-time-1/date-time#TOC-Standalone-vs.-Format-Styles
         switch (token) {
           // ms
           case "S":
diff --git a/src/impl/locale.js b/src/impl/locale.js
index 0f797b17..fe7a146d 100644
--- a/src/impl/locale.js
+++ b/src/impl/locale.js
@@ -183,20 +183,28 @@ class PolyDateFormatter {
 
     let z;
     if (dt.zone.universal && this.hasIntl) {
-      // Chromium doesn't support fixed-offset zones like Etc/GMT+8 in its formatter,
-      // See https://bugs.chromium.org/p/chromium/issues/detail?id=364374.
-      // So we have to make do. Two cases:
-      // 1. The format options tell us to show the zone. We can't do that, so the best
-      // we can do is format the date in UTC.
-      // 2. The format options don't tell us to show the zone. Then we can adjust them
-      // the time and tell the formatter to show it to us in UTC, so that the time is right
-      // and the bad zone doesn't show up.
-      // We can clean all this up when Chrome fixes this.
-      z = "UTC";
-      if (opts.timeZoneName) {
+      // 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.
+      const gmtOffset = -1 * (dt.offset / 60);
+      if (gmtOffset >= -14 && gmtOffset <= 12 && gmtOffset % 1 === 0) {
+        z = gmtOffset >= 0 ? `Etc/GMT+${gmtOffset}` : `Etc/GMT${gmtOffset}`;
         this.dt = dt;
       } else {
-        this.dt = dt.offset === 0 ? dt : DateTime.fromMillis(dt.ts + dt.offset * 60 * 1000);
+        // Not all fixed-offset zones like Etc/+4:30 are present in tzdata.
+        // So we have to make do. Two cases:
+        // 1. The format options tell us to show the zone. We can't do that, so the best
+        // we can do is format the date in UTC.
+        // 2. The format options don't tell us to show the zone. Then we can adjust them
+        // the time and tell the formatter to show it to us in UTC, so that the time is right
+        // and the bad zone doesn't show up.
+        z = "UTC";
+        if (opts.timeZoneName) {
+          this.dt = dt;
+        } else {
+          this.dt = dt.offset === 0 ? dt : DateTime.fromMillis(dt.ts + dt.offset * 60 * 1000);
+        }
       }
     } else if (dt.zone.type === "local") {
       this.dt = dt;
@@ -418,7 +426,7 @@ export default class Locale {
     return listStuff(this, length, defaultOK, English.eras, () => {
       const intl = { era: length };
 
-      // This is utter bullshit. Different calendars are going to define eras totally differently. What I need is the minimum set of dates
+      // This is problematic. Different calendars are going to define eras totally differently. What I need is the minimum set of dates
       // to definitely enumerate them.
       if (!this.eraCache[length]) {
         this.eraCache[length] = [DateTime.utc(-40, 1, 1), DateTime.utc(2017, 1, 1)].map(dt =>
diff --git a/src/impl/regexParser.js b/src/impl/regexParser.js
index 74ba737c..8a000430 100644
--- a/src/impl/regexParser.js
+++ b/src/impl/regexParser.js
@@ -97,10 +97,10 @@ function extractISOYmd(match, cursor) {
 
 function extractISOTime(match, cursor) {
   const item = {
-    hour: int(match, cursor, 0),
-    minute: int(match, cursor + 1, 0),
-    second: int(match, cursor + 2, 0),
-    millisecond: parseMillis(match[cursor + 3])
+    hours: int(match, cursor, 0),
+    minutes: int(match, cursor + 1, 0),
+    seconds: int(match, cursor + 2, 0),
+    milliseconds: parseMillis(match[cursor + 3])
   };
 
   return [item, null, cursor + 4];
@@ -118,6 +118,10 @@ function extractIANAZone(match, cursor) {
   return [{}, zone, cursor + 1];
 }
 
+// ISO time parsing
+
+const isoTimeOnly = RegExp(`^T?${isoTimeBaseRegex.source}$`);
+
 // ISO duration parsing
 
 const isoDuration = /^-?P(?:(?:(-?\d{1,9})Y)?(?:(-?\d{1,9})M)?(?:(-?\d{1,9})W)?(?:(-?\d{1,9})D)?(?:T(?:(-?\d{1,9})H)?(?:(-?\d{1,9})M)?(?:(-?\d{1,20})(?:[.,](-?\d{1,9}))?S)?)?)$/;
@@ -295,6 +299,12 @@ export function parseISODuration(s) {
   return parse(s, [isoDuration, extractISODuration]);
 }
 
+const extractISOTimeOnly = combineExtractors(extractISOTime);
+
+export function parseISOTimeOnly(s) {
+  return parse(s, [isoTimeOnly, extractISOTimeOnly]);
+}
+
 const sqlYmdWithTimeExtensionRegex = combineRegexes(sqlYmdRegex, sqlTimeExtensionRegex);
 const sqlTimeCombinedRegex = combineRegexes(sqlTimeRegex);
 
diff --git a/src/impl/util.js b/src/impl/util.js
index b345c694..59f6f860 100644
--- a/src/impl/util.js
+++ b/src/impl/util.js
@@ -99,11 +99,17 @@ export function floorMod(x, n) {
 }
 
 export function padStart(input, n = 2) {
-  if (input.toString().length < n) {
-    return ("0".repeat(n) + input).slice(-n);
+  const minus = input < 0 ? "-" : "";
+  const target = minus ? input * -1 : input;
+  let result;
+
+  if (target.toString().length < n) {
+    result = ("0".repeat(n) + target).slice(-n);
   } else {
-    return input.toString();
+    result = target.toString();
   }
+
+  return `${minus}${result}`;
 }
 
 export function parseInteger(string) {
diff --git a/src/luxon.js b/src/luxon.js
index 31b97387..f84bb770 100644
--- a/src/luxon.js
+++ b/src/luxon.js
@@ -9,7 +9,10 @@ import InvalidZone from "./zones/invalidZone.js";
 import LocalZone from "./zones/localZone.js";
 import Settings from "./settings.js";
 
+const VERSION = "1.25.0";
+
 export {
+  VERSION,
   DateTime,
   Duration,
   Interval,
diff --git a/src/zones/localZone.js b/src/zones/localZone.js
index b8cbcdc2..dae9b2a3 100644
--- a/src/zones/localZone.js
+++ b/src/zones/localZone.js
@@ -4,7 +4,7 @@ import Zone from "../zone.js";
 let singleton = null;
 
 /**
- * Represents the local zone for this Javascript environment.
+ * Represents the local zone for this JavaScript environment.
  * @implements {Zone}
  */
 export default class LocalZone extends Zone {
diff --git a/tasks/build.js b/tasks/build.js
index 775bc52a..e5643a04 100644
--- a/tasks/build.js
+++ b/tasks/build.js
@@ -5,7 +5,7 @@ const rollup = require("rollup"),
   rollupMinify = require("rollup-plugin-babel-minify"),
   rollupNode = require("@rollup/plugin-node-resolve"),
   rollupCommonJS = require("@rollup/plugin-commonjs"),
-  UglifyJS = require("uglify-js"),
+  UglifyES = require("uglify-es"),
   fs = require("fs");
 
 // For some reason, the minifier is currently producing total giberrish, at least for the global build.
@@ -103,7 +103,7 @@ async function buildLibrary(dest, opts) {
 
   if (opts.minify && !TRUST_MINIFY) {
     const code = fs.readFileSync(`build/${dest}/luxon.js`, "utf8"),
-      ugly = UglifyJS.minify(code, {
+      ugly = UglifyES.minify(code, {
         toplevel: !opts.global,
         output: {
           comments: false
@@ -175,7 +175,8 @@ async function cjsBrowser() {
 async function es6() {
   await buildLibrary("es6", {
     format: "es",
-    compile: false
+    compile: false,
+    minify: true
   });
 }
 


Test patch:
diff --git a/test/datetime/diff.test.js b/test/datetime/diff.test.js
index e4e29f3c..cb439b6f 100644
--- a/test/datetime/diff.test.js
+++ b/test/datetime/diff.test.js
@@ -25,6 +25,14 @@ test("DateTime#diff makes simple diffs", () => {
     years: 1
   });
 
+  expect(diffObjs({ year: 2016, month: 4 }, { year: 2016, month: 1 }, "quarters")).toEqual({
+    quarters: 1
+  });
+
+  expect(diffObjs({ year: 2017, month: 10 }, { year: 2017, month: 4 }, "quarters")).toEqual({
+    quarters: 2
+  });
+
   expect(
     diffObjs({ year: 2016, month: 6, day: 28 }, { year: 2016, month: 5, day: 28 }, "months")
   ).toEqual({ months: 1 });
diff --git a/test/datetime/format.test.js b/test/datetime/format.test.js
index 516b5705..eb04f9d3 100644
--- a/test/datetime/format.test.js
+++ b/test/datetime/format.test.js
@@ -91,6 +91,15 @@ test("DateTime#toISODate() returns ISO 8601 date in format [±YYYYY]", () => {
   );
 });
 
+test("DateTime#toISODate() correctly pads negative years", () => {
+  expect(DateTime.fromObject({ year: -1, month: 1, day: 1, zone: "utc" }).toISODate()).toBe(
+    "-0001-01-01"
+  );
+  expect(DateTime.fromObject({ year: -10, month: 1, day: 1, zone: "utc" }).toISODate()).toBe(
+    "-0010-01-01"
+  );
+});
+
 //------
 // #toISOWeekDate()
 //------
@@ -133,10 +142,6 @@ test("DateTime#toISOTime({suppressSeconds: true}) will suppress milliseconds if
   expect(dt.set({ second: 0, millisecond: 0 }).toISOTime({ suppressSeconds: true })).toBe("09:23Z");
 });
 
-test("DateTime#toISOTime({suppressSeconds: true}) will suppress milliseconds if they're zero", () => {
-  expect(dt.set({ second: 0, millisecond: 0 }).toISOTime({ suppressSeconds: true })).toBe("09:23Z");
-});
-
 test("DateTime#toISOTime() handles other offsets", () => {
   expect(dt.setZone("America/New_York").toISOTime()).toBe("05:23:54.123-04:00");
 });
@@ -147,6 +152,14 @@ test("DateTime#toISOTime() can omit the offset", () => {
 
 test("DateTime#toISOTime() can output the basic format", () => {
   expect(dt.toISOTime({ format: "basic" })).toBe("092354.123Z");
+  const dt2 = dt.set({ second: 0, millisecond: 0 });
+  expect(dt2.toISOTime({ format: "basic", suppressMilliseconds: true })).toBe("092300Z");
+  expect(dt2.toISOTime({ format: "basic", suppressSeconds: true })).toBe("0923Z");
+});
+
+test("DateTime#toISOTime can include the prefix", () => {
+  expect(dt.toISOTime({ includePrefix: true })).toBe("T09:23:54.123Z");
+  expect(dt.toISOTime({ format: "basic", includePrefix: true })).toBe("T092354.123Z");
 });
 
 test("DateTime#toISOTime() returns null for invalid DateTimes", () => {
@@ -314,8 +327,14 @@ test("DateTime#toLocaleString() shows things in the right fixed-offset zone", ()
   expect(dt.setZone("UTC-8").toLocaleString(DateTime.DATETIME_SHORT)).toBe("5/25/1982, 1:23 AM");
 });
 
-test("DateTime#toLocaleString() does the best it can with a fixed-offset zone when showing the zone", () => {
+test("DateTime#toLocaleString() shows things in the right fixed-offset zone when showing the zone", () => {
   expect(dt.setZone("UTC-8").toLocaleString(DateTime.DATETIME_FULL)).toBe(
+    "May 25, 1982, 1:23 AM GMT-8"
+  );
+});
+
+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/misc.test.js b/test/datetime/misc.test.js
index 5c4bc783..3074c1fa 100644
--- a/test/datetime/misc.test.js
+++ b/test/datetime/misc.test.js
@@ -21,6 +21,34 @@ test("DateTime#hasSame() checks the unit", () => {
   expect(dt.hasSame(dt.plus({ days: 1 }), "days")).toBe(false);
 });
 
+test("DateTime#hasSame() checks high-order units", () => {
+  const dt1 = DateTime.fromISO("2001-02-03");
+  const dt2 = DateTime.fromISO("2001-05-03");
+  expect(dt1.hasSame(dt2, "year")).toBe(true);
+  expect(dt1.hasSame(dt2, "month")).toBe(false);
+  // Even when days are equal, return false when a higher-order unit differs.
+  expect(dt1.hasSame(dt2, "day")).toBe(false);
+});
+
+// #584
+test("DateTime#hasSame() ignores time offsets and is symmetric", () => {
+  const d1 = DateTime.fromISO("2019-10-02T01:02:03.045+03:00", {
+    zone: "Europe/Helsinki"
+  });
+  const d2 = DateTime.fromISO("2019-10-02T01:02:03.045-05:00", {
+    zone: "America/Chicago"
+  });
+
+  expect(d1.hasSame(d2, "day")).toBe(true);
+  expect(d2.hasSame(d1, "day")).toBe(true);
+  expect(d1.hasSame(d2, "hour")).toBe(true);
+  expect(d2.hasSame(d1, "hour")).toBe(true);
+  expect(d1.hasSame(d2, "second")).toBe(true);
+  expect(d2.hasSame(d1, "second")).toBe(true);
+  expect(d1.hasSame(d2, "millisecond")).toBe(true);
+  expect(d2.hasSame(d1, "millisecond")).toBe(true);
+});
+
 test("DateTime#hasSame() returns false for invalid DateTimes", () => {
   const dt = DateTime.now(),
     invalid = DateTime.invalid("because");
diff --git a/test/duration/equality.test.js b/test/duration/equality.test.js
index 6d22dfca..bfbd50de 100644
--- a/test/duration/equality.test.js
+++ b/test/duration/equality.test.js
@@ -18,6 +18,14 @@ test("equals identically constructed but one has sting type values", () => {
   expect(l1.equals(l2)).toBe(true);
 });
 
+// #809
+test("equals with extra zero units", () => {
+  const l1 = Duration.fromObject({ years: 5, days: 6 }),
+    l2 = Duration.fromObject({ years: 5, days: 6, minutes: 0, seconds: -0 });
+  expect(l1.equals(l2)).toBe(true);
+  expect(l2.equals(l1)).toBe(true);
+});
+
 test("does not equal an invalid duration", () => {
   const l1 = Duration.fromObject({ years: 5, days: 6 }),
     l2 = Duration.invalid("because");
diff --git a/test/duration/format.test.js b/test/duration/format.test.js
index 699ecdaf..1555e536 100644
--- a/test/duration/format.test.js
+++ b/test/duration/format.test.js
@@ -16,6 +16,7 @@ const dur = () =>
 //------
 // #toISO()
 //------
+
 test("Duration#toISO fills out every field", () => {
   expect(dur().toISO()).toBe("P1Y2M1W3DT4H5M6.007S");
 });
@@ -62,6 +63,55 @@ test("Duration#toISO handles mixed negative/positive numbers in seconds/millisec
   expect(Duration.fromObject({ seconds: -17, milliseconds: 548 }).toISO()).toBe("PT-16.452S");
 });
 
+//------
+// #toISOTime()
+//------
+
+const hhmmssSSS = Duration.fromObject({ hours: 11, minutes: 22, seconds: 33, milliseconds: 444 });
+const hhSSS = Duration.fromObject({ hours: 11, milliseconds: 444 });
+const hhss = Duration.fromObject({ hours: 11, seconds: 33 });
+const hh = Duration.fromObject({ hours: 11 });
+
+test("Duration#toISOTime creates a correct extended string", () => {
+  expect(hhmmssSSS.toISOTime()).toBe("11:22:33.444");
+});
+
+test("Duration#toISOTime suppresses milliseconds correctly", () => {
+  expect(hhSSS.toISOTime({ suppressMilliseconds: true })).toBe("11:00:00.444");
+  expect(hhss.toISOTime({ suppressMilliseconds: true })).toBe("11:00:33");
+  expect(hh.toISOTime({ suppressMilliseconds: true })).toBe("11:00:00");
+});
+
+test("Duration#toISOTime suppresses seconds correctly", () => {
+  expect(hhSSS.toISOTime({ suppressSeconds: true })).toBe("11:00:00.444");
+  expect(hhss.toISOTime({ suppressSeconds: true })).toBe("11:00:33.000");
+  expect(hh.toISOTime({ suppressSeconds: true })).toBe("11:00");
+});
+
+test("Duration#toISOTime includes the prefix correctly", () => {
+  expect(hh.toISOTime({ includePrefix: true })).toBe("T11:00:00.000");
+});
+
+test("Duration#toISOTime creates a correct basic string", () => {
+  expect(hhmmssSSS.toISOTime({ format: "basic" })).toBe("112233.444");
+  expect(hh.toISOTime({ format: "basic", suppressMilliseconds: true })).toBe("110000");
+  expect(hh.toISOTime({ format: "basic", suppressSeconds: true })).toBe("1100");
+});
+
+test("Duration#toISOTime returns null if the value is outside the range of one day", () => {
+  expect(Duration.fromObject({ hours: 24 }).toISOTime()).toBe(null);
+  expect(Duration.fromObject({ milliseconds: -1 }).toISOTime()).toBe(null);
+});
+
+//------
+// #toMillis()
+//------
+
+test("Duration#toMillis returns the value in milliseconds", () => {
+  expect(Duration.fromMillis(1000).toMillis()).toBe(1000);
+  expect(dur().valueOf()).toBe(dur().toMillis());
+});
+
 //------
 // #toJSON()
 //------
@@ -81,6 +131,7 @@ test("Duration#toString returns the ISO representation", () => {
 //------
 // #toFormat()
 //------
+
 test("Duration#toFormat('S') returns milliseconds", () => {
   expect(dur().toFormat("S")).toBe("37598706007");
 
diff --git a/test/duration/parse.test.js b/test/duration/parse.test.js
index f8ec42db..cbabbf2d 100644
--- a/test/duration/parse.test.js
+++ b/test/duration/parse.test.js
@@ -61,11 +61,11 @@ test("Duration.fromISO can parse fractions of seconds", () => {
   });
 });
 
-test("Duration.fromISO rejects junk", () => {
-  const rejects = s => {
-    expect(Duration.fromISO(s).isValid).toBe(false);
-  };
+const rejects = s => {
+  expect(Duration.fromISO(s).isValid).toBe(false);
+};
 
+test("Duration.fromISO rejects junk", () => {
   rejects("poop");
   rejects("PTglorb");
   rejects("P5Y34S");
@@ -74,3 +74,36 @@ test("Duration.fromISO rejects junk", () => {
   rejects("P34K");
   rejects("P5D2W");
 });
+
+//------
+// #fromISOTime()
+//------
+
+const checkTime = (s, ob) => {
+  expect(Duration.fromISOTime(s).toObject()).toEqual(ob);
+};
+
+test("Duration.fromISOTime can parse a variety of extended ISO time formats", () => {
+  checkTime("11:22:33.444", { hours: 11, minutes: 22, seconds: 33, milliseconds: 444 });
+  checkTime("11:22:33", { hours: 11, minutes: 22, seconds: 33 });
+  checkTime("11:22", { hours: 11, minutes: 22, seconds: 0 });
+  checkTime("T11:22", { hours: 11, minutes: 22, seconds: 0 });
+});
+
+test("Duration.fromISOTime can parse a variety of basic ISO time formats", () => {
+  checkTime("112233.444", { hours: 11, minutes: 22, seconds: 33, milliseconds: 444 });
+  checkTime("112233", { hours: 11, minutes: 22, seconds: 33 });
+  checkTime("1122", { hours: 11, minutes: 22, seconds: 0 });
+  checkTime("11", { hours: 11, minutes: 0, seconds: 0 });
+  checkTime("T1122", { hours: 11, minutes: 22, seconds: 0 });
+});
+
+const rejectsTime = s => {
+  expect(Duration.fromISOTime(s).isValid).toBe(false);
+};
+
+test("Duration.fromISOTime rejects junk", () => {
+  rejectsTime("poop");
+  rejectsTime("Tglorb");
+  rejectsTime("-00:00");
+});