はじめに
Yahooカレンダーなどの一般的なカレンダーアプリでは、繰り返し予定を編集する際に「この予定のみを編集」「すべての予定を編集」「以降の予定を編集」といった選択肢が表示されます。この記事では、このような挙動をNext.jsとTypeScriptで実装する方法について詳しく解説します。
以下の自作のカレンダーから抜粋しています。
このカレンダーの概要は
で説明しているので気になる方がいればぜひ。
Yahooカレンダーの繰り返し予定編集の挙動
Yahooカレンダーで繰り返し予定を編集しようとすると、以下の3つの選択肢が表示されます:
- この予定のみを編集 - 選択した日付の予定のみを編集し、他の繰り返し予定には影響しない
- すべての予定を編集 - 繰り返し予定の元となるオリジナルの予定を編集し、すべての繰り返し予定に変更が反映される
- 以降の予定を編集 - 選択した日付以降の繰り返し予定のみを編集し、それ以前の予定には影響しない
実装方法
1. データ構造の設計
まず、繰り返し予定を管理するためのデータ構造を定義します。
// app/types/event.ts
export interface Schedule {
id: string;
type: 'schedule';
name: string;
startTime: Date;
endTime: Date;
repeat: RepeatType; // 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'
repeatStartDate: Date; // 繰り返しの開始日
repeatEndDate: Date | null; // 繰り返しの終了日(nullの場合は無期限)
location?: string;
memo?: string;
blackoutDates?: Date[]; // 除外する日付の配列
}
このデータ構造の重要なポイントは以下の3つです:
-
repeatStartDate: 繰り返し予定の開始日を管理 -
repeatEndDate: 繰り返し予定の終了日を管理(nullの場合は無期限) -
blackoutDates: 特定の日付を除外するための配列
2. カレンダーでの予定表示ロジック
カレンダーに予定を表示する際は、カレンダーを表示するロジック(CalendarCell.tsx)の中にgetSchedulesForDate関数を使用して、指定された日付に表示すべき予定を動的に取り出します。
// app/components/Calendar_component/CalendarCell.tsx
export default function CalendarCell({ currentYM, schedules, tasks, selectedDate, setSelectedDate, today }: CalendarCellProps) {
// カレンダー生成に必要な日付情報を計算(useMemoでメモ化)
const calendarInfo = useMemo(() => {
// 月の最終日を取得(翌月の0日目 = 当月の最終日)
const daysInMonth = new Date(currentYM.year, currentYM.month + 1, 0).getDate();
// 月の1日目の曜日を取得(0:日曜日 ~ 6:土曜日)
const dayOfFirstDate = new Date(currentYM.year, currentYM.month, 1).getDay();
// 月の最終日の曜日を取得
const dayOfLastDate = new Date(currentYM.year, currentYM.month + 1, 0).getDay();
return { daysInMonth, dayOfFirstDate, dayOfLastDate };
}, [currentYM.year, currentYM.month]);
// カレンダーセルのリストを生成(useMemoでメモ化)
const calendarCellList = useMemo(() => {
const { daysInMonth, dayOfFirstDate, dayOfLastDate } = calendarInfo;
const cellList = [];
// カレンダーの日付セルを生成
// 先月の残り日数、当月の日数、来月の日数を含めてループ
for (let i = 1 - dayOfFirstDate; i <= daysInMonth + 6 - dayOfLastDate; i++) {
// 処理対象の日付オブジェクトを生成
const processDate = new Date(currentYM.year, currentYM.month, i);
processDate.setHours(0, 0, 0, 0);
// 対応する日付の予定を取得
const schedulesForDate = getSchedulesForDate(schedules, processDate);
// 対応する日付のタスクを取得
const tasksForDate = getTasksForDate(tasks, processDate);
// 日付セルを生成してリストに追加
cellList.push(
<div
key={i}
className="relative p-1 sm:p-2 border border-gray-200"
onClick={() => setSelectedDate(processDate)}
>
{/* 日付表示 */}
<div className="text-xs sm:text-sm font-medium mb-1 sm:mb-2">
{processDate.getDate()}
</div>
{/* イベント表示エリア */}
<div className="space-y-0.5 sm:space-y-1">
{/* スケジュール(予定) */}
{schedulesForDate.map(schedule => (
<div
key={schedule.id}
className="text-xs truncate bg-blue-100 text-blue-800 px-1 sm:px-2 py-0.5 sm:py-1 rounded border-l-2 border-blue-400"
>
{schedule.name}
</div>
))}
{/* タスク */}
{tasksForDate.map(task => (
<div
key={task.id}
className="text-xs truncate bg-green-100 text-green-800 px-1 sm:px-2 py-0.5 sm:py-1 rounded border-l-2 border-green-400"
>
{task.name}
</div>
))}
</div>
);
}
return cellList;
}, [currentYM, schedules, tasks, selectedDate, setSelectedDate, calendarInfo]);
return (
<div className="grid grid-cols-7">
{calendarCellList}
</div>
);
}
// app/Utils/CalendarCellUtil.ts
export const getSchedulesForDate = (schedules: Schedule[], processDate: Date): Schedule[] => {
return schedules.filter(schedule => {
// 繰り返し終了日をチェック
if (schedule.repeatEndDate !== null && schedule.repeatEndDate < processDate) {
return false;
}
// 除外日をチェック
if (schedule.blackoutDates && schedule.blackoutDates.some(blackoutDate =>
blackoutDate.getFullYear() === processDate.getFullYear() &&
blackoutDate.getMonth() === processDate.getMonth() &&
blackoutDate.getDate() === processDate.getDate()
)) {
return false;
}
// 繰り返しパターンに応じて表示判定
if (schedule.repeat === "weekly") {
const weekDays = getWeekDaysInScheduleRange(schedule.startTime, schedule.endTime);
return weekDays.includes(processDate.getDay()) &&
processDate >= schedule.startTime &&
processDate >= schedule.repeatStartDate;
}
// 他の繰り返しパターンも同様に処理...
});
};
3. 繰り返し予定の編集ロジック
繰り返し予定の編集ボタンをクリックすると、RepeatEditOptionModalが表示され、3つの選択肢が提示されます。
// app/components/RepeatEditOptionModal.tsx
export default function RepeatEditOptionModal({
setRepeatEditOpitonModalOpen,
setIsEditScheduleModalOpen,
editingSchedule,
setEditingSchedule,
selectedDate
}: RepeatEditOptionModalProps) {
// この予定のみを編集
const processThisSchedule = async () => {
if (!editingSchedule) return;
// 元の予定のblackoutDatesに選択日付を追加
const addBlackoutDates = addBlackoutDatesFunction(editingSchedule, selectedDate);
// 元の予定を更新(除外日を追加)
await updateDoc(doc(db, "users", userId, 'schedules', editingSchedule.id), {
blackoutDates: [...(editingSchedule.blackoutDates || []), ...addBlackoutDates]
});
// 新しい予定を作成(選択日付に合わせた時間で)
const newSchedule = {
...editingSchedule,
startTime: makeStartTime(addBlackoutDates, editingSchedule.startTime),
endTime: makeEndTime(addBlackoutDates, editingSchedule.endTime),
repeat: "none", // 繰り返しなしの単発予定として作成
repeatStartDate: selectedDate,
repeatEndDate: null
};
// 新しい予定を保存
const newScheduleRef = await addDoc(collection(db, "users", userId, 'schedules'), newSchedule);
setEditingSchedule({ ...newSchedule, id: newScheduleRef.id });
};
// 以降の予定を編集
const processFutureSchedules = async () => {
if (!editingSchedule) return;
// 元の予定のrepeatEndDateを選択日付の前日に設定
const previousDay = new Date(selectedDate);
previousDay.setDate(selectedDate.getDate() - 1);
await updateDoc(doc(db, "users", userId, 'schedules', editingSchedule.id), {
repeatEndDate: previousDay
});
// 新しい予定を作成(選択日付以降の繰り返し予定として)
const addBlackoutDates = addBlackoutDatesFunction(editingSchedule, selectedDate);
const newSchedule = {
...editingSchedule,
repeatStartDate: addBlackoutDates[0], // 選択日付から開始
startTime: makeStartTime(addBlackoutDates, editingSchedule.startTime),
endTime: makeEndTime(addBlackoutDates, editingSchedule.endTime)
};
const newScheduleRef = await addDoc(collection(db, "users", userId, 'schedules'), newSchedule);
setEditingSchedule({ ...newSchedule, id: newScheduleRef.id });
};
return (
<div className="fixed inset-0 flex items-center justify-center bg-black/30 z-50">
<div className="relative bg-white border border-gray-200 p-4 w-full max-w-sm">
<div className="flex flex-col gap-2 mt-7">
<button onClick={() => {
setRepeatEditOpitonModalOpen(false);
setIsEditScheduleModalOpen(true);
}}>すべての予定を編集</button>
<button onClick={() => {
processThisSchedule();
setRepeatEditOpitonModalOpen(false);
setIsEditScheduleModalOpen(true);
}}>この予定だけを編集</button>
<button onClick={() => {
processFutureSchedules();
setRepeatEditOpitonModalOpen(false);
setIsEditScheduleModalOpen(true);
}}>以降の予定を編集</button>
</div>
</div>
</div>
);
}
4. 各編集パターンの詳細ロジック
「すべての予定を編集」の場合
これは最もシンプルで、元の繰り返し予定を直接編集するだけです。変更はすべての繰り返し予定に反映されます。
// 通常の編集モーダルを開くだけ
setIsEditScheduleModalOpen(true);
「この予定のみを編集」の場合
- 元の予定の
blackoutDatesに選択日付を追加して、その日付での表示を停止 - 選択日付に合わせた時間で新しい単発予定を作成
const processThisSchedule = async () => {
// 1. 元の予定から選択日付を除外
const addBlackoutDates = addBlackoutDatesFunction(editingSchedule, selectedDate);
await updateDoc(doc(db, "users", userId, 'schedules', editingSchedule.id), {
blackoutDates: [...(editingSchedule.blackoutDates || []), ...addBlackoutDates]
});
// 2. 新しい単発予定を作成
const newSchedule = {
...editingSchedule,
startTime: makeStartTime(addBlackoutDates, editingSchedule.startTime),
endTime: makeEndTime(addBlackoutDates, editingSchedule.endTime),
repeat: "none",
repeatStartDate: selectedDate,
repeatEndDate: null
};
await addDoc(collection(db, "users", userId, 'schedules'), newSchedule);
};
「以降の予定を編集」の場合
- 元の予定の
repeatEndDateを選択日付の前日に設定して、それ以降の繰り返しを停止 - 選択日付以降の新しい繰り返し予定を作成
const processFutureSchedules = async () => {
// 1. 元の予定の繰り返し終了日を設定
const previousDay = new Date(selectedDate);
previousDay.setDate(selectedDate.getDate() - 1);
await updateDoc(doc(db, "users", userId, 'schedules', editingSchedule.id), {
repeatEndDate: previousDay
});
// 2. 新しい繰り返し予定を作成
const addBlackoutDates = addBlackoutDatesFunction(editingSchedule, selectedDate);
const newSchedule = {
...editingSchedule,
repeatStartDate: addBlackoutDates[0], // 選択日付から開始
startTime: makeStartTime(addBlackoutDates, editingSchedule.startTime),
endTime: makeEndTime(addBlackoutDates, editingSchedule.endTime)
};
await addDoc(collection(db, "users", userId, 'schedules'), newSchedule);
};
5. 補助関数の実装
時間の調整や除外日の計算を行う補助関数も重要です。
// app/Utils/DeleteUtil.ts
export const addBlackoutDatesFunction = (editingSchedule: Schedule, selectedDate: Date) => {
const startDate = new Date(editingSchedule.startTime);
startDate.setHours(0, 0, 0, 0);
const endDate = new Date(editingSchedule.endTime);
endDate.setHours(0, 0, 0, 0);
const editingScheduleDays = getWeekDaysInScheduleRange(startDate, endDate);
const processDate = new Date(selectedDate);
const addBlackoutDates: Date[] = [];
// 選択日付を含む週の範囲で除外日を計算
while (editingScheduleDays.includes(processDate.getDay())) {
processDate.setDate(processDate.getDate() - 1);
}
processDate.setDate(processDate.getDate() + 1);
while (editingScheduleDays.includes(processDate.getDay())) {
const dateCopy = new Date(processDate);
addBlackoutDates.push(dateCopy);
processDate.setDate(processDate.getDate() + 1);
}
return addBlackoutDates;
};
export const makeStartTime = (addBlackoutDates: Date[], startTime: Date) => {
const newStartTime = new Date(addBlackoutDates[0]);
newStartTime.setHours(startTime.getHours());
newStartTime.setMinutes(startTime.getMinutes());
newStartTime.setSeconds(startTime.getSeconds());
newStartTime.setMilliseconds(startTime.getMilliseconds());
return newStartTime;
};
export const makeEndTime = (addBlackoutDates: Date[], endTime: Date) => {
const newEndTime = new Date(addBlackoutDates[addBlackoutDates.length - 1]);
newEndTime.setHours(endTime.getHours());
newEndTime.setMinutes(endTime.getMinutes());
newEndTime.setSeconds(endTime.getSeconds());
newEndTime.setMilliseconds(endTime.getMilliseconds());
return newEndTime;
};
まとめ
この実装により、Yahooカレンダーと同様の繰り返し予定編集機能を実現できます。重要なポイントは以下の通りです:
-
データ構造の設計:
repeatStartDate、repeatEndDate、blackoutDatesを適切に管理 - 動的な表示ロジック: 繰り返しパターンに応じて予定を動的に表示
- 3つの編集パターン: それぞれ異なるロジックでデータを分割・更新
- 補助関数の活用: 時間調整や除外日計算を適切に行う
この実装を参考に、独自のカレンダーアプリに繰り返し予定の編集機能を追加してみてください。初心者の方でも、段階的に理解していけば必ず実装できるはずです!