0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[カレンダー作成]某Yカレンダーと同じ繰り返し予定の編集の挙動を再現するには

Posted at

はじめに

Yahooカレンダーなどの一般的なカレンダーアプリでは、繰り返し予定を編集する際に「この予定のみを編集」「すべての予定を編集」「以降の予定を編集」といった選択肢が表示されます。この記事では、このような挙動をNext.jsとTypeScriptで実装する方法について詳しく解説します。

以下の自作のカレンダーから抜粋しています。

このカレンダーの概要は

で説明しているので気になる方がいればぜひ。

Yahooカレンダーの繰り返し予定編集の挙動

Yahooカレンダーで繰り返し予定を編集しようとすると、以下の3つの選択肢が表示されます:

  1. この予定のみを編集 - 選択した日付の予定のみを編集し、他の繰り返し予定には影響しない
  2. すべての予定を編集 - 繰り返し予定の元となるオリジナルの予定を編集し、すべての繰り返し予定に変更が反映される
  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);

「この予定のみを編集」の場合

  1. 元の予定のblackoutDatesに選択日付を追加して、その日付での表示を停止
  2. 選択日付に合わせた時間で新しい単発予定を作成
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);
};

「以降の予定を編集」の場合

  1. 元の予定のrepeatEndDateを選択日付の前日に設定して、それ以降の繰り返しを停止
  2. 選択日付以降の新しい繰り返し予定を作成
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カレンダーと同様の繰り返し予定編集機能を実現できます。重要なポイントは以下の通りです:

  1. データ構造の設計: repeatStartDaterepeatEndDateblackoutDatesを適切に管理
  2. 動的な表示ロジック: 繰り返しパターンに応じて予定を動的に表示
  3. 3つの編集パターン: それぞれ異なるロジックでデータを分割・更新
  4. 補助関数の活用: 時間調整や除外日計算を適切に行う

この実装を参考に、独自のカレンダーアプリに繰り返し予定の編集機能を追加してみてください。初心者の方でも、段階的に理解していけば必ず実装できるはずです!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?