diff --git a/src/App.tsx b/src/App.tsx index 195c5b05..c3e75dbc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,10 @@ -import { Notifications, ChevronLeft, ChevronRight, Delete, Edit, Close } from '@mui/icons-material'; +import ChevronLeft from '@mui/icons-material/ChevronLeft'; +import ChevronRight from '@mui/icons-material/ChevronRight'; +import Close from '@mui/icons-material/Close'; +import Delete from '@mui/icons-material/Delete'; +import Edit from '@mui/icons-material/Edit'; +import Notifications from '@mui/icons-material/Notifications'; +import Repeat from '@mui/icons-material/Repeat'; import { Alert, AlertTitle, @@ -35,8 +41,7 @@ import { useEventForm } from './hooks/useEventForm.ts'; import { useEventOperations } from './hooks/useEventOperations.ts'; import { useNotifications } from './hooks/useNotifications.ts'; import { useSearch } from './hooks/useSearch.ts'; -// import { Event, EventForm, RepeatType } from './types'; -import { Event, EventForm } from './types'; +import { Event, EventForm, RepeatType } from './types'; import { formatDate, formatMonth, @@ -46,6 +51,7 @@ import { getWeeksAtMonth, } from './utils/dateUtils'; import { findOverlappingEvents } from './utils/eventOverlap'; +import { generateInstancesForEvent } from './utils/recurrenceUtils'; import { getTimeErrorMessage } from './utils/timeValidation'; const categories = ['업무', '개인', '가족', '기타']; @@ -77,11 +83,11 @@ function App() { isRepeating, setIsRepeating, repeatType, - // setRepeatType, + setRepeatType, repeatInterval, - // setRepeatInterval, + setRepeatInterval, repeatEndDate, - // setRepeatEndDate, + setRepeatEndDate, notificationTime, setNotificationTime, startTimeError, @@ -94,9 +100,14 @@ function App() { editEvent, } = useEventForm(); - const { events, saveEvent, deleteEvent } = useEventOperations(Boolean(editingEvent), () => - setEditingEvent(null) - ); + const { + events, + saveEvent, + deleteEvent, + saveRecurringEvents, + updateRecurringSeries, + deleteRecurringSeries, + } = useEventOperations(Boolean(editingEvent), () => setEditingEvent(null)); const { notifications, notifiedEvents, setNotifications } = useNotifications(events); const { view, setView, currentDate, holidays, navigate } = useCalendarView(); @@ -104,9 +115,65 @@ function App() { const [isOverlapDialogOpen, setIsOverlapDialogOpen] = useState(false); const [overlappingEvents, setOverlappingEvents] = useState([]); + const [isRecurringDialogOpen, setIsRecurringDialogOpen] = useState(false); + const [recurringDialogType, setRecurringDialogType] = useState<'edit' | 'delete'>('edit'); + const [selectedEvent, setSelectedEvent] = useState(null); const { enqueueSnackbar } = useSnackbar(); + const handleEditClick = (event: Event) => { + if (event.repeat.type !== 'none' && event.repeat.id) { + // 반복 일정인 경우 다이얼로그 표시 + setSelectedEvent(event); + setRecurringDialogType('edit'); + setIsRecurringDialogOpen(true); + } else { + // 단일 일정인 경우 바로 수정 폼 표시 + editEvent(event); + } + }; + + const handleDeleteClick = (event: Event) => { + if (event.repeat.type !== 'none' && event.repeat.id) { + // 반복 일정인 경우 다이얼로그 표시 + setSelectedEvent(event); + setRecurringDialogType('delete'); + setIsRecurringDialogOpen(true); + } else { + // 단일 일정인 경우 바로 삭제 + deleteEvent(event.id); + } + }; + + const handleSingleEdit = () => { + if (!selectedEvent) return; + setIsRecurringDialogOpen(false); + // repeat.type을 'none'으로 변경하여 단일 일정으로 전환 + editEvent({ + ...selectedEvent, + repeat: { type: 'none', interval: 1 }, + }); + }; + + const handleSeriesEdit = () => { + if (!selectedEvent) return; + setIsRecurringDialogOpen(false); + // 전체 시리즈 수정을 위해 수정 폼 표시 + editEvent(selectedEvent); + }; + + const handleSingleDelete = async () => { + if (!selectedEvent) return; + setIsRecurringDialogOpen(false); + await deleteEvent(selectedEvent.id); + }; + + const handleSeriesDelete = async () => { + if (!selectedEvent || !selectedEvent.repeat.id) return; + setIsRecurringDialogOpen(false); + await deleteRecurringSeries(selectedEvent.repeat.id); + }; + const addOrUpdateEvent = async () => { if (!title || !date || !startTime || !endTime) { enqueueSnackbar('필수 정보를 모두 입력해주세요.', { variant: 'error' }); @@ -135,6 +202,42 @@ function App() { notificationTime, }; + // 반복 일정 생성 + if (isRepeating && !editingEvent && repeatEndDate) { + const baseEvent: Event = { + ...(eventData as EventForm), + id: '', + }; + + const rangeStart = new Date(date); + const rangeEnd = new Date(repeatEndDate); + + const instances = generateInstancesForEvent(baseEvent, rangeStart, rangeEnd); + + // 반복 일정은 겹침 검사를 하지 않음 + await saveRecurringEvents(instances); + resetForm(); + return; + } + + // 반복 시리즈 전체 수정 + if (editingEvent && editingEvent.repeat.type !== 'none' && editingEvent.repeat.id) { + const updateData: Partial = { + title, + date, + startTime, + endTime, + description, + location, + category, + notificationTime, + }; + await updateRecurringSeries(editingEvent.repeat.id, updateData); + resetForm(); + return; + } + + // 단일 일정 또는 수정 const overlapping = findOverlappingEvents(eventData, events); if (overlapping.length > 0) { setOverlappingEvents(overlapping); @@ -414,7 +517,12 @@ function App() { control={ setIsRepeating(e.target.checked)} + onChange={(e) => { + setIsRepeating(e.target.checked); + if (e.target.checked && repeatType === 'none') { + setRepeatType('weekly'); + } + }} /> } label="반복 일정" @@ -437,15 +545,16 @@ function App() { - {/* ! 반복은 8주차 과제에 포함됩니다. 구현하고 싶어도 참아주세요~ */} - {/* {isRepeating && ( + {isRepeating && ( - 반복 유형 + 반복 유형 +``` + +### 6. generateInstancesForEvent 사용 + +**파일**: `src/utils/recurrenceUtils.ts` + +```typescript +import { generateInstancesForEvent } from '../utils/recurrenceUtils'; + +// 반복 일정 생성 시 +const instances = generateInstancesForEvent( + baseEvent, + new Date(baseEvent.date), + new Date(repeatEndDate) +); + +// 생성된 인스턴스를 saveRecurringEvents로 저장 +await saveRecurringEvents(instances); +``` + +--- + +## 🔗 참고할 기존 코드 + +### 1. Hook 패턴 + +**파일**: `src/hooks/useEventOperations.ts` + +기존 `saveEvent`, `deleteEvent` 패턴을 참고하여 새 함수 추가: + +```typescript +// 기존 패턴 +const saveEvent = async (event: Event) => { + try { + const method = event.id ? 'PUT' : 'POST'; + const url = event.id ? `/api/events/${event.id}` : '/api/events'; + const response = await fetch(url, { method, body: JSON.stringify(event) }); + // ... 상태 업데이트 + enqueueSnackbar('일정 저장 완료', { variant: 'success' }); + } catch (error) { + enqueueSnackbar('일정 저장 실패', { variant: 'error' }); + } +}; + +// 새로운 함수도 동일한 패턴으로 +const saveRecurringEvents = async (events: Event[]) => { + try { + const response = await fetch('/api/events-list', { + method: 'POST', + body: JSON.stringify({ events }), + }); + // ... 상태 업데이트 + enqueueSnackbar('일정 생성 완료', { variant: 'success' }); + } catch (error) { + enqueueSnackbar('일정 생성 실패', { variant: 'error' }); + } +}; +``` + +### 2. 통합 테스트에서 사용된 컴포넌트 + +**예상 파일 구조**: + +- `src/components/EventForm.tsx` - 일정 입력 폼 +- `src/components/EventList.tsx` - 일정 목록 표시 +- `src/App.tsx` - 최상위 컴포넌트 + +**필요한 aria-label / testId**: + +- `data-testid="event-submit-button"` - 제출 버튼 +- `data-testid="event-list"` - 이벤트 목록 +- `aria-label="Edit event"` - 수정 버튼 +- `aria-label="Delete event"` - 삭제 버튼 +- `aria-label="반복 일정 아이콘"` - 반복 아이콘 + +### 3. 기존 유틸리티 함수 + +**파일**: `src/utils/recurrenceUtils.ts` + +```typescript +// 이미 구현되어 있고 모든 테스트 통과 +export function generateInstancesForEvent(event: Event, rangeStart: Date, rangeEnd: Date): Event[]; + +export function getNextOccurrence(date: Date, type: RepeatType, interval: number): Date; +``` + +--- + +## 📊 테스트 실패 메시지 분석 + +### Phase 1, 2: Hook 함수 없음 + +``` +❌ result.current.saveRecurringEvents is not a function +❌ result.current.updateRecurringSeries is not a function +❌ result.current.deleteRecurringSeries is not a function +``` + +**해결**: `useEventOperations` 훅에 3개 함수 추가 + +### Phase 3: UI 요소 없음 + +``` +❌ Unable to find an element with the text: /반복 일정/i +❌ Unable to find an element with the text: /해당 일정만 수정하시겠어요?/i +❌ Unable to find an element by: [aria-label="반복 일정 아이콘"] +``` + +**해결**: 반복 체크박스, 다이얼로그, 아이콘 UI 추가 + +### Phase 4: 속성 없음 + +``` +❌ expect(element).toHaveAttribute('max', '2025-12-31') +``` + +**해결**: 반복 종료일 input에 max="2025-12-31" 추가 + +--- + +## 📁 수정/생성할 파일 목록 + +### 1. `src/hooks/useEventOperations.ts` (수정) + +**추가할 내용**: + +- `saveRecurringEvents` 함수 +- `updateRecurringSeries` 함수 +- `deleteRecurringSeries` 함수 +- Hook의 return에 3개 함수 추가 + +**예상 라인 수**: +80~100 라인 + +### 2. `src/components/EventForm.tsx` (수정 또는 생성) + +**추가할 내용**: + +- 반복 일정 체크박스 +- 반복 유형 선택 +- 반복 간격 입력 +- 반복 종료일 입력 (max="2025-12-31") +- 제출 로직 분기 (단일/반복) + +**예상 라인 수**: +100~150 라인 + +### 3. `src/components/RecurringEventDialog.tsx` (생성) + +**새 컴포넌트**: + +- 단일/전체 선택 다이얼로그 +- 수정/삭제 모드 지원 +- "예" / "아니오" 버튼 + +**예상 라인 수**: +60~80 라인 + +### 4. `src/components/EventList.tsx` 또는 `EventItem.tsx` (수정) + +**추가할 내용**: + +- 반복 아이콘 표시 로직 +- 수정 버튼 클릭 시 다이얼로그 로직 +- 삭제 버튼 클릭 시 다이얼로그 로직 + +**예상 라인 수**: +40~60 라인 + +### 5. `src/App.tsx` (수정 가능) + +**추가 필요 시**: + +- 다이얼로그 상태 관리 +- 반복 일정 생성 플로우 연결 + +**예상 라인 수**: +30~50 라인 + +### 6. `src/types.ts` (확인) + +**현재 타입**: + +```typescript +export interface RepeatInfo { + type: RepeatType; + interval: number; + endDate?: string; + id?: string; // 이미 정의되어 있는지 확인 필요 +} +``` + +**필요 시 추가**: `id?: string;` + +--- + +## ✅ 완료 조건 + +다음 모든 조건이 충족되면 **GREEN 단계 완료**: + +- [ ] `useEventOperations` 훅에 3개 함수 구현 +- [ ] 반복 일정 생성 UI 구현 +- [ ] 반복 아이콘 표시 +- [ ] 수정/삭제 다이얼로그 구현 +- [ ] 단일 수정 시 repeat.type 변환 +- [ ] 반복 종료일 max 속성 설정 +- [ ] 반복 일정 겹침 검사 제외 +- [ ] **모든 17개 테스트 통과** ✅ +- [ ] 타입스크립트 컴파일 에러 없음 +- [ ] Lint 에러 없음 + +--- + +## 🧪 테스트 실행 방법 + +```bash +# Hook 테스트만 실행 +pnpm test medium.useEventOperations.spec.ts --run + +# 통합 테스트만 실행 +pnpm test medium.integration.spec.tsx --run + +# 전체 테스트 실행 +pnpm test --run +``` + +--- + +## 📚 참고 문서 + +- **기능 명세서**: `src/ai/specs/recurring-events-spec.md` +- **테스트 계획서**: `src/ai/test-plans/recurring-events-test-plan.md` +- **Kent Beck TDD 원칙**: `src/ai/docs/kent-beck-tdd.md` +- **서버 API**: `server.js` (실제 API 엔드포인트 구현 확인) + +--- + +## 🎯 우선순위 요약 + +**가장 중요한 순서**: + +1. ⭐⭐⭐ Hook 3개 함수 구현 (7개 테스트 통과) +2. ⭐⭐ 반복 생성 UI + 다이얼로그 (8개 테스트 통과) +3. ⭐ 반복 아이콘 + 엣지 케이스 (2개 테스트 통과) + +**예상 소요 시간**: 3~5시간 + +--- + +## 🚨 절대 수정하면 안 되는 것 + +- ❌ **테스트 파일 수정 금지** + - `src/__tests__/hooks/medium.useEventOperations.spec.ts` + - `src/__tests__/medium.integration.spec.tsx` + - `src/__mocks__/handlersUtils.ts` +- ❌ **기존 유틸리티 함수 수정 금지** + - `src/utils/recurrenceUtils.ts` (이미 완벽하게 테스트됨) +- ❌ **기존 테스트 통과율 유지** + - 새로운 코드가 기존 테스트를 깨뜨리면 안됨 + +--- + +## 최종 체크리스트 + +작업 시작 전 확인: + +- [ ] 테스트 계획서를 읽었는가? +- [ ] 인수인계 문서를 이해했는가? +- [ ] Kent Beck TDD 원칙을 숙지했는가? +- [ ] 기존 코드 구조를 파악했는가? + +작업 완료 후 확인: + +- [ ] 모든 17개 테스트가 통과하는가? +- [ ] 기존 테스트들도 여전히 통과하는가? +- [ ] Lint 에러가 없는가? +- [ ] 타입 에러가 없는가? +- [ ] 코드가 명확하고 이해하기 쉬운가? + +--- + +**모든 테스트가 통과하면 다음 에이전트(리팩터링)로 진행합니다!** 🚀 diff --git a/src/ai/reports/TDD-Engineer-result.md b/src/ai/reports/TDD-Engineer-result.md new file mode 100644 index 00000000..b399db0d --- /dev/null +++ b/src/ai/reports/TDD-Engineer-result.md @@ -0,0 +1,178 @@ +# 🧪 TDD-Engineer 결과 리포트 + +## 📅 작성일 + +2025-10-29 + +## 🎯 목표 + +반복 일정(Recurrence) 기능의 핵심 유틸리티 함수를 TDD 방법론으로 구현 + +## ✅ 완료된 작업 + +### Phase 1: 핵심 유틸리티 구현 (완료) + +#### 1. 테스트 파일 + +- **파일**: `src/__tests__/unit/easy.recurrenceUtils.spec.ts` +- **총 테스트 수**: 12개 +- **통과**: 12개 ✅ +- **실패**: 0개 + +#### 2. 구현 파일 + +- **파일**: `src/utils/recurrenceUtils.ts` +- **함수**: `generateInstancesForEvent` + +## 📊 테스트 결과 + +### 테스트 카테고리 + +#### 매일 반복 (3개 테스트) + +- ✅ `should generate daily instances for 7 days` +- ✅ `should generate daily instances with interval 2 (every 2 days)` +- ✅ `should respect endDate when generating daily instances` + +#### 매주 반복 (2개 테스트) + +- ✅ `should generate weekly instances for 3 weeks` +- ✅ `should generate weekly instances with interval 2 (every 2 weeks)` + +#### 매월 반복 (2개 테스트) + +- ✅ `should generate monthly instances for 3 months` +- ✅ `should skip months without 31st day when recurring on 31st` ⭐ 엣지 케이스 + +#### 매년 반복 (2개 테스트) + +- ✅ `should generate yearly instances for 3 years` +- ✅ `should skip non-leap years when recurring on Feb 29` ⭐ 엣지 케이스 + +#### 반복 없음 (1개 테스트) + +- ✅ `should return single instance when repeat type is none` + +#### 범위 외 처리 (2개 테스트) + +- ✅ `should not generate instances before rangeStart` +- ✅ `should not generate instances after rangeEnd` + +## 🧠 처리된 엣지 케이스 + +### 1. 31일 매월 반복 + +**시나리오**: 1월 31일에 매월 반복 일정 생성 + +- ✅ 2월은 31일이 없으므로 건너뛰기 +- ✅ 3월 31일 생성 +- ✅ 4월은 31일이 없으므로 건너뛰기 + +**구현 방법**: + +- 목표 월의 마지막 날짜 확인 +- 원래 날짜가 존재하지 않으면 다음 달로 이동 +- 유효한 날짜를 찾을 때까지 반복 + +### 2. 윤년 2월 29일 매년 반복 + +**시나리오**: 2024년 2월 29일에 매년 반복 일정 생성 + +- ✅ 2024년 (윤년) - 생성 +- ✅ 2025년 (평년) - 건너뛰기 +- ✅ 2026년 (평년) - 건너뛰기 +- ✅ 2027년 (평년) - 건너뛰기 +- ✅ 2028년 (윤년) - 생성 + +**구현 방법**: + +- 윤년 확인 로직 구현: `(year % 4 === 0 && year % 100 !== 0) || year % 400 === 0` +- 윤년이 아닌 해는 건너뛰기 +- 다음 윤년까지 반복 + +## 🔄 TDD 사이클 + +### RED (실패하는 테스트) + +1. 초기 테스트 작성 시 2개 실패: + - 31일 매월 반복 테스트 + - 윤년 2월 29일 매년 반복 테스트 + +### GREEN (테스트 통과) + +1. `getNextOccurrence` 함수에 엣지 케이스 처리 로직 추가 +2. 재귀 호출 대신 while 루프로 건너뛰기 구현 +3. 모든 테스트 통과 ✅ + +### REFACTOR (리팩토링) + +- 가독성을 위한 함수 분리 +- 주석 추가로 의도 명확화 +- 무한 루프 방지 메커니즘 추가 + +## 📝 코드 품질 + +### 구현된 함수 + +#### `generateInstancesForEvent` + +- 반복 일정의 모든 인스턴스 생성 +- 범위 내의 날짜만 포함 +- 종료일 존중 + +#### `getNextOccurrence` + +- 다음 반복 날짜 계산 +- 엣지 케이스 자동 처리 +- 안전 장치 포함 (무한 루프 방지) + +#### `formatDate` + +- YYYY-MM-DD 형식 변환 + +#### `isLeapYear` + +- 윤년 확인 + +## ⚠️ 알려진 제한사항 + +1. **최대 반복 횟수**: 1000회로 제한 (무한 루프 방지) +2. **날짜 범위**: 9999년까지만 지원 +3. **월간 반복 검색**: 최대 24개월 (2년)까지 유효한 날짜 검색 +4. **연간 반복 검색**: 최대 10년까지 윤년 검색 + +## 🎯 다음 단계 + +### Phase 2: 수정/삭제 헬퍼 함수 (예정) + +- [ ] `editInstance` 테스트 및 구현 +- [ ] `editAll` 테스트 및 구현 +- [ ] `deleteInstance` 테스트 및 구현 +- [ ] `deleteAll` 테스트 및 구현 + +**파일**: `src/__tests__/unit/medium.recurrenceUtils.spec.ts` + +### Phase 3: 훅 통합 (예정) + +- [ ] `useEventOperations` 확장 +- [ ] 반복 일정 생성 로직 통합 +- [ ] 수정/삭제 다이얼로그 상태 관리 + +### Phase 4: 이벤트 확장 (예정) + +- [ ] `expandRecurringEvents` 구현 +- [ ] 캘린더 뷰 통합 + +## 📚 참고 문서 + +- PRD: `src/ai/PRD/recurrence-feature.md` +- TDD 원칙: `src/ai/docs/kent-beck-tdd.md` +- 테스트 파일: `src/__tests__/unit/easy.recurrenceUtils.spec.ts` + +## 🏆 결론 + +✅ **Phase 1 완료**: 핵심 반복 일정 생성 로직이 TDD 방법론에 따라 성공적으로 구현됨 +✅ **모든 엣지 케이스 처리**: 31일 매월 반복, 윤년 2월 29일 매년 반복 +✅ **테스트 커버리지**: 12/12 테스트 통과 (100%) + +**다음 에이전트 핸드오프 준비 완료** → Phase 2로 진행 가능 diff --git a/src/ai/specs/recurring-events-spec.md b/src/ai/specs/recurring-events-spec.md new file mode 100644 index 00000000..c1882d81 --- /dev/null +++ b/src/ai/specs/recurring-events-spec.md @@ -0,0 +1,517 @@ +# 반복 일정 기능 명세서 + +## 1. 기능 목적 및 목표 + +### 왜 이 기능이 필요한가? +- 사용자가 매일, 매주, 매월, 매년 반복되는 일정을 효율적으로 관리할 수 있도록 함 +- 반복되는 일정을 일일이 생성하지 않고 한 번의 입력으로 여러 인스턴스를 자동 생성 +- 반복 일정의 일괄 수정/삭제 또는 개별 수정/삭제를 선택할 수 있는 유연성 제공 + +### 이 기능으로 달성하고자 하는 목표는? +- 반복 일정 생성, 표시, 수정, 삭제의 완전한 CRUD 구현 +- 특수한 날짜 케이스(31일, 윤년 2월 29일) 올바른 처리 +- 사용자에게 명확한 피드백 제공 (반복 아이콘, 다이얼로그) + +## 2. 구체적인 요구사항 + +### 2.1 기능적 요구사항 + +#### F1. 반복 유형 선택 +- **입력**: 일정 생성/수정 폼에서 "반복 일정" 체크박스를 선택 +- **동작**: + - 체크박스 선택 시 반복 설정 UI 표시 + - 반복 유형 선택 드롭다운: `매일`, `매주`, `매월`, `매년` + - 반복 간격 입력 필드: 숫자 (기본값: 1, 최소값: 1) + - 반복 종료일 선택: 날짜 선택기 (최대: 2025-12-31) +- **출력**: + - `repeat.type`: `'daily' | 'weekly' | 'monthly' | 'yearly'` + - `repeat.interval`: 양의 정수 + - `repeat.endDate`: YYYY-MM-DD 형식 문자열 (최대 2025-12-31) + - `repeat.id`: 반복 시리즈 식별자 (서버에서 생성) + +#### F2. 특수 날짜 처리 +- **31일 매월 반복**: + - 31일에 매월 반복 선택 시, 31일이 존재하는 달에만 일정 생성 + - 예: 1월 31일 → 3월 31일 → 5월 31일... (2월, 4월 등은 건너뜀) +- **윤년 2월 29일 매년 반복**: + - 2월 29일에 매년 반복 선택 시, 윤년에만 일정 생성 + - 예: 2024-02-29 → 2028-02-29 → 2032-02-29... +- **구현**: `recurrenceUtils.ts`의 기존 `getNextOccurrence` 함수 사용 + +#### F3. 반복 일정 생성 +- **API 호출**: `POST /api/events-list` +- **입력**: + ```typescript + { + events: Event[] // generateInstancesForEvent로 생성된 인스턴스 배열 + } + ``` +- **동작**: + 1. 사용자가 반복 일정 정보 입력 및 "일정 추가" 클릭 + 2. `generateInstancesForEvent` 함수로 시작일부터 종료일까지 모든 인스턴스 생성 + 3. 생성된 인스턴스 배열을 `/api/events-list`에 전송 + 4. 서버에서 모든 인스턴스에 동일한 `repeat.id` 부여 +- **검증**: + - ✅ 반복 일정은 일정 겹침 검사를 하지 않음 + - 필수 필드 검증 (제목, 날짜, 시작/종료 시간) + - 시간 유효성 검증 (종료 시간 > 시작 시간) + +#### F4. 반복 일정 표시 +- **캘린더 뷰**: + - 반복 일정 옆에 반복 아이콘 표시 (예: 🔁 또는 MUI Repeat 아이콘) + - 반복 일정과 단일 일정을 시각적으로 구분 +- **이벤트 리스트**: + - 반복 정보 표시: "반복: {interval}{단위}마다 (종료: {endDate})" + - 예: "반복: 1주마다 (종료: 2025-12-31)" + +#### F5. 반복 일정 수정 +- **트리거**: 반복 일정의 수정 버튼 클릭 +- **다이얼로그 표시**: + - 제목: "반복 일정 수정" + - 내용: "해당 일정만 수정하시겠어요?" + - 버튼: "예" / "아니오" + +- **F5-1. 단일 수정 ("예" 선택)**: + - **API 호출**: `PUT /api/events/:id` + - **동작**: + 1. 해당 일정의 `repeat.type`을 `'none'`으로 변경 + 2. `repeat.id` 제거 (또는 undefined) + 3. 단일 일정으로 변환 + - **결과**: + - 해당 일정만 수정됨 + - 반복 아이콘 사라짐 + - 다른 반복 일정 인스턴스는 유지 + +- **F5-2. 전체 수정 ("아니오" 선택)**: + - **API 호출**: `PUT /api/recurring-events/:repeatId` + - **입력**: 수정할 필드 (title, description, location, category, notificationTime 등) + - **동작**: + 1. 동일한 `repeat.id`를 가진 모든 일정 조회 + 2. 모든 인스턴스의 공통 필드 일괄 수정 + 3. 날짜/시간은 각 인스턴스마다 유지 + - **결과**: + - 모든 반복 일정 인스턴스 수정됨 + - 반복 아이콘 유지 + - **제약사항**: + - 날짜와 시간은 변경되지 않음 (각 인스턴스의 날짜는 그대로) + - 반복 유형/간격/종료일은 변경 불가 (새로 생성 필요) + +#### F6. 반복 일정 삭제 +- **트리거**: 반복 일정의 삭제 버튼 클릭 +- **다이얼로그 표시**: + - 제목: "반복 일정 삭제" + - 내용: "해당 일정만 삭제하시겠어요?" + - 버튼: "예" / "아니오" + +- **F6-1. 단일 삭제 ("예" 선택)**: + - **API 호출**: `DELETE /api/events/:id` + - **동작**: 해당 일정만 삭제 + - **결과**: 다른 반복 일정 인스턴스는 유지 + +- **F6-2. 전체 삭제 ("아니오" 선택)**: + - **API 호출**: `DELETE /api/recurring-events/:repeatId` + - **동작**: 동일한 `repeat.id`를 가진 모든 일정 삭제 + - **결과**: 해당 반복 시리즈의 모든 인스턴스 삭제 + +### 2.2 비기능적 요구사항 + +#### 성능 요구사항 +- 최대 1000개의 반복 인스턴스 생성 제한 (무한 루프 방지) +- 반복 종료일 최대값: 2025-12-31 + +#### 접근성 요구사항 +- 다이얼로그는 키보드로 조작 가능 (Tab, Enter, Esc) +- 반복 아이콘에 aria-label 제공 +- 폼 필드에 적절한 label 연결 + +#### 사용성 요구사항 +- 수정/삭제 다이얼로그의 문구는 명확해야 함: + - "해당 일정만 수정하시겠어요?" / "해당 일정만 삭제하시겠어요?" +- 반복 종료일 입력 시 max="2025-12-31" 속성 설정 +- 성공/실패 시 명확한 스낵바 메시지 + +## 3. 제외 범위 (Out of Scope) + +### 3.1 이 기능에서 다루지 않는 것들 +- ❌ 반복 일정의 날짜/시간 일괄 변경 (F5-2에서 제외) +- ❌ 반복 유형/간격/종료일 수정 (새로 생성해야 함) +- ❌ 특정 날짜부터 이후만 수정/삭제 ("이 일정 및 향후 일정" 옵션) +- ❌ 반복 예외 추가 (특정 날짜 제외) +- ❌ 복잡한 반복 규칙 (매월 첫째 주 월요일 등) +- ❌ 반복 일정 겹침 검사 (요구사항에서 명시적으로 제외) + +### 3.2 향후 버전에서 고려할 사항 +- 반복 일정의 부분 수정/삭제 ("이 일정 및 향후 일정") +- 반복 예외 날짜 관리 +- 더 복잡한 반복 규칙 (요일 기반, 상대적 날짜 등) + +## 4. 성공 기준 (Acceptance Criteria) + +### AC1. 반복 일정 생성 +- ✅ 반복 체크박스 선택 시 반복 설정 UI가 표시됨 +- ✅ 매일/매주/매월/매년 중 선택 가능 +- ✅ 반복 간격 입력 가능 (최소 1) +- ✅ 반복 종료일 선택 가능 (최대 2025-12-31) +- ✅ "일정 추가" 클릭 시 모든 반복 인스턴스가 생성됨 +- ✅ 31일 매월 반복 시 31일이 없는 달은 건너뜀 +- ✅ 윤년 29일 매년 반복 시 윤년만 생성됨 +- ✅ 반복 일정은 겹침 검사를 하지 않음 + +### AC2. 반복 일정 표시 +- ✅ 캘린더 뷰에서 반복 일정에 아이콘이 표시됨 +- ✅ 이벤트 리스트에서 반복 정보가 표시됨 +- ✅ 반복 일정과 단일 일정이 시각적으로 구분됨 + +### AC3. 반복 일정 수정 +- ✅ 반복 일정 수정 시 "해당 일정만 수정하시겠어요?" 다이얼로그가 표시됨 +- ✅ "예" 선택 시: + - 해당 일정만 수정됨 + - 단일 일정으로 변환됨 (repeat.type = 'none') + - 반복 아이콘이 사라짐 +- ✅ "아니오" 선택 시: + - 모든 반복 인스턴스의 공통 필드가 수정됨 + - 반복 아이콘이 유지됨 + - 날짜/시간은 변경되지 않음 + +### AC4. 반복 일정 삭제 +- ✅ 반복 일정 삭제 시 "해당 일정만 삭제하시겠어요?" 다이얼로그가 표시됨 +- ✅ "예" 선택 시 해당 일정만 삭제됨 +- ✅ "아니오" 선택 시 모든 반복 인스턴스가 삭제됨 + +### AC5. 사용자 피드백 +- ✅ 성공/실패 시 적절한 스낵바 메시지 표시 +- ✅ 다이얼로그는 Esc 키로 닫을 수 있음 + +## 5. 기존 코드베이스 분석 + +### 5.1 관련 기존 기능 + +#### ✅ 재사용 가능한 Utils +1. **`src/utils/recurrenceUtils.ts`** ⭐ 핵심 유틸리티 + - `generateInstancesForEvent(event, rangeStart, rangeEnd)`: 반복 인스턴스 생성 + - `getNextOccurrence()`: 다음 반복 날짜 계산 (31일, 윤년 처리 포함) + - `isLeapYear()`: 윤년 확인 + - **상태**: ✅ 이미 구현됨, 재사용 가능 + +2. **`src/utils/eventUtils.ts`** + - `getFilteredEvents()`: 이벤트 필터링 + - **활용**: 반복 인스턴스 필터링에 재사용 + +3. **`src/utils/dateUtils.ts`** + - `formatDate()`, `getWeekDates()`, `getWeeksAtMonth()` 등 + - **활용**: 날짜 포맷팅에 재사용 + +#### ✅ 재사용 가능한 Hooks +1. **`src/hooks/useEventOperations.ts`** + - `saveEvent()`: 단일 이벤트 저장 (PUT /api/events/:id) + - `deleteEvent()`: 단일 이벤트 삭제 (DELETE /api/events/:id) + - **필요한 추가 기능**: + - `saveRecurringEvents()`: 반복 인스턴스 일괄 생성 (POST /api/events-list) + - `updateRecurringSeries()`: 반복 시리즈 수정 (PUT /api/recurring-events/:repeatId) + - `deleteRecurringSeries()`: 반복 시리즈 삭제 (DELETE /api/recurring-events/:repeatId) + +2. **`src/hooks/useEventForm.ts`** + - 반복 관련 state는 이미 존재 (`isRepeating`, `repeatType`, `repeatInterval`, `repeatEndDate`) + - **상태**: ✅ 재사용 가능, 수정 불필요 + +3. **`src/hooks/useCalendarView.ts`** + - 뷰 관리 및 날짜 네비게이션 + - **상태**: ✅ 재사용 가능, 수정 불필요 + +#### ✅ 재사용 가능한 컴포넌트 +- MUI 다이얼로그: 겹침 경고 다이얼로그 패턴 재사용 +- MUI 아이콘: `Repeat` 아이콘 사용 + +### 5.2 수정이 필요한 부분 + +#### 1. **`src/types.ts`** - RepeatInfo 타입 확장 +**현재**: +```typescript +export interface RepeatInfo { + type: RepeatType; + interval: number; + endDate?: string; +} +``` + +**필요한 변경**: +```typescript +export interface RepeatInfo { + type: RepeatType; + interval: number; + endDate?: string; + id?: string; // 반복 시리즈 식별자 (서버에서 부여) +} +``` + +#### 2. **`src/hooks/useEventOperations.ts`** - 반복 API 함수 추가 +**추가 필요**: +- `saveRecurringEvents(eventForm: EventForm): Promise` +- `updateRecurringSeries(repeatId: string, updateData: Partial): Promise` +- `deleteRecurringSeries(repeatId: string): Promise` + +#### 3. **`src/App.tsx`** - UI 및 로직 수정 +**필요한 변경**: +- 441-478라인 반복 설정 UI 주석 해제 및 활성화 +- 캘린더 뷰에서 반복 아이콘 표시 로직 추가 +- 수정/삭제 버튼 클릭 시 반복 일정 확인 다이얼로그 추가 +- `addOrUpdateEvent` 함수 수정: 반복 일정 생성 로직 추가 +- 반복 종료일 max 속성 추가: `max="2025-12-31"` + +#### 4. **새로 추가할 파일 없음** ✅ +- 모든 필요한 유틸리티와 훅은 이미 존재하거나 기존 파일에 추가 가능 + +### 5.3 영향 받는 부분 + +#### 영향 받을 수 있는 기능 +1. **검색 기능** (`useSearch`) + - 반복 인스턴스가 증가하므로 검색 결과 증가 가능 + - 현재 로직으로 자동 처리됨 (수정 불필요) + +2. **알림 기능** (`useNotifications`) + - 반복 일정의 각 인스턴스마다 알림 설정 + - 현재 로직으로 자동 처리됨 (수정 불필요) + +3. **이벤트 필터링** (`getFilteredEvents`) + - 반복 인스턴스가 자동으로 포함됨 + - 수정 불필요 + +#### 영향 받지 않는 기능 +- ❌ 겹침 검사: 반복 일정은 겹침 검사를 하지 않음 (요구사항) +- ✅ 캘린더 뷰: 반복 인스턴스가 자동으로 표시됨 +- ✅ 일정 리스트: 반복 정보 표시 로직 이미 존재 (558-568라인) + +## 6. 기술적 고려사항 + +### 6.1 API 설계 + +#### API 엔드포인트 (server.js 기반) +모든 필요한 API는 이미 구현되어 있음: + +1. **반복 인스턴스 생성** + - `POST /api/events-list` + - 요청 본문: + ```json + { + "events": [ + { + "title": "주간 회의", + "date": "2024-11-01", + "startTime": "10:00", + "endTime": "11:00", + "description": "", + "location": "", + "category": "업무", + "repeat": { + "type": "weekly", + "interval": 1, + "endDate": "2024-12-31" + }, + "notificationTime": 10 + }, + // ... 생성된 모든 인스턴스 + ] + } + ``` + - 응답: 서버에서 각 이벤트에 id와 repeat.id 부여 + +2. **반복 시리즈 수정** + - `PUT /api/recurring-events/:repeatId` + - 요청 본문: + ```json + { + "title": "새 제목", + "description": "새 설명", + "location": "새 위치", + "category": "개인", + "notificationTime": 60 + } + ``` + - 동작: 해당 repeatId를 가진 모든 이벤트의 필드 수정 + +3. **반복 시리즈 삭제** + - `DELETE /api/recurring-events/:repeatId` + - 동작: 해당 repeatId를 가진 모든 이벤트 삭제 + +4. **단일 이벤트 수정/삭제** (기존 API) + - `PUT /api/events/:id`: 반복 → 단일 전환 시 사용 + - `DELETE /api/events/:id`: 단일 삭제 시 사용 + +### 6.2 기술 스택 + +#### 프론트엔드 +- **UI 라이브러리**: Material-UI (MUI) v5 + - `Dialog`, `DialogTitle`, `DialogContent`, `DialogActions` + - `Repeat` 아이콘 (from `@mui/icons-material`) + - `Select`, `MenuItem`, `TextField`, `Checkbox` + +- **상태 관리**: React Hooks (useState, useEffect) + - 기존 커스텀 훅 활용 + +- **알림**: notistack (`enqueueSnackbar`) + +#### 백엔드 +- **서버**: Express.js (server.js) +- **데이터 저장**: JSON 파일 (`realEvents.json`, `e2e.json`) +- **API**: RESTful + +#### 데이터 모델 +```typescript +interface Event { + id: string; + title: string; + date: string; // YYYY-MM-DD + startTime: string; // HH:mm + endTime: string; // HH:mm + description: string; + location: string; + category: string; + repeat: { + type: 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'; + interval: number; + endDate?: string; // YYYY-MM-DD + id?: string; // 반복 시리즈 식별자 + }; + notificationTime: number; // 분 단위 +} +``` + +### 6.3 아키텍처 패턴 + +#### 레이어 구조 +``` +┌─────────────────────────────────────┐ +│ UI Layer (App.tsx) │ +│ - 반복 설정 폼 │ +│ - 수정/삭제 다이얼로그 │ +│ - 캘린더 뷰 (반복 아이콘) │ +└─────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────┐ +│ Hooks Layer (useEventOperations) │ +│ - saveRecurringEvents() │ +│ - updateRecurringSeries() │ +│ - deleteRecurringSeries() │ +└─────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────┐ +│ Utils Layer (recurrenceUtils) │ +│ - generateInstancesForEvent() │ +│ - getNextOccurrence() │ +└─────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────┐ +│ API Layer (server.js) │ +│ - POST /api/events-list │ +│ - PUT /api/recurring-events/:id │ +│ - DELETE /api/recurring-events/:id │ +└─────────────────────────────────────┘ +``` + +#### 데이터 흐름 +1. **생성 흐름**: + - 사용자 입력 → EventForm state → generateInstancesForEvent() → saveRecurringEvents() → POST /api/events-list → 서버에서 repeat.id 부여 + +2. **수정 흐름**: + - 수정 버튼 클릭 → 다이얼로그 표시 → 사용자 선택 + - "예": updateEvent(id, {..., repeat: {type: 'none'}}) → PUT /api/events/:id + - "아니오": updateRecurringSeries(repeatId, updateData) → PUT /api/recurring-events/:repeatId + +3. **삭제 흐름**: + - 삭제 버튼 클릭 → 다이얼로그 표시 → 사용자 선택 + - "예": deleteEvent(id) → DELETE /api/events/:id + - "아니오": deleteRecurringSeries(repeatId) → DELETE /api/recurring-events/:repeatId + +## 7. 엣지 케이스 + +### EC1. 날짜 관련 +1. **31일 매월 반복** + - 시나리오: 1월 31일에 매월 반복 생성 + - 예상 동작: 31일이 있는 달에만 생성 (1, 3, 5, 7, 8, 10, 12월) + - 구현: `getNextOccurrence`에서 처리됨 ✅ + +2. **윤년 2월 29일 매년 반복** + - 시나리오: 2024-02-29에 매년 반복 생성 + - 예상 동작: 윤년에만 생성 (2024, 2028, 2032...) + - 구현: `getNextOccurrence`에서 처리됨 ✅ + +3. **반복 종료일 초과** + - 시나리오: 반복 종료일이 2025-12-31을 초과하는 경우 + - 예상 동작: 입력 필드에서 max="2025-12-31" 제한 + - 구현: TextField의 max 속성 설정 필요 + +4. **과거 날짜로 반복 생성** + - 시나리오: 과거 날짜에 반복 일정 생성 + - 예상 동작: 생성은 되지만 캘린더 뷰에 표시 안됨 (범위 밖) + - 구현: 현재 로직으로 처리됨 (특별한 처리 불필요) + +### EC2. 수정/삭제 관련 +1. **단일 일정을 반복으로 착각** + - 시나리오: repeat.type === 'none'인 일정 수정/삭제 + - 예상 동작: 다이얼로그 표시 안함, 바로 수정/삭제 + - 구현: `repeat.type !== 'none'` 조건으로 다이얼로그 표시 제어 + +2. **반복 일정을 단일로 전환 후 재수정** + - 시나리오: 반복 → 단일 전환 후 다시 수정 + - 예상 동작: 다이얼로그 표시 안함 (이미 단일 일정) + - 구현: repeat.type 확인으로 처리 + +3. **반복 ID가 없는 경우** + - 시나리오: repeat.type !== 'none'이지만 repeat.id가 없음 + - 예상 동작: 단일 수정/삭제로 처리 + - 구현: `repeat.id` 존재 여부 확인 + +### EC3. API 관련 +1. **네트워크 오류** + - 시나리오: API 호출 실패 + - 예상 동작: 에러 스낵바 표시, 이벤트 목록 갱신 안됨 + - 구현: try-catch로 처리 (기존 패턴 재사용) + +2. **1000개 이상 인스턴스 생성** + - 시나리오: 매일 반복 + 긴 종료일 + - 예상 동작: `generateInstancesForEvent`에서 1000개 제한 + - 구현: while 루프 iterationCount < 1000 조건 ✅ + +3. **존재하지 않는 repeatId** + - 시나리오: 서버에서 해당 repeatId를 찾을 수 없음 + - 예상 동작: 404 에러, 에러 스낵바 표시 + - 구현: 서버에서 처리 (142-151라인) ✅ + +### EC4. UI/UX 관련 +1. **다이얼로그 중복 표시** + - 시나리오: 빠르게 여러 번 수정/삭제 버튼 클릭 + - 예상 동작: 한 번만 표시 (state로 제어) + - 구현: `isDialogOpen` state로 제어 + +2. **반복 종료일 < 시작일** + - 시나리오: 시작일보다 이전 날짜를 종료일로 선택 + - 예상 동작: 인스턴스 생성 안됨 또는 경고 + - 구현: 클라이언트 검증 추가 필요 + +## 8. 다음 단계 + +### 테스트 설계 에이전트를 위한 인수인계 사항 + +#### 주요 테스트 포인트 +1. **유닛 테스트**: + - ✅ `recurrenceUtils.ts`: 이미 테스트 존재 가능성 확인 필요 + - 새로운 hook 함수들 (saveRecurringEvents, updateRecurringSeries, deleteRecurringSeries) + +2. **통합 테스트**: + - 반복 일정 생성 → 표시 → 수정 (단일/전체) → 삭제 (단일/전체) 전체 흐름 + +3. **엣지 케이스 테스트**: + - 31일 매월 반복 + - 윤년 2월 29일 매년 반복 + - 반복 종료일 최대값 제한 + +#### 우선순위 +1. **High**: 반복 인스턴스 생성 (generateInstancesForEvent), 수정/삭제 다이얼로그 +2. **Medium**: 반복 아이콘 표시, API 에러 처리 +3. **Low**: UI 접근성, 경계 조건 + +#### 참고할 기존 테스트 패턴 +- `src/__tests__/unit/easy.recurrenceUtils.spec.ts`: 반복 유틸 테스트 (확인 필요) +- `src/__tests__/hooks/medium.useEventOperations.spec.ts`: 이벤트 CRUD 테스트 패턴 +- `src/__tests__/medium.integration.spec.tsx`: 통합 테스트 패턴 + diff --git a/src/ai/test-plans/recurring-events-test-plan.md b/src/ai/test-plans/recurring-events-test-plan.md new file mode 100644 index 00000000..149c22d5 --- /dev/null +++ b/src/ai/test-plans/recurring-events-test-plan.md @@ -0,0 +1,699 @@ +# 반복 일정 기능 테스트 계획서 + +## 1. 테스트 전략 + +### 1.1 테스트 레벨 + +- **Unit Tests**: 순수 함수 및 유틸리티 + - ✅ `recurrenceUtils.ts`의 `generateInstancesForEvent` - 이미 완전히 테스트됨 + - 추가 테스트 불필요 (기존 테스트가 모든 케이스 커버) + +- **Hook Tests**: 커스텀 훅 함수들 + - `useEventOperations`에 추가될 3개 함수: + - `saveRecurringEvents()` - 반복 인스턴스 일괄 생성 + - `updateRecurringSeries()` - 반복 시리즈 전체 수정 + - `deleteRecurringSeries()` - 반복 시리즈 전체 삭제 + - 단일 수정/삭제 시 `repeat.type` 변환 로직 + +- **Integration Tests**: 전체 워크플로우 + - 반복 일정 생성 → 표시 → 수정(단일/전체) → 삭제(단일/전체) + - UI 다이얼로그 인터랙션 + - 반복 아이콘 표시 + +### 1.2 API 테스트 전략 + +**Mock API 엔드포인트 (server.js 기반)**: +- `POST /api/events-list` - 벌크 작업 (반복 이벤트 생성) + - 요청: `{ events: Event[] }` + - 응답: 각 이벤트에 동일한 `repeat.id` 부여 + +- `PUT /api/recurring-events/:repeatId` - 시리즈 수정 + - 요청: 수정할 필드 (title, description, location, category, notificationTime) + - 응답: 해당 repeatId를 가진 모든 이벤트 수정 + +- `DELETE /api/recurring-events/:repeatId` - 시리즈 삭제 + - 응답: 해당 repeatId를 가진 모든 이벤트 삭제 + +- `PUT /api/events/:id` - 단일 이벤트 수정 (기존) + - 반복 → 단일 전환: `repeat.type: 'none'`, `repeat.id: undefined` + +- `DELETE /api/events/:id` - 단일 이벤트 삭제 (기존) + +### 1.3 TDD 접근 방식 + +**Red-Green-Refactor 사이클 적용**: + +1. **Red (실패하는 테스트 작성)** + - 가장 간단한 케이스부터 시작 (예: 매일 반복 생성) + - 테스트가 실패하는 것 확인 + - 검증: 함수가 존재하지 않거나 예상 동작 안함 + +2. **Green (최소한의 코드로 통과)** + - 테스트를 통과할 만큼만 구현 + - 검증: 모든 테스트 통과 + - 최적화나 복잡한 로직은 나중으로 미룸 + +3. **Refactor (구조 개선)** + - 중복 제거 + - 명확성 개선 + - 검증: 테스트는 여전히 통과 + +**각 사이클에서 검증할 사항**: +- Red: 테스트가 올바른 이유로 실패하는가? +- Green: 테스트가 통과하는가? (모든 테스트) +- Refactor: 코드가 더 명확해졌는가? 중복이 제거되었는가? + +## 2. 테스트 목록 + +### 2.1 우선순위 High (핵심 기능) + +| 테스트 ID | 테스트 설명 | 검증 사항 | 난이도 | 예상 파일 | +|----------|-----------|---------|-------|----------| +| T-001 | 반복 인스턴스 일괄 생성 API 호출 | `saveRecurringEvents`가 POST /api/events-list 호출, events 배열 전송, 성공 시 이벤트 목록 업데이트 | Medium | hooks/medium.useEventOperations.spec.ts | +| T-002 | 반복 시리즈 전체 수정 API 호출 | `updateRecurringSeries`가 PUT /api/recurring-events/:repeatId 호출, 동일 repeatId의 모든 이벤트 수정 | Medium | hooks/medium.useEventOperations.spec.ts | +| T-003 | 반복 시리즈 전체 삭제 API 호출 | `deleteRecurringSeries`가 DELETE /api/recurring-events/:repeatId 호출, 동일 repeatId의 모든 이벤트 삭제 | Medium | hooks/medium.useEventOperations.spec.ts | +| T-004 | 단일 수정 시 repeat.type 변환 | 반복 일정 단일 수정 시 repeat.type이 'none'으로, repeat.id가 undefined로 변경 | Medium | hooks/medium.useEventOperations.spec.ts | + +### 2.2 우선순위 Medium (통합 및 UI) + +| 테스트 ID | 테스트 설명 | 검증 사항 | 난이도 | 예상 파일 | +|----------|-----------|---------|-------|----------| +| T-101 | 반복 일정 생성 전체 흐름 | 반복 체크박스 선택 → 유형/간격/종료일 입력 → 생성 → 여러 인스턴스 캘린더에 표시 | Medium | medium.integration.spec.tsx | +| T-102 | 반복 아이콘 표시 | 캘린더 뷰에서 repeat.type !== 'none'인 일정에 반복 아이콘 표시 | Easy | medium.integration.spec.tsx | +| T-103 | 수정 다이얼로그 - 단일 수정 | "예" 선택 → 해당 일정만 수정, 다른 인스턴스 유지 | Medium | medium.integration.spec.tsx | +| T-104 | 수정 다이얼로그 - 전체 수정 | "아니오" 선택 → 모든 반복 인스턴스 수정 | Medium | medium.integration.spec.tsx | +| T-105 | 삭제 다이얼로그 - 단일 삭제 | "예" 선택 → 해당 일정만 삭제, 다른 인스턴스 유지 | Medium | medium.integration.spec.tsx | +| T-106 | 삭제 다이얼로그 - 전체 삭제 | "아니오" 선택 → 모든 반복 인스턴스 삭제 | Medium | medium.integration.spec.tsx | +| T-107 | 단일 일정 수정/삭제 시 다이얼로그 미표시 | repeat.type === 'none'인 일정은 다이얼로그 표시 안함 | Easy | medium.integration.spec.tsx | + +### 2.3 우선순위 Low (엣지 케이스 및 에러 처리) + +| 테스트 ID | 테스트 설명 | 검증 사항 | 난이도 | 예상 파일 | +|----------|-----------|---------|-------|----------| +| T-201 | 반복 인스턴스 생성 API 실패 | POST /api/events-list 실패 시 에러 스낵바 표시, 이벤트 목록 유지 | Easy | hooks/medium.useEventOperations.spec.ts | +| T-202 | 반복 시리즈 수정 API 실패 (404) | 존재하지 않는 repeatId로 수정 시 에러 스낵바 표시 | Easy | hooks/medium.useEventOperations.spec.ts | +| T-203 | 반복 시리즈 삭제 API 실패 (404) | 존재하지 않는 repeatId로 삭제 시 에러 스낵바 표시 | Easy | hooks/medium.useEventOperations.spec.ts | +| T-204 | repeat.id가 없는 반복 일정 처리 | repeat.type !== 'none'이지만 repeat.id가 없으면 단일 수정/삭제로 처리 | Medium | hooks/medium.useEventOperations.spec.ts | +| T-205 | 반복 종료일 최대값 제한 (2025-12-31) | 종료일이 2025-12-31을 넘지 않는지 확인 | Easy | medium.integration.spec.tsx | +| T-206 | 반복 일정 겹침 검사 제외 확인 | 겹치는 반복 일정 생성 시 겹침 경고 다이얼로그 미표시 | Easy | medium.integration.spec.tsx | + +## 3. 엣지 케이스 분석 + +### 3.1 경계값 테스트 + +- **반복 종료일 최대값**: 2025-12-31 + - UI에서 max 속성으로 제한 + - generateInstancesForEvent의 rangeEnd도 제한 + +- **반복 간격 최소값**: 1 + - UI에서 min 속성으로 제한 + - 0 또는 음수는 불가 + +- **인스턴스 최대 개수**: 1000개 + - `generateInstancesForEvent`의 iterationCount < 1000 + - 이미 구현됨 (기존 테스트로 검증 완료) + +### 3.2 예외 상황 + +- **존재하지 않는 repeatId**: + - PUT/DELETE /api/recurring-events/:repeatId → 404 응답 + - 에러 스낵바 표시: "일정 수정 실패" / "일정 삭제 실패" + +- **repeat.id가 없는 반복 일정**: + - repeat.type !== 'none'이지만 repeat.id === undefined + - 단일 수정/삭제로 처리 (시리즈 수정/삭제 불가) + +- **네트워크 오류**: + - API 호출 실패 시 에러 스낵바 표시 + - 이벤트 목록 상태 유지 (변경 안됨) + +### 3.3 데이터 검증 + +- **반복 종료일 < 시작일**: + - 클라이언트 검증 필요 + - 경고 메시지 또는 인스턴스 0개 생성 + +- **필수 필드 누락**: + - 제목, 날짜, 시작/종료 시간 필수 + - 기존 검증 로직 재사용 + +- **시간 유효성**: + - 종료 시간 > 시작 시간 + - 기존 검증 로직 재사용 + +### 3.4 특수 날짜 케이스 (이미 테스트됨) + +✅ 다음 케이스는 `src/__tests__/unit/easy.recurrenceUtils.spec.ts`에서 이미 완전히 테스트됨: +- **31일 매월 반복**: 31일이 없는 달 건너뛰기 +- **윤년 2월 29일 매년 반복**: 윤년이 아닌 해 건너뛰기 +- **반복 유형별 인스턴스 생성**: 매일/매주/매월/매년 +- **반복 간격**: interval 1, 2 등 +- **범위 외 처리**: rangeStart, rangeEnd 밖의 인스턴스 제외 + +## 4. 테스트 파일 구조 + +``` +src/__tests__/ +├── unit/ +│ └── easy.recurrenceUtils.spec.ts (✅ 이미 존재, 수정 불필요) +├── hooks/ +│ └── medium.useEventOperations.spec.ts (✏️ 4개 함수 테스트 추가) +└── medium.integration.spec.tsx (✏️ 반복 일정 통합 테스트 추가) +``` + +## 5. 각 테스트 상세 설계 + +### 테스트 네이밍 규칙 ⚠️ + +``` +✅ 올바른 네이밍: +- describe: 영어 (함수/클래스명) +- it/test: 한글 (무엇을 테스트하는지 명확하게) + +예시: +describe('useEventOperations', () => { + describe('saveRecurringEvents', () => { + it('반복 인스턴스 배열을 POST /api/events-list로 전송하고 성공적으로 생성해야 한다', () => { + // ... + }); + }); +}); +``` + +### T-001: 반복 인스턴스 일괄 생성 API 호출 + +```typescript +describe('useEventOperations', () => { + describe('saveRecurringEvents', () => { + it('반복 인스턴스 배열을 POST /api/events-list로 전송하고 성공적으로 생성해야 한다', async () => { + // Arrange: Mock 핸들러 설정, 반복 일정 폼 준비 + setupMockHandlerRecurringCreation(); + const { result } = renderHook(() => useEventOperations(false)); + + const eventForm: EventForm = { + title: '주간 회의', + date: '2025-01-01', + startTime: '10:00', + endTime: '11:00', + description: '', + location: '', + category: '업무', + isRepeating: true, + repeatType: 'weekly', + repeatInterval: 1, + repeatEndDate: '2025-01-31', + notificationTime: 10, + }; + + // Act: saveRecurringEvents 호출 + await act(async () => { + await result.current.saveRecurringEvents(eventForm); + }); + + // Assert: 여러 인스턴스가 생성되었는지, 모두 동일한 repeat.id를 가지는지 확인 + expect(result.current.events.length).toBeGreaterThan(1); + const repeatId = result.current.events[0].repeat.id; + expect(repeatId).toBeDefined(); + expect(result.current.events.every(e => e.repeat.id === repeatId)).toBe(true); + }); + }); +}); +``` + +### T-002: 반복 시리즈 전체 수정 API 호출 + +```typescript +describe('useEventOperations', () => { + describe('updateRecurringSeries', () => { + it('동일한 repeatId를 가진 모든 이벤트를 수정해야 한다', async () => { + // Arrange: 반복 이벤트가 이미 존재하는 상태 + setupMockHandlerRecurringUpdate(); + const { result } = renderHook(() => useEventOperations(true)); + await act(() => Promise.resolve(null)); + + const repeatId = result.current.events[0].repeat.id!; + const updateData = { title: '수정된 제목', location: '새 장소' }; + + // Act: updateRecurringSeries 호출 + await act(async () => { + await result.current.updateRecurringSeries(repeatId, updateData); + }); + + // Assert: 동일 repeatId의 모든 이벤트가 수정되었는지 확인 + const updatedEvents = result.current.events.filter(e => e.repeat.id === repeatId); + expect(updatedEvents.every(e => e.title === '수정된 제목')).toBe(true); + expect(updatedEvents.every(e => e.location === '새 장소')).toBe(true); + }); + }); +}); +``` + +### T-003: 반복 시리즈 전체 삭제 API 호출 + +```typescript +describe('useEventOperations', () => { + describe('deleteRecurringSeries', () => { + it('동일한 repeatId를 가진 모든 이벤트를 삭제해야 한다', async () => { + // Arrange: 반복 이벤트가 이미 존재하는 상태 + setupMockHandlerRecurringDelete(); + const { result } = renderHook(() => useEventOperations(true)); + await act(() => Promise.resolve(null)); + + const initialCount = result.current.events.length; + const repeatId = result.current.events[0].repeat.id!; + const seriesCount = result.current.events.filter(e => e.repeat.id === repeatId).length; + + // Act: deleteRecurringSeries 호출 + await act(async () => { + await result.current.deleteRecurringSeries(repeatId); + }); + + // Assert: 해당 시리즈의 모든 이벤트가 삭제되었는지 확인 + expect(result.current.events.length).toBe(initialCount - seriesCount); + expect(result.current.events.every(e => e.repeat.id !== repeatId)).toBe(true); + }); + }); +}); +``` + +### T-004: 단일 수정 시 repeat.type 변환 + +```typescript +describe('useEventOperations', () => { + describe('saveEvent (단일 수정)', () => { + it('반복 일정을 단일 수정하면 repeat.type이 none으로 변경되어야 한다', async () => { + // Arrange: 반복 이벤트가 존재하는 상태 + setupMockHandlerSingleUpdate(); + const { result } = renderHook(() => useEventOperations(true)); + await act(() => Promise.resolve(null)); + + const targetEvent = result.current.events[0]; + expect(targetEvent.repeat.type).not.toBe('none'); + + // Act: 단일 이벤트로 수정 (repeat.type을 'none'으로) + const updatedEvent = { + ...targetEvent, + title: '단일 일정으로 변경', + repeat: { type: 'none' as const, interval: 1 }, + }; + + await act(async () => { + await result.current.saveEvent(updatedEvent); + }); + + // Assert: 해당 이벤트만 수정되고 repeat.type이 'none', repeat.id가 undefined + const modifiedEvent = result.current.events.find(e => e.id === targetEvent.id); + expect(modifiedEvent?.repeat.type).toBe('none'); + expect(modifiedEvent?.repeat.id).toBeUndefined(); + }); + }); +}); +``` + +### T-101 ~ T-107: 통합 테스트 (medium.integration.spec.tsx) + +```typescript +describe('반복 일정 통합 테스트', () => { + it('반복 일정 생성부터 삭제까지 전체 흐름이 정상 작동해야 한다', async () => { + // Arrange: App 렌더링 + const { user } = setupIntegrationTest(); + render(); + + // Act & Assert 1: 반복 체크박스 선택 → 반복 설정 UI 표시 + const repeatCheckbox = screen.getByLabelText(/반복 일정/i); + await user.click(repeatCheckbox); + expect(screen.getByLabelText(/반복 유형/i)).toBeInTheDocument(); + + // Act & Assert 2: 반복 일정 입력 및 생성 + await user.type(screen.getByLabelText(/제목/i), '주간 회의'); + await user.selectOptions(screen.getByLabelText(/반복 유형/i), 'weekly'); + await user.type(screen.getByLabelText(/반복 종료일/i), '2025-01-31'); + await user.click(screen.getByRole('button', { name: /일정 추가/i })); + + // Assert: 캘린더에 여러 인스턴스 표시, 반복 아이콘 확인 + const eventElements = await screen.findAllByText(/주간 회의/i); + expect(eventElements.length).toBeGreaterThan(1); + + const repeatIcons = screen.getAllByLabelText(/반복 일정/i); + expect(repeatIcons.length).toBeGreaterThan(0); + + // Act & Assert 3: 수정 다이얼로그 - 전체 수정 + const firstEvent = eventElements[0]; + const editButton = within(firstEvent.closest('[data-testid="event-item"]')!).getByRole('button', { name: /수정/i }); + await user.click(editButton); + + // 다이얼로그 확인 + expect(screen.getByText(/해당 일정만 수정하시겠어요?/i)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /아니오/i })); + + // 수정 폼에서 제목 변경 + await user.clear(screen.getByLabelText(/제목/i)); + await user.type(screen.getByLabelText(/제목/i), '전체 수정된 회의'); + await user.click(screen.getByRole('button', { name: /수정/i })); + + // Assert: 모든 인스턴스 제목 변경 확인 + const updatedEvents = await screen.findAllByText(/전체 수정된 회의/i); + expect(updatedEvents.length).toBe(eventElements.length); + + // Act & Assert 4: 삭제 다이얼로그 - 단일 삭제 + const deleteButton = within(updatedEvents[0].closest('[data-testid="event-item"]')!).getByRole('button', { name: /삭제/i }); + await user.click(deleteButton); + + expect(screen.getByText(/해당 일정만 삭제하시겠어요?/i)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /예/i })); + + // Assert: 하나만 삭제, 나머지는 유지 + const remainingEvents = screen.queryAllByText(/전체 수정된 회의/i); + expect(remainingEvents.length).toBe(updatedEvents.length - 1); + }); +}); +``` + +### T-201 ~ T-203: 에러 처리 테스트 + +```typescript +describe('useEventOperations', () => { + describe('에러 처리', () => { + it('반복 인스턴스 생성 실패 시 에러 스낵바를 표시해야 한다', async () => { + // Arrange: API 실패 설정 + server.use( + http.post('/api/events-list', () => { + return new HttpResponse(null, { status: 500 }); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + // Act: saveRecurringEvents 호출 + await act(async () => { + await result.current.saveRecurringEvents(mockEventForm); + }); + + // Assert: 에러 스낵바 표시 + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 생성 실패', { variant: 'error' }); + + server.resetHandlers(); + }); + + it('존재하지 않는 repeatId 수정 시 에러 스낵바를 표시해야 한다', async () => { + // Arrange: 404 응답 설정 + server.use( + http.put('/api/recurring-events/:repeatId', () => { + return new HttpResponse(null, { status: 404 }); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + // Act: updateRecurringSeries 호출 + await act(async () => { + await result.current.updateRecurringSeries('non-existent-id', { title: '수정' }); + }); + + // Assert: 에러 스낵바 표시 + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 수정 실패', { variant: 'error' }); + + server.resetHandlers(); + }); + }); +}); +``` + +## 6. Mock/Stub 계획 + +### 6.1 API Mock + +**MSW를 사용하여 server.js의 API 모킹** + +`src/__mocks__/handlersUtils.ts`에 다음 함수 추가: + +```typescript +export const setupMockHandlerRecurringCreation = () => { + const mockEvents: Event[] = []; + + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: mockEvents }); + }), + http.post('/api/events-list', async ({ request }) => { + const { events } = (await request.json()) as { events: Event[] }; + + // 동일한 repeat.id 생성 + const repeatId = `repeat-${Date.now()}`; + const createdEvents = events.map((event, index) => ({ + ...event, + id: String(mockEvents.length + index + 1), + repeat: { ...event.repeat, id: repeatId }, + })); + + mockEvents.push(...createdEvents); + return HttpResponse.json({ events: createdEvents }, { status: 201 }); + }) + ); +}; + +export const setupMockHandlerRecurringUpdate = () => { + const mockEvents: Event[] = [ + // 반복 일정 인스턴스 3개 (동일 repeatId) + { + id: '1', + title: '원래 회의', + date: '2025-01-01', + startTime: '10:00', + endTime: '11:00', + description: '', + location: '회의실', + category: '업무', + repeat: { type: 'weekly', interval: 1, id: 'repeat-1' }, + notificationTime: 10, + }, + { + id: '2', + title: '원래 회의', + date: '2025-01-08', + startTime: '10:00', + endTime: '11:00', + description: '', + location: '회의실', + category: '업무', + repeat: { type: 'weekly', interval: 1, id: 'repeat-1' }, + notificationTime: 10, + }, + { + id: '3', + title: '원래 회의', + date: '2025-01-15', + startTime: '10:00', + endTime: '11:00', + description: '', + location: '회의실', + category: '업무', + repeat: { type: 'weekly', interval: 1, id: 'repeat-1' }, + notificationTime: 10, + }, + ]; + + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: mockEvents }); + }), + http.put('/api/recurring-events/:repeatId', async ({ params, request }) => { + const { repeatId } = params; + const updateData = (await request.json()) as Partial; + + // 동일 repeatId의 모든 이벤트 수정 + mockEvents.forEach((event, index) => { + if (event.repeat.id === repeatId) { + mockEvents[index] = { ...event, ...updateData }; + } + }); + + const updatedEvents = mockEvents.filter(e => e.repeat.id === repeatId); + return HttpResponse.json({ events: updatedEvents }); + }) + ); +}; + +export const setupMockHandlerRecurringDelete = () => { + const mockEvents: Event[] = [ + // 위와 동일한 반복 일정 3개 + // ... (생략) + ]; + + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: mockEvents }); + }), + http.delete('/api/recurring-events/:repeatId', ({ params }) => { + const { repeatId } = params; + + // 동일 repeatId의 모든 이벤트 삭제 + const indexesToDelete = mockEvents + .map((e, i) => (e.repeat.id === repeatId ? i : -1)) + .filter(i => i !== -1) + .reverse(); // 뒤에서부터 삭제 + + indexesToDelete.forEach(index => { + mockEvents.splice(index, 1); + }); + + return new HttpResponse(null, { status: 204 }); + }) + ); +}; + +export const setupMockHandlerSingleUpdate = () => { + // 반복 일정 → 단일 일정 전환 테스트용 + // repeat.type을 'none'으로 변경, repeat.id 제거 +}; +``` + +### 6.2 테스트 데이터 + +**기본 반복 일정 템플릿**: +```typescript +const baseRecurringEvent: Event = { + id: '1', + title: '반복 일정', + date: '2025-01-01', + startTime: '10:00', + endTime: '11:00', + description: '테스트 반복 일정', + location: '회의실', + category: '업무', + repeat: { + type: 'weekly', + interval: 1, + endDate: '2025-01-31', + id: 'repeat-123' + }, + notificationTime: 10, +}; + +const baseEventForm: EventForm = { + title: '새 반복 일정', + date: '2025-01-01', + startTime: '10:00', + endTime: '11:00', + description: '', + location: '', + category: '업무', + isRepeating: true, + repeatType: 'weekly', + repeatInterval: 1, + repeatEndDate: '2025-01-31', + notificationTime: 10, +}; +``` + +## 7. 예상 커버리지 + +- **목표 커버리지**: 90% +- **핵심 기능 커버리지**: 100% + - `generateInstancesForEvent`: 100% (이미 달성) + - `saveRecurringEvents`: 100% + - `updateRecurringSeries`: 100% + - `deleteRecurringSeries`: 100% + - 단일 수정/삭제 로직: 100% + +## 8. 테스트 작성 순서 + +TDD Red-Green-Refactor 사이클을 따라 다음 순서로 작성: + +### Phase 1: Hook 함수 기본 기능 (T-001 ~ T-004) +1. **T-001**: `saveRecurringEvents` - 반복 인스턴스 일괄 생성 + - Red: 함수 없음 → 테스트 실패 + - Green: 최소 구현 (POST /api/events-list 호출) + - Refactor: 에러 처리, 중복 제거 + +2. **T-002**: `updateRecurringSeries` - 반복 시리즈 전체 수정 + - Red: 함수 없음 → 테스트 실패 + - Green: PUT /api/recurring-events/:repeatId 호출 + - Refactor: 중복 제거 + +3. **T-003**: `deleteRecurringSeries` - 반복 시리즈 전체 삭제 + - Red: 함수 없음 → 테스트 실패 + - Green: DELETE /api/recurring-events/:repeatId 호출 + - Refactor: 중복 제거 + +4. **T-004**: 단일 수정 시 repeat.type 변환 + - Red: 변환 로직 없음 → 테스트 실패 + - Green: repeat.type을 'none'으로 변경 + - Refactor: 조건 명확화 + +### Phase 2: 에러 처리 (T-201 ~ T-204) +5. **T-201**: 반복 인스턴스 생성 API 실패 +6. **T-202**: 반복 시리즈 수정 404 에러 +7. **T-203**: 반복 시리즈 삭제 404 에러 +8. **T-204**: repeat.id 없는 반복 일정 처리 + +### Phase 3: 통합 테스트 (T-101 ~ T-107) +9. **T-102**: 반복 아이콘 표시 (가장 간단) +10. **T-107**: 단일 일정 다이얼로그 미표시 +11. **T-101**: 반복 일정 생성 전체 흐름 +12. **T-103**: 수정 다이얼로그 - 단일 수정 +13. **T-104**: 수정 다이얼로그 - 전체 수정 +14. **T-105**: 삭제 다이얼로그 - 단일 삭제 +15. **T-106**: 삭제 다이얼로그 - 전체 삭제 + +### Phase 4: UI 엣지 케이스 (T-205 ~ T-206) +16. **T-205**: 반복 종료일 최대값 제한 +17. **T-206**: 반복 일정 겹침 검사 제외 + +## 9. 다음 단계 + +### 테스트 작성 에이전트를 위한 인수인계 사항 + +#### ⚠️ 먼저 작성해야 할 테스트 +1. **T-001**: `saveRecurringEvents` (가장 핵심) +2. **T-002**: `updateRecurringSeries` +3. **T-003**: `deleteRecurringSeries` +4. **T-004**: 단일 수정 시 repeat.type 변환 + +#### 💡 테스트 데이터 준비 방법 +- `handlersUtils.ts`에 Mock 핸들러 3개 추가: + - `setupMockHandlerRecurringCreation` + - `setupMockHandlerRecurringUpdate` + - `setupMockHandlerRecurringDelete` +- 반복 일정 인스턴스 3개 이상 포함하는 mockEvents 배열 사용 + +#### 🔗 참고할 기존 테스트 +- **Hook 테스트**: `src/__tests__/hooks/medium.useEventOperations.spec.ts` + - `renderHook`, `act`, `waitFor` 패턴 + - `setupMockHandlerCreation` 등 Mock 핸들러 사용 패턴 + - notistack mock 패턴 + +- **통합 테스트**: `src/__tests__/medium.integration.spec.tsx` + - `render()`, `user-event` 사용 패턴 + - 다이얼로그 인터랙션 테스트 + +#### ⚠️ 특별히 주의할 점 +1. **repeatId 일관성**: 동일 시리즈의 모든 인스턴스는 동일한 `repeat.id`를 가져야 함 +2. **단일 vs 전체 수정/삭제**: + - 단일: `PUT/DELETE /api/events/:id` 사용 + - 전체: `PUT/DELETE /api/recurring-events/:repeatId` 사용 +3. **repeat.type 변환**: 단일 수정 시 `repeat.type: 'none'`, `repeat.id: undefined` +4. **기존 테스트 영향 없음**: `recurrenceUtils.spec.ts`는 수정하지 않음 (이미 완전함) +5. **네이밍 규칙 준수**: describe는 영어, it은 한글 + +#### 📁 수정/생성할 파일 +1. **수정**: `src/__mocks__/handlersUtils.ts` - Mock 핸들러 3개 추가 +2. **수정**: `src/__tests__/hooks/medium.useEventOperations.spec.ts` - 테스트 추가 +3. **수정**: `src/__tests__/medium.integration.spec.tsx` - 통합 테스트 추가 +4. **확인만**: `src/__tests__/unit/easy.recurrenceUtils.spec.ts` - 수정 불필요 + +--- + +## 자체 검토 체크리스트 + +- [x] 모든 요구사항에 대한 테스트가 설계되었는가? +- [x] 각 테스트의 목적이 명확한가? +- [x] 엣지 케이스가 충분히 커버되는가? +- [x] 테스트 우선순위가 적절한가? +- [x] AAA 패턴이 적용되었는가? +- [x] Kent Beck TDD 원칙이 반영되었는가? +- [x] 테스트 네이밍 규칙을 준수하는가? (describe: 영어, it: 한글) +- [x] 기존 테스트 패턴을 재사용하는가? +- [x] Mock 전략이 명확한가? +- [x] 테스트 작성 순서가 TDD에 적합한가? + diff --git a/src/hooks/useEventOperations.ts b/src/hooks/useEventOperations.ts index 3216cc05..9dad8509 100644 --- a/src/hooks/useEventOperations.ts +++ b/src/hooks/useEventOperations.ts @@ -44,7 +44,7 @@ export const useEventOperations = (editing: boolean, onSave?: () => void) => { await fetchEvents(); onSave?.(); - enqueueSnackbar(editing ? '일정이 수정되었습니다.' : '일정이 추가되었습니다.', { + enqueueSnackbar(editing ? '일정 수정 완료' : '일정이 추가되었습니다.', { variant: 'success', }); } catch (error) { @@ -62,13 +62,73 @@ export const useEventOperations = (editing: boolean, onSave?: () => void) => { } await fetchEvents(); - enqueueSnackbar('일정이 삭제되었습니다.', { variant: 'info' }); + enqueueSnackbar('일정 삭제 완료', { variant: 'info' }); } catch (error) { console.error('Error deleting event:', error); enqueueSnackbar('일정 삭제 실패', { variant: 'error' }); } }; + const saveRecurringEvents = async (events: Event[]) => { + try { + const response = await fetch('/api/events-list', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ events }), + }); + + if (!response.ok) { + throw new Error('Failed to save recurring events'); + } + + await fetchEvents(); + onSave?.(); + enqueueSnackbar('일정 생성 완료', { variant: 'success' }); + } catch (error) { + console.error('Error saving recurring events:', error); + enqueueSnackbar('일정 생성 실패', { variant: 'error' }); + } + }; + + const updateRecurringSeries = async (repeatId: string, updateData: Partial) => { + try { + const response = await fetch(`/api/recurring-events/${repeatId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData), + }); + + if (!response.ok) { + throw new Error('Failed to update recurring series'); + } + + await fetchEvents(); + onSave?.(); + enqueueSnackbar('일정 수정 완료', { variant: 'success' }); + } catch (error) { + console.error('Error updating recurring series:', error); + enqueueSnackbar('일정 수정 실패', { variant: 'error' }); + } + }; + + const deleteRecurringSeries = async (repeatId: string) => { + try { + const response = await fetch(`/api/recurring-events/${repeatId}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error('Failed to delete recurring series'); + } + + await fetchEvents(); + enqueueSnackbar('일정 삭제 완료', { variant: 'info' }); + } catch (error) { + console.error('Error deleting recurring series:', error); + enqueueSnackbar('일정 삭제 실패', { variant: 'error' }); + } + }; + async function init() { await fetchEvents(); enqueueSnackbar('일정 로딩 완료!', { variant: 'info' }); @@ -79,5 +139,13 @@ export const useEventOperations = (editing: boolean, onSave?: () => void) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - return { events, fetchEvents, saveEvent, deleteEvent }; + return { + events, + fetchEvents, + saveEvent, + deleteEvent, + saveRecurringEvents, + updateRecurringSeries, + deleteRecurringSeries, + }; }; diff --git a/src/types.ts b/src/types.ts index a08a8aa7..1ea29781 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ export interface RepeatInfo { type: RepeatType; interval: number; endDate?: string; + id?: string; } export interface EventForm { diff --git a/src/utils/recurrenceUtils.ts b/src/utils/recurrenceUtils.ts new file mode 100644 index 00000000..5d93a7b8 --- /dev/null +++ b/src/utils/recurrenceUtils.ts @@ -0,0 +1,164 @@ +import { Event } from '../types'; + +/** + * 반복 일정의 모든 인스턴스를 생성합니다. + * @param event 원본 반복 일정 + * @param rangeStart 표시 범위 시작일 + * @param rangeEnd 표시 범위 종료일 (최대 2025-12-31) + */ +export function generateInstancesForEvent(event: Event, rangeStart: Date, rangeEnd: Date): Event[] { + // 반복이 없으면 단일 인스턴스만 반환 + if (event.repeat.type === 'none') { + const eventDate = new Date(event.date); + if (eventDate >= rangeStart && eventDate <= rangeEnd) { + return [event]; + } + return []; + } + + const instances: Event[] = []; + const startDate = new Date(event.date); + const endLimit = event.repeat.endDate ? new Date(event.repeat.endDate) : rangeEnd; + const effectiveEnd = endLimit < rangeEnd ? endLimit : rangeEnd; + + let currentDate = new Date(startDate); + let iterationCount = 0; + let instanceId = 0; + + while (currentDate <= effectiveEnd && iterationCount < 1000) { + // 범위 내의 유효한 날짜인지 확인 + if (currentDate >= rangeStart && currentDate <= effectiveEnd) { + const dateString = formatDate(currentDate); + + // 인스턴스 생성 + instances.push({ + ...event, + id: `${event.id}-${instanceId}`, + date: dateString, + }); + instanceId++; + } + + // 다음 반복 날짜 계산 + iterationCount++; + const nextDate = getNextOccurrence( + startDate, + event.repeat.type, + event.repeat.interval, + iterationCount + ); + + // 날짜가 변경되지 않으면 (건너뛰기 실패) 무한 루프 방지 + if (nextDate.getTime() === currentDate.getTime()) { + break; + } + + currentDate = nextDate; + } + + return instances; +} + +/** + * 날짜를 YYYY-MM-DD 형식으로 포맷합니다. + */ +function formatDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +/** + * 다음 반복 날짜를 계산합니다. + * 유효하지 않은 날짜(31일이 없는 달, 윤년이 아닌 해의 2/29)는 건너뜁니다. + */ +function getNextOccurrence( + startDate: Date, + repeatType: 'daily' | 'weekly' | 'monthly' | 'yearly' | 'none', + interval: number, + count: number +): Date { + const originalDay = startDate.getDate(); + const originalMonth = startDate.getMonth(); + + switch (repeatType) { + case 'daily': { + const result = new Date(startDate); + result.setDate(startDate.getDate() + interval * count); + return result; + } + + case 'weekly': { + const result = new Date(startDate); + result.setDate(startDate.getDate() + interval * count * 7); + return result; + } + + case 'monthly': { + let attempts = 0; + let monthsToAdd = interval * count; + + while (attempts < 24) { + // 최대 2년치 시도 + const targetYear = + startDate.getFullYear() + Math.floor((startDate.getMonth() + monthsToAdd) / 12); + const targetMonth = (startDate.getMonth() + monthsToAdd) % 12; + const lastDayOfTargetMonth = new Date(targetYear, targetMonth + 1, 0).getDate(); + + // 원래 날짜가 해당 달에 존재하는지 확인 + if (originalDay <= lastDayOfTargetMonth) { + const result = new Date(targetYear, targetMonth, originalDay); + result.setHours(startDate.getHours(), startDate.getMinutes(), startDate.getSeconds()); + return result; + } + + // 이 달은 건너뛰고 다음 달로 + monthsToAdd += interval; + attempts++; + } + + // 안전 장치: 찾지 못하면 먼 미래 날짜 반환 + return new Date(9999, 11, 31); + } + + case 'yearly': { + let yearsToAdd = interval * count; + let attempts = 0; + + while (attempts < 10) { + // 최대 10년치 시도 + const targetYear = startDate.getFullYear() + yearsToAdd; + + // 윤년 2월 29일 처리 + if (originalMonth === 1 && originalDay === 29) { + if (isLeapYear(targetYear)) { + const result = new Date(targetYear, originalMonth, originalDay); + result.setHours(startDate.getHours(), startDate.getMinutes(), startDate.getSeconds()); + return result; + } + // 윤년이 아니면 다음 해로 + yearsToAdd += interval; + attempts++; + } else { + const result = new Date(targetYear, originalMonth, originalDay); + result.setHours(startDate.getHours(), startDate.getMinutes(), startDate.getSeconds()); + return result; + } + } + + // 안전 장치: 찾지 못하면 먼 미래 날짜 반환 + return new Date(9999, 11, 31); + } + + default: + return new Date(startDate); + } +} + +/** + * 윤년 확인 + */ +function isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +}