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" 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/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/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/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/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) { diff --git a/test/duration/format.test.js b/test/duration/format.test.js index 55ac90b36..1ab3214d2 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() //------ @@ -323,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", () => { 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); +});