Reactで、土日と祝日を除いた営業日を数える方法

​​React​JavaScript,Playwright,TypeScript

土日と祝日を除いた営業日は、祝日の一覧を "YYYY-MM-DD" をキーにしたオブジェクトで持ち、1日ずつ「土日でも祝日でもないか」を確かめて数えられます。日付を Date のまま比べず、input type="date" の値と同じ文字列で照らし合わせるのがコツです。こうすると、ブラウザのタイムゾーンが日本でなくても結果が変わりません。

ここでは、開始日から終了日までの営業日の数と、開始日から「何営業日後」の日付を出す BusinessDayCounter というコンポーネントを作りました。祝日の一覧には、npm で配布されている @holiday-jp/holiday_jp を使っています。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0、@holiday-jp/holiday_jp 2.5.2 です。.tsx を esbuild で変換し、tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを Playwright で操作しました。タイムゾーンは Asia/Tokyo と America/New_York の2つで試しています。祝日の一覧を内閣府の CSV と照らし合わせる作業、Firefox と Safari、スマートフォンでの日付の入力、スクリーンリーダーでの読み上げは試していません。

白い背景に、開始日 2026/04/27、終了日 2026/05/08 の2つの日付の欄。その下に大きな水色の数字で「営業日 6 日」、続けて「平日の祝日(数えていません)」と、04/29 昭和の日、05/04 みどりの日、05/05 こどもの日、05/06 こどもの日 振替休日の4行。いちばん下に「営業日数」の欄に 10 が入り、「開始日の 10 営業日後は 2026-05-15」と表示されている
2026年4月27日から5月8日までの営業日を数えたところ

祝日の一覧を用意する

祝日には、年ごとに日付が変わるものや、振替休日、その年だけの休日があります。@holiday-jp/holiday_jp の一覧では、海の日は2019年が7月15日、2020年が7月23日、2021年が7月22日です。2019年10月22日は「休日(祝日扱い)」でした。規則から計算するより、出来上がった一覧を使うほうが確実です。

@holiday-jp/holiday_jp は、holidays というオブジェクトに、"2026-05-06" のような日付をキーにして、祝日の名前などを入れています。検証環境の 2.5.2 には、1970年から2050年までの 1,329 件が入っていました。2026年の分には、振替休日の「こどもの日 振替休日」(5月6日)と、敬老の日と秋分の日に挟まれた「休日」(9月22日)も入っています。元になっている holiday_jp のデータセットの README によると、このデータは内閣府の祝日の CSV(syukujitsu.csv)と照らし合わせてテストされています。

祝日の一覧を読み込むファイルです。一覧に入っている年の範囲も、ここで出しておきます。

ts
import holiday_jp from '@holiday-jp/holiday_jp';
import type { Holidays } from './businessDays';

export const holidays: Holidays = holiday_jp.holidays;

// 一覧に入っている最初の年の1月1日から、最後の年の12月31日まで
const dates = Object.keys(holidays).sort();
export const coverage = {
  from: `${dates[0].slice(0, 4)}-01-01`,
  to: `${dates[dates.length - 1].slice(0, 4)}-12-31`,
};

import holiday_jp from の形で読み込むため、tsconfig.json に "esModuleInterop": true を入れて型チェックしました。

営業日を数える関数

計算はこのファイルにまとめました。

ts
// input type="date" の value("YYYY-MM-DD")で、土日と祝日を除いた営業日を扱う関数

// 祝日の一覧。キーは "YYYY-MM-DD"
export type Holidays = Readonly<Record<string, { readonly name: string }>>;

const MS_PER_DAY = 86_400_000;

// 1970-01-01 から何日目か(UTC で数えるので、タイムゾーンの影響を受けない)
function toDayNumber(value: string): number {
  const [y, m, d] = value.split('-').map(Number);
  const date = new Date(0);
  date.setUTCFullYear(y, m - 1, d);
  return date.getTime() / MS_PER_DAY;
}

function fromDayNumber(n: number): string {
  return new Date(n * MS_PER_DAY).toISOString().slice(0, 10);
}

