diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ef5c333e..bfc3d8aa 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,29 +4,29 @@ ### 필수 -- [ ] 반복 유형 선택 +- [x] 반복 유형 선택 - 일정 생성 또는 수정 시 반복 유형을 선택할 수 있다. - 반복 유형은 다음과 같다: 매일, 매주, 매월, 매년 - 31일에 매월을 선택한다면 -> 매월 마지막이 아닌, 31일에만 생성하세요. - 윤년 29일에 매년을 선택한다면 -> 29일에만 생성하세요! -- [ ] 반복 일정 표시 +- [x] 반복 일정 표시 - 캘린더 뷰에서 반복 일정을 시각적으로 구분하여 표시한다. - 아이콘을 넣든 태그를 넣든 자유롭게 해보세요! -- [ ] 반복 종료 +- [x] 반복 종료 - 반복 종료 조건을 지정할 수 있다. - 옵션: 특정 날짜까지, 특정 횟수만큼, 또는 종료 없음 (예제 특성상, 2025-06-30까지) -- [ ] 반복 일정 단일 수정 +- [x] 반복 일정 단일 수정 - 반복일정을 수정하면 단일 일정으로 변경됩니다. - 반복일정 아이콘도 사라집니다. -- [ ] 반복 일정 단일 삭제 +- [x] 반복 일정 단일 삭제 - 반복일정을 삭제하면 해당 일정만 삭제합니다. ### 선택 -- [ ] 반복 간격 설정 +- [x] 반복 간격 설정 - 각 반복 유형에 대해 간격을 설정할 수 있다. - 예: 2일마다, 3주마다, 2개월마다 등 -- [ ] 예외 날짜 처리: +- [x] 예외 날짜 처리: - 반복 일정 중 특정 날짜를 제외할 수 있다. - 반복 일정 중 특정 날짜의 일정을 수정할 수 있다. - [ ] 요일 지정 (주간 반복의 경우): @@ -34,25 +34,63 @@ - [ ] 월간 반복 옵션: - 매월 특정 날짜에 반복되도록 설정할 수 있다. - 매월 특정 순서의 요일에 반복되도록 설정할 수 있다. -- [ ] 반복 일정 전체 수정 및 삭제 +- [x] 반복 일정 전체 수정 및 삭제 - 반복 일정의 모든 일정을 수정할 수 있다. - 반복 일정의 모든 일정을 삭제할 수 있다. ## 심화 과제 -- [ ] 이 앱에 적합한 테스트 전략을 만들었나요? +- [x] 이 앱에 적합한 테스트 전략을 만들었나요? ### 각 팀원들의 테스트 전략은? -(작성 필요) +**Given-When-Then BDD 전략** 사용 + +- **Given**: 테스트의 전제 조건과 초기 상태를 명확히 정의합니다. +- **When**: 테스트하고자 하는 구체적인 동작이나 이벤트를 기술합니다 +- **Then**: 예상되는 결과나 상태 변화를 명시합니다. ### 합의된 테스트 전략과 그 이유는 무엇인가요? -(작성 필요) +**BDD(Given-When-Then) 전략을 선택한 이유:** + +1. TEO 코치님으로 부터 배운 테스트 방식이기도 하고, +2. **이해하기 쉬워서** 팀원들이 테스트 의도를 명확하게 파악 가능하고, +3. **비즈니스 요구사항과 직접적으로 연결**되 명세를 파악하기 쉽고, +4. **TDD Red-Green-Refactor 사이클**과 유사한 방식으로 진행할 수 있어서 선택하였습니다! ### 추가로 작성된 테스트 코드는 어떤 것들이 있나요? -(작성 필요) +**반복 일정 기능 관련 TDD 테스트 (`src/__tests__/unit/repeatEvent.spec.ts`)** + +1. **31일 기준 매월 반복 처리** + + - 31일이 없는 달은 제외된다 + +2. **윤년 2월 29일 매년 반복 처리** + + - 평년에는 해당 날짜가 생성되지 않는다 + +3. **반복 종료 조건 처리** + + - 특정 날짜까지 종료 조건이 적용된다 + - 특정 횟수만큼 종료 조건이 적용된다 + +4. **반복 간격 계산** + + - 매일 반복에 2일 간격이 적용된다 + - 주간 반복에 2주 간격이 적용된다 + +5. **반복 일정 전체 조작** + + - 반복 일정 전체 수정 시 모든 관련 일정이 업데이트된다 + - 반복 일정 전체 삭제 시 모든 관련 일정이 제거된다 + +6. **예외 날짜 처리** + - 반복 일정 중 특정 날짜가 제외된다 + - 예외 날짜가 없으면 모든 반복 일정이 생성된다 + +**총 10개의 새로운 테스트 케이스** 추가하여 반복 일정의 핵심 비즈니스 로직 검증 --- diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..fb9ce844 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,133 @@ +# 캘린더 반복 일정 기능 TDD 가이드 + +## 테스트 레벨별 구조 + +### Unit Tests (src/**tests**/unit/repeatEvent.spec.ts) + +순수 함수와 비즈니스 로직 테스트 + +#### 1. 반복 날짜 계산 로직 + +``` +Given: 31일 기준 매월 반복 설정이 주어졌을 때 +When: 다음 6개월의 반복 날짜를 계산할 때 +Then: 31일이 없는 달(2월, 4월, 6월, 9월, 11월)은 제외되어야 한다 +``` + +``` +Given: 윤년 2024년 2월 29일 기준 매년 반복 설정이 주어졌을 때 +When: 다음 3년의 반복 날짜를 계산할 때 +Then: 평년(2025, 2026, 2027)에는 해당 날짜가 생성되지 않아야 한다 +``` + +#### 2. 반복 종료 조건 처리 + +``` +Given: 반복 일정과 "특정 날짜까지" 종료 조건이 주어졌을 때 +When: 반복 일정을 생성할 때 +Then: 지정한 종료 날짜를 넘지 않는 일정들만 반환되어야 한다 +``` + +``` +Given: 반복 일정과 "특정 횟수만큼" 종료 조건이 주어졌을 때 +When: 반복 일정을 생성할 때 +Then: 지정한 횟수만큼의 일정들만 반환되어야 한다 +``` + +#### 3. 반복 간격 계산 + +``` +Given: 매일 반복에 2일 간격 설정이 주어졌을 때 +When: 10개의 반복 일정을 생성할 때 +Then: 2일씩 간격을 두고 일정이 생성되어야 한다 +``` + +### Integration Tests (src/**tests**/integration/repeatEventFlow.spec.ts) + +전체 기능 플로우와 컴포넌트 간 상호작용 테스트 + +#### 1. 반복 일정 생성부터 표시까지 플로우 + +``` +Given: 일정 생성 폼이 렌더링되어 있을 때 +When: 반복 유형을 선택하고 일정을 생성할 때 +Then: 캘린더에 반복 일정이 시각적으로 구분되어 표시되어야 한다 +``` + +#### 2. 반복 일정 단일 수정 플로우 + +``` +Given: 캘린더에 반복 일정이 표시되어 있을 때 +When: 특정 날짜의 반복 일정을 수정할 때 +Then: 해당 일정은 단일 일정으로 변경되고 반복 표시가 사라져야 한다 +``` + +#### 3. 반복 일정 단일 삭제 플로우 + +``` +Given: 캘린더에 반복 일정이 표시되어 있을 때 +When: 특정 날짜의 반복 일정을 삭제할 때 +Then: 해당 날짜 일정만 삭제되고 다른 반복 일정은 유지되어야 한다 +``` + +#### 4. 반복 일정 전체 수정/삭제 플로우 + +``` +Given: 반복 일정이 여러 개 생성되어 있을 때 +When: 반복 일정 전체를 수정/삭제할 때 +Then: 모든 관련 반복 일정이 함께 수정/삭제되어야 한다 +``` + +### 선택 기능 Unit Tests + +#### 4. 요일 지정 반복 계산 + +``` +Given: 주간 반복에 특정 요일(월, 수, 금) 설정이 주어졌을 때 +When: 4주간의 반복 일정을 계산할 때 +Then: 지정된 요일에만 일정이 생성되어야 한다 +``` + +#### 5. 월간 반복 옵션 계산 + +``` +Given: "매월 둘째 주 화요일" 설정이 주어졌을 때 +When: 6개월간의 반복 일정을 계산할 때 +Then: 각 달의 둘째 주 화요일에만 일정이 생성되어야 한다 +``` + +#### 6. 예외 날짜 처리 + +``` +Given: 반복 일정과 예외 날짜 목록이 주어졌을 때 +When: 반복 일정을 생성할 때 +Then: 예외 날짜는 제외된 일정 목록이 반환되어야 한다 +``` + +## 테스트 작성 가이드 + +1. **Given**: 테스트의 전제 조건과 초기 상태를 명확히 정의 +2. **When**: 테스트하고자 하는 구체적인 동작이나 이벤트를 기술 +3. **Then**: 예상되는 결과나 상태 변화를 명시 + +### 테스트 파일 구조 + +``` +src/__tests__/ +├── unit/ +│ └── repeatEvent.spec.ts # 순수 함수 테스트 (날짜 계산, 검증 등) +├── integration/ +│ └── repeatEventFlow.spec.ts # 컴포넌트 간 상호작용 테스트 +└── hooks/ + └── useRepeatEvent.spec.ts # 반복 일정 관련 커스텀 훅 테스트 +``` + +### 테스트 작성 우선순위 + +1. **Unit Tests**: 핵심 비즈니스 로직부터 (31일 매월, 윤년 처리 등) +2. **Integration Tests**: 필수 기능 플로우 (생성→표시→수정→삭제) +3. **선택 기능**: 간격 설정, 요일 지정 등 + +### Mock 데이터 활용 + +반복 일정 관련 테스트 시 `src/__mocks__/` 디렉토리의 mock 데이터를 활용하여 일관된 테스트 환경 구성 diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..51d66bbb --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,237 @@ +# 캘린더 반복 일정 기능 TDD 설계문서 + +## 프로젝트 개요 + +### 현재 상태 분석 + +- React + TypeScript + Vite 기반 캘린더 애플리케이션 +- 테스트 도구: Vitest + Testing Library +- 기존 기능: 일정 생성/수정/삭제, 캘린더 뷰, 검색, 알림 +- **반복 일정 상태**: UI와 상태관리는 구현되어 있으나 핵심 비즈니스 로직은 미구현 + - `useEventForm.ts`: 반복 설정 상태 관리 완료 + - `App.tsx`: 반복 설정 UI 완료 + - **미구현**: 실제 반복 일정 생성/처리 로직 + +### 기존 타입 구조 (이미 정의됨) + +```typescript +export interface RepeatInfo { + type: RepeatType; // 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' + interval: number; + endDate?: string; +} + +export interface Event extends EventForm { + id: string; + repeat: RepeatInfo; +} +``` + +### 기존 파일 구조 활용 + +- `src/utils/dateUtils.ts`: 기존 날짜 유틸리티에 반복 로직 추가 +- `src/hooks/useEventForm.ts`: 이미 반복 설정 상태 관리 구현됨 +- `src/hooks/useEventOperations.ts`: 일정 CRUD 작업 처리 + +## PR 템플릿 기반 구현 요구사항 + +### 필수 구현 사항 +1. **31일 매월 반복**: 31일이 없는 달은 제외 +2. **윤년 29일 매년 반복**: 평년에는 제외 +3. **반복 일정 시각적 구분 표시** +4. **반복 종료 조건**: 특정 날짜까지, 특정 횟수만큼 +5. **반복 일정 단일 수정**: 단일 일정으로 변경 +6. **반복 일정 단일 삭제**: 해당 일정만 삭제 + +### 선택 구현 사항 +- 반복 간격 설정, 예외 날짜 처리, 요일 지정 반복 등 + +## TDD 구현 계획 + +### 1단계: Unit Tests - 핵심 비즈니스 로직 + +#### 1.1 `dateUtils.ts`에 반복 날짜 계산 함수 추가 + +**테스트 파일**: `src/__tests__/unit/repeatEvent.spec.ts` + +##### Given-When-Then 구조: + +```javascript +// 31일 매월 반복 테스트 (PR 템플릿 필수 요구사항) +describe('generateRepeatDates - 31일 기준 매월 반복 처리', () => { + test('31일이 없는 달은 제외된다', () => { + // Given: 31일 기준 매월 반복 설정이 있는 이벤트 + // When: generateRepeatDates 함수로 반복 날짜를 계산할 때 + // Then: 31일이 없는 달(2월, 4월, 6월, 9월, 11월)은 제외되어야 한다 + }); +}); + +// 윤년 처리 테스트 (PR 템플릿 필수 요구사항) +describe('generateRepeatDates - 윤년 2월 29일 매년 반복 처리', () => { + test('평년에는 해당 날짜가 생성되지 않는다', () => { + // Given: 윤년 2024년 2월 29일 기준 매년 반복 설정이 있는 이벤트 + // When: generateRepeatDates 함수로 향후 3년간 반복 날짜를 계산할 때 + // Then: 평년(2025, 2026, 2027)에는 해당 날짜가 생성되지 않아야 한다 + }); +}); +``` + +**새로 추가할 함수들**: + +- `generateRepeatDates(event: Event, endDate: Date): string[]` +- `isValidRepeatDate(date: Date, originalDate: Date, repeatType: RepeatType): boolean` + +#### 1.2 반복 종료 조건 처리 + +##### Given-When-Then 구조: + +```javascript +describe('반복 종료 조건 처리', () => { + test('특정 날짜까지 종료 조건이 적용된다', () => { + // Given: 반복 일정과 "특정 날짜까지" 종료 조건 + // When: 반복 일정을 생성 + // Then: 지정한 종료 날짜를 넘지 않는 일정들만 반환 + }); + + test('특정 횟수만큼 종료 조건이 적용된다', () => { + // Given: 반복 일정과 "특정 횟수만큼" 종료 조건 + // When: 반복 일정을 생성 + // Then: 지정한 횟수만큼의 일정들만 반환 + }); +}); +``` + +### 2단계: Integration Tests - 전체 기능 플로우 + +#### 2.1 반복 일정 생성부터 표시까지 + +**테스트 파일**: `src/__tests__/integration/repeatEventFlow.spec.ts` + +##### Given-When-Then 구조: + +```javascript +describe('반복 일정 생성 플로우', () => { + test('반복 유형 선택 후 캘린더에 구분되어 표시된다', () => { + // Given: 일정 생성 폼이 렌더링 + // When: 반복 유형을 선택하고 일정을 생성 + // Then: 캘린더에 반복 일정이 시각적으로 구분되어 표시 + }); +}); + +describe('반복 일정 단일 수정/삭제 플로우', () => { + test('특정 날짜 반복 일정 수정시 단일 일정으로 변경된다', () => { + // Given: 캘린더에 반복 일정이 표시 + // When: 특정 날짜의 반복 일정을 수정 + // Then: 해당 일정은 단일 일정으로 변경되고 반복 표시가 사라짐 + }); + + test('특정 날짜 반복 일정 삭제시 해당 날짜만 삭제된다', () => { + // Given: 캘린더에 반복 일정이 표시 + // When: 특정 날짜의 반복 일정을 삭제 + // Then: 해당 날짜 일정만 삭제되고 다른 반복 일정은 유지 + }); +}); + +describe('반복 일정 전체 수정/삭제 플로우', () => { + test('반복 일정 전체 수정/삭제시 모든 관련 일정이 함께 처리된다', () => { + // Given: 반복 일정이 여러 개 생성 + // When: 반복 일정 전체를 수정/삭제 + // Then: 모든 관련 반복 일정이 함께 수정/삭제 + }); +}); +``` + +### 3단계: Hooks Tests - 상태 관리 + +#### 3.1 반복 일정 관리 훅 + +**테스트 파일**: `src/__tests__/hooks/useRepeatEvent.spec.ts` + +##### Given-When-Then 구조: + +```javascript +describe('useRepeatEvent 훅', () => { + test('반복 일정 생성 시 모든 반복 인스턴스가 생성된다', () => { + // Given: 반복 설정이 있는 일정 데이터 + // When: createRepeatEvent 함수 호출 + // Then: 설정에 따른 모든 반복 일정이 생성됨 + }); + + test('단일 반복 일정 수정 시 해당 인스턴스만 수정된다', () => { + // Given: 생성된 반복 일정들 + // When: 특정 인스턴스를 수정 + // Then: 해당 인스턴스만 수정되고 나머지는 유지 + }); +}); +``` + +## TDD 구현 순서 + +### Phase 1: 테스트 작성 및 Red 상태 확인 + +1. Given-When-Then 주석으로 테스트 시나리오 정리 +2. 실패하는 테스트 코드 작성 +3. `npm test` 실행하여 Red 상태 확인 + +### Phase 2: 최소 구현으로 Green 상태 달성 + +1. 테스트를 통과하는 최소한의 구현 +2. 기능적 완성보다는 테스트 통과에 집중 + +### Phase 3: Refactor 단계 + +1. 코드 품질 개선 +2. 중복 제거 및 최적화 +3. 추가 테스트 케이스 보강 + +## 구현 대상 파일 구조 + +``` +src/ +├── utils/ +│ └── repeatUtils.ts # 반복 일정 계산 로직 +├── hooks/ +│ └── useRepeatEvent.ts # 반복 일정 상태 관리 +├── types.ts # RepeatInfo 타입 확장 +└── __tests__/ + ├── unit/ + │ └── repeatEvent.spec.ts # 비즈니스 로직 테스트 + ├── integration/ + │ └── repeatEventFlow.spec.ts # 전체 플로우 테스트 + └── hooks/ + └── useRepeatEvent.spec.ts # 훅 테스트 +``` + +## TDD 준수 원칙 + +1. **절대 구현 코드 먼저 작성 금지** +2. **Given-When-Then 주석 먼저 작성** +3. **Red → Green → Refactor 사이클 준수** +4. **한 번에 하나의 테스트만 통과시키기** +5. **테스트가 실패하는 것을 확인한 후 구현 시작** + +## 선택 기능 (추후 구현) + +### 요일 지정 반복 + +```javascript +// Given: 주간 반복에 특정 요일(월, 수, 금) 설정 +// When: 4주간의 반복 일정을 계산 +// Then: 지정된 요일에만 일정이 생성 +``` + +### 월간 반복 옵션 + +```javascript +// Given: "매월 둘째 주 화요일" 설정 +// When: 6개월간의 반복 일정을 계산 +// Then: 각 달의 둘째 주 화요일에만 일정이 생성 +``` + +### 예외 날짜 처리 + +```javascript +// Given: 반복 일정과 예외 날짜 목록 +// When: 반복 일정을 생성 +// Then: 예외 날짜는 제외된 일정 목록이 반환 +``` diff --git a/src/App.tsx b/src/App.tsx index 195c5b05..11048a58 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,16 @@ -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, Box, Button, + Chip, Checkbox, Dialog, DialogActions, @@ -35,8 +42,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, @@ -44,6 +50,7 @@ import { getEventsForDay, getWeekDates, getWeeksAtMonth, + markRepeatEvents, } from './utils/dateUtils'; import { findOverlappingEvents } from './utils/eventOverlap'; import { getTimeErrorMessage } from './utils/timeValidation'; @@ -77,11 +84,17 @@ function App() { isRepeating, setIsRepeating, repeatType, - // setRepeatType, + setRepeatType, repeatInterval, - // setRepeatInterval, + setRepeatInterval, repeatEndDate, - // setRepeatEndDate, + setRepeatEndDate, + repeatEndCount, + setRepeatEndCount, + endType, + setEndType, + excludeDates, + setExcludeDates, notificationTime, setNotificationTime, startTimeError, @@ -94,16 +107,23 @@ function App() { editEvent, } = useEventForm(); - const { events, saveEvent, deleteEvent } = useEventOperations(Boolean(editingEvent), () => - setEditingEvent(null) + const { events, saveEvent, deleteEvent, deleteAllRepeatEventsLocal } = useEventOperations( + Boolean(editingEvent), + () => setEditingEvent(null) ); const { notifications, notifiedEvents, setNotifications } = useNotifications(events); const { view, setView, currentDate, holidays, navigate } = useCalendarView(); const { searchTerm, filteredEvents, setSearchTerm } = useSearch(events, currentDate, view); + const markedEvents = markRepeatEvents(filteredEvents); const [isOverlapDialogOpen, setIsOverlapDialogOpen] = useState(false); const [overlappingEvents, setOverlappingEvents] = useState([]); + const [bulkOperationDialog, setBulkOperationDialog] = useState<{ + open: boolean; + type: 'edit' | 'delete'; + event: Event | null; + }>({ open: false, type: 'edit', event: null }); const { enqueueSnackbar } = useSnackbar(); @@ -130,7 +150,10 @@ function App() { repeat: { type: isRepeating ? repeatType : 'none', interval: repeatInterval, - endDate: repeatEndDate || undefined, + endDate: isRepeating && endType === 'date' ? repeatEndDate || undefined : undefined, + endCount: + isRepeating && endType === 'count' ? Number(repeatEndCount) || undefined : undefined, + excludeDates: isRepeating && excludeDates.length > 0 ? excludeDates : undefined, }, notificationTime, }; @@ -178,7 +201,7 @@ function App() { {date.getDate()} - {filteredEvents + {markedEvents .filter( (event) => new Date(event.date).toDateString() === date.toDateString() ) @@ -201,6 +224,7 @@ function App() { > {isNotified && } + {event.isRepeatEvent && } )} - {getEventsForDay(filteredEvents, day).map((event) => { + {getEventsForDay(markedEvents, day).map((event) => { const isNotified = notifiedEvents.includes(event.id); return ( {isNotified && } + {event.isRepeatEvent && ( + + )} {/* ! 반복은 8주차 과제에 포함됩니다. 구현하고 싶어도 참아주세요~ */} - {/* {isRepeating && ( + {isRepeating && ( 반복 유형 @@ -465,17 +492,85 @@ function App() { /> - 반복 종료일 + 반복 종료 조건 + + + {endType === 'date' && ( + + 종료 날짜 + setRepeatEndDate(e.target.value)} + /> + + )} + {endType === 'count' && ( + + 반복 횟수 + setRepeatEndCount(e.target.value)} + slotProps={{ htmlInput: { min: 1 } }} + /> + + )} + + + )} + + {isRepeating && ( + + 예외 날짜 + + setRepeatEndDate(e.target.value)} + label="제외할 날짜" + InputLabelProps={{ shrink: true }} + onChange={(e) => { + const newDate = e.target.value; + if (newDate && !excludeDates.includes(newDate)) { + setExcludeDates([...excludeDates, newDate]); + } + }} /> - - + + 제외할 날짜를 선택하세요 + + + {excludeDates.length > 0 && ( + + 제외된 날짜들: + + {excludeDates.map((date, index) => ( + { + setExcludeDates(excludeDates.filter((_, i) => i !== index)); + }} + size="small" + /> + ))} + + + )} + - )} */} + )} + + + + + {notifications.length > 0 && ( {notifications.map((notification, index) => ( diff --git a/src/__tests__/unit/repeatEvent.spec.ts b/src/__tests__/unit/repeatEvent.spec.ts new file mode 100644 index 00000000..1b310b8d --- /dev/null +++ b/src/__tests__/unit/repeatEvent.spec.ts @@ -0,0 +1,441 @@ +import { describe, test, expect } from 'vitest'; + +import { Event } from '../../types'; +import { + generateRepeatDates, + updateAllRepeatEvents, + deleteAllRepeatEvents, +} from '../../utils/dateUtils'; + +describe('31일 기준 매월 반복 처리', () => { + test('31일이 없는 달은 제외된다', () => { + // Given: 31일 기준 매월 반복 설정이 주어졌을 때 + const baseEvent: Event = { + id: 'test-1', + title: '31일 반복 일정', + date: '2024-01-31', + startTime: '10:00', + endTime: '11:00', + description: '', + location: '', + category: '', + repeat: { + type: 'monthly', + interval: 1, + endDate: '2024-07-31', + }, + notificationTime: 0, + }; + + // When: 다음 6개월의 반복 날짜를 계산할 때 + const endDate = new Date('2024-07-31'); + const repeatDates = generateRepeatDates(baseEvent, endDate); + + // Then: 31일이 없는 달(2월, 4월, 6월, 9월, 11월)은 제외되어야 한다 + const expectedDates = [ + '2024-01-31', // 원본 + '2024-03-31', // 3월 31일 (2월 건너뜀) + '2024-05-31', // 5월 31일 (4월 건너뜀) + '2024-07-31', // 7월 31일 (6월 건너뜀) + ]; + + expect(repeatDates).toEqual(expectedDates); + }); +}); + +describe('윤년 2월 29일 매년 반복 처리', () => { + test('평년에는 해당 날짜가 생성되지 않는다', () => { + // Given: 윤년 2024년 2월 29일 기준 매년 반복 설정이 주어졌을 때 + const baseEvent: Event = { + id: 'test-2', + title: '윤년 반복 일정', + date: '2024-02-29', + startTime: '09:00', + endTime: '10:00', + description: '', + location: '', + category: '', + repeat: { + type: 'yearly', + interval: 1, + endDate: '2028-12-31', + }, + notificationTime: 0, + }; + + // When: 다음 4년의 반복 날짜를 계산할 때 + const endDate = new Date('2028-12-31'); + const repeatDates = generateRepeatDates(baseEvent, endDate); + + // Then: 평년(2025, 2026, 2027)에는 해당 날짜가 생성되지 않아야 한다 + // 2028년은 윤년이므로 2월 29일이 생성되어야 함 + const expectedDates = [ + '2024-02-29', // 원본 (2024년은 윤년) + '2028-02-29', // 2028년은 윤년이므로 생성됨 + // 2025, 2026, 2027년은 평년이므로 2월 29일이 없어서 제외됨 + ]; + + expect(repeatDates).toEqual(expectedDates); + }); +}); + +describe('반복 종료 조건 처리', () => { + test('특정 날짜까지 종료 조건이 적용된다', () => { + // Given: 반복 일정과 "특정 날짜까지" 종료 조건이 주어졌을 때 + const baseEvent: Event = { + id: 'test-3', + title: '종료 날짜 테스트', + date: '2024-01-01', + startTime: '14:00', + endTime: '15:00', + description: '', + location: '', + category: '', + repeat: { + type: 'monthly', + interval: 1, + endDate: '2024-03-15', // 3월 15일까지만 + }, + notificationTime: 0, + }; + + // When: 반복 일정을 생성할 때 + const endDate = new Date('2024-12-31'); // 더 큰 범위로 설정 + const repeatDates = generateRepeatDates(baseEvent, endDate); + + // Then: 지정한 종료 날짜를 넘지 않는 일정들만 반환되어야 한다 + const expectedDates = [ + '2024-01-01', // 원본 + '2024-02-01', // 2월 1일 + '2024-03-01', // 3월 1일 (3월 15일 이전이므로 포함) + // '2024-04-01'은 endDate(3월 15일) 이후이므로 제외됨 + ]; + + expect(repeatDates).toEqual(expectedDates); + }); + + test('특정 횟수만큼 종료 조건이 적용된다', () => { + // Given: 반복 일정과 "특정 횟수만큼" 종료 조건이 주어졌을 때 + const baseEvent: Event = { + id: 'test-count', + title: '횟수 제한 테스트', + date: '2024-01-01', + startTime: '14:00', + endTime: '15:00', + description: '', + location: '', + category: '', + repeat: { + type: 'weekly', + interval: 1, + endCount: 5, // 5번만 반복 + }, + notificationTime: 0, + }; + + // When: 반복 일정을 생성할 때 + const endDate = new Date('2024-12-31'); // 충분히 먼 미래 날짜 + const repeatDates = generateRepeatDates(baseEvent, endDate); + + // Then: 지정한 횟수(5번)만큼의 일정들만 반환되어야 한다 + const expectedDates = [ + '2024-01-01', // 원본 + '2024-01-08', // +1주 + '2024-01-15', // +1주 + '2024-01-22', // +1주 + '2024-01-29', // +1주 (총 5개) + ]; + + expect(repeatDates).toEqual(expectedDates); + expect(repeatDates).toHaveLength(5); + }); +}); + +describe('반복 간격 계산', () => { + test('매일 반복에 2일 간격이 적용된다', () => { + // Given: 매일 반복에 2일 간격 설정이 주어졌을 때 + const baseEvent: Event = { + id: 'test-4', + title: '간격 테스트', + date: '2024-01-01', + startTime: '08:00', + endTime: '09:00', + description: '', + location: '', + category: '', + repeat: { + type: 'daily', + interval: 2, // 2일 간격 + endDate: '2024-01-20', + }, + notificationTime: 0, + }; + + // When: 10개의 반복 일정을 생성할 때 + const endDate = new Date('2024-01-20'); + const repeatDates = generateRepeatDates(baseEvent, endDate); + + // Then: 2일씩 간격을 두고 일정이 생성되어야 한다 + const expectedDates = [ + '2024-01-01', // 원본 (1월 1일) + '2024-01-03', // +2일 (1월 3일) + '2024-01-05', // +2일 (1월 5일) + '2024-01-07', // +2일 (1월 7일) + '2024-01-09', // +2일 (1월 9일) + '2024-01-11', // +2일 (1월 11일) + '2024-01-13', // +2일 (1월 13일) + '2024-01-15', // +2일 (1월 15일) + '2024-01-17', // +2일 (1월 17일) + '2024-01-19', // +2일 (1월 19일) + ]; + + expect(repeatDates).toEqual(expectedDates); + }); + + test('주간 반복에 2주 간격이 적용된다', () => { + // Given: 주간 반복에 2주 간격 설정이 주어졌을 때 + const baseEvent: Event = { + id: 'test-5', + title: '주간 간격 테스트', + date: '2024-01-01', // 월요일 + startTime: '10:00', + endTime: '11:00', + description: '', + location: '', + category: '', + repeat: { + type: 'weekly', + interval: 2, // 2주 간격 + endDate: '2024-02-29', + }, + notificationTime: 0, + }; + + // When: 주간 반복 일정을 생성할 때 + const endDate = new Date('2024-02-29'); + const repeatDates = generateRepeatDates(baseEvent, endDate); + + // Then: 2주씩 간격을 두고 일정이 생성되어야 한다 + const expectedDates = [ + '2024-01-01', // 원본 (1월 1일 월요일) + '2024-01-15', // +2주 (1월 15일 월요일) + '2024-01-29', // +2주 (1월 29일 월요일) + '2024-02-12', // +2주 (2월 12일 월요일) + '2024-02-26', // +2주 (2월 26일 월요일) + ]; + + expect(repeatDates).toEqual(expectedDates); + }); +}); + +describe('반복 일정 전체 조작', () => { + test('반복 일정 전체 수정 시 모든 관련 일정이 업데이트된다', () => { + // Given: 반복 일정 그룹이 여러 개 있을 때 + const events: Event[] = [ + { + id: 'repeat-1', + title: '주간 회의', + date: '2024-01-01', + startTime: '10:00', + endTime: '11:00', + description: '', + location: '', + category: '', + repeat: { type: 'weekly', interval: 1 }, + notificationTime: 10, + }, + { + id: 'repeat-2', + title: '주간 회의', + date: '2024-01-08', + startTime: '10:00', + endTime: '11:00', + description: '', + location: '', + category: '', + repeat: { type: 'weekly', interval: 1 }, + notificationTime: 10, + }, + { + id: 'other-1', + title: '다른 일정', + date: '2024-01-05', + startTime: '14:00', + endTime: '15:00', + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 1 }, + notificationTime: 5, + }, + ]; + + // When: 반복 일정 전체를 수정할 때 + const updates = { + title: '수정된 주간 회의', + startTime: '14:00', + endTime: '15:00', + }; + const updatedEvents = updateAllRepeatEvents(events, '주간 회의', updates); + + // Then: 같은 제목의 반복 일정들만 모두 수정되어야 한다 + const weeklyEvents = updatedEvents.filter((event) => event.title === '수정된 주간 회의'); + expect(weeklyEvents).toHaveLength(2); + expect(weeklyEvents[0].startTime).toBe('14:00'); + expect(weeklyEvents[0].endTime).toBe('15:00'); + expect(weeklyEvents[1].startTime).toBe('14:00'); + expect(weeklyEvents[1].endTime).toBe('15:00'); + + // 다른 일정은 변경되지 않아야 한다 + const otherEvent = updatedEvents.find((event) => event.id === 'other-1'); + expect(otherEvent?.title).toBe('다른 일정'); + expect(otherEvent?.startTime).toBe('14:00'); + }); + + test('반복 일정 전체 삭제 시 모든 관련 일정이 제거된다', () => { + // Given: 반복 일정 그룹이 여러 개 있을 때 + const events: Event[] = [ + { + id: 'repeat-1', + title: '주간 회의', + date: '2024-01-01', + startTime: '10:00', + endTime: '11:00', + description: '', + location: '', + category: '', + repeat: { type: 'weekly', interval: 1 }, + notificationTime: 10, + }, + { + id: 'repeat-2', + title: '주간 회의', + date: '2024-01-08', + startTime: '10:00', + endTime: '11:00', + description: '', + location: '', + category: '', + repeat: { type: 'weekly', interval: 1 }, + notificationTime: 10, + }, + { + id: 'repeat-3', + title: '주간 회의', + date: '2024-01-15', + startTime: '10:00', + endTime: '11:00', + description: '', + location: '', + category: '', + repeat: { type: 'weekly', interval: 1 }, + notificationTime: 10, + }, + { + id: 'other-1', + title: '다른 일정', + date: '2024-01-05', + startTime: '14:00', + endTime: '15:00', + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 1 }, + notificationTime: 5, + }, + ]; + + // When: 반복 일정 전체를 삭제할 때 + const updatedEvents = deleteAllRepeatEvents(events, '주간 회의'); + + // Then: 같은 제목의 반복 일정들만 모두 삭제되어야 한다 + const weeklyEvents = updatedEvents.filter((event) => event.title === '주간 회의'); + expect(weeklyEvents).toHaveLength(0); + + // 다른 일정은 유지되어야 한다 + expect(updatedEvents).toHaveLength(1); + expect(updatedEvents[0].title).toBe('다른 일정'); + }); +}); + +describe('예외 날짜 처리', () => { + test('반복 일정 중 특정 날짜가 제외된다', () => { + // Given: 반복 일정과 예외 날짜 목록이 주어졌을 때 + const baseEvent: Event = { + id: 'exclude-test', + title: '예외 날짜 테스트', + date: '2024-01-01', + startTime: '10:00', + endTime: '11:00', + description: '', + location: '', + category: '', + repeat: { + type: 'weekly', + interval: 1, + endDate: '2024-02-29', + excludeDates: ['2024-01-15', '2024-01-29'], // 1월 15일, 29일 제외 + }, + notificationTime: 10, + }; + + // When: 반복 일정을 생성할 때 + const endDate = new Date('2024-02-29'); + const repeatDates = generateRepeatDates(baseEvent, endDate); + + // Then: 예외 날짜는 제외된 일정 목록이 반환되어야 한다 + const expectedDates = [ + '2024-01-01', // 원본 + '2024-01-08', // +1주 + // '2024-01-15' 제외됨 + '2024-01-22', // +1주 + // '2024-01-29' 제외됨 + '2024-02-05', // +1주 + '2024-02-12', // +1주 + '2024-02-19', // +1주 + '2024-02-26', // +1주 + ]; + + expect(repeatDates).toEqual(expectedDates); + // 제외된 날짜들이 포함되지 않았는지 확인 + expect(repeatDates).not.toContain('2024-01-15'); + expect(repeatDates).not.toContain('2024-01-29'); + }); + + test('예외 날짜가 없으면 모든 반복 일정이 생성된다', () => { + // Given: 예외 날짜가 없는 반복 일정이 주어졌을 때 + const baseEvent: Event = { + id: 'no-exclude-test', + title: '예외 날짜 없음 테스트', + date: '2024-01-01', + startTime: '14:00', + endTime: '15:00', + description: '', + location: '', + category: '', + repeat: { + type: 'weekly', + interval: 1, + endDate: '2024-01-29', + // excludeDates 없음 + }, + notificationTime: 5, + }; + + // When: 반복 일정을 생성할 때 + const endDate = new Date('2024-01-29'); + const repeatDates = generateRepeatDates(baseEvent, endDate); + + // Then: 모든 반복 일정이 생성되어야 한다 + const expectedDates = [ + '2024-01-01', // 원본 + '2024-01-08', // +1주 + '2024-01-15', // +1주 + '2024-01-22', // +1주 + '2024-01-29', // +1주 + ]; + + expect(repeatDates).toEqual(expectedDates); + expect(repeatDates).toHaveLength(5); + }); +}); diff --git a/src/__tests__/unit/repeatEventManager.spec.ts b/src/__tests__/unit/repeatEventManager.spec.ts new file mode 100644 index 00000000..0bccdfaf --- /dev/null +++ b/src/__tests__/unit/repeatEventManager.spec.ts @@ -0,0 +1,276 @@ +import { describe, test, expect } from 'vitest'; + +import { Event, EventForm } from '../../types'; +import { + createRepeatEvents, + updateSingleRepeatEvent, + deleteSingleRepeatEvent, + markRepeatEvents, +} from '../../utils/dateUtils'; + +describe('반복 일정 분할 생성', () => { + test('반복 설정이 있는 이벤트는 여러 개의 개별 이벤트로 생성된다', () => { + // Given: 매월 반복 설정이 있는 EventForm + const baseEventForm: EventForm = { + title: '매월 회의', + date: '2024-01-15', + startTime: '10:00', + endTime: '11:00', + description: '매월 정기 회의', + location: '회의실 A', + category: '업무', + repeat: { + type: 'monthly', + interval: 1, + endDate: '2024-04-15', + }, + notificationTime: 10, + }; + + // When: createRepeatEvents 함수 호출 + const events = createRepeatEvents(baseEventForm); + + // Then: 반복 날짜별로 개별 Event 객체들이 생성됨 (각각 고유 ID 포함) + expect(events).toHaveLength(4); // 1월, 2월, 3월, 4월 + expect(events[0].date).toBe('2024-01-15'); + expect(events[1].date).toBe('2024-02-15'); + expect(events[2].date).toBe('2024-03-15'); + expect(events[3].date).toBe('2024-04-15'); + + // 모든 이벤트는 고유 ID를 가져야 함 + const ids = events.map((event) => event.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(events.length); + + // 모든 이벤트는 동일한 기본 정보를 가져야 함 + events.forEach((event) => { + expect(event.title).toBe(baseEventForm.title); + expect(event.startTime).toBe(baseEventForm.startTime); + expect(event.endTime).toBe(baseEventForm.endTime); + expect(event.description).toBe(baseEventForm.description); + expect(event.location).toBe(baseEventForm.location); + expect(event.category).toBe(baseEventForm.category); + expect(event.notificationTime).toBe(baseEventForm.notificationTime); + }); + }); + + test('반복 없음 설정 이벤트는 단일 이벤트로만 생성된다', () => { + // Given: repeat.type이 'none'인 EventForm + const baseEventForm: EventForm = { + title: '단일 일정', + date: '2024-01-15', + startTime: '14:00', + endTime: '15:00', + description: '한번만 실행되는 일정', + location: '카페', + category: '개인', + repeat: { + type: 'none', + interval: 1, + }, + notificationTime: 5, + }; + + // When: createRepeatEvents 함수 호출 + const events = createRepeatEvents(baseEventForm); + + // Then: 원본 이벤트 하나만 반환됨 + expect(events).toHaveLength(1); + expect(events[0].date).toBe('2024-01-15'); + expect(events[0].title).toBe(baseEventForm.title); + expect(events[0].repeat.type).toBe('none'); + }); +}); + +describe('반복 일정 개별 처리', () => { + test('반복 일정 중 하나를 수정하면 단일 일정으로 변경된다', () => { + // Given: 반복으로 생성된 여러 이벤트 중 특정 이벤트 ID + const existingEvents: Event[] = [ + { + id: 'repeat-1', + title: '매주 운동', + date: '2024-01-01', + startTime: '07:00', + endTime: '08:00', + description: '주간 운동', + location: '체육관', + category: '건강', + repeat: { type: 'weekly', interval: 1 }, + notificationTime: 30, + }, + { + id: 'repeat-2', + title: '매주 운동', + date: '2024-01-08', + startTime: '07:00', + endTime: '08:00', + description: '주간 운동', + location: '체육관', + category: '건강', + repeat: { type: 'weekly', interval: 1 }, + notificationTime: 30, + }, + { + id: 'repeat-3', + title: '매주 운동', + date: '2024-01-15', + startTime: '07:00', + endTime: '08:00', + description: '주간 운동', + location: '체육관', + category: '건강', + repeat: { type: 'weekly', interval: 1 }, + notificationTime: 30, + }, + ]; + + const updates = { + title: '특별 운동 세션', + startTime: '08:00', + endTime: '09:30', + description: '개별 수정된 운동', + }; + + // When: updateSingleRepeatEvent 함수로 해당 이벤트 수정 + const updatedEvents = updateSingleRepeatEvent(existingEvents, 'repeat-2', updates); + + // Then: 해당 이벤트의 repeat.type이 'none'으로 변경되고 내용이 업데이트됨 + const updatedEvent = updatedEvents.find((event) => event.id === 'repeat-2'); + expect(updatedEvent).toBeDefined(); + expect(updatedEvent!.repeat.type).toBe('none'); + expect(updatedEvent!.title).toBe('특별 운동 세션'); + expect(updatedEvent!.startTime).toBe('08:00'); + expect(updatedEvent!.endTime).toBe('09:30'); + expect(updatedEvent!.description).toBe('개별 수정된 운동'); + + // 다른 이벤트들은 변경되지 않아야 함 + const otherEvents = updatedEvents.filter((event) => event.id !== 'repeat-2'); + otherEvents.forEach((event) => { + expect(event.repeat.type).toBe('weekly'); + expect(event.title).toBe('매주 운동'); + }); + }); + + test('반복 일정 중 하나를 삭제하면 해당 이벤트만 삭제된다', () => { + // Given: 반복으로 생성된 여러 이벤트가 저장된 상태 + const existingEvents: Event[] = [ + { + id: 'daily-1', + title: '매일 독서', + date: '2024-01-01', + startTime: '20:00', + endTime: '21:00', + description: '일일 독서 시간', + location: '집', + category: '자기계발', + repeat: { type: 'daily', interval: 1 }, + notificationTime: 15, + }, + { + id: 'daily-2', + title: '매일 독서', + date: '2024-01-02', + startTime: '20:00', + endTime: '21:00', + description: '일일 독서 시간', + location: '집', + category: '자기계발', + repeat: { type: 'daily', interval: 1 }, + notificationTime: 15, + }, + { + id: 'daily-3', + title: '매일 독서', + date: '2024-01-03', + startTime: '20:00', + endTime: '21:00', + description: '일일 독서 시간', + location: '집', + category: '자기계발', + repeat: { type: 'daily', interval: 1 }, + notificationTime: 15, + }, + ]; + + // When: deleteSingleRepeatEvent 함수로 특정 이벤트 삭제 + const remainingEvents = deleteSingleRepeatEvent(existingEvents, 'daily-2'); + + // Then: 해당 이벤트만 삭제되고 나머지 반복 이벤트들은 유지됨 + expect(remainingEvents).toHaveLength(2); + expect(remainingEvents.find((event) => event.id === 'daily-2')).toBeUndefined(); + expect(remainingEvents.find((event) => event.id === 'daily-1')).toBeDefined(); + expect(remainingEvents.find((event) => event.id === 'daily-3')).toBeDefined(); + + // 남은 이벤트들은 여전히 반복 설정을 유지해야 함 + remainingEvents.forEach((event) => { + expect(event.repeat.type).toBe('daily'); + }); + }); +}); + +describe('반복 일정 시각적 구분 처리', () => { + test('반복 이벤트는 시각적 구분을 위한 플래그를 가진다', () => { + // Given: 반복 설정으로 생성된 이벤트들 + const events: Event[] = [ + { + id: 'repeat-1', + title: '반복 일정', + date: '2024-01-01', + startTime: '10:00', + endTime: '11:00', + description: '', + location: '', + category: '', + repeat: { type: 'weekly', interval: 1 }, + notificationTime: 0, + }, + { + id: 'single-1', + title: '단일 일정', + date: '2024-01-02', + startTime: '14:00', + endTime: '15:00', + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 1 }, + notificationTime: 0, + }, + ]; + + // When: 이벤트 목록을 확인할 때 + const markedEvents = markRepeatEvents(events); + + // Then: 각 반복 이벤트는 isRepeatEvent 플래그가 true로 설정됨 + const repeatEvent = markedEvents.find((event) => event.id === 'repeat-1'); + const singleEvent = markedEvents.find((event) => event.id === 'single-1'); + + expect(repeatEvent).toHaveProperty('isRepeatEvent', true); + expect(singleEvent).toHaveProperty('isRepeatEvent', false); + }); + + test('단일 수정된 반복 이벤트는 반복 표시가 제거된다', () => { + // Given: 반복으로 생성된 이벤트 중 하나가 수정된 상태 + const events: Event[] = [ + { + id: 'modified-repeat', + title: '수정된 일정', + date: '2024-01-01', + startTime: '10:00', + endTime: '11:00', + description: '단일 수정됨', + location: '', + category: '', + repeat: { type: 'none', interval: 1 }, // 단일 수정으로 인해 'none'으로 변경됨 + notificationTime: 0, + }, + ]; + + // When: 해당 이벤트의 상태를 확인할 때 + const markedEvents = markRepeatEvents(events); + + // Then: isRepeatEvent 플래그가 false로 변경됨 + const modifiedEvent = markedEvents.find((event) => event.id === 'modified-repeat'); + expect(modifiedEvent).toHaveProperty('isRepeatEvent', false); + }); +}); diff --git a/src/hooks/useEventForm.ts b/src/hooks/useEventForm.ts index 9dfcc46a..e888caf1 100644 --- a/src/hooks/useEventForm.ts +++ b/src/hooks/useEventForm.ts @@ -17,6 +17,13 @@ export const useEventForm = (initialEvent?: Event) => { const [repeatType, setRepeatType] = useState(initialEvent?.repeat.type || 'none'); const [repeatInterval, setRepeatInterval] = useState(initialEvent?.repeat.interval || 1); const [repeatEndDate, setRepeatEndDate] = useState(initialEvent?.repeat.endDate || ''); + const [repeatEndCount, setRepeatEndCount] = useState(initialEvent?.repeat.endCount || ''); + const [excludeDates, setExcludeDates] = useState( + initialEvent?.repeat.excludeDates || [] + ); + const [endType, setEndType] = useState<'date' | 'count' | 'never'>( + initialEvent?.repeat.endDate ? 'date' : initialEvent?.repeat.endCount ? 'count' : 'never' + ); const [notificationTime, setNotificationTime] = useState(initialEvent?.notificationTime || 10); const [editingEvent, setEditingEvent] = useState(null); @@ -50,6 +57,9 @@ export const useEventForm = (initialEvent?: Event) => { setRepeatType('none'); setRepeatInterval(1); setRepeatEndDate(''); + setRepeatEndCount(''); + setExcludeDates([]); + setEndType('never'); setNotificationTime(10); }; @@ -66,6 +76,9 @@ export const useEventForm = (initialEvent?: Event) => { setRepeatType(event.repeat.type); setRepeatInterval(event.repeat.interval); setRepeatEndDate(event.repeat.endDate || ''); + setRepeatEndCount(event.repeat.endCount || ''); + setExcludeDates(event.repeat.excludeDates || []); + setEndType(event.repeat.endDate ? 'date' : event.repeat.endCount ? 'count' : 'never'); setNotificationTime(event.notificationTime); }; @@ -92,6 +105,12 @@ export const useEventForm = (initialEvent?: Event) => { setRepeatInterval, repeatEndDate, setRepeatEndDate, + repeatEndCount, + setRepeatEndCount, + endType, + setEndType, + excludeDates, + setExcludeDates, notificationTime, setNotificationTime, startTimeError, diff --git a/src/hooks/useEventOperations.ts b/src/hooks/useEventOperations.ts index 3216cc05..21388b02 100644 --- a/src/hooks/useEventOperations.ts +++ b/src/hooks/useEventOperations.ts @@ -69,6 +69,26 @@ export const useEventOperations = (editing: boolean, onSave?: () => void) => { } }; + const deleteAllRepeatEventsLocal = async (targetTitle: string) => { + try { + const eventsToDelete = events.filter( + (event) => event.title === targetTitle && event.repeat.type !== 'none' + ); + + // Send delete requests to backend for all matching events + const promises = eventsToDelete.map((event) => + fetch(`/api/events/${event.id}`, { method: 'DELETE' }) + ); + + await Promise.all(promises); + await fetchEvents(); + enqueueSnackbar('반복 일정이 전체 삭제되었습니다.', { variant: 'info' }); + } catch (error) { + console.error('Error deleting all repeat events:', error); + enqueueSnackbar('반복 일정 전체 삭제 실패', { variant: 'error' }); + } + }; + async function init() { await fetchEvents(); enqueueSnackbar('일정 로딩 완료!', { variant: 'info' }); @@ -79,5 +99,11 @@ 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, + deleteAllRepeatEventsLocal, + }; }; diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index f9ec573b..aa3ebf3d 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Event } from '../types'; import { createNotificationMessage, getUpcomingEvents } from '../utils/notificationUtils'; @@ -7,7 +7,7 @@ export const useNotifications = (events: Event[]) => { const [notifications, setNotifications] = useState<{ id: string; message: string }[]>([]); const [notifiedEvents, setNotifiedEvents] = useState([]); - const checkUpcomingEvents = () => { + const checkUpcomingEvents = useCallback(() => { const now = new Date(); const upcomingEvents = getUpcomingEvents(events, now, notifiedEvents); @@ -20,7 +20,7 @@ export const useNotifications = (events: Event[]) => { ]); setNotifiedEvents((prev) => [...prev, ...upcomingEvents.map(({ id }) => id)]); - }; + }, [events, notifiedEvents]); const removeNotification = (index: number) => { setNotifications((prev) => prev.filter((_, i) => i !== index)); @@ -29,7 +29,7 @@ export const useNotifications = (events: Event[]) => { useEffect(() => { const interval = setInterval(checkUpcomingEvents, 1000); // 1초마다 체크 return () => clearInterval(interval); - }, [events, notifiedEvents]); + }, [checkUpcomingEvents]); return { notifications, notifiedEvents, setNotifications, removeNotification }; }; diff --git a/src/types.ts b/src/types.ts index a08a8aa7..3cd73f5e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,8 @@ export interface RepeatInfo { type: RepeatType; interval: number; endDate?: string; + endCount?: number; + excludeDates?: string[]; } export interface EventForm { diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts index be78512c..4d02515b 100644 --- a/src/utils/dateUtils.ts +++ b/src/utils/dateUtils.ts @@ -1,4 +1,4 @@ -import { Event } from '../types.ts'; +import { Event, EventForm } from '../types.ts'; /** * 주어진 년도와 월의 일수를 반환합니다. @@ -108,3 +108,169 @@ export function formatDate(currentDate: Date, day?: number) { fillZero(day ?? currentDate.getDate()), ].join('-'); } + +export function generateRepeatDates(event: Event, endDate: Date): string[] { + const dates: string[] = [event.date]; + + // event.repeat.endDate가 있으면 더 이른 날짜를 사용 + const effectiveEndDate = event.repeat.endDate + ? new Date(Math.min(new Date(event.repeat.endDate).getTime(), endDate.getTime())) + : endDate; + + // endCount가 설정된 경우 횟수로 종료 조건을 제한 + const maxCount = event.repeat.endCount || Infinity; + + if (event.repeat.type === 'daily') { + const startDate = new Date(event.date); + let currentDate = new Date(startDate); + + while (currentDate <= effectiveEndDate && dates.length < maxCount) { + currentDate.setDate(currentDate.getDate() + event.repeat.interval); + + if (currentDate <= effectiveEndDate && dates.length < maxCount) { + const dateString = formatDate(currentDate); + dates.push(dateString); + } + } + } else if (event.repeat.type === 'weekly') { + const startDate = new Date(event.date); + let currentDate = new Date(startDate); + + while (currentDate <= effectiveEndDate && dates.length < maxCount) { + currentDate.setDate(currentDate.getDate() + 7 * event.repeat.interval); + + if (currentDate <= effectiveEndDate && dates.length < maxCount) { + const dateString = formatDate(currentDate); + dates.push(dateString); + } + } + } else if (event.repeat.type === 'monthly') { + const startDate = new Date(event.date); + const originalDay = startDate.getDate(); + let currentDate = new Date(startDate); + + while (currentDate <= effectiveEndDate && dates.length < maxCount) { + currentDate.setMonth(currentDate.getMonth() + event.repeat.interval); + currentDate.setDate(1); + + const lastDayOfMonth = getDaysInMonth(currentDate.getFullYear(), currentDate.getMonth() + 1); + + if (originalDay <= lastDayOfMonth) { + currentDate.setDate(originalDay); + + if (currentDate <= effectiveEndDate && dates.length < maxCount) { + const dateString = formatDate(currentDate); + dates.push(dateString); + } + } + } + } else if (event.repeat.type === 'yearly') { + const startDate = new Date(event.date); + const originalMonth = startDate.getMonth(); + const originalDay = startDate.getDate(); + let currentYear = startDate.getFullYear(); + + while (dates.length < maxCount) { + currentYear += event.repeat.interval; + + const candidateDate = new Date(currentYear, originalMonth, 1); + if (candidateDate > effectiveEndDate) break; + + const lastDayOfMonth = getDaysInMonth(currentYear, originalMonth + 1); + + if (originalDay <= lastDayOfMonth) { + candidateDate.setDate(originalDay); + + if (candidateDate <= effectiveEndDate && dates.length < maxCount) { + const dateString = formatDate(candidateDate); + dates.push(dateString); + } + } + } + } + + // 예외 날짜가 있다면 해당 날짜들을 제외 + if (event.repeat.excludeDates && event.repeat.excludeDates.length > 0) { + return dates.filter((date) => !event.repeat.excludeDates?.includes(date)); + } + + return dates; +} + +function generateId(): string { + return Math.random().toString(36).substr(2, 9); +} + +export function createRepeatEvents(eventForm: EventForm): Event[] { + const baseEvent: Event = { + ...eventForm, + id: generateId(), + }; + + if (eventForm.repeat.type === 'none') { + return [baseEvent]; + } + + const endDate = eventForm.repeat.endDate + ? new Date(eventForm.repeat.endDate) + : new Date('2025-06-30'); + const repeatDates = generateRepeatDates(baseEvent, endDate); + + return repeatDates.map((date) => ({ + ...baseEvent, + id: generateId(), + date, + })); +} + +export function updateSingleRepeatEvent( + events: Event[], + eventId: string, + updates: Partial +): Event[] { + return events.map((event) => { + if (event.id === eventId) { + return { + ...event, + ...updates, + repeat: { type: 'none', interval: 1 }, + }; + } + return event; + }); +} + +export function deleteSingleRepeatEvent(events: Event[], eventId: string): Event[] { + return events.filter((event) => event.id !== eventId); +} + +export interface EventWithDisplay extends Event { + isRepeatEvent?: boolean; +} + +export function markRepeatEvents(events: Event[]): EventWithDisplay[] { + return events.map((event) => ({ + ...event, + isRepeatEvent: event.repeat.type !== 'none', + })); +} + +export function updateAllRepeatEvents( + events: Event[], + targetTitle: string, + updates: Partial +): Event[] { + return events.map((event) => { + if (event.title === targetTitle && event.repeat.type !== 'none') { + return { + ...event, + ...updates, + }; + } + return event; + }); +} + +export function deleteAllRepeatEvents(events: Event[], targetTitle: string): Event[] { + return events.filter((event) => !(event.title === targetTitle && event.repeat.type !== 'none')); +}