Reactで、cronの式から次の実行時刻を一覧にする方法

​​React​cron,cron-parser,Intl.DateTimeFormat,Playwright,TypeScript

cron の式から次の実行時刻を出すには、cron-parser の CronExpressionParser.parse() に、式と「いつから数えるか」と「どのタイムゾーンで数えるか」を渡します。返ってきたものから take(5) で5件取り出せば、次の5回分の時刻が Date で手に入ります。

ここでは、cron の式とタイムゾーンを選ぶと次の5回を表示する CronNextRuns という部品を作りました。計算は cronNext.ts にまとめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、cron-parser 5.10.3、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を esbuild で変換し、tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを Playwright で操作しました。時計は Playwright の page.clock で 2026年11月5日 10:17(日本時間)などに合わせています。Firefox と Safari、スマートフォンでの表示と、cron の本体(cronie など)で実際にジョブを動かしたときの時刻は試していません。

「cron の式(分 時 日 月 曜日)」の入力欄に「0 9 * * 1-5」、タイムゾーンに「Asia/Tokyo(このブラウザ)」が入っている。その下に「2026/11/06(金) 09:00」から「2026/11/12(木) 09:00」までの5行が番号付きで並んでいる
2026年11月5日 10:17 に、平日の9時の式から次の5回を出した結果

cron-parser で、次の5回の時刻を取り出す

計算の関数です。

ts
// cron の式から、from より後の実行時刻を count 件求める
import { CronExpressionParser } from 'cron-parser';

export type NextRuns =
  | { ok: true; dates: Date[] }
  | { ok: false; message: string };

export function nextRuns(expr: string, tz: string, from: Date, count = 5): NextRuns {
  const text = expr.trim();
  const fieldCount = text === '' ? 0 : text.split(/\s+/).length;
  // cron-parser は足りない項目を * で埋め、6項目なら先頭を「秒」として読む。
  // 書き間違いに気づけるように、@daily などの別名以外は5項目だけを受け付ける
  if (!text.startsWith('@') && fieldCount !== 5) {
    return {
      ok: false,
      message: `項目が${fieldCount}個です。「分 時 日 月 曜日」の5つを空白で区切って書いてください`,
    };
  }
  try {
    const interval = CronExpressionParser.parse(text, { currentDate: from, tz });
    return { ok: true, dates: interval.take(count).map((d) => d.toDate()) };
  } catch (e) {
    return { ok: false, message: e instanceof Error ? e.message : String(e) };
  }
}

currentDate は数え始める時刻、tz は式を読むタイムゾーンです。cron-parser の README のオプションの表に、どちらも載っています。take() が返すのは cron-parser の CronDate なので、toDate() で Date に直しています。

返ってくる時刻は、currentDate より後のものだけです。Node.js で 0 9 * * * を日本時間で数えると、8時59分59秒999 から数えた場合は当日の9時が先頭でした。ちょうど9時0分0秒000 から数えた場合は、翌日の9時が先頭になりました。

項目の数は、部品の側で確かめる

cron-parser に渡す前に項目の数を数えているのは、5つでない式でもエラーにならないためです。検証環境で cron-parser にそのまま渡すと、空の文字列と * * * は、どちらも毎分の式として読まれました。0 9 * * * * のように6つ書くと、先頭が「秒」になり、「毎時9分」の時刻が並びました。秒から始まる6つの形は、README の「Cron Format」の図にも載っています。

入力欄に打っている途中の式も、そのまま計算に回ります。項目が足りないまま毎分の時刻が並ぶと、まだ書き終わっていないことに気づきにくいので、@daily のような別名を除いて5つのときだけ計算するようにしました。

値がおかしい式は、cron-parser がエラーを投げます。部品では、そのメッセージを「読み取れませんでした:」の後ろに出しています。

入力した式 表示されたメッセージ
* * * 項目が3個です。「分 時 日 月 曜日」の5つを空白で区切って書いてください
0 9 * * * * 項目が6個です。「分 時 日 月 曜日」の5つを空白で区切って書いてください
60 * * * * Constraint error, got value 60 expected range 0-59
0 9 * * 8 Constraint error, got value 8 expected range 0-7
0 9 31 2 * Invalid explicit day of month definition
@reboot Validation error, cannot resolve alias "reb"

2月31日のように、ありえない日付の式もエラーになりました。一方で 0 0 29 2 *(2月29日)は、2028年、2032年…と、うるう年の2月29日が5件並びました。

部品にまとめる

