ReactとDay.jsで工数カレンダーを自作する

ReactとDay.jsで工数カレンダーを自作する

1. 工数カレンダーを自作することになった背景

Webサービスにおけるカレンダーは、日付を選択するだけのシンプルなUIに見えますが、実際に実装するには、月ごとの日数、曜日の配置、空白の日付セル、日付ごとの情報の配置など、さまざまな要素を考慮する必要があります。特に工数カレンダーは、日付だけを表示する一般的なカレンダーとは異なり、工数や金額などの業務情報も確認できる必要があるため、サービスの要件に合った構造が必要でした。

今回の作業では、バンジャンノートサービスで使用する工数カレンダーを自作しました。基本的な日付表示だけでなく、日付ごとの情報を追加できることや、複数の画面で再利用できるよう、コンポーネントの役割を分けることにも重点を置きました。

カレンダーの基本構造を実装するうえで重要視したのは、日付の計算と画面上の表示を分離することでした。日付を正確に計算できても、画面上で曜日に合わせて配置されなければ、カレンダーを正常に利用できません。反対に、画面の構造が適切でも日付の計算が間違っていれば、日付が抜けたり、誤った位置に表示されたりする可能性があります。そのため、日付を計算するロジックと、計算結果を画面に配置する構造の両方を考慮しました。

2. Reactコンポーネントと日付データの構造

まず、カレンダー全体を管理するCustomCalendar、1か月分の日付を表示するCalendarMonthGrid、日付の配列を生成するbuildWeeks関数に役割を分けました。

CustomCalendarはカレンダー全体の状態と画面構成を管理し、CalendarMonthGridは渡された月の曜日と日付をレンダリングします。buildWeeksは特定の月の日付を計算し、週単位に分ける役割を担います。

このように機能を分けると、日付計算のロジックと画面構造を個別に確認できます。日付の生成方法を変更する際に画面のほかの機能まで修正する範囲を減らせます。また、UIを変更する際に日付計算のロジックに与える影響も抑えられます。

日付ごとの情報はCalendarDayData型として定義しました。

export type CalendarDayData = {
  effort?: number;
  amount?: number;
  holidayName?: string;
  totalWorkers?: number;
  attendedWorkers?: number;
  memo?: string;
};

各プロパティに?を付けたのは、日付によって保持する情報が異なる場合があるためです。ある日付には工数だけがあり、別の日付には祝日名やメモがあることも考えられます。そのため、必要な情報だけを任意で渡せるようにしました。

日付ごとのデータは、日付文字列をキーとして使うオブジェクトで管理します。

type WorkdaysCalendarProps = {
  value: Dayjs | null;
  onChange: (date: Dayjs | null) => void;
  data?: Record<string, CalendarDayData>;
};

Record<string, CalendarDayData>は、文字列のキーと日付ごとの情報オブジェクトを関連付ける型です。日付をキーに使えば、その日付に必要な情報をすぐに取得できます。

const calendarData: Record<string, CalendarDayData> = {
  '2026-10-01': {
    effort: 1,
  },
  '2026-10-02': {
    effort: 0.5,
    memo: '오전 근무',
  },
};

この構造を使うには、日付キーの形式を統一する必要があります。2026-10-01と2026.10.01は異なる文字列なので、日付を生成する箇所とデータを取得する箇所で同じ形式を使わなければなりません。今回の実装では、Day.jsのformat()を利用してYYYY-MM-DD形式に統一しました。

3. Day.jsを使った日付計算

カレンダー実装の要となるのは、特定の月の日付を正確に計算することです。月ごとに日数が異なり、月初の曜日に応じて前方に空白を配置する必要があるため、日付を単純に1日から並べるだけではカレンダーの構造を作れません。

今回の作業では、日付計算を簡潔にするためにDay.jsを使いました。Day.jsは、日付の生成、月の日数の確認、日付の移動、日付の比較、文字列への変換などの機能を提供するJavaScriptライブラリです。

まず、dayjs()で現在の日付を生成し、startOf()で特定の単位の開始点を取得しました。

const [visibleMonth, setVisibleMonth] = useState(value ?? dayjs());

const next = visibleMonth
  .add(delta, 'month')
  .startOf('month');

visibleMonthは、現在表示する月を表します。親コンポーネントから日付が渡された場合はその日付を基準とし、渡されなければ現在の日付を使います。

