From 781d962d5e77982ba9c51a587915fd2ffd4b29d4 Mon Sep 17 00:00:00 2001 From: spokodev Date: Wed, 5 Aug 2026 21:27:45 +0100 Subject: [PATCH 1/5] fix: avoid exponential notation in Duration#toISO (#1784) --- src/duration.js | 24 +++++++++++++++++------- test/duration/format.test.js | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/duration.js b/src/duration.js index d67577469..ed27f9c96 100644 --- a/src/duration.js +++ b/src/duration.js @@ -209,6 +209,15 @@ function removeZeroes(vals) { return newVals; } +// Render a number for toISO(). JS prints very small magnitudes in exponential +// notation (e.g. `1e-7`), which is not valid ISO 8601 and which fromISO() cannot +// parse, so expand those to a plain decimal. (toFixed keeps exponential notation +// for magnitudes >= 1e21, so very large durations are left untouched here.) +function toISONumber(value) { + const str = `${value}`; + return str.includes("e") ? value.toFixed(20).replace(/\.?0+$/, "") : str; +} + /** * A Duration object represents a period of time, like "2 months" or "1 day, 1 hour". Conceptually, it's just a map of units to their quantities, accompanied by some additional configuration and methods for creating, parsing, interrogating, transforming, and formatting them. They can be used on their own or in conjunction with other Luxon types; for example, you can use {@link DateTime#plus} to add a Duration object to a DateTime, producing another DateTime. * @@ -566,18 +575,19 @@ export default class Duration { if (!this.isValid) return null; let s = "P"; - if (this.years !== 0) s += this.years + "Y"; - if (this.months !== 0 || this.quarters !== 0) s += this.months + this.quarters * 3 + "M"; - if (this.weeks !== 0) s += this.weeks + "W"; - if (this.days !== 0) s += this.days + "D"; + if (this.years !== 0) s += toISONumber(this.years) + "Y"; + if (this.months !== 0 || this.quarters !== 0) + s += toISONumber(this.months + this.quarters * 3) + "M"; + if (this.weeks !== 0) s += toISONumber(this.weeks) + "W"; + if (this.days !== 0) s += toISONumber(this.days) + "D"; if (this.hours !== 0 || this.minutes !== 0 || this.seconds !== 0 || this.milliseconds !== 0) s += "T"; - if (this.hours !== 0) s += this.hours + "H"; - if (this.minutes !== 0) s += this.minutes + "M"; + if (this.hours !== 0) s += toISONumber(this.hours) + "H"; + if (this.minutes !== 0) s += toISONumber(this.minutes) + "M"; if (this.seconds !== 0 || this.milliseconds !== 0) // this will handle "floating point madness" by removing extra decimal places // https://stackoverflow.com/questions/588004/is-floating-point-math-broken - s += roundTo(this.seconds + this.milliseconds / 1000, 3) + "S"; + s += toISONumber(roundTo(this.seconds + this.milliseconds / 1000, 3)) + "S"; if (s === "P") s += "T0S"; return s; } diff --git a/test/duration/format.test.js b/test/duration/format.test.js index 55ac90b36..125c4f6ad 100644 --- a/test/duration/format.test.js +++ b/test/duration/format.test.js @@ -77,6 +77,25 @@ test("Duration#toISO handles mixed negative/positive numbers in seconds/millisec expect(Duration.fromObject({ seconds: -17, milliseconds: 548 }).toISO()).toBe("PT-16.452S"); }); +test("Duration#toISO does not use exponential notation for very small values", () => { + // JS renders very small magnitudes in exponential notation (e.g. "1e-7"), + // which is not valid ISO 8601 and which Duration.fromISO cannot parse. + expect(Duration.fromObject({ years: 1e-7 }).toISO()).toBe("P0.0000001Y"); + expect(Duration.fromObject({ days: 1e-7 }).toISO()).toBe("P0.0000001D"); + expect(Duration.fromObject({ hours: 1e-7 }).toISO()).toBe("PT0.0000001H"); + expect(Duration.fromObject({ minutes: 1e-7 }).toISO()).toBe("PT0.0000001M"); +}); + +test("Duration#toISO output round-trips small fractional values through fromISO", () => { + // Converting a small duration to a coarse unit yields a tiny fractional value. + const dur = Duration.fromObject({ milliseconds: 1 }).shiftTo("hours"); + expect(dur.toISO()).not.toMatch(/e/i); + expect(Duration.fromISO(dur.toISO()).isValid).toBe(true); + + const exact = Duration.fromObject({ hours: 1e-7 }); + expect(Duration.fromISO(exact.toISO()).hours).toBe(1e-7); +}); + //------ // #toISOTime() //------ From 8a798d11be628373a5f7742391162950e5b222be Mon Sep 17 00:00:00 2001 From: Alexander Kireyev Date: Thu, 6 Aug 2026 03:48:20 +0700 Subject: [PATCH 2/5] Fix lost negative sign in Duration#toFormat when largest unit is zero (#1786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With signMode "negativeLargestOnly", the negative sign is shown only on the largest formatted unit. When that unit rounds to zero (e.g. a -30 minute duration formatted as "h:mm"), "auto" sign display dropped the sign entirely, producing "0:30" — indistinguishable from a positive duration. Use negative zero for the largest unit in that case so the sign is preserved, yielding "-0:30". --- src/impl/formatter.js | 16 +++++++++++++++- test/duration/format.test.js | 27 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/impl/formatter.js b/src/impl/formatter.js index f7e2d04ae..ae6218ee5 100644 --- a/src/impl/formatter.js +++ b/src/impl/formatter.js @@ -412,7 +412,21 @@ export default class Formatter { // "auto" and "negative" are the same, but "auto" has better support signDisplay = "auto"; } - return this.num(lildur.get(mapped) * inversionFactor, token.length, signDisplay); + let value = lildur.get(mapped) * inversionFactor; + // When the negative sign is only shown on the largest unit (signMode + // "negativeLargestOnly") and that unit rounds to zero, "auto" sign display + // would drop the sign entirely (positive zero formats without a sign), + // making a negative duration indistinguishable from a positive one. Use + // negative zero so the sign is preserved on the largest unit. + if ( + this.opts.signMode === "negativeLargestOnly" && + mapped === info.largestUnit && + info.isNegativeDuration && + value === 0 + ) { + value = -0; + } + return this.num(value, token.length, signDisplay); } else { return token; } diff --git a/test/duration/format.test.js b/test/duration/format.test.js index 125c4f6ad..1ab3214d2 100644 --- a/test/duration/format.test.js +++ b/test/duration/format.test.js @@ -342,6 +342,33 @@ test("Duration#toFormat shows no negative sign on the largest unit when using si ).toBe("4503"); }); +test("Duration#toFormat keeps the negative sign on the largest unit when it is zero with signMode negativeLargestOnly", () => { + // The largest formatted unit rounds to zero, but the duration is negative. + // The sign must still appear on the largest unit, otherwise the result is + // indistinguishable from a positive duration. + expect( + Duration.fromObject({ minutes: -30 }).toFormat("h:mm", { + signMode: "negativeLargestOnly", + }) + ).toBe("-0:30"); + expect( + Duration.fromObject({ seconds: -30 }).toFormat("h:mm:ss", { + signMode: "negativeLargestOnly", + }) + ).toBe("-0:00:30"); + expect( + Duration.fromObject({ minutes: -45 }).toFormat("d:h:m", { + signMode: "negativeLargestOnly", + }) + ).toBe("-0:0:45"); + // A positive duration whose largest unit is zero must remain unsigned. + expect( + Duration.fromObject({ minutes: 30 }).toFormat("h:mm", { + signMode: "negativeLargestOnly", + }) + ).toBe("0:30"); +}); + // - signMode all test("Duration#toFormat with signMode all shows positive sign on positive durations", () => { From aeb07e37399b3d5ecbbb11b484ea844a6caf50c0 Mon Sep 17 00:00:00 2001 From: Sayantan Mandal Date: Thu, 6 Aug 2026 02:32:54 +0530 Subject: [PATCH 3/5] Reject out-of-range 12-hour values with a meridiem in fromFormat (#1787) DateTime.fromFormat('18:30 AM', 'h:mm a') returned a valid DateTime even though 18 is not a valid hour for the 12-hour 'h' token. When a meridiem is present the hour must be in [1, 12]; otherwise the result is now invalid. Closes #1625 --- src/impl/tokenParser.js | 16 ++++++++++++---- test/datetime/tokenParse.test.js | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/impl/tokenParser.js b/src/impl/tokenParser.js index 7a2196c7e..db93f980e 100644 --- a/src/impl/tokenParser.js +++ b/src/impl/tokenParser.js @@ -368,8 +368,15 @@ function dateTimeFromMatches(matches) { matches.M = (matches.q - 1) * 3 + 1; } + let hourInvalidReason; + if (!isUndefined(matches.h)) { - if (matches.h < 12 && matches.a === 1) { + if (!isUndefined(matches.a) && (matches.h < 1 || matches.h > 12)) { + // "h" is the 12-hour token, so when a meridiem ("a") is also present the + // hour must be within [1, 12]. Anything else (e.g. "18:30 AM") is not a + // valid 12-hour time, so the result is invalid. + hourInvalidReason = `the 12-hour value "${matches.h}" is not in the [1, 12] range`; + } else if (matches.h < 12 && matches.a === 1) { matches.h += 12; } else if (matches.h === 12 && matches.a === 0) { matches.h = 0; @@ -393,7 +400,7 @@ function dateTimeFromMatches(matches) { return r; }, {}); - return [vals, zone, specificOffset]; + return [vals, zone, specificOffset, hourInvalidReason]; } let dummyDateTimeCache = null; @@ -449,9 +456,9 @@ export class TokenParser { return { input, tokens: this.tokens, invalidReason: this.invalidReason }; } else { const [rawMatches, matches] = match(input, this.regex, this.handlers), - [result, zone, specificOffset] = matches + [result, zone, specificOffset, parseInvalidReason] = matches ? dateTimeFromMatches(matches) - : [null, null, undefined]; + : [null, null, undefined, undefined]; if (hasOwnProperty(matches, "a") && hasOwnProperty(matches, "H")) { throw new ConflictingSpecificationError( "Can't include meridiem when specifying 24-hour format" @@ -466,6 +473,7 @@ export class TokenParser { result, zone, specificOffset, + invalidReason: parseInvalidReason, }; } } diff --git a/test/datetime/tokenParse.test.js b/test/datetime/tokenParse.test.js index c35e1dafe..7e0c01b22 100644 --- a/test/datetime/tokenParse.test.js +++ b/test/datetime/tokenParse.test.js @@ -74,6 +74,20 @@ test("DateTime.fromFormat() throws if you specify meridiem with 24-hour time", ( expect(() => DateTime.fromFormat("930PM", "Hmma")).toThrow(ConflictingSpecificationError); }); +// #1625 +test("DateTime.fromFormat() rejects 12-hour values outside [1, 12] with a meridiem", () => { + expect(DateTime.fromFormat("18:30 AM", "h:mm a").isValid).toBe(false); + expect(DateTime.fromFormat("16:00 PM", "h:mm a").isValid).toBe(false); + expect(DateTime.fromFormat("0:30 AM", "h:mm a").isValid).toBe(false); + expect(DateTime.fromFormat("13:00 PM", "h:mm a").isValid).toBe(false); + + // Valid 12-hour values are still parsed correctly. + expect(DateTime.fromFormat("8:30 AM", "h:mm a").hour).toBe(8); + expect(DateTime.fromFormat("12:30 AM", "h:mm a").hour).toBe(0); + expect(DateTime.fromFormat("12:30 PM", "h:mm a").hour).toBe(12); + expect(DateTime.fromFormat("1:00 PM", "h:mm a").hour).toBe(13); +}); + // #714 test("DateTime.fromFormat() makes dots optional and handles non breakable spaces", () => { function parseMeridiem(input, isAM) { From f2e767dbc74f0b037683a362571f6ec26df035db Mon Sep 17 00:00:00 2001 From: Take Weiland Date: Wed, 5 Aug 2026 23:07:04 +0200 Subject: [PATCH 4/5] Fix a typo in toRelative JSDoc example --- src/datetime.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/datetime.js b/src/datetime.js index d61106f6f..fd32d47d2 100644 --- a/src/datetime.js +++ b/src/datetime.js @@ -2279,7 +2279,7 @@ export default class DateTime { * @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 * @example DateTime.now().plus({ days: 1 }).toRelative() //=> "in 1 day" - * @example DateTime.now().setLocale("es").toRelative({ days: 1 }) //=> "dentro de 1 día" + * @example DateTime.now().setLocale("es").plus({ days: 1 }).toRelative() //=> "dentro de 1 día" * @example DateTime.now().plus({ days: 1 }).toRelative({ locale: "fr" }) //=> "dans 23 heures" * @example DateTime.now().minus({ days: 2 }).toRelative() //=> "2 days ago" * @example DateTime.now().minus({ days: 2 }).toRelative({ unit: "hours" }) //=> "48 hours ago" From aeb7427a8d27f9737bf6d93f836296fe964471de Mon Sep 17 00:00:00 2001 From: Mark Xian Date: Thu, 6 Aug 2026 05:13:03 +0800 Subject: [PATCH 5/5] fix(interval): respect endpoint zones in hasSame for empty intervals (#1790) Interval#hasSame short-circuited to true for any empty interval, ignoring the case where the two endpoints share an instant but sit in different zones with differing local unit values. Compare the endpoints directly instead so a zone mismatch is respected. Closes #1424 --- src/interval.js | 5 ++++- test/interval/info.test.js | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/interval.js b/src/interval.js index 83ab504ff..5b088cb5b 100644 --- a/src/interval.js +++ b/src/interval.js @@ -285,7 +285,10 @@ export default class Interval { * @return {boolean} */ hasSame(unit) { - return this.isValid ? this.isEmpty() || this.e.minus(1).hasSame(this.s, unit) : false; + if (!this.isValid) return false; + // For an empty interval, compare the endpoints directly so that endpoints + // in different zones (with differing local unit values) are respected. + return this.isEmpty() ? this.s.hasSame(this.e, unit) : this.e.minus(1).hasSame(this.s, unit); } /** diff --git a/test/interval/info.test.js b/test/interval/info.test.js index 9be5510a8..67ae7169a 100644 --- a/test/interval/info.test.js +++ b/test/interval/info.test.js @@ -260,3 +260,13 @@ test.each([ i = Interval.fromDateTimes(n, n); expect(i.hasSame("day")).toBe(true); }); + +test("Interval#hasSame respects the zones of an empty interval's endpoints", () => { + // Same instant, but the endpoints are in different zones so their local + // hours differ (12:15 in UTC+2 vs 11:15 in UTC+1). + const s = DateTime.fromISO("2023-01-01T10:15:00.000+00:00", { zone: "UTC+2" }), + e = DateTime.fromISO("2023-01-01T10:15:00.000+00:00", { zone: "UTC+1" }), + i = Interval.fromDateTimes(s, e); + expect(i.isEmpty()).toBe(true); + expect(i.hasSame("hour")).toBe(false); +});