Reactで、2つの日付の間の日数を数える方法

​​React​JavaScript,Playwright,Temporal,TypeScript

2つの日付の間の日数は、Temporal.PlainDate の until() を使うと、時刻やタイムゾーンを考えずに数えられます。同じ仕組みで「1か月20日」のような言い方も出せます。Temporal のないブラウザでは、日付を UTC の0時に置いて引き算すれば、同じ日数になります。

ここでは、input type="date" を2つ並べて、日数、「何か月何日」、土日を除いた日数を表示する DayCounter という部品を作りました。計算は dateDiff.ts にまとめています。

補足

検証環境は 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 です。.tsx を esbuild で変換し、tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを Playwright で操作しました。Firefox と Safari、スマートフォンでの日付の入力、スクリーンリーダーでの読み上げは試していません。Temporal のないブラウザでの動きは、Chromium で Temporal を消したページで代わりに確かめました。

開始日 11/05/2026、終了日 12/25/2026 が入った2つの日付の入力欄と、チェックの入った「開始日も1日目として数える」。その下に大きな水色の文字で「51 日」、続けて「1か月21日」「土日を除くと 37 日」と表示されている
2026年11月5日から12月25日までを、開始日も含めて数えた結果

検証環境の Chromium では、日付の欄が「月/日/年」の順で表示されました。MDN の input type="date" の説明によると、表示の書式はブラウザのロケールで変わりますが、value はいつも yyyy-mm-dd の形です。以下の関数は、この value の文字列をそのまま受け取ります。

日数は Temporal.PlainDate の until() で数える

計算の関数をまとめたファイルです。

ts
// input type="date" の value("YYYY-MM-DD")どうしの差を求める関数

const MS_PER_DAY = 86_400_000;
const hasTemporal = typeof Temporal !== 'undefined';

// Temporal がないブラウザ向け:1970-01-01 から何日目か(UTC で数える)
function utcDayNumber(value: string): number {
  const [y, m, d] = value.split('-').map(Number);
  const date = new Date(0);
  date.setUTCFullYear(y, m - 1, d); // Date.UTC だと 0〜99 年が 1900 年代になる
  return date.getTime() / MS_PER_DAY;
}

// end − start の日数。end のほうが前なら負の数になる。
// useTemporal は、2つの求め方を比べるための引数
export function diffDays(start: string, end: string, useTemporal = hasTemporal): number {
  if (useTemporal) {
    return Temporal.PlainDate.from(start).until(end).days;
  }
  return utcDayNumber(end) - utcDayNumber(start);
}

// 「何年何か月何日」。inclusive なら終了日の翌日までを数える。Temporal がないときは null
export function diffYmd(start: string, end: string, inclusive = false) {
  if (!hasTemporal) return null;
  const to = Temporal.PlainDate.from(end).add({ days: inclusive ? 1 : 0 });
  const d = Temporal.PlainDate.from(start).until(to, { largestUnit: 'years' });
  return { years: d.years, months: d.months, days: d.days };
}

// 曜日(0 = 日曜 … 6 = 土曜)
export function dayOfWeek(value: string): number {
  return new Date(utcDayNumber(value) * MS_PER_DAY).getUTCDay();
}

// firstDow の曜日から始まる length 日のうち、土日でない日の数
export function countWeekdays(firstDow: number, length: number): number {
  let count = Math.floor(length / 7) * 5;
  for (let i = 0; i < length % 7; i++) {
    const dow = (firstDow + i) % 7;
    if (dow !== 0 && dow !== 6) count++;
  }
  return count;
}

Temporal.PlainDate は、時刻もタイムゾーンも持たない「暦の上の日付」です。until() は、相手の日付までの長さを Temporal.Duration で返します。until() のオプションは since() と同じです。MDN の since() のオプションの説明によると、largestUnit を指定しなければ一番大きな単位が「日」になるので、.days が日数そのものになります。until() の説明によると、相手が前の日付なら負の長さになります。検証環境でも、2026年3月1日から1月31日までの days は -29 でした。

日付の欄が空のときの value は空文字です。Temporal.PlainDate.from('') は、検証環境では RangeError になりました。そのため部品の側で、2つとも入っているときだけ計算を呼ぶようにしています。

