Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/datetime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 17 additions & 7 deletions src/duration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
}
Expand Down
16 changes: 15 additions & 1 deletion src/impl/formatter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
16 changes: 12 additions & 4 deletions src/impl/tokenParser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -393,7 +400,7 @@ function dateTimeFromMatches(matches) {
return r;
}, {});

return [vals, zone, specificOffset];
return [vals, zone, specificOffset, hourInvalidReason];
}

let dummyDateTimeCache = null;
Expand Down Expand Up @@ -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"
Expand All @@ -466,6 +473,7 @@ export class TokenParser {
result,
zone,
specificOffset,
invalidReason: parseInvalidReason,
};
}
}
Expand Down
5 changes: 4 additions & 1 deletion src/interval.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
14 changes: 14 additions & 0 deletions test/datetime/tokenParse.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
46 changes: 46 additions & 0 deletions test/duration/format.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
//------
Expand Down Expand Up @@ -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", () => {
Expand Down
10 changes: 10 additions & 0 deletions test/interval/info.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading