Repository: moment/luxon (js)

Issue:
More control over DateTime#toRelative rounding behavior
**Is your feature request related to a problem? Please describe.**
For some units of time, rounding down is very unintuitive. It's probably fine if 29.99 minutes rounds down to 29, but it's fairly inaccurate to describe 1.99 years away as "1 year away." Padding in these situations that could be minutes away or years away can lead to a lot of extra code to figure out how much padding there should be in this situation.

**Describe the solution you'd like**
I think one possible solution would be to change the `round` option you can pass to `toRelative` from a on/off style boolean into a setting probably based on a string. Something like `round: 'down' | 'up' | 'closest' | 'off'` where, if we want to keep the current default, it defaults to `down`. `off` would be the equivalent of `false` currently, `up` would invert the behavior and always round upwards, and `closest` would round towards the closest integer, rather than specifically up or down.

**Describe alternatives you've considered**
With the existing options the only alternatives I've come up with are calculating the padding based on the kind of rounding I want based on the difference between the date for comparison and now, or altering the date for comparison itself to generate the term I want when handed to `toRelative`. An alternative to the addition I suggest above might be to allow handing a function to the `round` option that would receive a parameter of the unrounded relative value, and the resulting number would be whatever is returned from that function.


Pull request:
Rounding options to toRelative
This PR fully address the issue described in #1129 (and fully replace PR #1683) by introducing an additional `rounding` option. This PR is fully backward compatible.

## Problem

Before this PR, the only option for rounding with `toRelative()` is to rounds towards zero.

## Solution

With this PR, I add a new parameter `rounding` who can be `"trunc"` (towards zero), `"expand"` (away from zero), `"round"`, `"floor"`, or `"ceil"` to address all options. The `rounding` option works with `round: true` and `round: false`.

I'm open for better names for `"towardsZero"`, `"awayFromZero"`. 

Fixes #1129.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e5e969e..60678236 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Changelog
 
+# 3.6.1 (2025-03-31)
+- Add Fallback for `minimalDays` being removed from Intl.WeekInfo
+- Fix various caches when JS keywords like "constructor" are used for names
+
 # 3.6.0 (2025-03-25)
 - Add `Interval.lastDateTime`
 - Fix a bug that could cause wrong timezone calculations when multiple timezones are in use 