// 土日でも祝日でもない日か
export function isBusinessDay(value: string, holidays: Holidays): boolean {
  const dow = new Date(toDayNumber(value) * MS_PER_DAY).getUTCDay(); // 0 = 日曜 … 6 = 土曜
  return dow !== 0 && dow !== 6 && !Object.hasOwn(holidays, value);
}

// start から end まで(両端を含む)の営業日の数と、平日に当たったので除いた祝日
export function countBusinessDays(start: string, end: string, holidays: Holidays) {
  let count = 0;
  const skipped: { date: string; name: string }[] = [];
  for (let n = toDayNumber(start); n <= toDayNumber(end); n++) {
    const value = fromDayNumber(n);
    if (isBusinessDay(value, holidays)) count++;
    else if (Object.hasOwn(holidays, value) && isBusinessDay(value, {})) {
      skipped.push({ date: value, name: holidays[value].name });
    }
  }
  return { count, skipped };
}

// start の翌日から数えて、days 日目の営業日
export function addBusinessDays(start: string, days: number, holidays: Holidays): string {
  let n = toDayNumber(start);
  for (let left = days; left > 0; ) {
    n++;
    if (isBusinessDay(fromDayNumber(n), holidays)) left--;
  }
  return fromDayNumber(n);
}

日付は「1970年1月1日から何日目か」という整数にして、1日ずつ進めています。曜日を求めるのも、文字列に戻すのも UTC で行うので、ブラウザのタイムゾーンによって日付がずれません。toISOString() は、MDN の Date の説明にあるとおり、いつも UTC の日時を返します。その先頭の10文字が、そのまま "YYYY-MM-DD" になります。

祝日かどうかは Object.hasOwn() で確かめています。MDN の説明のとおり、Object.hasOwn() はオブジェクト自身が持つプロパティだけを見て、toString のように継承したプロパティには false を返します。

数え方は、開始日と終了日の両方を含めています。検証環境で、Asia/Tokyo と America/New_York のどちらでも次の結果になりました。

期間 営業日 除いた平日の祝日
2026-04-27〜2026-05-08 6 4/29、5/4、5/5、5/6
2026-09-18〜2026-09-25 3 9/21、9/22、9/23
2026-05-02〜2026-05-06 0 5/4、5/5、5/6
2026-01-01〜2026-12-31 244 17日

2026年5月3日の憲法記念日は日曜なので、skipped には入りません。その代わりに5月6日が振替休日として一覧に入っているので、平日の祝日として除かれます。

addBusinessDays は、開始日を数えずに、翌日から営業日を数えます。2026年4月28日の3営業日後は、4月30日、5月1日を経て、連休明けの5月7日でした。5月1日(金曜)の1営業日後も、5月2日(土曜)の1営業日後も、5月7日になります。

この2つの関数を、別の求め方と比べました。ローカル時刻の Date を1日ずつ進め、holiday_jp.isHoliday() で祝日を判定して数える方法です。1970年から2050年の中からランダムに選んだ2,000組の期間で、営業日の数はすべて一致しました。addBusinessDays の結果も、2,000組すべてで「営業日であり、開始日の翌日からその日までの営業日がちょうど指定した日数」になっていました。1970年1月1日から2050年12月31日まで(営業日は 20,083 日)を1日ずつ数えても、Node.js で 50ミリ秒かかりませんでした。

isHoliday() に Date を渡すと、タイムゾーンによって別の日として判定される

@holiday-jp/holiday_jp には、祝日かどうかを返す isHoliday() もあります。パッケージのコードを読むと、Date を渡したときは getFullYear()、getMonth()、getDate() でローカルの年月日に直してから一覧を引いていました。そのため、Date の作り方によっては、ブラウザのタイムゾーンで結果が変わります。

new Date('2026-01-02') のような日付だけの文字列は、MDN の Date の説明にあるとおり、UTC の0時として読まれます。検証環境で試すと、結果は次のとおりでした。

タイムゾーン new Date('2026-01-02') のローカルの日付 isHoliday(new Date('2026-01-02'))
Asia/Tokyo 2026-01-02 false
America/New_York 2026-01-01 true