月を移動する際は、add()で月数を加算した後、startOf('month')でその月の初日を基準にします。たとえば、2026年10月16日に1か月加えると11月16日になりますが、startOf('month')を適用すると、11月1日を基準とする日付になります。月単位で日付を管理する場合は日(day)まで維持する必要がないため、月の変更基準を明確にできます。

daysInMonth()は、その月の日数を返します。これを使えば、月ごとの日数を条件分岐で個別に判定する必要がありません。

for (let day = 1; day <= month.daysInMonth(); day += 1) {
  days.push(month.startOf('month').date(day));
}

ループは1日からその月の最終日まで実行されます。date(day)は日付の日(day)を指定するメソッドで、月初の日付を基準に各日付を生成します。2月のように年によって日数が変わる場合でも、daysInMonth()が実際の日数を返すため、同じロジックで処理できます。

曜日の計算にはday()を使います。このメソッドは日曜日を0、月曜日を1で表し、土曜日には6を返します。月初が何曜日かを確認すれば、日付の前に必要な空白の数を計算できます。

日付を文字列に変換するformat()も重要な役割を果たします。

const dateKey = date.format('YYYY-MM-DD');
const monthTitle = visibleMonth.format('YYYY년 M월');

1つ目のコードは、日付ごとのデータを取得するためのキーを生成し、2つ目のコードは画面に表示する月のタイトルを生成します。MMは月を常に2桁で表しますが、Mは1桁の月を先頭の0なしで表します。そのため、データキーには形式が一定のYYYY-MM-DDを使い、画面のタイトルには読みやすいYYYY年M月を使いました。

日付の比較にはisSame()を利用しました。

const isSelected = value?.isSame(date, 'day');
const isToday = date.isSame(dayjs(), 'day');

isSame()の2つ目の引数に'day'を渡すと、時刻ではなく日付が同じかどうかを比較できます。月が同じかどうかを確認する場合は'month'を指定できます。このように比較単位を目的に応じて設定すれば、日付と月を区別して処理できます。

Day.jsのもう1つの特徴は不変性です。add()とstartOf()は既存の日付オブジェクトを直接変更せず、計算結果を返します。そのため、元の日付を保持しながら、変更後の日付を別に管理できます。日付を計算する際に元の値が予期せず変更されるのを防ぎ、Reactの状態でも変更の流れを明確に管理するのに役立ちます。

4. 日付の配列を週単位に構成する

日付を計算した後は、実際のカレンダーの形に配置できるよう、配列を構成する必要があります。今回の実装では、buildWeeks関数で日付の配列を作り、1週間にあたる7つの要素に分けました。

const buildWeeks = (month: Dayjs) => {
  const days: (Dayjs | null)[] = Array.from(
    { length: month.startOf('month').day() },
    () => null,
  );

  for (let day = 1; day <= month.daysInMonth(); day += 1) {
    days.push(month.startOf('month').date(day));
  }

  return Array.from(
    { length: Math.ceil(days.length / 7) },
    (_, index) => days.slice(index * 7, index * 7 + 7),
  );
};

最初のステップでは、その月の初日の前に必要な空白を作ります。startOf('month').day()で初日の曜日を取得し、その数だけ配列にnullを追加します。初日が日曜日なら空白は不要で、水曜日なら前に3つの空白が必要です。

ここではArray.from()を使い、指定した長さの配列を生成しました。日付のない位置はnullで表し、配列の型を(Dayjs | null)[]と指定して、実際の日付と空白の両方が含まれることを示しています。

2つ目のステップでは、daysInMonth()で取得した日数分だけ繰り返し、日付を追加します。最後に、配列の長さを7で割ってMath.ceil()を適用し、必要な週の数を計算します。slice()は配列を7つずつに分割するために使いました。

この処理によって、日付は1列に並んだ配列から週単位の配列に変換されます。画面では、外側のループで各週を作り、内側のループで各日付のセルをレンダリングできます。

ただし、現在の実装では月初の前に必要な空白は追加しますが、最終週の残りのセルを埋める後方の空白は別途生成していません。そのため、最終週の配列の長さが7未満になることがあります。すべての行を同じ形で表示する必要がある場合は、日付配列の末尾に空白を追加するか、CSSレイアウトで行と列のサイズを一定に保つ方法を検討できます。

このように、日付配列をどのような構造で生成するかは、画面上の配置にも影響します。カレンダーの配置に問題が発生したときは、CSSだけを確認するのではなく、日付配列に空白が正しく含まれているか、各週の要素数がどのように構成されているかも併せて確認する必要があります。

