Skip to content

Commit

Permalink
add ISO8601 duration formatting (#4343)
Browse files Browse the repository at this point in the history
  • Loading branch information
fubhy authored and effect-bot committed Feb 6, 2025
1 parent a6ec764 commit e7ea1af
Show file tree
Hide file tree
Showing 3 changed files with 248 additions and 0 deletions.
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 @@ -853,3 +853,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
* 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 @@ -521,4 +521,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"))
})
})

0 comments on commit e7ea1af

Please sign in to comment.