America/New_York では、1月2日のつもりの Date が1月1日として扱われ、元日と判定されました。isHoliday('2026-01-02') のように文字列で渡すと、どちらのタイムゾーンでも false でした。この記事の関数が、ローカルの年月日に直さず、"YYYY-MM-DD" の文字列で holidays のキーを直接引いているのは、このずれを避けるためです。

コンポーネントにまとめる

tsx
import { useId, useState } from 'react';
import { addBusinessDays, countBusinessDays, type Holidays } from './businessDays';

type Props = {
  holidays: Holidays;
  // 祝日の一覧が用意されている範囲("YYYY-MM-DD")
  coverage: { from: string; to: string };
  defaultStart?: string;
  defaultEnd?: string;
};

export function BusinessDayCounter({ holidays, coverage, defaultStart = '', defaultEnd = '' }: Props) {
  const id = useId();
  const [start, setStart] = useState(defaultStart);
  const [end, setEnd] = useState(defaultEnd);
  const [days, setDays] = useState(10);

  // "YYYY-MM-DD" は、文字列のまま比べても日付の順になる
  const inCoverage = (v: string) => coverage.from <= v && v <= coverage.to;
  const ready = start !== '' && end !== '' && start <= end;
  const result = ready && inCoverage(start) && inCoverage(end)
    ? countBusinessDays(start, end, holidays)
    : null;
  const validDays = Number.isInteger(days) && days >= 1 && days <= 1000;
  const due = start !== '' && inCoverage(start) && validDays
    ? addBusinessDays(start, days, holidays)
    : null;

  return (
    <div className="business-days">
      <label htmlFor={`${id}-start`}>開始日</label>
      <input id={`${id}-start`} type="date" min={coverage.from} max={coverage.to}
        value={start} onChange={(e) => setStart(e.target.value)} />
      <label htmlFor={`${id}-end`}>終了日</label>
      <input id={`${id}-end`} type="date" min={coverage.from} max={coverage.to}
        value={end} onChange={(e) => setEnd(e.target.value)} />

      <output htmlFor={`${id}-start ${id}-end`} aria-live="polite">
        {!ready ? (
          <p>開始日と、それより後の終了日を選んでください</p>
        ) : !result ? (
          <p>{coverage.from.slice(0, 4)}年から{coverage.to.slice(0, 4)}年までの日付を選んでください</p>
        ) : (
          <>
            <p className="count">営業日 <strong>{result.count}</strong> 日</p>
            {result.skipped.length > 0 && (
              <>
                <p>平日の祝日(数えていません)</p>
                <ul>
                  {result.skipped.map((h) => (
                    <li key={h.date}>{h.date.slice(5).replace('-', '/')} {h.name}</li>
                  ))}
                </ul>
              </>
            )}
          </>
        )}
      </output>

      <label htmlFor={`${id}-days`}>営業日数</label>
      <input id={`${id}-days`} type="number" min={1} max={1000}
        value={Number.isNaN(days) ? '' : days} onChange={(e) => setDays(e.target.valueAsNumber)} />
      <output htmlFor={`${id}-start ${id}-days`} className="due">
        {!due
          ? '範囲内の開始日と、1〜1000 の営業日数を入れてください'
          : inCoverage(due)
            ? `開始日の ${days} 営業日後は ${due}`
            : '祝日の一覧がない年にかかるため、計算できません'}
      </output>
    </div>
  );
}
css
.business-days {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 8px 12px;
  align-items: center;
  max-width: 360px;
}
.business-days output {
  grid-column: 1 / -1;
}
.business-days output p,
.business-days ul {
  margin: 4px 0;
}
.business-days .count strong {
  font-size: 2rem;
  color: #067a98;
}

使う側では、祝日の一覧と範囲を渡します。

tsx
<BusinessDayCounter holidays={holidays} coverage={coverage}
  defaultStart="2026-04-27" defaultEnd="2026-05-08" />

日付の欄の値は、MDN の input type="date" の説明のとおり、表示の書式にかかわらず yyyy-mm-dd の形です。年を4桁で書く範囲では、文字列のまま大小を比べれば日付の前後が分かるので、start <= end や inCoverage は文字列の比較で書いています。

