「隔週」と「10 日ごと」——月をまたいでも、年をまたいでも、ずれない書き方

Kairos

1. cron に「隔週」は書けない

crontab に「2 週間ごと」と書きたくなって、*/14 と書いた人は多いと思う。日の欄に */14 と書くと、cron は「1 日、15 日、29 日」に動く。月が替わると 1 日からやり直す。2 月なら 1 日、15 日の次は 3 月 1 日(2 週間)、その次は 15 日、29 日、そして 4 月 1 日(3 日後)。「2 週間ごと」にはならない。

同じことを Kairos で書いて、動きを見ておく。「各月の 1 日から数えて 14 日おき」は、その月の何日目かを返す ordinalIn(day, month, d) を使って書ける:

premise JP { calendar-system: Gregorian; tz: "Asia/Tokyo"; wkst: Mon }
@JP
everyDay |> filter(d => (ordinalIn(day, month, d) - 1) mod 14 == 0)
$ kairos list --from 2026-01-01 --to 2026-04-02 cron14.kairos
2026-01-01
2026-01-15
2026-01-29
2026-02-01
2026-02-15
2026-03-01
2026-03-15
2026-03-29
2026-04-01

1 月 29 日の 3 日後に 2 月 1 日、3 月 29 日の 3 日後に 4 月 1 日。これは */14 の動きを忠実に書いただけで、隔週ではない。cron が悪いのではなく、「月の中の何日目か」で数えている限り、月境界でリセットされるのは当然である。Debian の crontab のマニュアルは、この制約の回避策として「epoch 秒を 86400×14 で割った余りで判定するシェル式」を公式に例示している。

2. 「ISO 週番号の偶奇」ハックは、53 週の年で壊れる

もう一つよく見る回避策が、「その週の ISO 週番号が偶数(奇数)のときだけ動かす」——date +%V の値を 2 で割る、というものだ。1 年は 52 週だから、偶数週だけ動かせば隔週になる、という理屈である。

2026 年は、それが壊れる年である。2026 年 1 月 1 日は木曜日で、ISO 週番号の規則では53 週ある年になる。年末年始の金曜日の週番号を並べると:

$ for d in 2026-12-18 2026-12-25 2027-01-01 2027-01-08 2027-01-15; do echo "$d W$(date -d $d +%V)"; done
2026-12-18 W51
2026-12-25 W52
2027-01-01 W53
2027-01-08 W01
2027-01-15 W02

W53 の次が W01。奇数週に動かす設定なら、1 月 1 日と 1 月 8 日に2 週連続で動く。偶数週なら12 月 25 日の次が 1 月 15 日で、3 週空く。給与を隔週金曜に払う会社なら、どちらも事故である。これも週番号が「年の中で何週目か」で数えている限り、年境界で起きる。

3. Kairos では、起点から数える

Kairos の stride(n, from:) は、入力の点を、起点 from: から数えて n 個ごとに残す。月の中の何日目か、年の中の何週目か、といった枠の中の位置は見ない。だから枠の境界でリセットされない。

10 日ごと:

premise JP { calendar-system: Gregorian; tz: "Asia/Tokyo"; wkst: Mon }
@JP
everyDay |> stride(10, from: 2026-01-05)
$ kairos list --from 2026-01-01 --to 2026-03-01 every10.kairos
2026-01-05
2026-01-15
2026-01-25
2026-02-04
2026-02-14
2026-02-24

1 月 25 日の 10 日後は 2 月 4 日。月が替わっても 10 日である。

隔週の金曜(2026 年 1 月 9 日を起点に):

@JP
everyDay |> filter(d => weekday(d) == Fri) |> stride(2, from: 2026-01-09)
$ kairos list --from 2026-01-01 --to 2027-01-01 biweekly.kairos | wc -l
26
$ kairos list --from 2026-12-01 --to 2027-02-01 biweekly.kairos
2026-12-11
2026-12-25
2027-01-08
2027-01-22

2026 年に 26 回。年をまたいでも 12 月 25 日の 2 週間後が 1 月 8 日で、W53 のことは何も起きない。式のどこにも週番号が出てこないからである。

読み方を一つだけ。stride(2, …) は「2 個ごと」であって「2 週ごと」ではない。何を数えるかは前の段が決める。前の段が filter(d => weekday(d) == Fri) で「金曜の列」にしてあるから、その 2 個ごと=隔週金曜になる。前の段を営業日の列にすれば「3 営業日ごと」、毎日の列なら「10 日ごと」。同じ stride で、数える対象だけが変わる。

4. from: は位相

