日本語 · English

Nearest weekday to the 15th — Quartz 15W by composition

Translated from the canonical Japanese page recipes/quartz-15w-nearest-weekday.md.

“The 15th of every month, or the nearest weekday if it falls on a weekend” is the requirement known as Quartz’s 15W. RRULE has no such vocabulary, and even the Quartz dialect cannot see holidays. Kairos has no dedicated Nearest roll convention — instead the requirement is a finite case split written as composition, which is exactly why it extends to holidays.

What happens elsewhere

Writing it in Kairos

Split the stream of 15ths three ways — weekday as is, Saturday backward, Sunday forward — and take the union:

# eval: 2026-01-01..2026-12-31
@JP
weekdays = everyDay |> filter(d => weekday(d) != Sat and weekday(d) != Sun)
d15 = everyDay |> within(month) |> nth(15)
(d15 |> filter(d => weekday(d) != Sat and weekday(d) != Sun))
  | (d15 |> filter(d => weekday(d) == Sat) |> roll(Preceding, on: weekdays))
  | (d15 |> filter(d => weekday(d) == Sun) |> roll(Following, on: weekdays))
#=> 2026-01-15 2026-02-16 2026-03-16 2026-04-15 2026-05-15 2026-06-15
#=> 2026-07-15 2026-08-14 2026-09-15 2026-10-15 2026-11-16 2026-12-15

February, March, and November (15th on a Sunday) move to the following Monday; August (15th on a Saturday) moves back to Friday 8/14. The case split hidden inside the single word “nearest” is visible as three lines of expression.

The holiday support that 15W cannot offer is just swapping weekdays for a business-day stream:

(d15 |> filter(…)) | (… |> roll(Preceding, on: bizDay)) | (… |> roll(Following, on: bizDay))

The roll targets become business days, and what to do when the 15th itself is a holiday is one more condition on d15 |> filter — a dedicated token folds the spec shut, while composition opens for repair.

Holiday support with real data — the 2026 US federal calendar

Here is that swap performed with real data. The roll targets become business days derived from the 2026 US federal holidays (observed dates):

# eval: 2026-01-01..2026-12-31 tz: America/New_York
premise US {
  calendar-system: Gregorian
  tz: "America/New_York"
  wkst: Sun
}

@US
federal2026 = [2026-01-01, 2026-01-19, 2026-02-16, 2026-05-25, 2026-06-19,
               2026-07-03, 2026-09-07, 2026-10-12, 2026-11-11, 2026-11-26,
               2026-12-25] covering: 2026..2026
satSun = everyDay |> filter(d => weekday(d) == Sat or weekday(d) == Sun)
bizDay = everyDay \ (satSun | federal2026)

d15 = everyDay |> within(month) |> nth(15)
(d15 |> filter(d => weekday(d) != Sat and weekday(d) != Sun))
  | (d15 |> filter(d => weekday(d) == Sat) |> roll(Preceding, on: bizDay))
  | (d15 |> filter(d => weekday(d) == Sun) |> roll(Following, on: bizDay))
#=> 2026-01-15 2026-02-17 2026-03-16 2026-04-15 2026-05-15 2026-06-15
#=> 2026-07-15 2026-08-14 2026-09-15 2026-10-15 2026-11-16 2026-12-15
#~> 範囲外 2026-01-01..2026-01-02(federal2026 covering 2026-01-01..2026-12-31)

February is the one to watch. The weekday nearest to Sunday 2/15 is Monday 2/16 — but 2/16 is Washington’s Birthday, a federal holiday — and because the roll targets are business days, the result escapes one more day to 2/17. The “nearest weekday that also avoids holidays”, which Quartz’s W structurally cannot express, falls out of swapping a single axis. The trailing annotation says that near the head of the window a roll target could lie before the coverage claim — the data’s edge made explicit instead of silently computed (the governing machinery: spec §4.10). Run the US version in the Playground.

Try it in your browser

Run it in the Playground — change nth(15) for any other day of the month.