Playwright で操作した結果は、次のとおりでした。Asia/Tokyo と America/New_York で、表示はすべて同じです。

操作 営業日の表示 何営業日後の表示
開始日 2026-09-18、終了日 2026-09-25、営業日数 3 営業日 3 日(敬老の日、休日、秋分の日) 2026-09-28
開始日 2026-12-25、終了日 2027-01-08、営業日数 10 営業日 10 日(元日) 2027-01-12
終了日を開始日より前にする 開始日と、それより後の終了日を選んでください 2027-01-12
営業日数を空にする (上と同じ) 範囲内の開始日と、1〜1000 の営業日数を入れてください

営業日数を 0 にしたときも、同じ案内が出ました。

祝日の一覧がない年は計算しない

一覧にない年では、countBusinessDays は土日だけを除いて数えます。一覧に2051年の祝日は1件もなく、2051年1月1日から10日までを数えると、土日でない日と同じ7日になりました。そこでコンポーネントでは、一覧の範囲の外では計算しないようにしました。

日付の欄には min と max で範囲を入れていますが、MDN の input type="date" の max 属性の説明のとおり、範囲の外の値は「制約検証に失敗する」だけで、入力そのものは止まりません。検証環境でも、max="2050-12-31" の欄にキーボードで「01012051」と打つと、value は "2051-01-01" になりました。そこで、inCoverage で範囲を確かめています。開始日 2050-12-20、終了日 2051-01-10 では「1970年から2050年までの日付を選んでください」が出ました。2050年12月20日の10営業日後は2051年になるので、「祝日の一覧がない年にかかるため、計算できません」と出ます。

会社の休みを足す

年末年始の休みのように、祝日ではない休みを除きたいときは、一覧にキーを足したオブジェクトを渡します。

ts
const companyHolidays: Holidays = {
  ...holidays,
  '2026-12-29': { name: '年末年始休業' },
  '2026-12-30': { name: '年末年始休業' },
  '2026-12-31': { name: '年末年始休業' },
  '2027-01-02': { name: '年末年始休業' },
  '2027-01-03': { name: '年末年始休業' },
};

2026年12月21日から2027年1月8日までの営業日は、祝日だけを除くと14日、休業日も除くと11日になりました。2026年12月25日の10営業日後は、2027年1月12日から1月15日に変わりました。スプレッド構文で新しいオブジェクトを作っているので、元の holidays には12月29日が入らないままでした。

使う年の分だけ読み込むと、ファイルが小さくなる

holiday_jp.holidays を読み込むと、1970年から2050年までの一覧がまるごとビルドに入ります。本番用に縮小してビルドすると、ファイルは 401,609 バイトで、そのうち祝日の一覧まわり(@holiday-jp/holiday_jp と、それを読み込むファイル)が 175,164 バイトでした。

holiday_jp-js の README には、年ごとのファイルを読み込んで setHolidays() に渡す書き方が載っています。年ごとのファイルの中身は、その年の祝日だけを入れた、holidays と同じ形のオブジェクトでした。この記事の関数には同じ形のオブジェクトを渡せばよいので、年ごとのファイルをスプレッド構文でつなげて渡しました。

ts
import h2026 from '@holiday-jp/holiday_jp/lib/holidays_every_year/2026';
import h2027 from '@holiday-jp/holiday_jp/lib/holidays_every_year/2027';
import h2028 from '@holiday-jp/holiday_jp/lib/holidays_every_year/2028';
import type { Holidays } from './businessDays';

// 使う年の分だけ読み込む
export const holidays: Holidays = { ...h2026, ...h2027, ...h2028 };
export const coverage = { from: '2026-01-01', to: '2028-12-31' };

この形に変えると、ファイルは 233,294 バイト、祝日の一覧まわりは 6,849 バイトになりました。gzip で圧縮した大きさでは、82,656 バイトから 71,347 バイトに減っています。coverage も3年分に変わるので、日付の欄の min と max も 2026-01-01 と 2028-12-31 になりました。

​Posted by 86