Repository: moment/luxon
Pull request #1265 (2022-08-17 11:13:04)

fix notes for quarter workaround

linux-foundation-easycla[bot]:  <a href="https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/19772872/47118129/1265/#/?version=2"><img src="https://s3.amazonaws.com/cla-project-logo-prod/cla-not-signed.svg" alt="CLA Not Signed" align="left" height="28" width="328"></a><br/><br /><ul><li><a href='https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/19772872/47118129/1265/#/?version=2' target='_blank'>:x:</a> - login: @infanf / name: Christopher Schreiner . The commit (e5a73080fefbfc35d7535ace40c0b4390d64dfd5) is not authorized under a signed CLA. <a href='https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/19772872/47118129/1265/#/?version=2' target='_blank'>Please click here to be authorized</a>. For further assistance with EasyCLA, <a href='https://jira.linuxfoundation.org/servicedesk/customer/portal/4' target='_blank'>please submit a support request ticket</a>.</li></ul>

Patch:
diff --git a/docs/formatting.md b/docs/formatting.md
index 7b67909d..3c81893d 100644
--- a/docs/formatting.md
+++ b/docs/formatting.md
@@ -37,6 +37,7 @@ DateTime objects can also be converted to numerical [Unix timestamps](https://en
 ```js
 dt.toMillis(); //=> 1492702320000
 dt.toSeconds(); //=> 1492702320.000
+dt.toUnixInteger(); // => 1492702320
 dt.valueOf(); //=> 1492702320000, same as .toMillis()
 ```
 
diff --git a/docs/install.md b/docs/install.md
index 9771cc4e..f29a875b 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -101,4 +101,13 @@ npm install --save-dev @types/luxon
 
 ## React Native
 
-React Native works just fine, but React Native for Android doesn't ship with Intl support, which you need for [a lot of Luxon's functionality](matrix.md). Use [jsc-android-buildscripts](https://github.com/SoftwareMansion/jsc-android-buildscripts) to fix it.
+React Native >=0.70 works just fine out of the box. Older versions of React Native for Android (or if you disable Hermes) doesn't include Intl support by default, which you need for [a lot of Luxon's functionality](matrix.md).
+
+For React Native >=0.60, you should configure the build flavor of jsc in `android/app/build.gradle`:
+
+```diff
+-def jscFlavor = 'org.webkit:android-jsc:+'
++def jscFlavor = 'org.webkit:android-jsc-intl:+'
+```
+
+For even older versions of React Native you can use [jsc-android-buildscripts](https://github.com/SoftwareMansion/jsc-android-buildscripts) to fix it.
diff --git a/docs/matrix.md b/docs/matrix.md
index 5b19ab7a..146bf844 100644
--- a/docs/matrix.md
+++ b/docs/matrix.md
@@ -56,4 +56,11 @@ Info.features(); //=> { relative: false }
 
 Specific notes on other platforms:
 
-- **React Native on (specifically) Android** doesn't come with Intl support, so all the possible-to-be-missing capabilities above are unavailable. Use [jsc-android-buildscripts](https://github.com/SoftwareMansion/jsc-android-buildscripts) to fix it.
+- **React Native <0.70 on (specifically) Android** doesn't include Intl support by default, so all the possible-to-be-missing capabilities above are unavailable. To fix this on React Native >=0.60, you should configure the build flavor of jsc in `android/app/build.gradle`:
+
+```diff
+-def jscFlavor = 'org.webkit:android-jsc:+'
++def jscFlavor = 'org.webkit:android-jsc-intl:+'
+```
+
+For even older versions of React Native you can use [jsc-android-buildscripts](https://github.com/SoftwareMansion/jsc-android-buildscripts) to fix it.
diff --git a/docs/moment.md b/docs/moment.md
index 625157da..08d9b02f 100644
--- a/docs/moment.md
+++ b/docs/moment.md
@@ -82,7 +82,7 @@ Here's a rough mapping of DateTime methods in Moment to ones in Luxon. I haven't
 | Second of minute       | `second()`                           | `second`      |                                        |
 | Millisecond of seconds | `millisecond()`                      | `millisecond` |                                        |
 | Week of ISO week year  | `weekYear`, `isoWeekYear`            | `weekYear`    |                                        |
-| Quarter                | `quarter`                            | None          | Just divide the months by 4            |
+| Quarter                | `quarter`                            | None          | Just divide the months by 3            |
 
 #### Programmatic get and set
 
diff --git a/package-lock.json b/package-lock.json
index eb0c64f0..e4cbfeac 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3330,9 +3330,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001441",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001441.tgz",
-      "integrity": "sha512-OyxRR4Vof59I3yGWXws6i908EtGbMzVUi3ganaZQHmydk1iwDhRnvaPG2WaR0KcqrDFKrxVZHULT396LEPhXfg==",
+      "version": "1.0.30001445",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001445.tgz",
+      "integrity": "sha512-8sdQIdMztYmzfTMO6KfLny878Ln9c2M0fc7EH60IjlP4Dc4PiCy7K2Vl3ITmWgOyPgVQKa5x+UP/KqFsxj4mBg==",
       "dev": true,
       "funding": [
         {
@@ -13125,9 +13125,9 @@
       "dev": true
     },
     "caniuse-lite": {
-      "version": "1.0.30001441",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001441.tgz",
-      "integrity": "sha512-OyxRR4Vof59I3yGWXws6i908EtGbMzVUi3ganaZQHmydk1iwDhRnvaPG2WaR0KcqrDFKrxVZHULT396LEPhXfg==",
+      "version": "1.0.30001445",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001445.tgz",
+      "integrity": "sha512-8sdQIdMztYmzfTMO6KfLny878Ln9c2M0fc7EH60IjlP4Dc4PiCy7K2Vl3ITmWgOyPgVQKa5x+UP/KqFsxj4mBg==",
       "dev": true
     },
     "ccount": {
diff --git a/src/datetime.js b/src/datetime.js
index 35474590..5120b056 100644
--- a/src/datetime.js
+++ b/src/datetime.js
@@ -168,7 +168,7 @@ function adjustTime(inst, dur) {
 // by handling the zone options
 function parseDataToDateTime(parsed, parsedZone, opts, format, text, specificOffset) {
   const { setZone, zone } = opts;
-  if (parsed && Object.keys(parsed).length !== 0) {
+  if ((parsed && Object.keys(parsed).length !== 0) || parsedZone) {
     const interpretationZone = parsedZone || zone,
       inst = DateTime.fromObject(parsed, {
         ...opts,
diff --git a/src/impl/formatter.js b/src/impl/formatter.js
index 4dcd6be7..7fe6891f 100644
--- a/src/impl/formatter.js
+++ b/src/impl/formatter.js
@@ -47,6 +47,9 @@ export default class Formatter {
   }
 
   static parseFormat(fmt) {
+    // white-space is always considered a literal in user-provided formats
+    // the " " token has a special meaning (see unitForToken)
+
     let current = null,
       currentFull = "",
       bracketed = false;
@@ -55,7 +58,7 @@ export default class Formatter {
       const c = fmt.charAt(i);
       if (c === "'") {
         if (currentFull.length > 0) {
-          splits.push({ literal: bracketed, val: currentFull });
+          splits.push({ literal: bracketed || /^\s+$/.test(currentFull), val: currentFull });
         }
         current = null;
         currentFull = "";
@@ -66,7 +69,7 @@ export default class Formatter {
         currentFull += c;
       } else {
         if (currentFull.length > 0) {
-          splits.push({ literal: false, val: currentFull });
+          splits.push({ literal: /^\s+$/.test(currentFull), val: currentFull });
         }
         currentFull = c;
         current = c;
@@ -74,7 +77,7 @@ export default class Formatter {
     }
 
     if (currentFull.length > 0) {
-      splits.push({ literal: bracketed, val: currentFull });
+      splits.push({ literal: bracketed || /^\s+$/.test(currentFull), val: currentFull });
     }
 
     return splits;
diff --git a/src/impl/locale.js b/src/impl/locale.js
index eb8c2bea..a1dcd33b 100644
--- a/src/impl/locale.js
+++ b/src/impl/locale.js
@@ -1,4 +1,4 @@
-import { padStart, roundTo, hasRelative } from "./util.js";
+import { padStart, roundTo, hasRelative, formatOffset } from "./util.js";
 import * as English from "./english.js";
 import Settings from "../settings.js";
 import DateTime from "../datetime.js";
@@ -197,9 +197,13 @@ class PolyNumberFormatter {
 class PolyDateFormatter {
   constructor(dt, intl, opts) {
     this.opts = opts;
+    this.originalZone = undefined;
 
     let z = undefined;
-    if (dt.zone.isUniversal) {
+    if (this.opts.timeZone) {
+      // Don't apply any workarounds if a timeZone is explicitly provided in opts
+      this.dt = dt;
+    } else if (dt.zone.type === "fixed") {
       // 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. Representing offset 0 when UTC is used to maintain previous behavior and does not become GMT.
@@ -212,25 +216,23 @@ class PolyDateFormatter {
         z = offsetZ;
         this.dt = dt;
       } else {
-        // 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.
+        // Not all fixed-offset zones like Etc/+4:30 are present in tzdata so
+        // we manually apply the offset and substitute the zone as needed.
         z = "UTC";
-        if (opts.timeZoneName) {
-          this.dt = dt;
-        } else {
-          this.dt = dt.offset === 0 ? dt : DateTime.fromMillis(dt.ts + dt.offset * 60 * 1000);
-        }
+        this.dt = dt.offset === 0 ? dt : dt.setZone("UTC").plus({ minutes: dt.offset });
+        this.originalZone = dt.zone;
       }
     } else if (dt.zone.type === "system") {
       this.dt = dt;
-    } else {
+    } else if (dt.zone.type === "iana") {
       this.dt = dt;
       z = dt.zone.name;
+    } else {
+      // Custom zones can have any offset / offsetName so we just manually
+      // apply the offset and substitute the zone as needed.
+      z = "UTC";
+      this.dt = dt.setZone("UTC").plus({ minutes: dt.offset });
+      this.originalZone = dt.zone;
     }
 
     const intlOpts = { ...this.opts };
@@ -239,11 +241,35 @@ class PolyDateFormatter {
   }
 
   format() {
+    if (this.originalZone) {
+      // If we have to substitute in the actual zone name, we have to use
+      // formatToParts so that the timezone can be replaced.
+      return this.formatToParts()
+        .map(({ value }) => value)
+        .join("");
+    }
     return this.dtf.format(this.dt.toJSDate());
   }
 
   formatToParts() {
-    return this.dtf.formatToParts(this.dt.toJSDate());
+    const parts = this.dtf.formatToParts(this.dt.toJSDate());
+    if (this.originalZone) {
+      return parts.map((part) => {
+        if (part.type === "timeZoneName") {
+          const offsetName = this.originalZone.offsetName(this.dt.ts, {
+            locale: this.dt.locale,
+            format: this.opts.timeZoneName,
+          });
+          return {
+            ...part,
+            value: offsetName,
+          };
+        } else {
+          return part;
+        }
+      });
+    }
+    return parts;
   }
 
   resolvedOptions() {
diff --git a/src/impl/tokenParser.js b/src/impl/tokenParser.js
index b91cdf49..5a913b0f 100644
--- a/src/impl/tokenParser.js
+++ b/src/impl/tokenParser.js
@@ -180,6 +180,10 @@ function unitForToken(token, loc) {
         // because we don't have any way to figure out what they are
         case "z":
           return simple(/[a-z_+-/]{1,256}?/i);
+        // this special-case "token" represents a place where a macro-token expanded into a white-space literal
+        // in this case we accept any non-newline white-space
+        case " ":
+          return simple(/[^\S\n\r]/);
         default:
           return literal(t);
       }
@@ -237,9 +241,10 @@ function tokenForPart(part, formatOpts) {
   const { type, value } = part;
 
   if (type === "literal") {
+    const isSpace = /^\s+$/.test(value);
     return {
-      literal: true,
-      val: value,
+      literal: !isSpace,
+      val: isSpace ? " " : value,
     };
   }
 
diff --git a/src/impl/util.js b/src/impl/util.js
index c07136f6..81bffc6e 100644
--- a/src/impl/util.js
+++ b/src/impl/util.js
@@ -166,7 +166,10 @@ export function objToLocalTS(obj) {
   // for legacy reasons, years between 0 and 99 are interpreted as 19XX; revert that
   if (obj.year < 100 && obj.year >= 0) {
     d = new Date(d);
-    d.setUTCFullYear(d.getUTCFullYear() - 1900);
+    // set the month and day again, this is necessary because year 2000 is a leap year, but year 100 is not
+    // so if obj.year is in 99, but obj.day makes it roll over into year 100,
+    // the calculations done by Date.UTC are using year 2000 - which is incorrect
+    d.setUTCFullYear(obj.year, obj.month - 1, obj.day);
   }
   return +d;
 }
diff --git a/src/interval.js b/src/interval.js
index a4c177ae..33caf6d4 100644
--- a/src/interval.js
+++ b/src/interval.js
@@ -240,7 +240,7 @@ export default class Interval {
     if (!this.isValid) return NaN;
     const start = this.start.startOf(unit),
       end = this.end.startOf(unit);
-    return Math.floor(end.diff(start, unit).get(unit)) + 1;
+    return Math.floor(end.diff(start, unit).get(unit)) + (end.valueOf() !== this.end.valueOf());
   }
 
   /**


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b533278d..b3d64cca 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -18,7 +18,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [18.9]
+        node-version: [18.13]
 
     steps:
     - uses: actions/checkout@v2
diff --git a/test/datetime/format.test.js b/test/datetime/format.test.js
index 23dcfd77..ce919613 100644
--- a/test/datetime/format.test.js
+++ b/test/datetime/format.test.js
@@ -1,6 +1,6 @@
 /* global test expect */
 
-import { DateTime } from "../../src/luxon";
+import { DateTime, Zone, FixedOffsetZone } from "../../src/luxon";
 
 const dtMaker = () =>
     DateTime.fromObject(
@@ -20,6 +20,50 @@ const dtMaker = () =>
   dt = dtMaker(),
   invalid = DateTime.invalid("because");
 
+class CustomZone extends Zone {
+  constructor(name, offset) {
+    super();
+    this._name = name;
+    this._offset = offset;
+  }
+
+  get isUniversal() {
+    return true;
+  }
+
+  get isValid() {
+    return true;
+  }
+
+  get name() {
+    return this._name;
+  }
+
+  get type() {
+    return "custom";
+  }
+
+  equals(zone) {
+    return zone instanceof CustomZone && zone._name === this._name && zone._offset === this._offset;
+  }
+
+  offset(_ms) {
+    return this._offset;
+  }
+
+  offsetName(_ms, { format }) {
+    if (format === "short") {
+      return this._name.substring(0, 4);
+    } else {
+      return this._name;
+    }
+  }
+
+  formatOffset(...args) {
+    return FixedOffsetZone.prototype.formatOffset(...args);
+  }
+}
+
 //------
 // #toJSON()
 //------
@@ -376,34 +420,56 @@ test("DateTime#toLocaleString() returns something different for invalid DateTime
 
 test("DateTime#toLocaleString() shows things in the right IANA zone", () => {
   expect(dt.setZone("America/New_York").toLocaleString(DateTime.DATETIME_SHORT)).toBe(
-    "5/25/1982, 5:23 AM"
+    "5/25/1982, 5:23 AM"
   );
 });
 
 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");
+  expect(dt.setZone("UTC-8").toLocaleString(DateTime.DATETIME_SHORT)).toBe("5/25/1982, 1:23 AM");
 });
 
 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 at 1:23 AM GMT-8"
+    "May 25, 1982 at 1:23 AM GMT-8"
   );
 });
 
 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 at 9:23 AM UTC"
+    "May 25, 1982 at 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 at 9:23 AM UTC"
+    "May 25, 1982 at 1:53 PM UTC+4:30"
+  );
+});
+
+test("DateTime#toLocaleString() does the best it can with unsupported fixed-offset zone with timeStyle full", () => {
+  expect(dt.setZone("UTC+4:30").toLocaleString({ timeStyle: "full" })).toBe("1:53:54 PM UTC+4:30");
+});
+
+test("DateTime#toLocaleString() shows things in the right custom zone", () => {
+  expect(dt.setZone(new CustomZone("CUSTOM", 30)).toLocaleString(DateTime.DATETIME_SHORT)).toBe(
+    "5/25/1982, 9:53 AM"
+  );
+});
+
+test("DateTime#toLocaleString() shows things in the right custom zone when showing the zone", () => {
+  expect(dt.setZone(new CustomZone("CUSTOM", 30)).toLocaleString(DateTime.DATETIME_FULL)).toBe(
+    "May 25, 1982 at 9:53 AM CUST"
+  );
+});
+
+test("DateTime#toLocaleString() shows things in the right custom zone with timeStyle full", () => {
+  expect(dt.setZone(new CustomZone("CUSTOM", 30)).toLocaleString({ timeStyle: "full" })).toBe(
+    "9:53:54 AM CUSTOM"
   );
 });
 
 test("DateTime#toLocaleString uses locale-appropriate time formats", () => {
-  expect(dt.reconfigure({ locale: "en-US" }).toLocaleString(DateTime.TIME_SIMPLE)).toBe("9:23 AM");
+  expect(dt.reconfigure({ locale: "en-US" }).toLocaleString(DateTime.TIME_SIMPLE)).toBe("9:23 AM");
   expect(dt.reconfigure({ locale: "en-US" }).toLocaleString(DateTime.TIME_24_SIMPLE)).toBe("09:23");
 
   // France has 24-hour time by default
@@ -429,7 +495,7 @@ test("DateTime#toLocaleString() accepts a zone even when the zone is set", () =>
       timeZoneName: "short",
       timeZone: "America/Los_Angeles",
     })
-  ).toBe("2:23 AM PDT");
+  ).toBe("2:23 AM PDT");
 });
 
 //------
diff --git a/test/datetime/math.test.js b/test/datetime/math.test.js
index 848d5ba6..aab17683 100644
--- a/test/datetime/math.test.js
+++ b/test/datetime/math.test.js
@@ -107,6 +107,13 @@ test("DateTime#plus works across the 100 barrier", () => {
   expect(d.day).toBe(2);
 });
 
+test("DateTime#plus works across the 100 barrier when passing through February", () => {
+  const d = DateTime.fromISO("0099-12-31").plus({ day: 61 });
+  expect(d.year).toBe(100);
+  expect(d.month).toBe(3);
+  expect(d.day).toBe(2);
+});
+
 test("DateTime#plus renders invalid when out of max. datetime range using days", () => {
   const d = DateTime.utc(1970, 1, 1, 0, 0, 0, 0).plus({ day: 1e8 + 1 });
   expect(d.isValid).toBe(false);
diff --git a/test/datetime/toFormat.test.js b/test/datetime/toFormat.test.js
index f8f6dfc7..f73b5aa0 100644
--- a/test/datetime/toFormat.test.js
+++ b/test/datetime/toFormat.test.js
@@ -370,8 +370,8 @@ test("DateTime#toFormat('DDDD') returns a long date representation", () => {
 });
 
 test("DateTime#toFormat('t') returns a short time representation", () => {
-  expect(dt.toFormat("t")).toBe("9:23 AM");
-  expect(dt.set({ hour: 13 }).toFormat("t")).toBe("1:23 PM");
+  expect(dt.toFormat("t")).toBe("9:23 AM");
+  expect(dt.set({ hour: 13 }).toFormat("t")).toBe("1:23 PM");
   expect(dt.reconfigure({ locale: "fr" }).toFormat("t")).toBe("09:23");
   expect(dt.set({ hour: 13 }).reconfigure({ locale: "fr" }).toFormat("t")).toBe("13:23");
 });
@@ -384,8 +384,8 @@ test("DateTime#toFormat('T') returns a short 24-hour time representation", () =>
 });
 
 test("DateTime#toFormat('tt') returns a medium time representation", () => {
-  expect(dt.toFormat("tt")).toBe("9:23:54 AM");
-  expect(dt.set({ hour: 13 }).toFormat("tt")).toBe("1:23:54 PM");
+  expect(dt.toFormat("tt")).toBe("9:23:54 AM");
+  expect(dt.set({ hour: 13 }).toFormat("tt")).toBe("1:23:54 PM");
   expect(dt.reconfigure({ locale: "fr" }).toFormat("tt")).toBe("09:23:54");
   expect(dt.set({ hour: 13 }).reconfigure({ locale: "fr" }).toFormat("tt")).toBe("13:23:54");
 });
@@ -414,16 +414,16 @@ test("DateTime#toFormat('TTT') returns a medium time representation", () => {
 });
 
 test("DateTime#toFormat('f') returns a short date/time representation without seconds", () => {
-  expect(dt.toFormat("f")).toBe("5/25/1982, 9:23 AM");
-  expect(dt.set({ hour: 13 }).toFormat("f")).toBe("5/25/1982, 1:23 PM");
+  expect(dt.toFormat("f")).toBe("5/25/1982, 9:23 AM");
+  expect(dt.set({ hour: 13 }).toFormat("f")).toBe("5/25/1982, 1:23 PM");
   expect(dt.reconfigure({ locale: "fr" }).toFormat("f")).toBe("25/05/1982 09:23");
   expect(dt.set({ hour: 13 }).reconfigure({ locale: "fr" }).toFormat("f")).toBe("25/05/1982 13:23");
 });
 
 test("DateTime#toFormat('ff') returns a medium date/time representation without seconds", () => {
-  expect(dt.toFormat("ff")).toBe("May 25, 1982, 9:23 AM");
-  expect(dt.set({ hour: 13 }).toFormat("ff")).toBe("May 25, 1982, 1:23 PM");
-  expect(dt.set({ month: 8 }).toFormat("ff")).toBe("Aug 25, 1982, 9:23 AM");
+  expect(dt.toFormat("ff")).toBe("May 25, 1982, 9:23 AM");
+  expect(dt.set({ hour: 13 }).toFormat("ff")).toBe("May 25, 1982, 1:23 PM");
+  expect(dt.set({ month: 8 }).toFormat("ff")).toBe("Aug 25, 1982, 9:23 AM");
   expect(dt.reconfigure({ locale: "fr" }).toFormat("ff")).toBe("25 mai 1982, 09:23");
   expect(dt.set({ month: 2 }).reconfigure({ locale: "fr" }).toFormat("ff")).toBe(
     "25 févr. 1982, 09:23"
@@ -434,9 +434,9 @@ test("DateTime#toFormat('ff') returns a medium date/time representation without
 });
 
 test("DateTime#toFormat('fff') returns a medium date/time representation without seconds", () => {
-  expect(ny.toFormat("fff")).toBe("May 25, 1982 at 9:23 AM EDT");
-  expect(ny.set({ hour: 13 }).toFormat("fff")).toBe("May 25, 1982 at 1:23 PM EDT");
-  expect(ny.set({ month: 8 }).toFormat("fff")).toBe("August 25, 1982 at 9:23 AM EDT");
+  expect(ny.toFormat("fff")).toBe("May 25, 1982 at 9:23 AM EDT");
+  expect(ny.set({ hour: 13 }).toFormat("fff")).toBe("May 25, 1982 at 1:23 PM EDT");
+  expect(ny.set({ month: 8 }).toFormat("fff")).toBe("August 25, 1982 at 9:23 AM EDT");
   expect(ny.reconfigure({ locale: "fr" }).toFormat("fff")).toBe("25 mai 1982 à 09:23 UTC−4");
   expect(ny.set({ month: 2 }).reconfigure({ locale: "fr" }).toFormat("fff")).toBe(
     "25 février 1982 à 09:23 UTC−5"
@@ -447,27 +447,27 @@ test("DateTime#toFormat('fff') returns a medium date/time representation without
 });
 
 test("DateTime#toFormat('ffff') returns a long date/time representation without seconds", () => {
-  expect(ny.toFormat("ffff")).toBe("Tuesday, May 25, 1982 at 9:23 AM Eastern Daylight Time");
+  expect(ny.toFormat("ffff")).toBe("Tuesday, May 25, 1982 at 9:23 AM Eastern Daylight Time");
   expect(ny.set({ hour: 13 }).toFormat("ffff")).toBe(
-    "Tuesday, May 25, 1982 at 1:23 PM Eastern Daylight Time"
+    "Tuesday, May 25, 1982 at 1:23 PM Eastern Daylight Time"
   );
   expect(ny.set({ month: 2 }).toFormat("ffff")).toBe(
-    "Thursday, February 25, 1982 at 9:23 AM Eastern Standard Time"
+    "Thursday, February 25, 1982 at 9:23 AM Eastern Standard Time"
   );
   expect(ny.reconfigure({ locale: "fr" }).toFormat("ffff")).toBe(
-    "mardi 25 mai 1982 à 09:23 heure d’été de l’Est"
+    "mardi 25 mai 1982 à 09:23 heure d’été de l’Est nord-américain"
   );
   expect(ny.set({ month: 2 }).reconfigure({ locale: "fr" }).toFormat("ffff")).toBe(
     "jeudi 25 février 1982 à 09:23 heure normale de l’Est nord-américain"
   );
   expect(ny.set({ hour: 13 }).reconfigure({ locale: "fr" }).toFormat("ffff")).toBe(
-    "mardi 25 mai 1982 à 13:23 heure d’été de l’Est"
+    "mardi 25 mai 1982 à 13:23 heure d’été de l’Est nord-américain"
   );
 });
 
 test("DateTime#toFormat('F') returns a short date/time representation with seconds", () => {
-  expect(dt.toFormat("F")).toBe("5/25/1982, 9:23:54 AM");
-  expect(dt.set({ hour: 13 }).toFormat("F")).toBe("5/25/1982, 1:23:54 PM");
+  expect(dt.toFormat("F")).toBe("5/25/1982, 9:23:54 AM");
+  expect(dt.set({ hour: 13 }).toFormat("F")).toBe("5/25/1982, 1:23:54 PM");
   expect(dt.reconfigure({ locale: "fr" }).toFormat("F")).toBe("25/05/1982 09:23:54");
   expect(dt.set({ hour: 13 }).reconfigure({ locale: "fr" }).toFormat("F")).toBe(
     "25/05/1982 13:23:54"
@@ -475,9 +475,9 @@ test("DateTime#toFormat('F') returns a short date/time representation with secon
 });
 
 test("DateTime#toFormat('FF') returns a medium date/time representation with seconds", () => {
-  expect(dt.toFormat("FF")).toBe("May 25, 1982, 9:23:54 AM");
-  expect(dt.set({ hour: 13 }).toFormat("FF")).toBe("May 25, 1982, 1:23:54 PM");
-  expect(dt.set({ month: 8 }).toFormat("FF")).toBe("Aug 25, 1982, 9:23:54 AM");
+  expect(dt.toFormat("FF")).toBe("May 25, 1982, 9:23:54 AM");
+  expect(dt.set({ hour: 13 }).toFormat("FF")).toBe("May 25, 1982, 1:23:54 PM");
+  expect(dt.set({ month: 8 }).toFormat("FF")).toBe("Aug 25, 1982, 9:23:54 AM");
   expect(dt.reconfigure({ locale: "fr" }).toFormat("FF")).toBe("25 mai 1982, 09:23:54");
   expect(dt.set({ month: 2 }).reconfigure({ locale: "fr" }).toFormat("FF")).toBe(
     "25 févr. 1982, 09:23:54"
@@ -488,9 +488,9 @@ test("DateTime#toFormat('FF') returns a medium date/time representation with sec
 });
 
 test("DateTime#toFormat('FFF') returns a medium date/time representation without seconds", () => {
-  expect(ny.toFormat("FFF")).toBe("May 25, 1982 at 9:23:54 AM EDT");
-  expect(ny.set({ hour: 13 }).toFormat("FFF")).toBe("May 25, 1982 at 1:23:54 PM EDT");
-  expect(ny.set({ month: 8 }).toFormat("FFF")).toBe("August 25, 1982 at 9:23:54 AM EDT");
+  expect(ny.toFormat("FFF")).toBe("May 25, 1982 at 9:23:54 AM EDT");
+  expect(ny.set({ hour: 13 }).toFormat("FFF")).toBe("May 25, 1982 at 1:23:54 PM EDT");
+  expect(ny.set({ month: 8 }).toFormat("FFF")).toBe("August 25, 1982 at 9:23:54 AM EDT");
   expect(ny.reconfigure({ locale: "fr" }).toFormat("FFF")).toBe("25 mai 1982 à 9:23:54 UTC−4");
   expect(ny.set({ month: 2 }).reconfigure({ locale: "fr" }).toFormat("FFF")).toBe(
     "25 février 1982 à 9:23:54 UTC−5"
@@ -501,21 +501,21 @@ test("DateTime#toFormat('FFF') returns a medium date/time representation without
 });
 
 test("DateTime#toFormat('FFFF') returns a long date/time representation without seconds", () => {
-  expect(ny.toFormat("FFFF")).toBe("Tuesday, May 25, 1982 at 9:23:54 AM Eastern Daylight Time");
+  expect(ny.toFormat("FFFF")).toBe("Tuesday, May 25, 1982 at 9:23:54 AM Eastern Daylight Time");
   expect(ny.set({ hour: 13 }).toFormat("FFFF")).toBe(
-    "Tuesday, May 25, 1982 at 1:23:54 PM Eastern Daylight Time"
+    "Tuesday, May 25, 1982 at 1:23:54 PM Eastern Daylight Time"
   );
   expect(ny.set({ month: 2 }).toFormat("FFFF")).toBe(
-    "Thursday, February 25, 1982 at 9:23:54 AM Eastern Standard Time"
+    "Thursday, February 25, 1982 at 9:23:54 AM Eastern Standard Time"
   );
   expect(ny.reconfigure({ locale: "fr" }).toFormat("FFFF")).toBe(
-    "mardi 25 mai 1982 à 9:23:54 heure d’été de l’Est"
+    "mardi 25 mai 1982 à 9:23:54 heure d’été de l’Est nord-américain"
   );
   expect(ny.set({ month: 2 }).reconfigure({ locale: "fr" }).toFormat("FFFF")).toBe(
     "jeudi 25 février 1982 à 9:23:54 heure normale de l’Est nord-américain"
   );
   expect(ny.set({ hour: 13 }).reconfigure({ locale: "fr" }).toFormat("FFFF")).toBe(
-    "mardi 25 mai 1982 à 13:23:54 heure d’été de l’Est"
+    "mardi 25 mai 1982 à 13:23:54 heure d’été de l’Est nord-américain"
   );
 });
 
diff --git a/test/datetime/tokenParse.test.js b/test/datetime/tokenParse.test.js
index 45095c55..9ba0d162 100644
--- a/test/datetime/tokenParse.test.js
+++ b/test/datetime/tokenParse.test.js
@@ -790,6 +790,12 @@ test("DateTime.fromFormat() parses localized macro tokens", () => {
   }
 });
 
+test("DateTime.fromFormat() allows non-breaking white-space to be substituted inside macro-tokens", () => {
+  expect(DateTime.fromFormat("5:54 PM", "t", { locale: "en-US" }).isValid).toBe(true);
+  expect(DateTime.fromFormat("5:54 PM", "t", { locale: "en-US" }).isValid).toBe(true);
+  expect(DateTime.fromFormat("5:54\nPM", "t", { locale: "en-US" }).isValid).toBe(false);
+});
+
 test("DateTime.fromFormat() throws if you don't provide a format", () => {
   expect(() => DateTime.fromFormat("yo")).toThrowError();
 });
@@ -837,6 +843,13 @@ test("DateTime.fromFormat containg special regex token", () => {
   ).toBe(false);
 });
 
+// #1362
+test("DateTime.fromFormat only an offset", () => {
+  const dt = DateTime.fromFormat("+0100", "ZZZ", { setZone: true });
+  expect(dt.isValid).toBe(true);
+  expect(dt.offset).toBe(60);
+});
+
 //------
 // .fromFormatExplain
 //-------
diff --git a/test/interval/format.test.js b/test/interval/format.test.js
index c983823b..f15e55f5 100644
--- a/test/interval/format.test.js
+++ b/test/interval/format.test.js
@@ -21,7 +21,7 @@ test("Interval#toString returns an unfriendly string for invalid intervals", ()
 //------
 
 test("Interval#toLocaleString defaults to the DATE_SHORT format", () =>
-  expect(interval.toLocaleString()).toBe("5/25/1982 – 10/14/1983"));
+  expect(interval.toLocaleString()).toBe("5/25/1982 – 10/14/1983"));
 
 test("Interval#toLocaleString returns an unfriendly string for invalid intervals", () =>
   expect(invalid.toLocaleString()).toBe("Invalid Interval"));
@@ -49,7 +49,7 @@ test("Interval#toLocaleString accepts numbering system settings from the start D
       interval.start.reconfigure({ numberingSystem: "beng" }),
       interval.end
     ).toLocaleString()
-  ).toBe("৫/২৫/১৯৮২ – ১০/১৪/১৯৮৩");
+  ).toBe("৫/২৫/১৯৮২ – ১০/১৪/১৯৮৩");
 });
 
 test("Interval#toLocaleString accepts ouptput calendar settings from the start DateTime", () => {
@@ -58,11 +58,11 @@ test("Interval#toLocaleString accepts ouptput calendar settings from the start D
       interval.start.reconfigure({ outputCalendar: "islamic" }),
       interval.end
     ).toLocaleString()
-  ).toBe("8/2/1402 – 1/8/1404 AH");
+  ).toBe("8/2/1402 – 1/8/1404 AH");
 });
 
 test("Interval#toLocaleString accepts options to the formatter", () => {
-  expect(interval.toLocaleString({ weekday: "short" })).toBe("Tue – Fri");
+  expect(interval.toLocaleString({ weekday: "short" })).toBe("Tue – Fri");
 });
 
 test("Interval#toLocaleString can override the start DateTime's locale", () => {
@@ -71,7 +71,7 @@ test("Interval#toLocaleString can override the start DateTime's locale", () => {
       interval.start.reconfigure({ locale: "be" }),
       interval.end
     ).toLocaleString({}, { locale: "fr" })
-  ).toBe("25/05/1982 – 14/10/1983");
+  ).toBe("25/05/1982 – 14/10/1983");
 });
 
 test("Interval#toLocaleString can override the start DateTime's numbering system", () => {
@@ -80,7 +80,7 @@ test("Interval#toLocaleString can override the start DateTime's numbering system
       interval.start.reconfigure({ numberingSystem: "beng" }),
       interval.end
     ).toLocaleString({ numberingSystem: "mong" })
-  ).toBe("᠕/᠒᠕/᠑᠙᠘᠒ – ᠑᠐/᠑᠔/᠑᠙᠘᠓");
+  ).toBe("᠕/᠒᠕/᠑᠙᠘᠒ – ᠑᠐/᠑᠔/᠑᠙᠘᠓");
 });
 
 test("Interval#toLocaleString can override the start DateTime's output calendar", () => {
@@ -89,7 +89,7 @@ test("Interval#toLocaleString can override the start DateTime's output calendar"
       interval.start.reconfigure({ outputCalendar: "islamic" }),
       interval.end
     ).toLocaleString({}, { outputCalendar: "coptic" })
-  ).toBe("9/17/1698 – 2/3/1700 ERA1");
+  ).toBe("9/17/1698 – 2/3/1700 ERA1");
 });
 
 test("Interval#toLocaleString shows things in the right IANA zone", () => {
@@ -98,7 +98,7 @@ test("Interval#toLocaleString shows things in the right IANA zone", () => {
       interval.start.setZone("Australia/Melbourne"),
       interval.end
     ).toLocaleString(DateTime.DATETIME_SHORT)
-  ).toBe("5/25/1982, 7:00 PM – 10/14/1983, 11:30 PM");
+  ).toBe("5/25/1982, 7:00 PM – 10/14/1983, 11:30 PM");
 });
 
 test("Interval#toLocaleString shows things in the right fixed-offset zone", () => {
@@ -106,7 +106,7 @@ test("Interval#toLocaleString shows things in the right fixed-offset zone", () =
     Interval.fromDateTimes(interval.start.setZone("UTC-8"), interval.end).toLocaleString(
       DateTime.DATETIME_SHORT
     )
-  ).toBe("5/25/1982, 1:00 AM – 10/14/1983, 5:30 AM");
+  ).toBe("5/25/1982, 1:00 AM – 10/14/1983, 5:30 AM");
 });
 
 test("Interval#toLocaleString shows things in the right fixed-offset zone when showing the zone", () => {
@@ -114,7 +114,7 @@ test("Interval#toLocaleString shows things in the right fixed-offset zone when s
     Interval.fromDateTimes(interval.start.setZone("UTC-8"), interval.end).toLocaleString(
       DateTime.DATETIME_FULL
     )
-  ).toBe("May 25, 1982 at 1:00 AM GMT-8 – October 14, 1983 at 5:30 AM GMT-8");
+  ).toBe("May 25, 1982 at 1:00 AM GMT-8 – October 14, 1983 at 5:30 AM GMT-8");
 });
 
 test("Interval#toLocaleString shows things with UTC if fixed-offset with 0 offset is used", () => {
@@ -122,7 +122,7 @@ test("Interval#toLocaleString shows things with UTC if fixed-offset with 0 offse
     Interval.fromDateTimes(interval.start.setZone("UTC"), interval.end).toLocaleString(
       DateTime.DATETIME_FULL
     )
-  ).toBe("May 25, 1982 at 9:00 AM UTC – October 14, 1983 at 1:30 PM UTC");
+  ).toBe("May 25, 1982 at 9:00 AM UTC – October 14, 1983 at 1:30 PM UTC");
 });
 
 test("Interval#toLocaleString does the best it can with unsupported fixed-offset zone when showing the zone", () => {
@@ -130,7 +130,7 @@ test("Interval#toLocaleString does the best it can with unsupported fixed-offset
     Interval.fromDateTimes(interval.start.setZone("UTC+4:30"), interval.end).toLocaleString(
       DateTime.DATETIME_FULL
     )
-  ).toBe("May 25, 1982 at 9:00 AM UTC – October 14, 1983 at 1:30 PM UTC");
+  ).toBe("May 25, 1982 at 9:00 AM UTC – October 14, 1983 at 1:30 PM UTC");
 });
 
 test("Interval#toLocaleString uses locale-appropriate time formats", () => {
@@ -138,24 +138,24 @@ test("Interval#toLocaleString uses locale-appropriate time formats", () => {
     Interval.after(interval.start.reconfigure({ locale: "en-US" }), { hour: 2 }).toLocaleString(
       DateTime.TIME_SIMPLE
     )
-  ).toBe("9:00 – 11:00 AM");
+  ).toBe("9:00 – 11:00 AM");
   expect(
     Interval.after(interval.start.reconfigure({ locale: "en-US" }), { hour: 2 }).toLocaleString(
       DateTime.TIME_24_SIMPLE
     )
-  ).toBe("09:00 – 11:00");
+  ).toBe("09:00 – 11:00");
 
   // France has 24-hour by default
   expect(
     Interval.after(interval.start.reconfigure({ locale: "fr" }), { hour: 2 }).toLocaleString(
       DateTime.TIME_SIMPLE
     )
-  ).toBe("09:00 – 11:00");
+  ).toBe("09:00 – 11:00");
   expect(
     Interval.after(interval.start.reconfigure({ locale: "fr" }), { hour: 2 }).toLocaleString(
       DateTime.TIME_24_SIMPLE
     )
-  ).toBe("09:00 – 11:00");
+  ).toBe("09:00 – 11:00");
 
   // Spain does't prefix with "0" and doesn't use spaces
   expect(
@@ -172,7 +172,7 @@ test("Interval#toLocaleString uses locale-appropriate time formats", () => {
 
 test("Interval#toLocaleString sets the separator between days for same-month dates", () => {
   expect(Interval.after(interval.start, { day: 2 }).toLocaleString(DateTime.DATE_MED)).toBe(
-    "May 25 – 27, 1982"
+    "May 25 – 27, 1982"
   );
 });
 
diff --git a/test/interval/info.test.js b/test/interval/info.test.js
index 466f0961..9be5510a 100644
--- a/test/interval/info.test.js
+++ b/test/interval/info.test.js
@@ -54,9 +54,14 @@ test("Interval#count('years') returns 2 if the interval crosses the new year", (
   expect(i.count("years")).toBe(2);
 });
 
+test("Interval#count() does not count the endpoint of the interval", () => {
+  const i = DateTime.fromISO("2022-10-01").until(DateTime.fromISO("2022-10-03"));
+  expect(i.count("days")).toBe(2);
+});
+
 test("Interval#count() uses milliseconds by default", () => {
   const i = DateTime.fromISO("2016-05-25T03:00").until(DateTime.fromISO("2016-05-25T14:00"));
-  expect(i.count()).toBe(39600001);
+  expect(i.count()).toBe(39600000);
 });
 
 test("Interval#count() returns NaN for invalid intervals", () => {