Repository: moment/luxon
Pull request #1377 (2023-02-09 04:45:28)

Custom zone formatting support
This addresses https://github.com/moment/luxon/issues/1363

This addresses a couple of bugs related to non-IANA timezone formatting
 * timezones can be displayed even if `timeZoneName` is not set and partial hour fixed-offset zones don't handle this properly
 * non-universal custom non-IANA zones are treated as IANA zones and are improperly formatted
 * allow all zones to have timezone in the format using `formatToParts`

linux-foundation-easycla[bot]:  <a href="https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/19772872/47118129/1377/#/?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/1377/#/?version=2' target='_blank'>:x:</a> - login: @thatsmydoing / name: Thomas Dy . The commit (03f23916f09d358493afe466c7b02af9a44695aa, ac0bcb6cb8a054c92cb34920d4aef856b5d1c5ee) is not authorized under a signed CLA. <a href='https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/19772872/47118129/1377/#/?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>
thatsmydoing: Theoretically, the logic for fixed offset timezones can simply reuse the one for custom zones but I thought that might be too much of a breaking change compared to the current approach of using IANA zones for whole hour offsets.
icambron: @thatsmydoing this is great. I think we can just merge this as-is. Thanks for the contribution!

Patch:
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() {


Test patch:
diff --git a/test/datetime/format.test.js b/test/datetime/format.test.js
index 355cf222..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()
 //------
@@ -398,7 +442,29 @@ test("DateTime#toLocaleString() shows things with UTC if fixed-offset zone with
 
 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"
   );
 });