📆

【個人開発】Chatendarのカレンダー機能を深掘り:Next.jsとTypeScriptでの実装を解説

2024/10/19に公開

はじめに

このブログでは、IT未経験で独学の私が開発したアプリである「Chatendar」の主要な機能について解説していきます。今回は、カレンダー機能に焦点を当て、その具体的な実装方法をご紹介します。

もし、全体のコードをご覧になりたい方は、私のGitHubにて公開していますので、そちらからご確認いただけます。

https://github.com/R-koma/calendar-chat

アプリの全体像や概要を知りたい方は、以下のサイトからご確認ください。

https://qiita.com/ryoma_itngineer/items/1a45121a2317b47d2003

カレンダー画面

cc-home.png

紹介する機能

・カレンダーの表示。(正確な曜日と日付の紐付け)
・矢印アイコンで月を切り替える。
・Todayボタンを押すと、現在の日付が含まれる月にジャンプする。
・今日の日付を水色の背景でハイライト表示。

これらの機能を実装するためのコードを紹介します。

コード

それぞれの機能を実装するためのコードを載せます。もし、全体のコードを見たい方がいましたら、私のGitHubから閲覧することができます。

https://github.com/R-koma/calendar-chat

カレンダーの表示

dateUtils.ts
export const getMonthStartEnd = (currentDate: Date) => {
  const startDate = new Date(
    currentDate.getFullYear(),
    currentDate.getMonth(),
    1,
  );
  const endDate = new Date(
    currentDate.getFullYear(),
    currentDate.getMonth() + 1,
    0,
  );
  return { startDate, endDate };
};

export const getPrevMonthEndDate = (currentDate: Date) => {
  return new Date(currentDate.getFullYear(), currentDate.getMonth(), 0);
};

// カレンダーの曜日を表示するために使われます。
export const getWeekdays = () => [
  'SUN',
  'MON',
  'TUE',
  'WED',
  'THU',
  'FRI',
  'SAT',
];

// カレンダーの月名を表示する際に使われます。
export const getMonthNames = () => [
  'Jan',
  'Feb',
  'Mar',
  'Apr',
  'May',
  'Jun',
  'Jul',
  'Aug',
  'Sep',
  'Oct',
  'Nov',
  'Dec',
];

getMonthStartEndは、指定された日付の月の開始日と終了日を返す関数です。JavaScriptDateオブジェクトのコンストラクタnew Date(year, monthIndex, day)を使用しています。

startDateは、currentDateの年と月をそのまま使用し、その月の最初の日(1日)を取得します。
endDate は、currentDateの年と月に1を足して次の月を指定し、日付を「0」とすることで前月の最終日を取得しています。Dateオブジェクトでは、日付に「0」を指定すると、指定した月の「前日」、すなわち前月の最終日を示します。
また、getPrevMonthEndDateは現在の月の前月の最終日を返す関数です。

CalendarDays.tsxなどで利用するために、これらの関数はdateUtils.ts内でexportされています。

CalendarDays.tsx
const weekdays = getWeekdays(); // 曜日(例: SUN, MON, TUEなど)を取得します。
const monthNames = getMonthNames(); // 関数は、月名(例: Jan, Feb, Marなど)を取得します。
const { startDate, endDate } = getMonthStartEnd(currentDate); // 現在の月の開始日 (startDate) と終了日 (endDate) を返します。

const weekdayElements = weekdays.map((day, index) => (
  <div
    key={`weekday-${day}`}
    className={`p-0 border border-gray-200 text-xs text-center date-cell
      ${getTextColor(index)}`}
  >
    {day}
  </div>
));

const prevMonthEndDate = getPrevMonthEndDate(currentDate);
const prevMonthDays = startDate.getDay();

const prevMonthElements = Array.from({ length: prevMonthDays }).map(
  (_, i) => (
    <div
      key={`prev-month-day-${prevMonthEndDate.getDate() - (prevMonthDays - 1 - i)}`}
      className="p-0 border border-gray-200  text-gray-400 date-cell"
    >
      {prevMonthEndDate.getDate() - (prevMonthDays - 1 - i)}
    </div>
  ),
);

// SUNの表示を赤色に、SATの表示を青色に、それ以外を灰色にする関数
const getDayOfWeekColor = (dayOfWeek: number) => {
  if (dayOfWeek === 0) {
    return 'text-red-500';
  }
  if (dayOfWeek === 6) {
    return 'text-blue-500';
  }
  return 'text-gray-900';
};

const currentMonthElements = Array.from({ length: endDate.getDate() }).map(
  (_, d) => {
    const currentDateDisplay = new Date(
      currentDate.getFullYear(),
      currentDate.getMonth(),
      d + 1,
    );
    const isToday =
      currentDateDisplay.toDateString() === new Date().toDateString();

    const dayOfWeek = currentDateDisplay.getDay();
    return (
      <div
        key={`day-${d + 1}`}
        className={`p-0 border border-gray-200 text-xs text-center cursor-pointer overflow-hidden flex flex-col
          ${getDayOfWeekColor(dayOfWeek)}
          ${isToday ? 'bg-blue-300' : ''}`}
        style={{ height: '95px' }}
        role="button"
        tabIndex={0}
      >
        <div className="flex-none px-2 py-1">
          {d + 1 === 1
            ? `${monthNames[currentDate.getMonth()]} ${d + 1}`
            : d + 1}
        </div>
      </div>
    );
  },
);

const totalDays = prevMonthDays + endDate.getDate();

const nextMonthDays = (7 - (totalDays % 7)) % 7;
const nextMonthElements = Array.from({ length: nextMonthDays }).map(
  (_, nextMonthDay) => (
    <div
      key={`next-month-day-${nextMonthDay + 1}`}
      className="p-4 border border-gray-200 text-xs text-gray-400 date-cell"
    >
      {nextMonthDay + 1 === 1
        ? `${monthNames[(currentDate.getMonth() + 1) % 12]} ${nextMonthDay + 1}`
        : nextMonthDay + 1}
    </div>
  ),
);

return (
  <div className="calendar-content">
    <div className="text-gray-800 pt-0 w-full">
      <div className="h-full">
        <div className="grid grid-cols-7 gap-0">{weekdayElements}</div>
        <div className="grid grid-cols-7 h-full gap-0">
          {prevMonthElements}
          {currentMonthElements}
          {nextMonthElements}
        </div>
      </div>
    </div>
  </div>
);
getWeekdays(), getMonthNames(), getMonthStartEnd()
const weekdays = getWeekdays();
const monthNames = getMonthNames();
const { startDate, endDate } = getMonthStartEnd(currentDate);

getWeekdays(), getMonthNames(), getMonthStartEnd()dateUtils.tsから取得したカレンダーの上部に曜日と月名、そして正しい範囲の日付を表示するための関数です。

weekdayElements
const weekdayElements = weekdays.map((day, index) => (
    <div
      key={`weekday-${day}`}
      className={`p-0 border border-gray-200 text-xs text-center date-cell
        ${getTextColor(index)}`}
    >
      {day}
    </div>
));

weekdayElements関数は、weekdays(曜日)をmap()で展開し、各曜日を表示しています。

getPrevMonthEndDate
const prevMonthEndDate = getPrevMonthEndDate(currentDate);

getPrevMonthEndDate(currentDate)によって、前月の最終日(prevMonthEndDate)を取得します。

startDate
const prevMonthDays = startDate.getDay();

startDate.getDay()を使って、カレンダーの最初の週に表示される前月の日数を計算します(prevMonthDays)。
これにより、前月の日付がカレンダーの空白を埋める形で表示されます。

prevMonthElements
const prevMonthElements = Array.from({ length: prevMonthDays }).map(
 (_, i) => (
    <div
    key={`prev-month-day-${prevMonthEndDate.getDate() - (prevMonthDays - 1 - i)}`}
    className="p-0 border border-gray-200  text-gray-400 date-cell"
    >
    {prevMonthEndDate.getDate() - (prevMonthDays - 1 - i)}
    </div>
 ),
);

prevMonthElementsの、Array.from({ length: prevMonthDays })は、長さが prevMonthDaysの配列を生成するためのコードです。この場合、prevMonthDays は前月の残り日数を表しています。このコードによって、prevMonthDays個の空の要素を持つ配列が生成されます。