from: は省略できない。書き忘れると静的エラーで止まる。理由は、隔週には「どちらの週か」という位相があり、それを決めるのは起点しかないからだ。1 月 9 日起点と 1 月 16 日起点は、どちらも「隔週金曜」で、どちらも正しく、出る日は全部違う。ISO 週の偶奇ハックが壊れたのは、位相を「年の中の週番号」という、年ごとに付け直される座標に預けていたからで、from: は位相を日付で固定する。

from: は入力の点でなくてもよい。from: 2026-01-01 と書けば「1 月 1 日以降の最初の金曜」が数え始めになる。起点より前の点は出ない。

5. 祝日なら前営業日

給与なら、隔週金曜が祝日のときは前営業日に払いたい。営業日の定義を premise に足して、roll(Preceding, on: bizDay) を最後に置く(roll と shift の違いは「寄せる」と「数える」は別の操作であるを参照):

premise JP {
  calendar-system: Gregorian; tz: "Asia/Tokyo"; wkst: Mon
  national = [2026-01-01, 2026-01-12, 2026-02-11, 2026-02-23, 2026-03-20, 2026-04-29, 2026-05-03..2026-05-06,
              2026-07-20, 2026-08-11, 2026-09-21..2026-09-23, 2026-10-12, 2026-11-03, 2026-11-23] covering: 2026..2026
  satSun = everyDay |> filter(d => weekday(d) == Sat or weekday(d) == Sun)
  bizDay = everyDay \ (satSun | national)
}
@JP
everyDay |> filter(d => weekday(d) == Fri) |> stride(2, from: 2026-01-09) |> roll(Preceding, on: bizDay)
$ kairos list --from 2026-01-01 --to 2026-05-01 biweekly-roll.kairos
2026-01-09
2026-01-23
2026-02-06
2026-02-20
2026-03-06
2026-03-19
2026-04-03
2026-04-17
# ⚠ 範囲外 2026-01-01..2026-01-02(JP.national covering 2026-01-01..2026-12-31)

3 月 20 日(金)は春分の日なので、19 日(木)へ寄った。数える段(stride)と丸める段(roll)が分かれているので、「隔週」の位相は祝日で崩れない——次は 4 月 3 日で、19 日から数え直してはいない。

末尾の注記は、1 月 1 日が祝日で、もしそこに点が落ちれば「前営業日」は 2025 年——祝日表が覆っていない年——になる、という言語からの一言である(この定義で 1 月 1 日に点は落ちないので実害はない)。この注記の読み方は前回の 25 日払いの例に書いた。

6. 「第 N」と「N ごと」

紛らわしいので、境目を書いておく。

  • 枠の中の位置で言う——「各月の第 2 金曜」「各月の 1 日と 15 日」。枠(月)が替わるたびに 数え直す。Kairos では within(month) |> nth(2) や、§1 の ordinalIn の形。cron の */14 が本当に欲しかったものが、実はこちらだったこともある(「毎月 1 日と 15 日」なら */14 より 1,15 と書くほうが素直で、Kairos でも ordinalIn(day, month, d) を使って書ける)。
  • 起点から数えて言う——「隔週」「10 日ごと」「3 営業日ごと」。枠の境界を無視して数え続ける。 Kairos では stride(n, from:)。

cron は前者しか持っていない。後者を前者で書こうとしたのが */14 と週番号ハックで、月末と年末で綻ぶのは仕組みからの帰結である。

7. 動かす

前回までの型がそのまま使える。月末最終営業日の自動実行まで通すのrun-if-today.sh に、この定義ファイルを渡すだけ:

0 9 * * 5  cd /srv/payroll && ./run-if-today.sh biweekly-roll.kairos ./pay.sh

cron 側は「毎週金曜 9 時」に叩き、動くかどうかの判定は定義に委ねる。隔週の位相も、祝日の前倒しも、cron には書かない。

まとめ

  • */14 は「月の何日目か」で数えるので月境界でリセットされる。ISO 週番号の偶奇は「年の何週目か」で 数えるので 53 週ある年(2026 年)の年末で壊れる——どちらも枠の中の座標で数えた帰結
  • stride(n, from:) は起点から数える。月も年もまたぐ。2026 年の隔週金曜は 26 回、年をまたいで 12/25 → 1/8
  • 何を数えるかは前の段が決める(金曜の列なら隔週、営業日の列なら N 営業日ごと)
  • from: は位相。省略はできない
  • 祝日の前倒しは最後に roll(Preceding, on: bizDay)——数える段と丸める段は分かれている

▶ Playground で定義を実行する——2026 年の隔週金曜 26 点が出る。コメントにある「10 日ごと」「*/14 と同じ動き」「祝日なら前営業日」の行と入れ替えて試せる。from: を 1 月 16 日に変えると、全部が 1 週ずれる。ブラウザ内で完結し、何も送信しない。


Kairos はスケジュール定義言語です。仕様と参照実装はGitHub で公開しています。

コメント

タイトルとURLをコピーしました