diff --git a/package-lock.json b/package-lock.json
index 0ea72235..72e97fc6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "luxon",
-  "version": "3.6.0",
+  "version": "3.6.1",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "luxon",
-      "version": "3.6.0",
+      "version": "3.6.1",
       "license": "MIT",
       "devDependencies": {
         "@babel/core": "^7.18.6",
diff --git a/package.json b/package.json
index be15508b..fdbb56cb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "luxon",
-  "version": "3.6.0",
+  "version": "3.6.1",
   "description": "Immutable date wrapper",
   "author": "Isaac Cambron",
   "keywords": [
diff --git a/src/datetime.js b/src/datetime.js
index 18a7d2e0..b17dec3a 100644
--- a/src/datetime.js
+++ b/src/datetime.js
@@ -449,8 +449,9 @@ function quickDT(obj, opts) {
 
 function diffRelative(start, end, opts) {
   const round = isUndefined(opts.round) ? true : opts.round,
+    rounding = isUndefined(opts.rounding) ? "trunc" : opts.rounding,
     format = (c, unit) => {
-      c = roundTo(c, round || opts.calendary ? 0 : 2, true);
+      c = roundTo(c, round || opts.calendary ? 0 : 2, opts.calendary ? "round" : rounding);
       const formatter = end.loc.clone(opts).relFormatter(opts);
       return formatter.format(c, unit);
     },
@@ -2195,12 +2196,13 @@ export default class DateTime {
 
   /**
    * Returns a string representation of a this time relative to now, such as "in two days". Can only internationalize if your
-   * platform supports Intl.RelativeTimeFormat. Rounds down by default.
+   * platform supports Intl.RelativeTimeFormat. Rounds towards zero by default.
    * @param {Object} options - options that affect the output
    * @param {DateTime} [options.base=DateTime.now()] - the DateTime to use as the basis to which this time is compared. Defaults to now.
    * @param {string} [options.style="long"] - the style of units, must be "long", "short", or "narrow"
    * @param {string|string[]} options.unit - use a specific unit or array of units; if omitted, or an array, the method will pick the best unit. Use an array or one of "years", "quarters", "months", "weeks", "days", "hours", "minutes", or "seconds"
    * @param {boolean} [options.round=true] - whether to round the numbers in the output.
+   * @param {string} [options.rounding="trunc"] - rounding method to use when rounding the numbers in the output. Can be "trunc" (toward zero), "expand" (away from zero), "round", "floor", or "ceil".
    * @param {number} [options.padding=0] - padding in milliseconds. This allows you to round up the result if it fits inside the threshold. Don't use in combination with {round: false} because the decimal output will include the padding.
    * @param {string} options.locale - override the locale of this DateTime
    * @param {string} options.numberingSystem - override the numberingSystem of this DateTime. The Intl system may choose not to honor this
diff --git a/src/duration.js b/src/duration.js
index c1135def..0aa46c97 100644
--- a/src/duration.js
+++ b/src/duration.js
@@ -488,21 +488,25 @@ export default class Duration {
    * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#options
    * @param {Object} opts - Formatting options. Accepts the same keys as the options parameter of the native `Intl.NumberFormat` constructor, as well as `listStyle`.
    * @param {string} [opts.listStyle='narrow'] - How to format the merged list. Corresponds to the `style` property of the options parameter of the native `Intl.ListFormat` constructor.
+   * @param {boolean} [opts.showZeros=true] - Show all units previously used by the duration even if they are zero
    * @example
    * ```js
-   * var dur = Duration.fromObject({ days: 1, hours: 5, minutes: 6 })
-   * dur.toHuman() //=> '1 day, 5 hours, 6 minutes'
-   * dur.toHuman({ listStyle: "long" }) //=> '1 day, 5 hours, and 6 minutes'
-   * dur.toHuman({ unitDisplay: "short" }) //=> '1 day, 5 hr, 6 min'
+   * var dur = Duration.fromObject({ months: 1, weeks: 0, hours: 5, minutes: 6 })
+   * dur.toHuman() //=> '1 month, 0 weeks, 5 hours, 6 minutes'
+   * dur.toHuman({ listStyle: "long" }) //=> '1 month, 0 weeks, 5 hours, and 6 minutes'
+   * dur.toHuman({ unitDisplay: "short" }) //=> '1 mth, 0 wks, 5 hr, 6 min'
+   * dur.toHuman({ showZeros: false }) //=> '1 month, 5 hours, 6 minutes'
    * ```
    */
   toHuman(opts = {}) {
     if (!this.isValid) return INVALID;
 
+    const showZeros = opts.showZeros !== false;
+
     const l = orderedUnits
       .map((unit) => {
         const val = this.values[unit];
-        if (isUndefined(val)) {
+        if (isUndefined(val) || (val === 0 && !showZeros)) {
           return null;
         }
         return this.loc
@@ -862,6 +866,17 @@ export default class Duration {
     return clone(this, { values: negated }, true);
   }
 
+  /**
+   * Removes all units with values equal to 0 from this Duration.
+   * @example Duration.fromObject({ years: 2, days: 0, hours: 0, minutes: 0 }).removeZeros().toObject() //=> { years: 2 }
+   * @return {Duration}
+   */
+  removeZeros() {
+    if (!this.isValid) return this;
+    const vals = removeZeroes(this.values);
+    return clone(this, { values: vals }, true);
+  }
+
   /**
    * Get the years.
    * @type {number}
diff --git a/src/impl/regexParser.js b/src/impl/regexParser.js
index ab095864..6710d64e 100644
--- a/src/impl/regexParser.js
+++ b/src/impl/regexParser.js
@@ -67,11 +67,11 @@ function simpleParse(...keys) {
 }
 
 // ISO and SQL parsing
-const offsetRegex = /(?:(Z)|([+-]\d\d)(?::?(\d\d))?)/;
+const offsetRegex = /(?:([Zz])|([+-]\d\d)(?::?(\d\d))?)/;
 const isoExtendedZone = `(?:${offsetRegex.source}?(?:\\[(${ianaRegex.source})\\])?)?`;
 const isoTimeBaseRegex = /(\d\d)(?::?(\d\d)(?::?(\d\d)(?:[.,](\d{1,30}))?)?)?/;
 const isoTimeRegex = RegExp(`${isoTimeBaseRegex.source}${isoExtendedZone}`);
-const isoTimeExtensionRegex = RegExp(`(?:T${isoTimeRegex.source})?`);
+const isoTimeExtensionRegex = RegExp(`(?:[Tt]${isoTimeRegex.source})?`);
 const isoYmdRegex = /([+-]\d{6}|\d{4})(?:-?(\d\d)(?:-?(\d\d))?)?/;
 const isoWeekRegex = /(\d{4})-?W(\d\d)(?:-?(\d))?/;
 const isoOrdinalRegex = /(\d{4})-?(\d{3})/;
diff --git a/src/impl/util.js b/src/impl/util.js
index a9b548c3..8f8c47b5 100644
--- a/src/impl/util.js
+++ b/src/impl/util.js
@@ -159,10 +159,24 @@ export function parseMillis(fraction) {
   }
 }
 
-export function roundTo(number, digits, towardZero = false) {
-  const factor = 10 ** digits,
-    rounder = towardZero ? Math.trunc : Math.round;
-  return rounder(number * factor) / factor;
+export function roundTo(number, digits, rounding = "round") {
+  const factor = 10 ** digits;
+  switch (rounding) {
+    case "expand":
+      return number > 0
+        ? Math.ceil(number * factor) / factor
+        : Math.floor(number * factor) / factor;
+    case "trunc":
+      return Math.trunc(number * factor) / factor;
+    case "round":
+      return Math.round(number * factor) / factor;
+    case "floor":
+      return Math.floor(number * factor) / factor;
+    case "ceil":
+      return Math.ceil(number * factor) / factor;
+    default:
+      throw new RangeError(`Value rounding ${rounding} is out of range`);
+  }
 }
 
 // DATE BASICS
diff --git a/src/luxon.js b/src/luxon.js
index ed810ec3..0a552009 100644
--- a/src/luxon.js
+++ b/src/luxon.js
@@ -9,7 +9,7 @@ import InvalidZone from "./zones/invalidZone.js";
 import SystemZone from "./zones/systemZone.js";
 import Settings from "./settings.js";
 
-const VERSION = "3.6.0";
+const VERSION = "3.6.1";
 
 export {
   VERSION,
diff --git a/src/package.json b/src/package.json
index f81023cf..817e7021 100644
--- a/src/package.json
+++ b/src/package.json
@@ -1,4 +1,4 @@
 {
   "type": "module",
-  "version": "3.6.0"
+  "version": "3.6.1"
 }


Test patch:
diff --git a/test/datetime/regexParse.test.js b/test/datetime/regexParse.test.js
index db84b968..85d12002 100644
--- a/test/datetime/regexParse.test.js
+++ b/test/datetime/regexParse.test.js
@@ -72,6 +72,20 @@ test("DateTime.fromISO() optionally adopts the UTC offset provided", () => {
     millisecond: 0,
   });
 
+  // #1610 - lowercase z
+  dt = DateTime.fromISO("1983-10-14T13:30z", { setZone: true });
+  expect(dt.zone.name).toBe("UTC");
+  expect(dt.offset).toBe(0);
+  expect(dt.toObject()).toEqual({
+    year: 1983,
+    month: 10,
+    day: 14,
+    hour: 13,
+    minute: 30,
+    second: 0,
+    millisecond: 0,
+  });
+
   // #580
   dt = DateTime.fromISO("2016-05-25T09:08:34.123-00:30", { setZone: true });
   expect(dt.zone.name).toBe("UTC-0:30");
@@ -114,6 +128,18 @@ test("DateTime.fromISO() can optionally specify a zone", () => {
 
 const isSame = (s, expected) => expect(DateTime.fromISO(s).toObject()).toEqual(expected);
 
+test("DateTime.fromISO() accepts both capital T and lowercase t", () => {
+  // #1610
+  const dt = DateTime.fromISO("2016-05-25T09:08:34.123+06:00");
+  isSame("2016-05-25t09:08:34.123+06:00", dt.toObject());
+});
+
+test("DateTime.fromISO() accepts both capital Z and lowercase z", () => {
+  // #1610
+  const dt = DateTime.fromISO("2016-05-25T09:08:34.123Z");
+  isSame("2016-05-25T09:08:34.123z", dt.toObject());
+});
+
 test("DateTime.fromISO() accepts just the year", () => {
   isSame("2016", {
     year: 2016,
diff --git a/test/datetime/relative.test.js b/test/datetime/relative.test.js
index 07453ea6..3caf060d 100644
--- a/test/datetime/relative.test.js
+++ b/test/datetime/relative.test.js
@@ -43,6 +43,144 @@ test("DateTime#toRelative takes a round argument", () => {
   expect(base.minus({ months: 15 }).toRelative({ base, round: false })).toBe("1.25 years ago");
 });
 
+test("DateTime#toRelative takes a rounding argument", () => {
+  const base = DateTime.fromObject({ year: 1983, month: 10, day: 14 });
+  expect(base.plus({ hours: 2, milliseconds: -1 }).toRelative({ base, rounding: "expand" })).toBe(
+    "in 2 hours"
+  );
+  expect(base.plus({ hours: 2, milliseconds: 1 }).toRelative({ base, rounding: "expand" })).toBe(
+    "in 3 hours"
+  );
+  expect(base.minus({ hours: 2, milliseconds: -1 }).toRelative({ base, rounding: "expand" })).toBe(
+    "2 hours ago"
+  );
+  expect(base.minus({ hours: 2, milliseconds: 1 }).toRelative({ base, rounding: "expand" })).toBe(
+    "3 hours ago"
+  );
+
+  expect(base.plus({ hours: 2, milliseconds: -1 }).toRelative({ base, rounding: "trunc" })).toBe(
+    "in 1 hour"
+  );
+  expect(base.plus({ hours: 2, milliseconds: 1 }).toRelative({ base, rounding: "trunc" })).toBe(
+    "in 2 hours"
+  );
+  expect(base.minus({ hours: 2, milliseconds: -1 }).toRelative({ base, rounding: "trunc" })).toBe(
+    "1 hour ago"
+  );
+  expect(base.minus({ hours: 2, milliseconds: 1 }).toRelative({ base, rounding: "trunc" })).toBe(
+    "2 hours ago"
+  );
+
+  expect(base.plus({ hours: 2, milliseconds: -1 }).toRelative({ base, rounding: "round" })).toBe(
+    "in 2 hours"
+  );
+  expect(base.plus({ hours: 2, milliseconds: 1 }).toRelative({ base, rounding: "round" })).toBe(
+    "in 2 hours"
+  );
+  expect(base.minus({ hours: 2, milliseconds: -1 }).toRelative({ base, rounding: "round" })).toBe(
+    "2 hours ago"
+  );
+  expect(base.minus({ hours: 2, milliseconds: 1 }).toRelative({ base, rounding: "round" })).toBe(
+    "2 hours ago"
+  );
+
+  expect(base.plus({ hours: 2, milliseconds: -1 }).toRelative({ base, rounding: "floor" })).toBe(
+    "in 1 hour"
+  );
+  expect(base.plus({ hours: 2, milliseconds: 1 }).toRelative({ base, rounding: "floor" })).toBe(
+    "in 2 hours"
+  );
+  expect(base.minus({ hours: 2, milliseconds: -1 }).toRelative({ base, rounding: "floor" })).toBe(
+    "2 hours ago"
+  );
+  expect(base.minus({ hours: 2, milliseconds: 1 }).toRelative({ base, rounding: "floor" })).toBe(
+    "3 hours ago"
+  );
+
+  expect(base.plus({ hours: 2, milliseconds: -1 }).toRelative({ base, rounding: "ceil" })).toBe(
+    "in 2 hours"
+  );
+  expect(base.plus({ hours: 2, milliseconds: 1 }).toRelative({ base, rounding: "ceil" })).toBe(
+    "in 3 hours"
+  );
+  expect(base.minus({ hours: 2, milliseconds: -1 }).toRelative({ base, rounding: "ceil" })).toBe(
+    "1 hour ago"
+  );
+  expect(base.minus({ hours: 2, milliseconds: 1 }).toRelative({ base, rounding: "ceil" })).toBe(
+    "2 hours ago"
+  );
+});
+
+test("DateTime#toRelative takes a round and a rounding argument", () => {
+  const base = DateTime.fromObject({ year: 1983, month: 10, day: 14 });
+  expect(
+    base.plus({ hours: 2, milliseconds: -1 }).toRelative({ base, round: false, rounding: "expand" })
+  ).toBe("in 2 hours");
+  expect(
+    base.plus({ hours: 2, milliseconds: 1 }).toRelative({ base, round: false, rounding: "expand" })
+  ).toBe("in 2.01 hours");
+  expect(
+    base
+      .minus({ hours: 2, milliseconds: -1 })
+      .toRelative({ base, round: false, rounding: "expand" })
+  ).toBe("2 hours ago");
+  expect(
+    base.minus({ hours: 2, milliseconds: 1 }).toRelative({ base, round: false, rounding: "expand" })
+  ).toBe("2.01 hours ago");
+
+  expect(
+    base.plus({ hours: 2, milliseconds: -1 }).toRelative({ base, round: false, rounding: "trunc" })
+  ).toBe("in 1.99 hours");
+  expect(
+    base.plus({ hours: 2, milliseconds: 1 }).toRelative({ base, round: false, rounding: "trunc" })
+  ).toBe("in 2 hours");
+  expect(
+    base.minus({ hours: 2, milliseconds: -1 }).toRelative({ base, round: false, rounding: "trunc" })
+  ).toBe("1.99 hours ago");
+  expect(
+    base.minus({ hours: 2, milliseconds: 1 }).toRelative({ base, round: false, rounding: "trunc" })
+  ).toBe("2 hours ago");
+
+  expect(
+    base.plus({ hours: 2, milliseconds: -1 }).toRelative({ base, round: false, rounding: "round" })
+  ).toBe("in 2 hours");
+  expect(
+    base.plus({ hours: 2, milliseconds: 1 }).toRelative({ base, round: false, rounding: "round" })
+  ).toBe("in 2 hours");
+  expect(
+    base.minus({ hours: 2, milliseconds: -1 }).toRelative({ base, round: false, rounding: "round" })
+  ).toBe("2 hours ago");
+  expect(
+    base.minus({ hours: 2, milliseconds: 1 }).toRelative({ base, round: false, rounding: "round" })
+  ).toBe("2 hours ago");
+
+  expect(
+    base.plus({ hours: 2, milliseconds: -1 }).toRelative({ base, round: false, rounding: "floor" })
+  ).toBe("in 1.99 hours");
+  expect(
+    base.plus({ hours: 2, milliseconds: 1 }).toRelative({ base, round: false, rounding: "floor" })
+  ).toBe("in 2 hours");
+  expect(
+    base.minus({ hours: 2, milliseconds: -1 }).toRelative({ base, round: false, rounding: "floor" })
+  ).toBe("2 hours ago");
+  expect(
+    base.minus({ hours: 2, milliseconds: 1 }).toRelative({ base, round: false, rounding: "floor" })
+  ).toBe("2.01 hours ago");
+
+  expect(
+    base.plus({ hours: 2, milliseconds: -1 }).toRelative({ base, round: false, rounding: "ceil" })
+  ).toBe("in 2 hours");
+  expect(
+    base.plus({ hours: 2, milliseconds: 1 }).toRelative({ base, round: false, rounding: "ceil" })
+  ).toBe("in 2.01 hours");
+  expect(
+    base.minus({ hours: 2, milliseconds: -1 }).toRelative({ base, round: false, rounding: "ceil" })
+  ).toBe("1.99 hours ago");
+  expect(
+    base.minus({ hours: 2, milliseconds: 1 }).toRelative({ base, round: false, rounding: "ceil" })
+  ).toBe("2 hours ago");
+});
+
 test("DateTime#toRelative takes a unit argument", () => {
   const base = DateTime.fromObject({ year: 2018, month: 10, day: 14 }, { zone: "UTC" });
   expect(base.plus({ months: 15 }).toRelative({ base, unit: "months" })).toBe("in 15 months");
diff --git a/test/duration/format.test.js b/test/duration/format.test.js
index 408660f8..0d46dd7f 100644
--- a/test/duration/format.test.js
+++ b/test/duration/format.test.js
@@ -309,6 +309,36 @@ test("Duration#toHuman accepts number format opts", () => {
   );
 });
 
+test("Duration#toHuman accepts hiding of zero values", () => {
+  expect(
+    Duration.fromObject({
+      years: 1,
+      months: 0,
+      weeks: 1,
+      days: 0,
+      hours: 4,
+      minutes: 0,
+      seconds: 6,
+      milliseconds: 0,
+    }).toHuman({ showZeros: false })
+  ).toEqual("1 year, 1 week, 4 hours, 6 seconds");
+});
+
+test("Duration#toHuman handles undefined showZeros", () => {
+  expect(
+    Duration.fromObject({
+      years: 1,
+      months: 0,
+      weeks: 1,
+      days: 0,
+      hours: 4,
+      minutes: 0,
+      seconds: 6,
+      milliseconds: 0,
+    }).toHuman({ showZeros: undefined })
+  ).toEqual("1 year, 0 months, 1 week, 0 days, 4 hours, 0 minutes, 6 seconds, 0 milliseconds");
+});
+
 test("Duration#toHuman works in differt languages", () => {
   expect(dur().reconfigure({ locale: "fr" }).toHuman()).toEqual(
     "1 an, 2 mois, 1 semaine, 3 jours, 4 heures, 5 minutes, 6 secondes, 7 millisecondes"
diff --git a/test/duration/units.test.js b/test/duration/units.test.js
index 68cd57af..a31e3322 100644
--- a/test/duration/units.test.js
+++ b/test/duration/units.test.js
@@ -359,3 +359,53 @@ test("Duration#valueOf value of the duration with lower and higher order units",
   const dur = Duration.fromObject({ days: 2, seconds: 1 });
   expect(dur.valueOf()).toBe(172801000);
 });
+
+//------
+// #removeZeroes()
+//-------
+
+test("Duration#removeZeros leaves empty object if everything was zero", () => {
+  expect(Duration.fromObject({ years: 0, days: 0, hours: 0 }).removeZeros().toObject()).toEqual({});
+});
+
+test("Duration#removeZeros removes units with zero value", () => {
+  expect(
+    Duration.fromObject({
+      years: 1,
+      months: 0,
+      weeks: 1,
+      days: 0,
+      hours: 4,
+      minutes: 0,
+      seconds: 6,
+      milliseconds: 0,
+    })
+      .removeZeros()
+      .toObject()
+  ).toEqual({
+    years: 1,
+    weeks: 1,
+    hours: 4,
+    seconds: 6,
+  });
+});
+
+test("Duration#removeZeros removes nothing if no value is zero", () => {
+  const dur = {
+    years: 1,
+    months: 2,
+    weeks: 1,
+    days: 3,
+    hours: 4,
+    minutes: 5,
+    seconds: 6,
+    milliseconds: 7,
+  };
+  expect(Duration.fromObject(dur).removeZeros().toObject()).toEqual(dur);
+});
+
+test("Duration#removeZeros maintains invalidity", () => {
+  const dur = Duration.invalid("because").removeZeros();
+  expect(dur.isValid).toBe(false);
+  expect(dur.invalidReason).toBe("because");
+});