【個人開発】Chatendarのカレンダー機能を深掘り:Next.jsとTypeScriptでの実装を解説
はじめに
このブログでは、IT未経験で独学の私が開発したアプリである「Chatendar」の主要な機能について解説していきます。今回は、カレンダー機能に焦点を当て、その具体的な実装方法をご紹介します。
もし、全体のコードをご覧になりたい方は、私のGitHubにて公開していますので、そちらからご確認いただけます。
アプリの全体像や概要を知りたい方は、以下のサイトからご確認ください。
カレンダー画面
紹介する機能
・カレンダーの表示。(正確な曜日と日付の紐付け)
・矢印アイコンで月を切り替える。
・Todayボタンを押すと、現在の日付が含まれる月にジャンプする。
・今日の日付を水色の背景でハイライト表示。
これらの機能を実装するためのコードを紹介します。
コード
それぞれの機能を実装するためのコードを載せます。もし、全体のコードを見たい方がいましたら、私のGitHubから閲覧することができます。
カレンダーの表示
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
は、指定された日付の月の開始日と終了日を返す関数です。JavaScript
のDate
オブジェクトのコンストラクタnew Date(year, monthIndex, day)
を使用しています。
・startDate
は、currentDate
の年と月をそのまま使用し、その月の最初の日(1日)を取得します。
・endDate
は、currentDate
の年と月に1
を足して次の月を指定し、日付を「0」とすることで前月の最終日を取得しています。Date
オブジェクトでは、日付に「0」を指定すると、指定した月の「前日」、すなわち前月の最終日を示します。
また、getPrevMonthEndDate
は現在の月の前月の最終日を返す関数です。
CalendarDays.tsx
などで利用するために、これらの関数はdateUtils.ts
内でexport
されています。
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>
);
const weekdays = getWeekdays();
const monthNames = getMonthNames();
const { startDate, endDate } = getMonthStartEnd(currentDate);
・getWeekdays()
, getMonthNames()
, getMonthStartEnd()
はdateUtils.ts
から取得したカレンダーの上部に曜日と月名、そして正しい範囲の日付を表示するための関数です。
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()
で展開し、各曜日を表示しています。
const prevMonthEndDate = getPrevMonthEndDate(currentDate);
・getPrevMonthEndDate(currentDate)
によって、前月の最終日(prevMonthEndDate
)を取得します。
const prevMonthDays = startDate.getDay();
・startDate.getDay()
を使って、カレンダーの最初の週に表示される前月の日数を計算します(prevMonthDays)。
これにより、前月の日付がカレンダーの空白を埋める形で表示されます。
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
個の空の要素を持つ配列が生成されます。
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
から始まるインデックスであるため、d
が0
の場合、値は1
となり、「1日」を示すので三項演算子で書かれたコードのtrueが採用されます。
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 レイアウトで表示しています。
weekdayElements
、prevMonthElements
、currentMonthElements
、nextMonthElements
がそれぞれカレンダーの一部として表示されます。
これにより、前月、当月、次月の全ての日付がカレンダーに表示されます。
矢印アイコンで月を切り替える
// hooks/useDate.ts
const [currentDate, setCurrentDate] = useState(new Date());
<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ボタンを押すと、現在の日付が含まれる月にジャンプ
// hooks/useDate.ts
const [currentDate, setCurrentDate] = useState(new Date());
<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
が更新されます。これにより、カレンダーは現在の年月にジャンプし、今日の日付が表示されます。
今日の日付を水色の背景でハイライト表示
const currentDateDisplay = new Date(
currentDate.getFullYear(),
currentDate.getMonth(),
d + 1,
);
const isToday =
currentDateDisplay.toDateString() === new Date().toDateString();
${isToday ? 'bg-blue-300' : ''}`}
・isToday
という変数でcurrentDateDisplay
が今日の日付かどうかを確認しています。
・テンプレートリテラルの中で三項演算子を用いてisToday
がtrue
ならbg-blue-300
(Tailwind CSSで定義されたクラス)を適用し、背景色を水色に変更しています。
まとめ
今回のブログでは、私が開発したアプリ「Chatendar」のカレンダー機能について、主要な実装部分を解説しました。
カレンダー機能は、アプリケーションにおいてユーザーにとって視覚的にも機能的にも重要な部分です。今後も「Chatendar」の他の機能について、引き続き解説していく予定です。
Discussion