Repository: moment/luxon
Pull request #1701 (2025-04-11 09:42:52)

Only add minus sign on largest unit of negative durations
Per discussion https://github.com/moment/luxon/discussions/1699#discussioncomment-12781313


Patch:
diff --git a/src/datetime.js b/src/datetime.js
index b17dec3a..40a3f551 100644
--- a/src/datetime.js
+++ b/src/datetime.js
@@ -214,21 +214,22 @@ function toTechFormat(dt, format, allowZ = true) {
     : null;
 }
 
-function toISODate(o, extended) {
+function toISODate(o, extended, precision) {
   const longFormat = o.c.year > 9999 || o.c.year < 0;
   let c = "";
   if (longFormat && o.c.year >= 0) c += "+";
   c += padStart(o.c.year, longFormat ? 6 : 4);
-
+  if (precision === "year") return c;
   if (extended) {
     c += "-";
     c += padStart(o.c.month);
+    if (precision === "month") return c;
     c += "-";
-    c += padStart(o.c.day);
   } else {
     c += padStart(o.c.month);
-    c += padStart(o.c.day);
+    if (precision === "month") return c;
   }
+  c += padStart(o.c.day);
   return c;
 }
 
@@ -238,26 +239,39 @@ function toISOTime(
   suppressSeconds,
   suppressMilliseconds,
   includeOffset,
-  extendedZone
+  extendedZone,
+  precision
 ) {
-  let c = padStart(o.c.hour);
-  if (extended) {
-    c += ":";
-    c += padStart(o.c.minute);
-    if (o.c.millisecond !== 0 || o.c.second !== 0 || !suppressSeconds) {
-      c += ":";
-    }
-  } else {
-    c += padStart(o.c.minute);
-  }
-
-  if (o.c.millisecond !== 0 || o.c.second !== 0 || !suppressSeconds) {
-    c += padStart(o.c.second);
-
-    if (o.c.millisecond !== 0 || !suppressMilliseconds) {
-      c += ".";
-      c += padStart(o.c.millisecond, 3);
-    }
+  let showSeconds = !suppressSeconds || o.c.millisecond !== 0 || o.c.second !== 0,
+    c = "";
+  switch (precision) {
+    case "day":
+    case "month":
+    case "year":
+      break;
+    default:
+      c += padStart(o.c.hour);
+      if (precision === "hour") break;
+      if (extended) {
+        c += ":";
+        c += padStart(o.c.minute);
+        if (precision === "minute") break;
+        if (showSeconds) {
+          c += ":";
+          c += padStart(o.c.second);
+        }
+      } else {
+        c += padStart(o.c.minute);
+        if (precision === "minute") break;
+        if (showSeconds) {
+          c += padStart(o.c.second);
+        }
+      }
+      if (precision === "second") break;
+      if (showSeconds && (!suppressMilliseconds || o.c.millisecond !== 0)) {
+        c += ".";
+        c += padStart(o.c.millisecond, 3);
+      }
   }
 
   if (includeOffset) {
@@ -1832,10 +1846,13 @@ export default class DateTime {
    * @param {boolean} [opts.includeOffset=true] - include the offset, such as 'Z' or '-04:00'
    * @param {boolean} [opts.extendedZone=false] - add the time zone format extension
    * @param {string} [opts.format='extended'] - choose between the basic and extended format
+   * @param {string} [opts.precision='milliseconds'] - truncate output to desired presicion: 'years', 'months', 'days', 'hours', 'minutes', 'seconds' or 'milliseconds'. When precision and suppressSeconds or suppressMilliseconds are used together, precision sets the maximum unit shown in the output, however seconds or milliseconds will still be suppressed if they are 0.
    * @example DateTime.utc(1983, 5, 25).toISO() //=> '1982-05-25T00:00:00.000Z'
    * @example DateTime.now().toISO() //=> '2017-04-22T20:47:05.335-04:00'
    * @example DateTime.now().toISO({ includeOffset: false }) //=> '2017-04-22T20:47:05.335'
    * @example DateTime.now().toISO({ format: 'basic' }) //=> '20170422T204705.335-0400'
+   * @example DateTime.now().toISO({ precision: 'day' }) //=> '2017-04-22Z'
+   * @example DateTime.now().toISO({ precision: 'minute' }) //=> '2017-04-22T20:47Z'
    * @return {string|null}
    */
   toISO({
@@ -1844,16 +1861,26 @@ export default class DateTime {
     suppressMilliseconds = false,
     includeOffset = true,
     extendedZone = false,
+    precision = "milliseconds",
   } = {}) {
     if (!this.isValid) {
       return null;
     }
 
+    precision = normalizeUnit(precision);
     const ext = format === "extended";
 
-    let c = toISODate(this, ext);
-    c += "T";
-    c += toISOTime(this, ext, suppressSeconds, suppressMilliseconds, includeOffset, extendedZone);
+    let c = toISODate(this, ext, precision);
+    if (orderedUnits.indexOf(precision) >= 3) c += "T";
+    c += toISOTime(
+      this,
+      ext,
+      suppressSeconds,
+      suppressMilliseconds,
+      includeOffset,
+      extendedZone,
+      precision
+    );
     return c;
   }
 
@@ -1861,16 +1888,17 @@ export default class DateTime {
    * Returns an ISO 8601-compliant string representation of this DateTime's date component
    * @param {Object} opts - options
    * @param {string} [opts.format='extended'] - choose between the basic and extended format
+   * @param {string} [opts.precision='day'] - truncate output to desired precision: 'years', 'months', or 'days'.
    * @example DateTime.utc(1982, 5, 25).toISODate() //=> '1982-05-25'
    * @example DateTime.utc(1982, 5, 25).toISODate({ format: 'basic' }) //=> '19820525'
+   * @example DateTime.utc(1982, 5, 25).toISODate({ precision: 'month' }) //=> '1982-05'
    * @return {string|null}
    */
-  toISODate({ format = "extended" } = {}) {
+  toISODate({ format = "extended", precision = "day" } = {}) {
     if (!this.isValid) {
       return null;
     }
-
-    return toISODate(this, format === "extended");
+    return toISODate(this, format === "extended", normalizeUnit(precision));
   }
 
   /**
@@ -1891,10 +1919,12 @@ export default class DateTime {
    * @param {boolean} [opts.extendedZone=true] - add the time zone format extension
    * @param {boolean} [opts.includePrefix=false] - include the `T` prefix
    * @param {string} [opts.format='extended'] - choose between the basic and extended format
+   * @param {string} [opts.precision='milliseconds'] - truncate output to desired presicion: 'hours', 'minutes', 'seconds' or 'milliseconds'. When precision and suppressSeconds or suppressMilliseconds are used together, precision sets the maximum unit shown in the output, however seconds or milliseconds will still be suppressed if they are 0.
    * @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'
+   * @example DateTime.utc().set({ hour: 7, minute: 34, second: 56 }).toISOTime({ precision: 'minute' }) //=> '07:34Z'
    * @return {string}
    */
   toISOTime({
@@ -1904,12 +1934,14 @@ export default class DateTime {
     includePrefix = false,
     extendedZone = false,
     format = "extended",
+    precision = "milliseconds",
   } = {}) {
     if (!this.isValid) {
       return null;
     }
 
-    let c = includePrefix ? "T" : "";
+    precision = normalizeUnit(precision);
+    let c = includePrefix && orderedUnits.indexOf(precision) >= 3 ? "T" : "";
     return (
       c +
       toISOTime(
@@ -1918,7 +1950,8 @@ export default class DateTime {
         suppressSeconds,
         suppressMilliseconds,
         includeOffset,
-        extendedZone
+        extendedZone,
+        precision
       )
     );
   }
diff --git a/src/impl/formatter.js b/src/impl/formatter.js
index f91b6c6e..0ac8d3fe 100644
--- a/src/impl/formatter.js
+++ b/src/impl/formatter.js
@@ -375,29 +375,30 @@ export default class Formatter {
     const tokenToField = (token) => {
         switch (token[0]) {
           case "S":
-            return "millisecond";
+            return "milliseconds";
           case "s":
-            return "second";
+            return "seconds";
           case "m":
-            return "minute";
+            return "minutes";
           case "h":
-            return "hour";
+            return "hours";
           case "d":
-            return "day";
+            return "days";
           case "w":
-            return "week";
+            return "weeks";
           case "M":
-            return "month";
+            return "months";
           case "y":
-            return "year";
+            return "years";
           default:
             return null;
         }
       },
-      tokenToString = (lildur) => (token) => {
+      tokenToString = (lildur, info) => (token) => {
         const mapped = tokenToField(token);
         if (mapped) {
-          return this.num(lildur.get(mapped), token.length);
+          const inversionFactor = info.isNegativeDuration && mapped !== info.largestUnit ? -1 : 1;
+          return this.num(lildur.get(mapped) * inversionFactor, token.length);
         } else {
           return token;
         }
@@ -407,7 +408,13 @@ export default class Formatter {
         (found, { literal, val }) => (literal ? found : found.concat(val)),
         []
       ),
-      collapsed = dur.shiftTo(...realTokens.map(tokenToField).filter((t) => t));
-    return stringifyTokens(tokens, tokenToString(collapsed));
+      collapsed = dur.shiftTo(...realTokens.map(tokenToField).filter((t) => t)),
+      durationInfo = {
+        isNegativeDuration: collapsed < 0,
+        // this relies on "collapsed" being based on "shiftTo", which builds up the object
+        // in order
+        largestUnit: Object.keys(collapsed.values)[0],
+      };
+    return stringifyTokens(tokens, tokenToString(collapsed, durationInfo));
   }
 }


Test patch:
diff --git a/test/datetime/format.test.js b/test/datetime/format.test.js
index d598a38f..8d0baa3c 100644
--- a/test/datetime/format.test.js
+++ b/test/datetime/format.test.js
@@ -1,6 +1,7 @@
 /* global test expect */
 
 import { DateTime, Zone, FixedOffsetZone } from "../../src/luxon";
+import { InvalidUnitError } from "../../src/errors";
 
 const dtMaker = () =>
     DateTime.fromObject(
@@ -158,6 +159,22 @@ test("DateTime#toISO() supports the extendedZone option", () => {
   );
 });
 
+test("DateTime#toISO({precision}) truncates time to desired precision", () => {
+  expect(dt.toISO({ precision: "millisecond" })).toBe("1982-05-25T09:23:54.123Z");
+  expect(dt.toISO({ precision: "second" })).toBe("1982-05-25T09:23:54Z");
+  expect(dt.toISO({ precision: "minute" })).toBe("1982-05-25T09:23Z");
+  expect(dt.toISO({ precision: "hours" })).toBe("1982-05-25T09Z");
+  expect(dt.toISO({ precision: "days" })).toBe("1982-05-25Z");
+  expect(dt.toISO({ precision: "months" })).toBe("1982-05Z");
+  expect(dt.toISO({ precision: "years" })).toBe("1982Z");
+});
+
+test("DateTime#toISO({precision}) throws when the precision is invalid", () => {
+  expect(() => dt.toISO({ precision: "ms" })).toThrow(InvalidUnitError);
+  expect(() => dt.toISO({ precision: "xxx" })).toThrow(InvalidUnitError);
+  expect(() => dt.toISO({ precision: null })).toThrow(TypeError);
+});
+
 //------
 // #toISODate()
 //------
@@ -195,6 +212,22 @@ test("DateTime#toISODate() correctly pads negative years", () => {
   );
 });
 
+test("DateTime#toISODate({precision}) truncates time to desired precision", () => {
+  expect(dt.toISODate({ precision: "millisecond" })).toBe("1982-05-25");
+  expect(dt.toISODate({ precision: "second" })).toBe("1982-05-25");
+  expect(dt.toISODate({ precision: "minute" })).toBe("1982-05-25");
+  expect(dt.toISODate({ precision: "hours" })).toBe("1982-05-25");
+  expect(dt.toISODate({ precision: "days" })).toBe("1982-05-25");
+  expect(dt.toISODate({ precision: "months" })).toBe("1982-05");
+  expect(dt.toISODate({ precision: "years" })).toBe("1982");
+});
+
+test("DateTime#toISODate({precision}) throws when the precision is invalid", () => {
+  expect(() => dt.toISODate({ precision: "ms" })).toThrow(InvalidUnitError);
+  expect(() => dt.toISODate({ precision: "xxx" })).toThrow(InvalidUnitError);
+  expect(() => dt.toISODate({ precision: null })).toThrow(TypeError);
+});
+
 //------
 // #toISOWeekDate()
 //------
@@ -261,6 +294,44 @@ test("DateTime#toISOTime() returns null for invalid DateTimes", () => {
   expect(invalid.toISOTime()).toBe(null);
 });
 
+test("DateTime#toISOTime({precision}) truncates time to desired precision", () => {
+  expect(dt.toISOTime({ precision: "millisecond" })).toBe("09:23:54.123Z");
+  expect(dt.toISOTime({ precision: "second" })).toBe("09:23:54Z");
+  expect(dt.toISOTime({ precision: "minute" })).toBe("09:23Z");
+  expect(dt.toISOTime({ precision: "hours" })).toBe("09Z");
+  expect(dt.toISOTime({ precision: "days" })).toBe("Z");
+  expect(dt.toISOTime({ precision: "months" })).toBe("Z");
+  expect(dt.toISOTime({ precision: "years" })).toBe("Z");
+});
+
+test("DateTime#toISOTime({precision}) throws when the precision is invalid", () => {
+  expect(() => dt.toISOTime({ precision: "ms" })).toThrow(InvalidUnitError);
+  expect(() => dt.toISOTime({ precision: "xxx" })).toThrow(InvalidUnitError);
+  expect(() => dt.toISOTime({ precision: null })).toThrow(TypeError);
+});
+
+test("DateTime#toISOTime({precision, suppressSeconds}) suppresses when precision is > 'hour'", () => {
+  const dt2 = dt.set({ second: 0, millisecond: 0 });
+  expect(dt2.toISOTime({ precision: "year", suppressSeconds: true })).toBe("Z");
+  expect(dt2.toISOTime({ precision: "month", suppressSeconds: true })).toBe("Z");
+  expect(dt2.toISOTime({ precision: "day", suppressSeconds: true })).toBe("Z");
+  expect(dt2.toISOTime({ precision: "hour", suppressSeconds: true })).toBe("09Z");
+  expect(dt2.toISOTime({ precision: "minute", suppressSeconds: true })).toBe("09:23Z");
+  expect(dt2.toISOTime({ precision: "second", suppressSeconds: true })).toBe("09:23Z");
+  expect(dt2.toISOTime({ precision: "millisecond", suppressSeconds: true })).toBe("09:23Z");
+});
+
+test("DateTime#toISOTime({precision, suppressMilliseconds}) suppresses when precision is > 'minute'", () => {
+  const dt2 = dt.set({ millisecond: 0 });
+  expect(dt2.toISOTime({ precision: "year", suppressSeconds: true })).toBe("Z");
+  expect(dt2.toISOTime({ precision: "month", suppressSeconds: true })).toBe("Z");
+  expect(dt2.toISOTime({ precision: "day", suppressSeconds: true })).toBe("Z");
+  expect(dt2.toISOTime({ precision: "hour", suppressMilliseconds: true })).toBe("09Z");
+  expect(dt2.toISOTime({ precision: "minute", suppressMilliseconds: true })).toBe("09:23Z");
+  expect(dt2.toISOTime({ precision: "second", suppressMilliseconds: true })).toBe("09:23:54Z");
+  expect(dt2.toISOTime({ precision: "millisecond", suppressMilliseconds: true })).toBe("09:23:54Z");
+});
+
 //------
 // #toRFC2822()
 //------
diff --git a/test/duration/format.test.js b/test/duration/format.test.js
index 0d46dd7f..591dddbc 100644
--- a/test/duration/format.test.js
+++ b/test/duration/format.test.js
@@ -283,6 +283,15 @@ test("Duration#toFormat returns a lame string for invalid durations", () => {
   expect(Duration.invalid("because").toFormat("yy")).toBe("Invalid Duration");
 });
 
+test("Duration#toFormat shows negative sign on the largest unit", () => {
+  expect(Duration.fromObject({ years: -3, seconds: -45 }).toFormat("yyss")).toBe("-0345");
+  expect(
+    Duration.fromObject({ years: -3, seconds: -45 }).toFormat("'before'yy'between'ss'after'")
+  ).toBe("before-03between45after");
+  // Intentionally have the seconds not first to make sure years is still picked as the largest unit
+  expect(Duration.fromObject({ seconds: -45, years: -3 }).toFormat("ssyy")).toBe("45-03");
+});
+
 //------
 // #humanize()
 //------