From c9508750b60686cea882f881b0caf1d989e7c5a5 Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Sun, 26 Jan 2025 12:52:11 +0100 Subject: [PATCH 1/4] add ISO8601 duration formatting --- .changeset/warm-bulldogs-grab.md | 5 ++ packages/effect/src/Duration.ts | 71 +++++++++++++++++++++++++++ packages/effect/test/Duration.test.ts | 38 ++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 .changeset/warm-bulldogs-grab.md diff --git a/.changeset/warm-bulldogs-grab.md b/.changeset/warm-bulldogs-grab.md new file mode 100644 index 00000000000..dd07aea00b6 --- /dev/null +++ b/.changeset/warm-bulldogs-grab.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Added `Duration.unsafeFormatIso` for formatting durations as ISO8601 string. diff --git a/packages/effect/src/Duration.ts b/packages/effect/src/Duration.ts index 815a2771f95..72b5125b87a 100644 --- a/packages/effect/src/Duration.ts +++ b/packages/effect/src/Duration.ts @@ -850,3 +850,74 @@ export const format = (self: DurationInput): string => { return pieces.join(" ") } + +/** + * Formats a Duration into an ISO8601 duration string. + * + * The ISO8601 duration format is generally specified as P[n]Y[n]M[n]DT[n]H[n]M[n]S. However, since + * the `Duration` type does not support years or months, this function will only output the days, hours, + * minutes and seconds. Thus, the effective format is P[n]DT[n]H[n]M[n]S. + * + * Milliseconds and nanoseconds are expressed as fractional seconds. + * + * @throws `RangeError` If the duration is not finite. + * + * @example + * ```ts + * import { Duration } from "effect" + * + * Duration.unsafeFormatIso(Duration.days(1)) // => "P1D" + * Duration.unsafeFormatIso(Duration.minutes(90)) // => "PT1H30M" + * Duration.unsafeFormatIso(Duration.millis(1500)) // => "PT1.5S" + * ``` + * + * @since 3.13.0 + * @category conversions + */ +export const unsafeFormatIso = (self: DurationInput): string => { + const duration = decode(self) + if (!isFinite(duration)) { + throw new RangeError("Cannot format infinite duration") + } + + const fragments = [] + const { + days, + hours, + millis, + minutes, + nanos, + seconds + } = parts(duration) + + if (days >= 7) { + const rest = days % 7 + const weeks = (days - rest) / 7 + fragments.push(`${weeks}W`) + if (rest !== 0) { + fragments.push(`${rest}D`) + } + } else if (days !== 0) { + fragments.push(`${days}D`) + } + + if (hours !== 0 || minutes !== 0 || seconds !== 0 || millis !== 0 || nanos !== 0) { + fragments.push("T") + + if (hours !== 0) { + fragments.push(`${hours}H`) + } + + if (minutes !== 0) { + fragments.push(`${minutes}M`) + } + + if (seconds !== 0 || millis !== 0 || nanos !== 0) { + const total = BigInt(seconds) * bigint1e9 + BigInt(millis) * bigint1e6 + BigInt(nanos) + const str = (Number(total) / 1e9).toFixed(9).replace(/\.?0+$/, "") + fragments.push(`${str}S`) + } + } + + return `P${fragments.join("") || "T0S"}` +} diff --git a/packages/effect/test/Duration.test.ts b/packages/effect/test/Duration.test.ts index 5c97b2d2bae..47f7c38c054 100644 --- a/packages/effect/test/Duration.test.ts +++ b/packages/effect/test/Duration.test.ts @@ -520,4 +520,42 @@ describe("Duration", () => { strictEqual(Duration.toWeeks("2 weeks"), 2) strictEqual(Duration.toWeeks("14 days"), 2) }) + + it("unsafeFormatIso", () => { + expect(Duration.unsafeFormatIso(Duration.zero)).toBe("PT0S") + expect(Duration.unsafeFormatIso(Duration.seconds(2))).toBe("PT2S") + expect(Duration.unsafeFormatIso(Duration.minutes(5))).toBe("PT5M") + expect(Duration.unsafeFormatIso(Duration.hours(3))).toBe("PT3H") + expect(Duration.unsafeFormatIso(Duration.days(1))).toBe("P1D") + + expect(Duration.unsafeFormatIso(Duration.minutes(90))).toBe("PT1H30M") + expect(Duration.unsafeFormatIso(Duration.hours(25))).toBe("P1DT1H") + expect(Duration.unsafeFormatIso(Duration.days(7))).toBe("P1W") + expect(Duration.unsafeFormatIso(Duration.days(10))).toBe("P1W3D") + + expect(Duration.unsafeFormatIso(Duration.millis(1500))).toBe("PT1.5S") + expect(Duration.unsafeFormatIso(Duration.micros(1500n))).toBe("PT0.0015S") + expect(Duration.unsafeFormatIso(Duration.nanos(1500n))).toBe("PT0.0000015S") + + expect(Duration.unsafeFormatIso( + Duration.days(1).pipe( + Duration.sum(Duration.hours(2)), + Duration.sum(Duration.minutes(30)) + ) + )).toBe("P1DT2H30M") + + expect(Duration.unsafeFormatIso( + Duration.hours(2).pipe( + Duration.sum(Duration.minutes(30)), + Duration.sum(Duration.millis(1500)) + ) + )).toBe("PT2H30M1.5S") + + expect(Duration.unsafeFormatIso("1 day")).toBe("P1D") + expect(Duration.unsafeFormatIso("90 minutes")).toBe("PT1H30M") + expect(Duration.unsafeFormatIso("1.5 seconds")).toBe("PT1.5S") + + expect(() => Duration.unsafeFormatIso(Duration.infinity)) + .toThrow(new RangeError("Cannot format infinite duration")) + }) }) From 4d72c63f18193eb9c4336872886af60267649c8c Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Thu, 30 Jan 2025 09:15:06 +0100 Subject: [PATCH 2/4] add `fromIso` parsing --- packages/effect/src/Duration.ts | 68 +++++++++++++++++++++++++++ packages/effect/test/Duration.test.ts | 42 +++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/packages/effect/src/Duration.ts b/packages/effect/src/Duration.ts index 72b5125b87a..45c37c0ce0a 100644 --- a/packages/effect/src/Duration.ts +++ b/packages/effect/src/Duration.ts @@ -921,3 +921,71 @@ export const unsafeFormatIso = (self: DurationInput): string => { return `P${fragments.join("") || "T0S"}` } + +/** + * Formats a Duration into an ISO8601 duration string. + * + * The ISO8601 duration format is generally specified as P[n]Y[n]M[n]W[n]DT[n]H[n]M[n]S. However, since + * the `Duration` type does not support years or months, this function will only output the days, hours, + * minutes and seconds. Thus, the effective format is P[n]W[n]DT[n]H[n]M[n]S. + * + * Milliseconds and nanoseconds are expressed as fractional seconds. + * + * Returns `Option.none()` if the duration is infinite. + * + * @example + * ```ts + * import { Duration, Option } from "effect" + * + * Duration.formatIso(Duration.days(1)) // => Option.some("P1D") + * Duration.formatIso(Duration.minutes(90)) // => Option.some("PT1H30M") + * Duration.formatIso(Duration.millis(1500)) // => Option.some("PT1.5S") + * Duration.formatIso(Duration.infinity) // => Option.none() + * ``` + * + * @since 3.13.0 + * @category conversions + */ +export const formatIso = (self: DurationInput): Option.Option => { + const duration = decode(self) + return isFinite(duration) ? Option.some(unsafeFormatIso(duration)) : Option.none() +} + +/** + * Parses an ISO8601 duration string into a `Duration`. + * + * Months are assumed to be 30 days and years are assumed to be 365 days. + * + * @example + * ```ts + * import { Duration, Option } from "effect" + * + * Duration.fromIso("P1D") // => Option.some(Duration.days(1)) + * Duration.fromIso("PT1H") // => Option.some(Duration.hours(1)) + * Duration.fromIso("PT1M") // => Option.some(Duration.minutes(1)) + * Duration.fromIso("PT1.5S") // => Option.some(Duration.seconds(1.5)) + * ``` + * + * @since 3.13.0 + * @category conversions + */ +export const fromIso = (iso: string): Option.Option => { + const result = DURATION_ISO_REGEX.exec(iso) + if (result == null) { + return Option.none() + } + + const [years, months, weeks, days, hours, mins, secs] = result.slice(1, 8).map((_) => _ ? Number(_) : 0) + const value = years * 365 * 24 * 60 * 60 + + months * 30 * 24 * 60 * 60 + + weeks * 7 * 24 * 60 * 60 + + days * 24 * 60 * 60 + + hours * 60 * 60 + + mins * 60 + + secs + + return Option.some(seconds(value)) +} + +const DURATION_ISO_REGEX = + /^P(?!$)(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?!$)(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/ diff --git a/packages/effect/test/Duration.test.ts b/packages/effect/test/Duration.test.ts index 47f7c38c054..1b8c0628d0c 100644 --- a/packages/effect/test/Duration.test.ts +++ b/packages/effect/test/Duration.test.ts @@ -558,4 +558,46 @@ describe("Duration", () => { expect(() => Duration.unsafeFormatIso(Duration.infinity)) .toThrow(new RangeError("Cannot format infinite duration")) }) + + it("fromIso", () => { + expect(Duration.fromIso("P1D")).toEqual(Option.some(Duration.days(1))) + expect(Duration.fromIso("PT1H")).toEqual(Option.some(Duration.hours(1))) + expect(Duration.fromIso("PT1M")).toEqual(Option.some(Duration.minutes(1))) + expect(Duration.fromIso("PT1.5S")).toEqual(Option.some(Duration.seconds(1.5))) + expect(Duration.fromIso("P1Y")).toEqual(Option.some(Duration.days(365))) + expect(Duration.fromIso("P1M")).toEqual(Option.some(Duration.days(30))) + expect(Duration.fromIso("P1W")).toEqual(Option.some(Duration.days(7))) + + expect(Duration.fromIso("P1Y2M3DT4H5M6.789S")).toEqual( + Option.some(Duration.seconds( + 365 * 24 * 60 * 60 + // 1 year + 60 * 24 * 60 * 60 + // 2 months + 3 * 24 * 60 * 60 + // 3 days + 4 * 60 * 60 + // 4 hours + 5 * 60 + // 5 minutes + 6.789 // 6.789 seconds + )) + ) + + expect(Duration.fromIso("P1DT12H")).toEqual( + Option.some(Duration.hours(36)) + ) + + expect(Duration.fromIso("1D")).toEqual(Option.none()) + expect(Duration.fromIso("P1H")).toEqual(Option.none()) + expect(Duration.fromIso("PT1D")).toEqual(Option.none()) + expect(Duration.fromIso("P1.5D")).toEqual(Option.none()) + expect(Duration.fromIso("P1.5Y")).toEqual(Option.none()) + expect(Duration.fromIso("P1.5M")).toEqual(Option.none()) + expect(Duration.fromIso("PT1.5H")).toEqual(Option.none()) + expect(Duration.fromIso("PT1.5M")).toEqual(Option.none()) + expect(Duration.fromIso("PDT1H")).toEqual(Option.none()) + expect(Duration.fromIso("P1D2H")).toEqual(Option.none()) + expect(Duration.fromIso("P")).toEqual(Option.none()) + expect(Duration.fromIso("PT")).toEqual(Option.none()) + expect(Duration.fromIso("random string")).toEqual(Option.none()) + expect(Duration.fromIso("P1YT")).toEqual(Option.none()) + expect(Duration.fromIso("P1S")).toEqual(Option.none()) + expect(Duration.fromIso("P1DT1S1H")).toEqual(Option.none()) + }) }) From f3f73705a09dc8215bd8e6de0853d9fe9b90ebbd Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Thu, 30 Jan 2025 09:15:54 +0100 Subject: [PATCH 3/4] adjust changeset --- .changeset/warm-bulldogs-grab.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/warm-bulldogs-grab.md b/.changeset/warm-bulldogs-grab.md index dd07aea00b6..31f05b97a8c 100644 --- a/.changeset/warm-bulldogs-grab.md +++ b/.changeset/warm-bulldogs-grab.md @@ -2,4 +2,4 @@ "effect": minor --- -Added `Duration.unsafeFormatIso` for formatting durations as ISO8601 string. +Added `Duration.unsafeFormatIso` and `Duration.fromIso` for formatting and parsing ISO8601 durations. From 1e4c3c05711c1fd0dc9ed2576dbccd03c5a3538f Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Fri, 31 Jan 2025 10:12:21 +0100 Subject: [PATCH 4/4] support years and months --- .changeset/warm-bulldogs-grab.md | 2 +- packages/effect/src/Duration.ts | 34 +++--- packages/effect/test/Duration.test.ts | 144 ++++++++++++++------------ 3 files changed, 102 insertions(+), 78 deletions(-) diff --git a/.changeset/warm-bulldogs-grab.md b/.changeset/warm-bulldogs-grab.md index 31f05b97a8c..c3c3bd82277 100644 --- a/.changeset/warm-bulldogs-grab.md +++ b/.changeset/warm-bulldogs-grab.md @@ -2,4 +2,4 @@ "effect": minor --- -Added `Duration.unsafeFormatIso` and `Duration.fromIso` for formatting and parsing ISO8601 durations. +Added `Duration.formatIso` and `Duration.fromIso` for formatting and parsing ISO8601 durations. diff --git a/packages/effect/src/Duration.ts b/packages/effect/src/Duration.ts index 45c37c0ce0a..1f099899e37 100644 --- a/packages/effect/src/Duration.ts +++ b/packages/effect/src/Duration.ts @@ -890,15 +890,27 @@ export const unsafeFormatIso = (self: DurationInput): string => { seconds } = parts(duration) - if (days >= 7) { - const rest = days % 7 - const weeks = (days - rest) / 7 + let rest = days + if (rest >= 365) { + const years = Math.floor(rest / 365) + rest %= 365 + fragments.push(`${years}Y`) + } + + if (rest >= 30) { + const months = Math.floor(rest / 30) + rest %= 30 + fragments.push(`${months}M`) + } + + if (rest >= 7) { + const weeks = Math.floor(rest / 7) + rest %= 7 fragments.push(`${weeks}W`) - if (rest !== 0) { - fragments.push(`${rest}D`) - } - } else if (days !== 0) { - fragments.push(`${days}D`) + } + + if (rest > 0) { + fragments.push(`${rest}D`) } if (hours !== 0 || minutes !== 0 || seconds !== 0 || millis !== 0 || nanos !== 0) { @@ -925,11 +937,7 @@ export const unsafeFormatIso = (self: DurationInput): string => { /** * Formats a Duration into an ISO8601 duration string. * - * The ISO8601 duration format is generally specified as P[n]Y[n]M[n]W[n]DT[n]H[n]M[n]S. However, since - * the `Duration` type does not support years or months, this function will only output the days, hours, - * minutes and seconds. Thus, the effective format is P[n]W[n]DT[n]H[n]M[n]S. - * - * Milliseconds and nanoseconds are expressed as fractional seconds. + * Months are assumed to be 30 days and years are assumed to be 365 days. * * Returns `Option.none()` if the duration is infinite. * diff --git a/packages/effect/test/Duration.test.ts b/packages/effect/test/Duration.test.ts index 1b8c0628d0c..f49d3f87624 100644 --- a/packages/effect/test/Duration.test.ts +++ b/packages/effect/test/Duration.test.ts @@ -521,83 +521,99 @@ describe("Duration", () => { strictEqual(Duration.toWeeks("14 days"), 2) }) - it("unsafeFormatIso", () => { - expect(Duration.unsafeFormatIso(Duration.zero)).toBe("PT0S") - expect(Duration.unsafeFormatIso(Duration.seconds(2))).toBe("PT2S") - expect(Duration.unsafeFormatIso(Duration.minutes(5))).toBe("PT5M") - expect(Duration.unsafeFormatIso(Duration.hours(3))).toBe("PT3H") - expect(Duration.unsafeFormatIso(Duration.days(1))).toBe("P1D") - - expect(Duration.unsafeFormatIso(Duration.minutes(90))).toBe("PT1H30M") - expect(Duration.unsafeFormatIso(Duration.hours(25))).toBe("P1DT1H") - expect(Duration.unsafeFormatIso(Duration.days(7))).toBe("P1W") - expect(Duration.unsafeFormatIso(Duration.days(10))).toBe("P1W3D") - - expect(Duration.unsafeFormatIso(Duration.millis(1500))).toBe("PT1.5S") - expect(Duration.unsafeFormatIso(Duration.micros(1500n))).toBe("PT0.0015S") - expect(Duration.unsafeFormatIso(Duration.nanos(1500n))).toBe("PT0.0000015S") - - expect(Duration.unsafeFormatIso( - Duration.days(1).pipe( - Duration.sum(Duration.hours(2)), - Duration.sum(Duration.minutes(30)) - ) - )).toBe("P1DT2H30M") + it("formatIso", () => { + assertSome(Duration.formatIso(Duration.zero), "PT0S") + assertSome(Duration.formatIso(Duration.seconds(2)), "PT2S") + assertSome(Duration.formatIso(Duration.minutes(5)), "PT5M") + assertSome(Duration.formatIso(Duration.hours(3)), "PT3H") + assertSome(Duration.formatIso(Duration.days(1)), "P1D") + + assertSome(Duration.formatIso(Duration.minutes(90)), "PT1H30M") + assertSome(Duration.formatIso(Duration.hours(25)), "P1DT1H") + assertSome(Duration.formatIso(Duration.days(7)), "P1W") + assertSome(Duration.formatIso(Duration.days(10)), "P1W3D") + + assertSome(Duration.formatIso(Duration.millis(1500)), "PT1.5S") + assertSome(Duration.formatIso(Duration.micros(1500n)), "PT0.0015S") + assertSome(Duration.formatIso(Duration.nanos(1500n)), "PT0.0000015S") + + assertSome( + Duration.formatIso( + Duration.seconds( + 365 * 24 * 60 * 60 + // 1 year + 60 * 24 * 60 * 60 + // 2 months + 3 * 24 * 60 * 60 + // 3 days + 4 * 60 * 60 + // 4 hours + 5 * 60 + // 5 minutes + 6.789 // 6.789 seconds + ) + ), + "P1Y2M3DT4H5M6.789S" + ) - expect(Duration.unsafeFormatIso( - Duration.hours(2).pipe( - Duration.sum(Duration.minutes(30)), - Duration.sum(Duration.millis(1500)) - ) - )).toBe("PT2H30M1.5S") + assertSome( + Duration.formatIso( + Duration.days(1).pipe( + Duration.sum(Duration.hours(2)), + Duration.sum(Duration.minutes(30)) + ) + ), + "P1DT2H30M" + ) - expect(Duration.unsafeFormatIso("1 day")).toBe("P1D") - expect(Duration.unsafeFormatIso("90 minutes")).toBe("PT1H30M") - expect(Duration.unsafeFormatIso("1.5 seconds")).toBe("PT1.5S") + assertSome( + Duration.formatIso( + Duration.hours(2).pipe( + Duration.sum(Duration.minutes(30)), + Duration.sum(Duration.millis(1500)) + ) + ), + "PT2H30M1.5S" + ) - expect(() => Duration.unsafeFormatIso(Duration.infinity)) - .toThrow(new RangeError("Cannot format infinite duration")) + assertSome(Duration.formatIso("1 day"), "P1D") + assertSome(Duration.formatIso("90 minutes"), "PT1H30M") + assertSome(Duration.formatIso("1.5 seconds"), "PT1.5S") + + assertNone(Duration.formatIso(Duration.infinity)) }) it("fromIso", () => { - expect(Duration.fromIso("P1D")).toEqual(Option.some(Duration.days(1))) - expect(Duration.fromIso("PT1H")).toEqual(Option.some(Duration.hours(1))) - expect(Duration.fromIso("PT1M")).toEqual(Option.some(Duration.minutes(1))) - expect(Duration.fromIso("PT1.5S")).toEqual(Option.some(Duration.seconds(1.5))) - expect(Duration.fromIso("P1Y")).toEqual(Option.some(Duration.days(365))) - expect(Duration.fromIso("P1M")).toEqual(Option.some(Duration.days(30))) - expect(Duration.fromIso("P1W")).toEqual(Option.some(Duration.days(7))) - - expect(Duration.fromIso("P1Y2M3DT4H5M6.789S")).toEqual( - Option.some(Duration.seconds( + assertSome(Duration.fromIso("P1D"), Duration.days(1)) + assertSome(Duration.fromIso("PT1H"), Duration.hours(1)) + assertSome(Duration.fromIso("PT1M"), Duration.minutes(1)) + assertSome(Duration.fromIso("PT1.5S"), Duration.seconds(1.5)) + assertSome(Duration.fromIso("P1Y"), Duration.days(365)) + assertSome(Duration.fromIso("P1M"), Duration.days(30)) + assertSome(Duration.fromIso("P1W"), Duration.days(7)) + assertSome(Duration.fromIso("P1DT12H"), Duration.hours(36)) + assertSome( + Duration.fromIso("P1Y2M3DT4H5M6.789S"), + Duration.seconds( 365 * 24 * 60 * 60 + // 1 year 60 * 24 * 60 * 60 + // 2 months 3 * 24 * 60 * 60 + // 3 days 4 * 60 * 60 + // 4 hours 5 * 60 + // 5 minutes 6.789 // 6.789 seconds - )) - ) - - expect(Duration.fromIso("P1DT12H")).toEqual( - Option.some(Duration.hours(36)) + ) ) - expect(Duration.fromIso("1D")).toEqual(Option.none()) - expect(Duration.fromIso("P1H")).toEqual(Option.none()) - expect(Duration.fromIso("PT1D")).toEqual(Option.none()) - expect(Duration.fromIso("P1.5D")).toEqual(Option.none()) - expect(Duration.fromIso("P1.5Y")).toEqual(Option.none()) - expect(Duration.fromIso("P1.5M")).toEqual(Option.none()) - expect(Duration.fromIso("PT1.5H")).toEqual(Option.none()) - expect(Duration.fromIso("PT1.5M")).toEqual(Option.none()) - expect(Duration.fromIso("PDT1H")).toEqual(Option.none()) - expect(Duration.fromIso("P1D2H")).toEqual(Option.none()) - expect(Duration.fromIso("P")).toEqual(Option.none()) - expect(Duration.fromIso("PT")).toEqual(Option.none()) - expect(Duration.fromIso("random string")).toEqual(Option.none()) - expect(Duration.fromIso("P1YT")).toEqual(Option.none()) - expect(Duration.fromIso("P1S")).toEqual(Option.none()) - expect(Duration.fromIso("P1DT1S1H")).toEqual(Option.none()) + assertNone(Duration.fromIso("1D")) + assertNone(Duration.fromIso("P1H")) + assertNone(Duration.fromIso("PT1D")) + assertNone(Duration.fromIso("P1.5D")) + assertNone(Duration.fromIso("P1.5Y")) + assertNone(Duration.fromIso("P1.5M")) + assertNone(Duration.fromIso("PT1.5H")) + assertNone(Duration.fromIso("PT1.5M")) + assertNone(Duration.fromIso("PDT1H")) + assertNone(Duration.fromIso("P1D2H")) + assertNone(Duration.fromIso("P")) + assertNone(Duration.fromIso("PT")) + assertNone(Duration.fromIso("random string")) + assertNone(Duration.fromIso("P1YT")) + assertNone(Duration.fromIso("P1S")) + assertNone(Duration.fromIso("P1DT1S1H")) }) })