From 3b41ad2179491455207379a0a831eaee73b648bd Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 6 Aug 2026 22:04:31 +0000 Subject: [PATCH 1/6] Add Cron.format --- packages/effect/src/Cron.ts | 29 +++++++++++++++++++++++++++-- packages/effect/test/Cron.test.ts | 7 +++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/effect/src/Cron.ts b/packages/effect/src/Cron.ts index 609d030e27c..7fb9f46458c 100644 --- a/packages/effect/src/Cron.ts +++ b/packages/effect/src/Cron.ts @@ -12,7 +12,7 @@ import * as Data from "./Data.ts" import type * as DateTime from "./DateTime.ts" import * as Equal from "./Equal.ts" import * as Equ from "./Equivalence.ts" -import { format } from "./Formatter.ts" +import { format as formatValue } from "./Formatter.ts" import { constVoid, dual, pipe } from "./Function.ts" import * as Hash from "./Hash.ts" import { type Inspectable, NodeInspectSymbol } from "./Inspectable.ts" @@ -152,7 +152,7 @@ const CronProto = { return toPojo(this) }, toString(this: Cron) { - return `Cron(${format(toPojo(this))})` + return `Cron(${formatValue(toPojo(this))})` }, toJSON(this: Cron) { const out = toPojo(this) @@ -612,6 +612,31 @@ export const parse = (cron: string, tz?: DateTime.TimeZone | string): Result.Res */ export const parseUnsafe = (cron: string, tz?: DateTime.TimeZone | string): Cron => Result.getOrThrow(parse(cron, tz)) +/** + * Formats a `Cron` instance as a six-field cron expression. + * + * **Warning** + * + * Formatting drops the timezone information stored by the `Cron` instance. + * + * **Example** (Formatting a cron expression) + * + * ```ts import.meta.vitest + * import { Cron } from "effect" + * + * const cron = Cron.parseUnsafe("23 0-20/2 * * 0", "UTC") + * + * Cron.format(cron) // => "0 23 0,2,4,6,8,10,12,14,16,18,20 * * 0" + * ``` + * + * @category getters + * @since 4.0.0 + */ +export const format = (cron: Cron): string => + [cron.seconds, cron.minutes, cron.hours, cron.days, cron.months, cron.weekdays] + .map((values) => values.size === 0 ? "*" : Array.from(values).join(",")) + .join(" ") + /** * Returns `true` when a date/time matches a `Cron` schedule. * diff --git a/packages/effect/test/Cron.test.ts b/packages/effect/test/Cron.test.ts index 3a3d1a5d2fd..fffed77e234 100644 --- a/packages/effect/test/Cron.test.ts +++ b/packages/effect/test/Cron.test.ts @@ -218,6 +218,13 @@ describe("Cron", () => { ) }) + it("format", () => { + strictEqual( + Cron.format(Cron.parseUnsafe("23 0-20/2 * * 0", "Europe/Berlin")), + "0 23 0,2,4,6,8,10,12,14,16,18,20 * * 0" + ) + }) + it("make supports requiring both days and weekdays", () => { const utc = DateTime.zoneMakeNamedUnsafe("UTC") const values = { From aaf616c680bb286cda281a45598f4a10e85d6e98 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 6 Aug 2026 22:13:55 +0000 Subject: [PATCH 2/6] Preserve compact cron formatting --- packages/effect/src/Cron.ts | 38 ++++++++++++++++++++++++++----- packages/effect/test/Cron.test.ts | 17 ++++++++++++-- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/packages/effect/src/Cron.ts b/packages/effect/src/Cron.ts index 7fb9f46458c..7a08f493ebc 100644 --- a/packages/effect/src/Cron.ts +++ b/packages/effect/src/Cron.ts @@ -613,7 +613,7 @@ export const parse = (cron: string, tz?: DateTime.TimeZone | string): Result.Res export const parseUnsafe = (cron: string, tz?: DateTime.TimeZone | string): Cron => Result.getOrThrow(parse(cron, tz)) /** - * Formats a `Cron` instance as a six-field cron expression. + * Formats a `Cron` instance as a cron expression. * * **Warning** * @@ -626,16 +626,42 @@ export const parseUnsafe = (cron: string, tz?: DateTime.TimeZone | string): Cron * * const cron = Cron.parseUnsafe("23 0-20/2 * * 0", "UTC") * - * Cron.format(cron) // => "0 23 0,2,4,6,8,10,12,14,16,18,20 * * 0" + * Cron.format(cron) // => "23 0-20/2 * * 0" * ``` * * @category getters * @since 4.0.0 */ -export const format = (cron: Cron): string => - [cron.seconds, cron.minutes, cron.hours, cron.days, cron.months, cron.weekdays] - .map((values) => values.size === 0 ? "*" : Array.from(values).join(",")) - .join(" ") +export const format = (cron: Cron): string => { + const segments = [cron.seconds, cron.minutes, cron.hours, cron.days, cron.months, cron.weekdays] + .map(formatSegment) + return (cron.seconds.size === 1 && cron.seconds.has(0) ? segments.slice(1) : segments).join(" ") +} + +const formatSegment = (values: ReadonlySet): string => { + if (values.size === 0) { + return "*" + } + const array = Array.from(values) + const segments: Array = [] + let index = 0 + while (index < array.length) { + const start = array[index]! + const step = array[index + 1]! - start + if (index + 2 < array.length && array[index + 2]! - array[index + 1]! === step) { + let end = index + 2 + while (end + 1 < array.length && array[end + 1]! - array[end]! === step) { + end++ + } + segments.push(`${start}-${array[end]}${step === 1 ? "" : `/${step}`}`) + index = end + 1 + } else { + segments.push(`${start}`) + index++ + } + } + return segments.join(",") +} /** * Returns `true` when a date/time matches a `Cron` schedule. diff --git a/packages/effect/test/Cron.test.ts b/packages/effect/test/Cron.test.ts index fffed77e234..7e1c9c3119f 100644 --- a/packages/effect/test/Cron.test.ts +++ b/packages/effect/test/Cron.test.ts @@ -218,10 +218,23 @@ describe("Cron", () => { ) }) - it("format", () => { + it("format preserves compact cron syntax", () => { strictEqual( Cron.format(Cron.parseUnsafe("23 0-20/2 * * 0", "Europe/Berlin")), - "0 23 0,2,4,6,8,10,12,14,16,18,20 * * 0" + "23 0-20/2 * * 0" + ) + }) + + it("format compacts multiple runs within a field", () => { + strictEqual( + Cron.format(Cron.make({ + minutes: [0, 1, 2, 10, 20, 30], + hours: [], + days: [], + months: [], + weekdays: [] + })), + "0-2,10-30/10 * * * *" ) }) From e7a9057e615f9851997bcabd4151a7a7951037e6 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 6 Aug 2026 22:25:55 +0000 Subject: [PATCH 3/6] Expand Cron.format coverage --- packages/effect/test/Cron.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/effect/test/Cron.test.ts b/packages/effect/test/Cron.test.ts index 7e1c9c3119f..386116bc004 100644 --- a/packages/effect/test/Cron.test.ts +++ b/packages/effect/test/Cron.test.ts @@ -238,6 +238,35 @@ describe("Cron", () => { ) }) + it("format preserves non-uniform values", () => { + strictEqual( + Cron.format(Cron.make({ + minutes: [1, 5, 11], + hours: [], + days: [], + months: [], + weekdays: [] + })), + "1,5,11 * * * *" + ) + }) + + it("format handles default, non-default, and unrestricted seconds", () => { + const format = (seconds?: Iterable) => + Cron.format(Cron.make({ + seconds, + minutes: [], + hours: [], + days: [], + months: [], + weekdays: [] + })) + + strictEqual(format(), "* * * * *") + strictEqual(format([15, 30]), "15,30 * * * * *") + strictEqual(format([]), "* * * * * *") + }) + it("make supports requiring both days and weekdays", () => { const utc = DateTime.zoneMakeNamedUnsafe("UTC") const values = { From 46d0b78597824f125c05961d5ace64524fd4feb0 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 6 Aug 2026 23:01:43 +0000 Subject: [PATCH 4/6] Document Cron.format caveats --- .changeset/warm-clocks-format.md | 5 +++++ packages/effect/src/Cron.ts | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .changeset/warm-clocks-format.md diff --git a/.changeset/warm-clocks-format.md b/.changeset/warm-clocks-format.md new file mode 100644 index 00000000000..ab6a13c8465 --- /dev/null +++ b/.changeset/warm-clocks-format.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add `Cron.format` for converting a `Cron` instance to a cron expression. diff --git a/packages/effect/src/Cron.ts b/packages/effect/src/Cron.ts index 7a08f493ebc..0f09cf1162e 100644 --- a/packages/effect/src/Cron.ts +++ b/packages/effect/src/Cron.ts @@ -615,9 +615,11 @@ export const parseUnsafe = (cron: string, tz?: DateTime.TimeZone | string): Cron /** * Formats a `Cron` instance as a cron expression. * - * **Warning** + * **Gotchas** * - * Formatting drops the timezone information stored by the `Cron` instance. + * Formatting drops the timezone information and the `and` restriction between + * days and weekdays. Parsing the result is therefore not guaranteed to produce + * an equivalent schedule. * * **Example** (Formatting a cron expression) * From 3e3cee94013688e4259ccbd34e323aeda8aa5d1d Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 6 Aug 2026 23:25:55 +0000 Subject: [PATCH 5/6] Add Cron.format seconds option --- .changeset/warm-clocks-format.md | 2 +- packages/effect/src/Cron.ts | 14 ++++++++++++-- packages/effect/test/Cron.test.ts | 7 +++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.changeset/warm-clocks-format.md b/.changeset/warm-clocks-format.md index ab6a13c8465..04173b8c378 100644 --- a/.changeset/warm-clocks-format.md +++ b/.changeset/warm-clocks-format.md @@ -2,4 +2,4 @@ "effect": patch --- -Add `Cron.format` for converting a `Cron` instance to a cron expression. +Add `Cron.format` for converting a `Cron` instance to a cron expression, with an option to include the seconds field. diff --git a/packages/effect/src/Cron.ts b/packages/effect/src/Cron.ts index 0f09cf1162e..1b1cf6b21b3 100644 --- a/packages/effect/src/Cron.ts +++ b/packages/effect/src/Cron.ts @@ -615,6 +615,11 @@ export const parseUnsafe = (cron: string, tz?: DateTime.TimeZone | string): Cron /** * Formats a `Cron` instance as a cron expression. * + * **Details** + * + * The default seconds field (`0`) is omitted unless `includeSeconds` is `true`. + * Other seconds configurations are always included. + * * **Gotchas** * * Formatting drops the timezone information and the `and` restriction between @@ -629,15 +634,20 @@ export const parseUnsafe = (cron: string, tz?: DateTime.TimeZone | string): Cron * const cron = Cron.parseUnsafe("23 0-20/2 * * 0", "UTC") * * Cron.format(cron) // => "23 0-20/2 * * 0" + * Cron.format(cron, { includeSeconds: true }) // => "0 23 0-20/2 * * 0" * ``` * * @category getters * @since 4.0.0 */ -export const format = (cron: Cron): string => { +export const format = (cron: Cron, options?: { + readonly includeSeconds?: boolean +}): string => { const segments = [cron.seconds, cron.minutes, cron.hours, cron.days, cron.months, cron.weekdays] .map(formatSegment) - return (cron.seconds.size === 1 && cron.seconds.has(0) ? segments.slice(1) : segments).join(" ") + return ( + options?.includeSeconds !== true && cron.seconds.size === 1 && cron.seconds.has(0) ? segments.slice(1) : segments + ).join(" ") } const formatSegment = (values: ReadonlySet): string => { diff --git a/packages/effect/test/Cron.test.ts b/packages/effect/test/Cron.test.ts index 386116bc004..d4d94266210 100644 --- a/packages/effect/test/Cron.test.ts +++ b/packages/effect/test/Cron.test.ts @@ -225,6 +225,13 @@ describe("Cron", () => { ) }) + it("format can include the default seconds field", () => { + strictEqual( + Cron.format(Cron.parseUnsafe("23 0-20/2 * * 0"), { includeSeconds: true }), + "0 23 0-20/2 * * 0" + ) + }) + it("format compacts multiple runs within a field", () => { strictEqual( Cron.format(Cron.make({ From e77bc1d68f72842a8d23c94ec2f63900016b21f0 Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 7 Aug 2026 11:28:11 +1200 Subject: [PATCH 6/6] Apply suggestion from @tim-smart --- packages/effect/src/Cron.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/effect/src/Cron.ts b/packages/effect/src/Cron.ts index 1b1cf6b21b3..259c6a6f298 100644 --- a/packages/effect/src/Cron.ts +++ b/packages/effect/src/Cron.ts @@ -641,7 +641,7 @@ export const parseUnsafe = (cron: string, tz?: DateTime.TimeZone | string): Cron * @since 4.0.0 */ export const format = (cron: Cron, options?: { - readonly includeSeconds?: boolean + readonly includeSeconds?: boolean | undefined }): string => { const segments = [cron.seconds, cron.minutes, cron.hours, cron.days, cron.months, cron.weekdays] .map(formatSegment)