currentMonthElements
const currentMonthElements = Array.from({ length: endDate.getDate() }).map(
(_, d) => {
    const currentDateDisplay = new Date(
        currentDate.getFullYear(),
        currentDate.getMonth(),
        d + 1,
    );
    const dayOfWeek = currentDateDisplay.getDay();
    return (
        <div
          key={`day-${d + 1}`}
          className={`p-0 border border-gray-200 text-xs text-center cursor-pointer overflow-hidden flex flex-col
            ${getDayOfWeekColor(dayOfWeek)}
          style={{ height: '95px' }}
          role="button"
          tabIndex={0}
          onKeyDown={(e) => {
            if (e.key === 'Enter' || e.key === ' ') {
              handleDateChange(currentDateDisplay);
              openModal();
            }
          }}
        >
        </div>
    );
 },
);

currentMonthElementsでは、現在の月の日付を表示するための要素を生成します。

<div className="flex-none px-2 py-1">
    {d + 1 === 1
    ? `${monthNames[currentDate.getMonth()]} ${d + 1}`
    : d + 1}
</div>

・このコードはカレンダーの日付セル内で、月初の日付のみに月名を表示させるためのコードです。dは、0から始まるインデックスであるため、d0の場合、値は1となり、「1日」を示すので三項演算子で書かれたコードのtrueが採用されます。

nextMonthElements
const totalDays = prevMonthDays + endDate.getDate();

const nextMonthDays = (7 - (totalDays % 7)) % 7;
const nextMonthElements = Array.from({ length: nextMonthDays }).map(
 (_, nextMonthDay) => (
    <div
    key={`next-month-day-${nextMonthDay + 1}`}
    className="p-4 border border-gray-200 text-xs text-gray-400 date-cell"
    >
     {nextMonthDay + 1 === 1
        ? `${monthNames[(currentDate.getMonth() + 1) % 12]} ${nextMonthDay + 1}`
        : nextMonthDay + 1}
    </div>
 ),
);

nextMonthElementsでは、カレンダーの最後の週に表示するため、次の月の日付を生成します。
totalDaysを使ってその月の合計日数を計算し、7日で割った余りで次月の日数を計算します。

レンダリング
<div className="grid grid-cols-7 h-full gap-0">
    {prevMonthElements}
    {currentMonthElements}
    {nextMonthElements}
</div>

・最後にカレンダーのレンダリングをして、カレンダー全体を grid レイアウトで表示しています。
weekdayElementsprevMonthElementscurrentMonthElementsnextMonthElementsがそれぞれカレンダーの一部として表示されます。

これにより、前月、当月、次月の全ての日付がカレンダーに表示されます。

矢印アイコンで月を切り替える

useDate.ts
// hooks/useDate.ts
const [currentDate, setCurrentDate] = useState(new Date());
CalendarHeader.tsx
<ArrowBackIosIcon
className="cursor-pointer mx-2"
style={{ fontSize: '16px' }}
onClick={() =>
  setCurrentDate(
    new Date(
      new Date(currentDate).setMonth(currentDate.getMonth() - 1),
    ),
  )
}
aria-label="Previous Month"
/>
<div className="cursor-pointer font-medium mx-2 text-xs">
{currentDate.toLocaleDateString('ja-JP', {
  year: 'numeric',
  month: 'long',
})}
</div>
<ArrowForwardIosIcon
className="cursor-pointer mx-2"
style={{ fontSize: '16px' }}
onClick={() =>
  setCurrentDate(
    new Date(
      new Date(currentDate).setMonth(currentDate.getMonth() + 1),
    ),
  )
}
aria-label="Next Month"
/>

currentDateはカレンダーの表示に使用され、CalendarDays.tsxでもcurrentDateを使って年、月、日が表示されます。

ArrowBackIosIconを押すと、setCurrentDateによってcurrentDateが前月に変更されます。

ArrowForwardIosIconを押すと、setCurrentDateによってcurrentDateが次月に変更されます。

・アイコンの間には、currentDate.toLocaleDateString()を使って現在表示している月が表示されます。

Todayボタンを押すと、現在の日付が含まれる月にジャンプ

useDate.ts
// hooks/useDate.ts
const [currentDate, setCurrentDate] = useState(new Date());
CalendarHeader.tsx
<button
    type="button"
    onClick={() => setCurrentDate(new Date())}
    className="text-blue-600 hover:text-blue-400 font-bold text-xxs bg-gray-800 p-0.5"
>
    Today
</button>

currentDateはカレンダーの表示に使用され、CalendarDays.tsxでもcurrentDateを使って年、月、日が表示されます。

Todayボタンを押すと、setCurrentDateに現在の日付(new Date())が渡され、currentDateが更新されます。これにより、カレンダーは現在の年月にジャンプし、今日の日付が表示されます。

今日の日付を水色の背景でハイライト表示

CalendarDays.tsx
const currentDateDisplay = new Date(
        currentDate.getFullYear(),
        currentDate.getMonth(),
        d + 1,
      );
      
const isToday =
    currentDateDisplay.toDateString() === new Date().toDateString();
CalendarDays.tsx
${isToday ? 'bg-blue-300' : ''}`}

isTodayという変数でcurrentDateDisplayが今日の日付かどうかを確認しています。

・テンプレートリテラルの中で三項演算子を用いてisTodaytrueならbg-blue-300(Tailwind CSSで定義されたクラス)を適用し、背景色を水色に変更しています。

まとめ

今回のブログでは、私が開発したアプリ「Chatendar」のカレンダー機能について、主要な実装部分を解説しました。

カレンダー機能は、アプリケーションにおいてユーザーにとって視覚的にも機能的にも重要な部分です。今後も「Chatendar」の他の機能について、引き続き解説していく予定です。

Discussion