tsx
import { useEffect, useId, useMemo, useState } from 'react';
import { nextRuns } from './cronNext';

const LOCAL_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
const ZONES = [...new Set([LOCAL_ZONE, 'Asia/Tokyo', 'UTC', 'America/New_York'])];
// setTimeout に渡せる待ち時間は約24.8日まで。長い待ちは1時間ごとに区切る
const MAX_WAIT = 60 * 60 * 1000;

export function CronNextRuns({ defaultExpr = '0 9 * * 1-5' }: { defaultExpr?: string }) {
  const id = useId();
  const [expr, setExpr] = useState(defaultExpr);
  const [tz, setTz] = useState(LOCAL_ZONE);
  const [now, setNow] = useState(() => new Date());

  const result = useMemo(() => nextRuns(expr, tz, now), [expr, tz, now]);
  const format = useMemo(
    () =>
      new Intl.DateTimeFormat('ja-JP', {
        timeZone: tz,
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
        weekday: 'short',
        hour: '2-digit',
        minute: '2-digit',
      }),
    [tz],
  );

  // いちばん近い実行時刻を過ぎたら、now を進めて一覧を出し直す
  const first = result.ok ? result.dates[0]?.getTime() : undefined;
  useEffect(() => {
    if (first === undefined) return;
    const wait = Math.min(Math.max(first - Date.now(), 0), MAX_WAIT);
    const timer = setTimeout(() => setNow(new Date()), wait);
    return () => clearTimeout(timer);
  }, [first, now]);

  return (
    <div className="cron-next">
      <label htmlFor={`${id}-expr`}>cron の式(分 時 日 月 曜日)</label>
      <input
        id={`${id}-expr`}
        value={expr}
        onChange={(e) => setExpr(e.target.value)}
        spellCheck={false}
        autoComplete="off"
      />
      <label htmlFor={`${id}-tz`}>タイムゾーン</label>
      <select id={`${id}-tz`} value={tz} onChange={(e) => setTz(e.target.value)}>
        {ZONES.map((z) => (
          <option key={z} value={z}>
            {z === LOCAL_ZONE ? `${z}(このブラウザ)` : z}
          </option>
        ))}
      </select>
      {result.ok ? (
        <ol aria-label="次の実行時刻">
          {result.dates.map((d) => (
            <li key={d.getTime()}>
              <time dateTime={d.toISOString()}>{format.format(d)}</time>
            </li>
          ))}
        </ol>
      ) : (
        <p role="alert">読み取れませんでした:{result.message}</p>
      )}
    </div>
  );
}
css
.cron-next {
  display: grid;
  gap: 6px;
  max-width: 360px;
}
.cron-next input {
  font: 1.1rem ui-monospace, monospace;
  padding: 6px 8px;
}
.cron-next ol {
  margin: 8px 0 0;
  padding-left: 1.6em;
  font-variant-numeric: tabular-nums;
}
.cron-next [role='alert'] {
  color: #d85878;
}

計算は、式・タイムゾーン・現在時刻のどれかが変わったときだけやり直せば足りるので、useMemo に入れています。表示に使う Intl.DateTimeFormat にも、timeZone オプションで同じ tz を渡しています。cron の式を読むタイムゾーンと、画面に出すタイムゾーンをそろえるためです。

タイムゾーンの選択肢の先頭は、ブラウザのタイムゾーンです。Playwright でブラウザのタイムゾーンを Asia/Tokyo にして開くと「Asia/Tokyo(このブラウザ)」が、America/New_York にして開くと「America/New_York(このブラウザ)」が選ばれた状態で表示されました。

タイムゾーンを変えると、同じ式でも時刻が変わる

cron の式には、タイムゾーンが書かれていません。同じ 0 9 * * 1-5 でも、どの地域の9時かで実際の時刻が変わります。日本時間の 2026年11月5日 10:17 に開いた部品でタイムゾーンを切り替えると、次のように並びました。

タイムゾーン 先頭の時刻(表示) 先頭の時刻(UTC)
Asia/Tokyo 2026/11/06(金) 09:00 2026-11-06T00:00Z
UTC 2026/11/05(木) 09:00 2026-11-05T09:00Z
America/New_York 2026/11/05(木) 09:00 2026-11-05T14:00Z

日本時間の5日9時はもう過ぎているので、Asia/Tokyo では翌日6日(金)が先頭です。UTC ではまだ5日の1時17分なので、5日(木)の9時が先頭になりました。ニューヨークはまだ4日(水)の20時17分で、次の平日の9時は5日(木)です。