TypeScript では、tsconfig.json の lib に esnext を入れて型チェックしました。lib を es2025 にすると、「Cannot find name 'Temporal'」のエラーになりました。

Temporal のないブラウザでは、UTC の日付どうしを引く

MDN のブラウザ互換性データでは、Temporal.PlainDate に対応したのは Chrome 144、Firefox 139 からで、Safari は Technology Preview だけです(2026年11月5日に確認)。そこで、Temporal がないときは utcDayNumber で日付を「1970年1月1日から何日目か」に直して引き算します。Chromium で Temporal を消したページを開き、2026年11月5日から12月25日までを数えると、「50 日」と「土日を除くと 36 日」は表示され、「何か月何日」の行だけが出なくなりました。

日付を UTC で扱えば夏時間の影響を受けないことは、以前の記事「Reactで、開始日と終了日を選ぶ入力欄を作る方法」で、Date.parse を使って確かめました。ここでは、Temporal と utcDayNumber の結果が一致するかを比べています。タイムゾーンを America/New_York、Europe/London、Australia/Sydney、Asia/Tokyo に変え、それぞれの地域で夏時間が始まる日や終わる日をまたぐ期間を数えても、どちらの求め方でも2日でした。年1〜9999のランダムな日付の組を10万組比べても、結果はすべて一致しました。

Date.UTC(年, 月, 日) を使わずに setUTCFullYear で年を入れているのは、2桁以下の年のためです。MDN の Date.UTC() の説明のとおり、Date.UTC は 0〜99 の年を 1900〜1999 年として扱います。日付の欄には、キーボードで「0050」のような年も打てます。0050年1月1日から2026年1月1日までを数えると、Date.UTC では 27,759 日、setUTCFullYear と Temporal では 721,719 日になりました。

「何か月何日」は、月末をまたぐと見た目と合わないことがある

until() に { largestUnit: 'years' } を渡すと、年・月・日に分けた長さが返ります。月末の日付から数えたときの結果は、次のとおりでした。

開始日 終了日 until() の結果 日数
2026-01-31 2026-02-27 27日 27
2026-01-31 2026-02-28 28日 28
2026-01-31 2026-03-01 1か月1日 29
2026-01-31 2026-03-31 2か月 59
2026-02-28 2026-03-31 1か月3日 31
2024-02-29 2025-02-28 11か月30日 365
2024-02-29 2025-03-01 1年1日 366

1月31日から2月28日は、2月の最後の日まで来ていても「1か月」ではなく「28日」でした。一方で、1月31日から3月1日は「1か月1日」で、日数は1日しか違わないのに、表示は大きく変わります。2024年2月29日から数えると、2025年2月28日でも「11か月30日」で、3月1日になってようやく「1年1日」になりました。

MDN の add() の説明によると、月を足した先にその日がないときは、その月の一番近い日(2月30日なら2月28日か29日)に寄せられます。検証環境でも、2026年1月31日に1か月足すと2月28日になりました。月の長さがそろっていない以上、「何か月何日」は1通りには決まりません。部品では日数を大きく出し、「何か月何日」は添える程度にしています。

開始日を数えに入れるかどうかを選べるようにする

until() の日数は、開始日を数えません。11月5日から11月9日までは4日です。旅行の日数のように両端の日を数えたいときは、1を足します。

部品では「開始日も1日目として数える」のチェックで切り替えています。チェックを入れると、日数に1を足し、「何か月何日」は終了日の翌日までの長さにします。2026年1月1日から1月31日までは、チェックなしで「30 日」「30日」、チェックありで「31 日」「1か月」と表示されました。

土日を除いた日数を数える

countWeekdays は、数える最初の日の曜日と日数から、土曜と日曜を除いた日数を出します。7日ごとに5日ずつ足し、余りの日だけ1日ずつ曜日を見ています。チェックなしのときは開始日の翌日から、チェックありのときは開始日から数えます。祝日は数えに入れていません。

2026年11月5日(木曜)から11月9日(月曜)までは、チェックなしで「土日を除くと 2 日」(金・月)、チェックありで「3 日」(木・金・月)でした。日付と日数をランダムに選んだ2,000組で、1日ずつ曜日を見て数えた結果と比べても、すべて一致しました。

部品にまとめる

