From 3489baca069730e65b56a7e33966c5657e2d45d7 Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Sun, 26 Jan 2025 12:52:11 +0100 Subject: [PATCH] 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 a92dd7b0ed9..08e74ad7dae 100644 --- a/packages/effect/src/Duration.ts +++ b/packages/effect/src/Duration.ts @@ -845,3 +845,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 118bb3ff30b..89460c4db92 100644 --- a/packages/effect/test/Duration.test.ts +++ b/packages/effect/test/Duration.test.ts @@ -520,4 +520,42 @@ describe("Duration", () => { expect(Duration.toWeeks("2 weeks")).toBe(2) expect(Duration.toWeeks("14 days")).toBe(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")) + }) })