cron の本体がどのタイムゾーンで式を読むかは、実装と設定で決まります。たとえば cronie の crontab(5) では、CRON_TZ という変数で crontab ごとのタイムゾーンを指定でき、その地域の時刻で式を書くように説明されています。部品で選ぶタイムゾーンは、ジョブを動かす側の設定に合わせます。

日と曜日を両方書くと、どちらかに当てはまる日に動く

cron の式では、「日」と「曜日」の両方に * 以外を書くと、両方に当てはまる日ではなく、どちらかに当てはまる日になります。cronie の crontab(5) にもそう書かれていて、例に挙がっている 30 4 1,15 * 5 は「毎月1日と15日、それに毎週金曜の4時30分」です。

部品にこの式を入れると、11月6日(金)、13日(金)、15日(日)、20日(金)、27日(金)の4時30分が並びました。15日は日曜ですが、日の欄に当てはまるので入っています。0 9 1 * 1 も「毎月1日の月曜」ではなく、11月9日から30日までの月曜4回と12月1日(火)が並びました。

cron-parser の README の Strict Mode の説明では、いくつかの cron の実装では日と曜日のどちらを優先するかがあいまいだとして、strict: true にすると両方の指定をエラーにできると書かれています。ただし strict モードは6項目(秒から書く形)も必須にするため、5項目で書く部品では使っていません。

「毎月第2月曜」のように日と曜日を組み合わせた指定は、cron-parser では曜日の欄に 1#2 と書けます。0 9 * * 1#2 は、11月9日、12月14日、1月11日…と、各月の第2月曜が並びました。月末の L(0 0 L * *)も、11月30日、12月31日、1月31日、2月28日のように各月の最後の日になりました。どちらも README の「Special Characters」の表にある書き方です。cronie の crontab(5) には、L や # の書き方は載っていません。ジョブを動かす側で同じ式が使えるかは、その実装の説明で確かめてください。

実行時刻を過ぎたら、一覧を進める

部品を開いたままにしていると、先頭の時刻はいつか過去になります。そこで useEffect で、先頭の時刻までの待ち時間を setTimeout に渡し、時間が来たら now を新しくして計算し直しています。cron-parser は currentDate より後の時刻だけを返すので、ちょうど先頭の時刻に計算し直せば、その時刻は一覧から外れます。

*/15 * * * * で確かめると、10時17分には先頭が「10:30」でした。時計を10時29分59秒まで進めても「10:30」のままで、10時30分0秒になったところで先頭が「10:45」に変わりました。

待ち時間を MAX_WAIT(1時間)で頭打ちにしているのは、setTimeout に渡せる待ち時間に上限があるためです。MDN の setTimeout() の説明によると、上限は 2,147,483,647 ミリ秒(約24.8日)で、超えるとオーバーフローします。検証環境の Chromium で30日分の待ち時間を渡すと、0ミリ秒で呼ばれました。次の実行が約24.8日より先になる式では、待ち時間をそのまま渡しても、指定したとおりには待ちません。

1時間で区切った部品では、@yearly の先頭は「2027/01/01(金) 00:00」で、1時間進めても変わりませんでした。さらに57日進めて2027年1月1日の11時30分にすると、先頭は「2028/01/01(土) 00:00」に変わっていました。依存配列に now も入れているので、1時間ごとにタイマーを掛け直し、そのたびに待ち時間を計り直します。

夏時間の切り替わる日も、cron-parser は1日1回として数える

夏時間のあるタイムゾーンでは、1日の中に「ない時刻」と「2回ある時刻」ができます。America/New_York のブラウザで、時計を夏時間の始まる前日(2026年3月7日)にして 30 2 * * * を入れると、次のように並びました。

text
2026/03/07(土) 02:30
2026/03/08(日) 03:30
2026/03/09(月) 02:30
2026/03/10(火) 02:30
2026/03/11(水) 02:30

3月8日は2時台がない日で、cron-parser は3時30分として数えました。夏時間の終わる11月1日は1時台が2回ありますが、30 1 * * * では、11月1日の1時30分は1回だけでした(UTC で 05:30、夏時間のほうの1時30分です)。

cron-parser を入れると、ブラウザに送る量が増える

cron-parser は、タイムゾーンの計算に Luxon を使っています(README の「Timezone Support」の節)。cronNext.ts だけを esbuild で本番用に縮めてまとめると、100,508 バイト(gzip で 30,859 バイト)になりました。そのうち Luxon が 71,234 バイト、cron-parser が 28,128 バイトです。

​Posted by 86