diff --git a/site/index.html b/site/index.html index ac57ebd13..5faa163bb 100644 --- a/site/index.html +++ b/site/index.html @@ -16,6 +16,10 @@ .dark .cover.show { background-color: rgb(79 58 120) !important; } + + .dark .markdown-section p.tip, .dark .markdown-section tr:nth-child(2n) { + background-color: #4c4c4c; + }
diff --git a/src/datetime.js b/src/datetime.js index 7ea16cec1..3ac0d5f8c 100644 --- a/src/datetime.js +++ b/src/datetime.js @@ -186,7 +186,7 @@ function adjustTime(inst, dur) { // helper useful in turning the results of parsing into real dates // by handling the zone options -function parseDataToDateTime(parsed, parsedZone, opts, format, text, specificOffset) { +export function parseDataToDateTime(parsed, parsedZone, opts, format, text, specificOffset) { const { setZone, zone } = opts; if ((parsed && Object.keys(parsed).length !== 0) || parsedZone) { const interpretationZone = parsedZone || zone, @@ -801,7 +801,7 @@ export default class DateTime { const normalized = normalizeObject(obj, normalizeUnitWithLocalWeeks); const { minDaysInFirstWeek, startOfWeek } = usesLocalWeekValues(normalized, loc); - const tsNow = Settings.now(), + const tsNow = opts.overrideNow ?? Settings.now(), offsetProvis = !isUndefined(opts.specificOffset) ? opts.specificOffset : zoneToUse.offset(tsNow), diff --git a/src/duration.js b/src/duration.js index 72db001e0..d67577469 100644 --- a/src/duration.js +++ b/src/duration.js @@ -114,6 +114,15 @@ const orderedUnits = [ const reverseUnits = orderedUnits.slice(0).reverse(); +// This is a map of which units to convert +// for toHuman due to missing support from Intl. +// if value is set, then key is what to convert to and value is what to convert +// if value is null, then key is a unit to be ignored +const humanizeUnitConversion = { + months: "quarters", + quarters: null, +}; + // clone really means "create another instance just like this one, but with these changes" function clone(dur, alts, clear = false) { // deep merge for vals @@ -509,7 +518,15 @@ export default class Duration { const l = orderedUnits .map((unit) => { - const val = this.values[unit]; + const convertUnit = humanizeUnitConversion[unit]; + if (convertUnit === null) return null; + let val = this.values[unit]; + if (convertUnit) { + const val2 = this.values[convertUnit]; + if (val2) { + val = (val ?? 0) + val2 * this.matrix[convertUnit][unit]; + } + } if (isUndefined(val) || (val === 0 && !showZeros)) { return null; } diff --git a/src/impl/regexParser.js b/src/impl/regexParser.js index 6710d64ea..c39dd2082 100644 --- a/src/impl/regexParser.js +++ b/src/impl/regexParser.js @@ -294,6 +294,39 @@ export function parseISODate(s) { ); } +// ISO Interval parsing + +// Note: Do not optimize the outer non-capturing group, it is necessary, because the +// regex is combined with other regexes and contains | +const partialIsoIntervalEndDate = /(?:(?:(\d\d)-)?(\d\d)?|(?:W(\d\d)-)?(\d)|(\d{3}))/; +const isoIntervalEndDateTime = combineRegexes(partialIsoIntervalEndDate, isoTimeExtensionRegex); + +const extractPartialIsoIntervalEndDate = simpleParse( + "month", + "day", + "weekNumber", + "weekDay", + "ordinal" +); + +const extractISOIntervalPartialDateAndTime = combineExtractors( + extractPartialIsoIntervalEndDate, + extractISOTime, + extractISOOffset, + extractIANAZone +); + +export function parseISOIntervalEnd(s) { + return parse( + s, + [isoIntervalEndDateTime, extractISOIntervalPartialDateAndTime], + [isoYmdWithTimeExtensionRegex, extractISOYmdTimeAndOffset], + [isoWeekWithTimeExtensionRegex, extractISOWeekTimeAndOffset], + [isoOrdinalWithTimeExtensionRegex, extractISOOrdinalDateAndTime], + [isoTimeCombinedRegex, extractISOTimeAndOffset] + ); +} + export function parseRFC2822Date(s) { return parse(preprocessRFC2822(s), [rfc2822, extractRFC2822]); } diff --git a/src/interval.js b/src/interval.js index 48f9ba496..1cdb6b118 100644 --- a/src/interval.js +++ b/src/interval.js @@ -1,10 +1,11 @@ -import DateTime, { friendlyDateTime } from "./datetime.js"; +import DateTime, { friendlyDateTime, parseDataToDateTime } from "./datetime.js"; import Duration from "./duration.js"; import Settings from "./settings.js"; import { InvalidArgumentError, InvalidIntervalError } from "./errors.js"; import Invalid from "./impl/invalid.js"; import Formatter from "./impl/formatter.js"; import * as Formats from "./impl/formats.js"; +import { parseISOIntervalEnd } from "./impl/regexParser.js"; const INVALID = "Invalid Interval"; @@ -134,11 +135,14 @@ export default class Interval { * @return {Interval} */ static fromISO(text, opts) { + const { zone, setZone, ...restOpts } = opts || {}; const [s, e] = (text || "").split("/", 2); if (s && e) { let start, startIsValid; try { - start = DateTime.fromISO(s, opts); + // we need to know the zone that was used in the string, so that we can + // default to it when parsing end, therefor use setZone: true + start = DateTime.fromISO(s, { ...restOpts, zone, setZone: true }); startIsValid = start.isValid; } catch (e) { startIsValid = false; @@ -146,12 +150,27 @@ export default class Interval { let end, endIsValid; try { - end = DateTime.fromISO(e, opts); + const [vals, parsedZone] = parseISOIntervalEnd(e); + const endParseOpts = { + ...restOpts, + overrideNow: startIsValid ? start.valueOf() : null, + zone: startIsValid ? start.zone : zone, + setZone: true, + }; + end = parseDataToDateTime(vals, parsedZone, endParseOpts, "ISO 8601 Interval end", e); endIsValid = end.isValid; } catch (e) { endIsValid = false; } + // if we overrode the user's choice for setZone earlier, make up for it now + if (startIsValid && !setZone) { + start = start.setZone(zone); + } + if (endIsValid && !setZone) { + end = end.setZone(zone); + } + if (startIsValid && endIsValid) { return Interval.fromDateTimes(start, end); } diff --git a/test/duration/format.test.js b/test/duration/format.test.js index 1c44c279d..55ac90b36 100644 --- a/test/duration/format.test.js +++ b/test/duration/format.test.js @@ -424,3 +424,58 @@ test("Duration#toHuman works in differt languages", () => { "1 an, 2 mois, 1 semaine, 3 jours, 4 heures, 5 minutes, 6 secondes, 7 millisecondes" ); }); + +test("Duration#toHuman handles quarters", () => { + expect( + Duration.fromObject({ + years: 1, + quarters: 2, + hours: 2, + }).toHuman() + ).toEqual("1 year, 6 months, 2 hours"); +}); + +test("Duration#toHuman handles quarters and months together", () => { + expect( + Duration.fromObject({ + years: 1, + months: 1, + quarters: 2, + hours: 2, + }).toHuman() + ).toEqual("1 year, 7 months, 2 hours"); +}); + +test("Duration#toHuman handles quarters and months with showZeros false", () => { + expect( + Duration.fromObject({ + years: 1, + months: 1, + quarters: 0, + hours: 2, + }).toHuman({ showZeros: false }) + ).toEqual("1 year, 1 month, 2 hours"); + expect( + Duration.fromObject({ + years: 1, + months: 0, + quarters: 1, + hours: 2, + }).toHuman({ showZeros: false }) + ).toEqual("1 year, 3 months, 2 hours"); + expect( + Duration.fromObject({ + years: 1, + months: 0, + quarters: 0, + hours: 2, + }).toHuman({ showZeros: false }) + ).toEqual("1 year, 2 hours"); + expect( + Duration.fromObject({ + years: 1, + quarters: 0, + hours: 2, + }).toHuman({ showZeros: false }) + ).toEqual("1 year, 2 hours"); +}); diff --git a/test/interval/parse.test.js b/test/interval/parse.test.js index fb70fb681..2512ac14f 100644 --- a/test/interval/parse.test.js +++ b/test/interval/parse.test.js @@ -144,3 +144,182 @@ test.each(badInputs)("Interval.fromISO will return invalid for [%s]", (s) => { expect(i.isValid).toBe(false); expect(i.invalidReason).toBe("unparsable"); }); + +describe("Interval.fromISO defaults missing values in end to start", () => { + test("Gregorian, end just time", () => { + const i = Interval.fromISO("1988-04-15T09/15:30"); + expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("1988-04-15T15:30:00.000-04:00"); + }); + test("Gregorian, end just time and zone", () => { + const i = Interval.fromISO("1988-04-15T09/15:30-07:00"); + expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("1988-04-15T18:30:00.000-04:00"); + }); + test("Gregorian, end just day", () => { + const i = Interval.fromISO("1988-04-15T09/17"); + expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("1988-04-17T00:00:00.000-04:00"); + }); + test("Gregorian, end day and time", () => { + const i = Interval.fromISO("1988-04-15T09/17T15:30"); + expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("1988-04-17T15:30:00.000-04:00"); + }); + test("Gregorian, end month and day", () => { + const i = Interval.fromISO("1988-04-15T09/05-17"); + expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("1988-05-17T00:00:00.000-04:00"); + }); + test("Gregorian, end month, day and time", () => { + const i = Interval.fromISO("1988-04-15T09/05-17T15:30"); + expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("1988-05-17T15:30:00.000-04:00"); + }); + test("Gregorian with zone in options and partial date", () => { + const i = Interval.fromISO("1988-04-15T09/19", { zone: "UTC-06:00" }); + expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-06:00"); + expect(i.end.toISO()).toBe("1988-04-19T00:00:00.000-06:00"); + }); + test("Gregorian with zone in options and partial date and time", () => { + const i = Interval.fromISO("1988-04-15T09/19T13:00", { zone: "UTC-06:00" }); + expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-06:00"); + expect(i.end.toISO()).toBe("1988-04-19T13:00:00.000-06:00"); + }); + test("Gregorian with zone in options and full date and time", () => { + const i = Interval.fromISO("1988-04-15T09/1989-03-01T13:00", { zone: "UTC-06:00" }); + expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-06:00"); + expect(i.end.toISO()).toBe("1989-03-01T13:00:00.000-06:00"); + }); + test("Gregorian with zone in options and end zone", () => { + const i = Interval.fromISO("1988-04-15T09/16T15:00-07:00", { zone: "UTC-06:00" }); + expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-06:00"); + expect(i.end.toISO()).toBe("1988-04-16T16:00:00.000-06:00"); + }); + test("Gregorian with zone in options, setZone and end zone", () => { + const i = Interval.fromISO("1988-04-15T09/16T15:00-07:00", { + zone: "UTC-06:00", + setZone: true, + }); + expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000-06:00"); + expect(i.end.toISO()).toBe("1988-04-16T15:00:00.000-07:00"); + }); + test("Gregorian with start zone", () => { + const i = Interval.fromISO("1988-04-15T09:00:00+01:00/17T15:30"); + expect(i.start.toISO()).toBe("1988-04-15T04:00:00.000-04:00"); + expect(i.end.toISO()).toBe("1988-04-17T10:30:00.000-04:00"); + }); + test("Gregorian with start zone and zone in options", () => { + const i = Interval.fromISO("1988-04-15T09:00:00+01:00/15T15:00", { zone: "UTC-06:00" }); + expect(i.start.toISO()).toBe("1988-04-15T02:00:00.000-06:00"); + expect(i.end.toISO()).toBe("1988-04-15T08:00:00.000-06:00"); + }); + test("Gregorian with start zone and setZone", () => { + const i = Interval.fromISO("1988-04-15T09:00:00+01:00/15T15:00", { setZone: true }); + expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000+01:00"); + expect(i.end.toISO()).toBe("1988-04-15T15:00:00.000+01:00"); + }); + test("Gregorian with two zones", () => { + const i = Interval.fromISO("1988-04-15T09:00:00+01:00/15T16:00+02:00"); + expect(i.start.toISO()).toBe("1988-04-15T04:00:00.000-04:00"); + expect(i.end.toISO()).toBe("1988-04-15T10:00:00.000-04:00"); + }); + test("Gregorian with two zones and setZone", () => { + const i = Interval.fromISO("1988-04-15T09:00:00+01:00/15T16:00+02:00", { setZone: true }); + expect(i.start.toISO()).toBe("1988-04-15T09:00:00.000+01:00"); + expect(i.end.toISO()).toBe("1988-04-15T16:00:00.000+02:00"); + }); + + // Week dates + test("Week, end just time", () => { + const i = Interval.fromISO("2025-W20-1T09/15:30"); + expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("2025-05-12T15:30:00.000-04:00"); + }); + test("Week, end just time and zone", () => { + const i = Interval.fromISO("2025-W20-1T09/15:30-07:00"); + expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("2025-05-12T18:30:00.000-04:00"); + }); + test("Week, end week day", () => { + const i = Interval.fromISO("2025-W20-1T09/3T15:30"); + expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("2025-05-14T15:30:00.000-04:00"); + }); + test("Week, end week number and week day", () => { + const i = Interval.fromISO("2025-W20-1T09/W21-3T15:30"); + expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("2025-05-21T15:30:00.000-04:00"); + }); + + // Ordinal dates + test("Ordinal, end just time", () => { + const i = Interval.fromISO("2025-132T09/15:30"); + expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("2025-05-12T15:30:00.000-04:00"); + }); + test("Ordinal, end just time and zone", () => { + const i = Interval.fromISO("2025-132T09/15:30-07:00"); + expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("2025-05-12T18:30:00.000-04:00"); + }); + test("Ordinal, end with ordinal", () => { + const i = Interval.fromISO("2025-132T09/135T15:30"); + expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("2025-05-15T15:30:00.000-04:00"); + }); + + // Mixed + test("Gregorian, end just weekday", () => { + const i = Interval.fromISO("2025-05-12T09/4T15:30"); + expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("2025-05-15T15:30:00.000-04:00"); + }); + test("Gregorian, end weekNumber and weekday", () => { + const i = Interval.fromISO("2025-05-12T09/W21-1T15:30"); + expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("2025-05-19T15:30:00.000-04:00"); + }); + test("Gregorian, end just ordinal", () => { + const i = Interval.fromISO("2025-05-12T09/135T15:30"); + expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("2025-05-15T15:30:00.000-04:00"); + }); + + test("Week date, end just day", () => { + const i = Interval.fromISO("2025-W20-1T09/15T15:30"); + expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("2025-05-15T15:30:00.000-04:00"); + }); + test("Week date, end month and day", () => { + const i = Interval.fromISO("2025-W20-1T09/06-15T15:30"); + expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("2025-06-15T15:30:00.000-04:00"); + }); + test("Week date, end just ordinal", () => { + const i = Interval.fromISO("2025-W20-1T09/135T15:30"); + expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("2025-05-15T15:30:00.000-04:00"); + }); + + test("Ordinal, end just day", () => { + const i = Interval.fromISO("2025-132T09/15T15:30"); + expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("2025-05-15T15:30:00.000-04:00"); + }); + test("Ordinal, end month and day", () => { + const i = Interval.fromISO("2025-132T09/06-15T15:30"); + expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("2025-06-15T15:30:00.000-04:00"); + }); + test("Ordinal, end just weekday", () => { + const i = Interval.fromISO("2025-132T09/4T15:30"); + expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("2025-05-15T15:30:00.000-04:00"); + }); + test("Ordinal, end weekNumber and weekday", () => { + const i = Interval.fromISO("2025-132T09/W21-1T15:30"); + expect(i.start.toISO()).toBe("2025-05-12T09:00:00.000-04:00"); + expect(i.end.toISO()).toBe("2025-05-19T15:30:00.000-04:00"); + }); +});