Business Day Schedules - ptweezy/cronstable GitHub Wiki

Business-day schedules (L-n, nW, LW, d#n)

Payroll, billing, and month-end jobs often need schedules such as "the last weekday", "three days before month-end", or "the third Friday". cronstable supports four forms for these schedules:

jobs:
  - name: monthly-close-prep
    command: ./stage-the-books
    schedule: "30 1 LW * *"       # the month's last weekday (Mon-Fri)
  - name: payroll-file-transmit
    command: ./send-payroll
    schedule: "0 7 L-3 * *"       # three days before the month's final day
  - name: invoice-day-run
    command: ./bill
    schedule: "0 8 15W * *"       # the weekday nearest the 15th
  - name: board-pack-render
    command: ./render
    schedule: "0 6 * * 5#3"       # the third Friday (also spelled fri#3)

The forms

Form Field Meaning
L-<n> day-of-month n days before the month's final day, n in 1 to 30 (L-1 in January is the 30th; a bare L stays the final day itself)
<n>W day-of-month the weekday (Mon-Fri) nearest day n, within the same month
LW day-of-month the month's last weekday
<d>#<n> day-of-week the month's n-th such weekday, n in 1 to 5; d is any single weekday value (numeric 0-7 with 7 meaning Sunday, or a name like fri)

These forms can be combined with plain values and each other: 1,15W,L in day-of-month, or mon#1,L5 in day-of-week. L5 means the month's last Friday; 5#3 means its third Friday.

Exact edge rules

The rules match Quartz, the scheduler these spellings come from:

  • <n>W shifts to the nearest weekday. A Saturday target resolves to the Friday before; a Sunday target to the Monday after. At the month's edges the shift flips inward so the fire never leaves the month. 1W on a Saturday 1st fires Monday the 3rd, and 31W on a Sunday 31st fires Friday the 29th.
  • A target the month never reaches does not fire that month. 31W in April behaves like a plain 31: no April fire. The schedule linter warns (skipped-months) when every selected month is too short.
  • L-<n> skips months that are too short. L-30 reaches day 1 only in 31-day months. L-28 runs in February only in leap years, which the linter notes as leap-day-only.
  • <d>#<n> skips months without that occurrence. 5#5 fires only in months with five Fridays. Valid ordinals are 1 to 5.
  • The day-field AND rule applies unchanged. When day-of-month and day-of-week are both restricted, a day must satisfy both. 0 0 15W * fri therefore fires only when the weekday nearest the 15th is a Friday. See schedules and time zones.

Quartz compatibility notes

With these forms, most Quartz day expressions paste in unchanged, ? included (0 0 12 ? * MON#2 * parses verbatim). Two differences remain deliberate:

  • Weekday numbers differ. Quartz numbers Sunday to Saturday as 1 to 7. This dialect keeps its own 0 to 7 (both 0 and 7 are Sunday). A pasted numeric 6#3 therefore means the third Saturday here, not Quartz's third Friday. Weekday names agree in both dialects, so FRI#3 means the same thing everywhere. Prefer names when porting.
  • Last-weekday is spelled L5, not 5L. A trailing-L Quartz form fails with a hint naming the L<n> spelling. Likewise # outside day-of-week and W outside day-of-month fail with a hint naming the one field each is valid in.

Where the forms show up

These forms are supported throughout cronstable:

  • The plain-English describers, server and dashboard alike: 0 0 L-3 * * reads "At 00:00, on 3 days before the last day of the month".
  • Schedule linting: the preceding month-reachability checks, plus the day-field AND warning when combined with a weekday restriction.
  • The no-run explainer (GET /schedule/why, Model Context Protocol cron_why_no_run) decomposes them per instant: "day-of-month wanted the weekday nearest day 15 (15W)".
  • Schedule load, duplicate detection (semantic equality treats fri#3 and 5#3 as equal), and the fire previews.
  • The calendar export and week calendar.

See also: Schedules and Timezones for the whole dialect, Hashed Schedules for the H forms.

⚠️ **GitHub.com Fallback** ⚠️