tsx
import { useId, useState } from 'react';
import { countWeekdays, dayOfWeek, diffDays, diffYmd } from './dateDiff';

type Props = { defaultStart?: string; defaultEnd?: string };

export function DayCounter({ defaultStart = '', defaultEnd = '' }: Props) {
  const id = useId();
  const [start, setStart] = useState(defaultStart);
  const [end, setEnd] = useState(defaultEnd);
  const [countFirstDay, setCountFirstDay] = useState(false);

  return (
    <div className="day-counter">
      <label htmlFor={`${id}-start`}>開始日</label>
      <input
        id={`${id}-start`}
        type="date"
        max="9999-12-31"
        value={start}
        onChange={(e) => setStart(e.target.value)}
      />
      <label htmlFor={`${id}-end`}>終了日</label>
      <input
        id={`${id}-end`}
        type="date"
        max="9999-12-31"
        value={end}
        onChange={(e) => setEnd(e.target.value)}
      />
      <label className="check">
        <input
          type="checkbox"
          checked={countFirstDay}
          onChange={(e) => setCountFirstDay(e.target.checked)}
        />
        開始日も1日目として数える
      </label>
      <output htmlFor={`${id}-start ${id}-end`} aria-live="polite">
        {start && end ? (
          <Result start={start} end={end} countFirstDay={countFirstDay} />
        ) : (
          <p>2つの日付を選んでください</p>
        )}
      </output>
    </div>
  );
}

function Result({ start, end, countFirstDay }: { start: string; end: string; countFirstDay: boolean }) {
  const signed = diffDays(start, end);
  // 終了日が前でも、数える範囲は「前の日付 → 後ろの日付」にそろえる
  const [from, to] = signed < 0 ? [end, start] : [start, end];
  const days = Math.abs(signed) + (countFirstDay ? 1 : 0);
  const firstDow = (dayOfWeek(from) + (countFirstDay ? 0 : 1)) % 7;
  const ymd = diffYmd(from, to, countFirstDay);

  return (
    <>
      <p className="days">
        <strong>{days.toLocaleString('ja-JP')}</strong> 日
      </p>
      {signed < 0 && <p>終了日のほうが前なので、入れ替えて数えています</p>}
      {ymd && (
        <p>
          {ymd.years > 0 && `${ymd.years}年`}
          {ymd.months > 0 && `${ymd.months}か月`}
          {(ymd.days > 0 || (ymd.years === 0 && ymd.months === 0)) && `${ymd.days}日`}
        </p>
      )}
      <p>土日を除くと {countWeekdays(firstDow, days).toLocaleString('ja-JP')} 日</p>
    </>
  );
}
css
.day-counter {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 8px 12px;
  align-items: center;
  max-width: 360px;
}
.day-counter .check,
.day-counter output {
  grid-column: 1 / -1;
}
.day-counter output p {
  margin: 4px 0;
}
.day-counter .days strong {
  font-size: 2rem;
  color: #067a98;
}

日付が片方でも空なら、Result を描かずに「2つの日付を選んでください」と出します。開始日を空にすると、この表示に切り替わりました。

終了日のほうが前のときは、2つを入れ替えてから数えます。開始日を2026年12月25日、終了日を11月5日にすると、「50 日」と「終了日のほうが前なので、入れ替えて数えています」が並びました。エラーにして止める代わりに、どちらの順で選んでも日数が分かるようにしています。

2つの日付の欄には max="9999-12-31" を付けています。年を4桁までにする使い方は以前の記事「Reactで、開始日と終了日を選ぶ入力欄を作る方法」でも紹介しましたが、Temporal で計算する場合は、付けないと、年を5桁以上打ったときに部品が消えてしまいます。開始日の欄から max を外して年に「123456」と打つと、value は1桁ごとに変わり、5桁の "12345-01-01" になったところで、ページに RangeError が出ました。同じ文字列を Temporal.PlainDate.from() に渡しても、同じ RangeError になります。描画の途中でエラーが起きたため、React のリファレンスにある、エラーバウンダリの説明のとおり、React は部品をまるごと画面から削除しました。

max を付けた欄では、同じように打っても年は下4桁の「3456」になり、value は "3456-01-01" でした。max を外した欄で打ったとき以外は、コンソールにエラーや警告は出ませんでした。

​Posted by 86