5. CSS Gridを使ったカレンダーレイアウト

日付の計算が終わったら、計算したデータを実際のカレンダーの形で表示する必要があります。今回の実装では、曜日の領域と日付の領域を分け、各週を個別の行として構成しました。

<div className="custom-calendar-weekdays">
  {WEEKDAYS.map((weekday) => (
    <div key={weekday} className="custom-calendar-weekday">
      {weekday}
    </div>
  ))}
</div>

<div className="custom-calendar-grid">
  {buildWeeks(month).map((week, weekIdx) => (
    <div className="custom-calendar-week" key={weekIdx}>
      {week.map((date, idx) => {
        if (!date) {
          return (
            <div
              key={`empty-${weekIdx}-${idx}`}
              className="custom-calendar-day empty"
            />
          );
        }

        return (
          <div
            key={date.format('YYYY-MM-DD')}
            className="custom-calendar-day"
          >
            <div className="custom-calendar-day-number">
              {date.date()}
            </div>
          </div>
        );
      })}
    </div>
  ))}
</div>

曜日はWEEKDAYS配列を繰り返し処理してレンダリングし、日付の領域はbuildWeeks()が返す週単位の配列を繰り返し処理してレンダリングします。データ配列の構造と画面の構造を対応させると、日付の計算結果がどのように表示されるかを把握しやすくなります。

空白も個別の要素としてレンダリングすることが重要です。日付のない要素を単にスキップすると、実際の日付が前に詰まり、誤った曜日に配置される可能性があります。そのため、空白にも日付セルと同じレイアウトの流れを適用し、日付が正しい位置に表示されるようにする必要があります。

CSS Gridを使ったカレンダーでは、7つの列を均等に保つことが重要です。日付ごとの情報の長さが異なっても各セルの幅が変わらず、日付番号や工数情報が表示される位置も一貫している必要があります。

枠線も考慮すべき点です。隣り合う日付セルのすべての辺に枠線を適用すると、境界線が重なって太く見えることがあります。反対に、一部の枠線を取り除くだけで対処すると、最終行や最終列で線が抜けることがあります。そのため、セルと全体領域のどちらが枠線を担当するのかを決め、空白を含むすべての要素が同じルールに従うように構成する必要があります。

カレンダーの最終行も、日付配列の構造によって変わることがあります。前述のように最終週に空白が追加されていない場合、CSSがその行をどのように配置するかによって画面の形が変わります。そのため、意図したレイアウトを実現するには、CSSだけでなく日付データの生成方法も併せて検討する必要があります。

今回の実装を通じて、カレンダーUIではデータの生成と画面への配置を別々に考えるのが難しいことを確認しました。日付の計算結果が正確か、空白が正しく表現されているか、各行と列が一定のルールに沿って配置されているかを併せて考慮することで、安定したカレンダーレイアウトを作れます。

6. 実装を通じて学んだこと

工数カレンダーを自作することで、カレンダーは単に日付を並べるUIではなく、日付計算、データ構造、コンポーネント設計、CSSレイアウトが連携して動作する機能だと学びました。

Day.jsを活用すれば、月の開始日や日数を取得し、曜日を確認し、日付を生成して、必要な形式に変換する過程を明確に表現できます。特にstartOf()、daysInMonth()、day()、date()、format()、add()、isSame()をそれぞれの目的に合わせて使うことで、日付計算に必要な条件分岐を減らし、コードの意図を明確にできました。

また、日付計算のロジックを別の関数に分け、それを画面を構成するコンポーネントと連携させる方法は、保守性の面でも有効です。日付の生成方法と画面表示を分ければ、それぞれを個別に確認でき、後から機能を追加する際にも修正範囲を把握しやすくなります。

何より重要だったのは、日付配列の構造が画面上の配置に直接影響するという点です。日付を正しい曜日に配置するには、月初の前の空白を正確に計算し、最終行の形まで考慮する必要があります。画面上の問題を解決する際は、CSSだけを修正するのではなく、データが生成される過程から確認することが大切です。

今回の作業は、デザインに合わせて画面を構成するだけでなく、UIを正確に動作させるために必要な計算の仕組みとデータ構造を理解するきっかけになりました。ライブラリのメソッドを使うだけでなく、それぞれのメソッドがどのような値を返し、実装の過程でなぜ必要なのかを理解することが、安定して保守しやすいコードを書くうえで重要だと学びました。

KKAMJJING

Site footer