Repository: moment/luxon
Pull request #1670 (2024-12-05 12:42:06)

Create ISO 8601 Date/Time Values w/o Milliseconds (re-implemented, ready for review)
This PR duplicates and updates PR: https://github.com/moment/luxon/pull/748 and issue: https://github.com/moment/luxon/issues/651

I've incorporated the feedback from that review and PR, minus renaming "supress[milli]second" which should be in a different PR if the change is made.

dobon: > Should we also support this for calendar units? `2024-05` is a valid ISO date, referring to the month of may 2025.

There was discussion about this in the prior PR, but no conclusion. I've added another commit to enable precision from Year to Millisecond, and it seems good to me. Do the test cases show the function behaving the way you had in mind @diesieben07 ?

Also, I still need to update the method comments to specify truncation behavior and suppressSeconds / suppressMilliseconds behavior (your other comment). I'll include a no-code patch for that in the coming days.

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
       )
     );
   }


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()
 //------