Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add ISO8601 duration formatting #4343

Merged
merged 4 commits into from
Feb 5, 2025
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
5 changes: 5 additions & 0 deletions .changeset/warm-bulldogs-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": minor
---

Added `Duration.formatIso` and `Duration.fromIso` for formatting and parsing ISO8601 durations.
147 changes: 147 additions & 0 deletions packages/effect/src/Duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -850,3 +850,150 @@ 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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gcanti whoops. I forgot to remove this comment. I decided to go with 30 days per month qnd 365 days per year instead.

* 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)

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`)
}

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"}`
}

/**
* Formats a Duration into an ISO8601 duration string.
*
* Months are assumed to be 30 days and years are assumed to be 365 days.
*
* 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<string> => {
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<Duration> => {
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)?)?$/
96 changes: 96 additions & 0 deletions packages/effect/test/Duration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,4 +520,100 @@ describe("Duration", () => {
strictEqual(Duration.toWeeks("2 weeks"), 2)
strictEqual(Duration.toWeeks("14 days"), 2)
})

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"
)

assertSome(
Duration.formatIso(
Duration.days(1).pipe(
Duration.sum(Duration.hours(2)),
Duration.sum(Duration.minutes(30))
)
),
"P1DT2H30M"
)

assertSome(
Duration.formatIso(
Duration.hours(2).pipe(
Duration.sum(Duration.minutes(30)),
Duration.sum(Duration.millis(1500))
)
),
"PT2H30M1.5S"
)

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", () => {
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
)
)

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"))
})
})