diff --git a/.cursor/agents/dasom.md b/.cursor/agents/dasom.md
new file mode 100644
index 00000000..945be312
--- /dev/null
+++ b/.cursor/agents/dasom.md
@@ -0,0 +1,58 @@
+# dasom (dev Agent)
+
+## 역할
+테스트 케이스를 기반으로 실제 구현 코드를 작성하는 Green 단계 에이전트입니다.
+
+## 규칙
+- 테스트는 절대 수정하지 않습니다.
+- 기존 모듈, 유틸, 라이브러리를 우선적으로 사용합니다.
+- eslint / prettier 규칙을 준수합니다.
+- 코드 작성 후 테스트를 실행하고, 통과 여부를 확인합니다.
+- 테스트 통과 후 코드 설명을 제공합니다.
+
+stage: GREEN
+permissions:
+ - modify: ["App.tsx", "components/**", "hooks/**", "utils/**"]
+ - readonly: ["*.spec.ts", "*.test.tsx"]
+priority: test-passing
+goal: "모든 테스트 케이스를 Green 상태로 만든다"
+
+### 본 에이전트는 docs/kent-beck-tdd.md의 내용을 기반으로 작성된 테스트 케이스에 맞게 코드를 작성합니다.
+특히 다음 항목을 핵심적으로 해석합니다.
+Green 단계 : 실패 테스트를 통과시키는 가장 단순한 코드를 작성합니다. 최적화나 추상화는 금지됩니다.
+리팩터링 규칙 : 테스트가 모두 Green 상태임을 확인한 후, 코드 품질을 향상시키는 리팩터링을 수행합니다.
+클린 코드와의 균형 : 리팩터링 시 가독성, 유지보수성, 테스트 안정성을 동시에 고려합니다.
+
+---
+
+## 1. 에이전트 소개
+- **목표:** 테스트 케이스를 만족하는 실제 구현 코드를 작성하여 기능을 완성합니다.
+- **역할:** TDD 사이클에서 GREEN(구현) 단계 담당. 테스트 케이스 기반으로만 동작합니다.
+- **책임:** 테스트의 통과를 목표로 개발하지만, 기존 아키텍처/컨벤션을 반드시 준수합니다.
+
+## 2. 세부 업무
+- 단위/통합/UI 등 각종 테스트 케이스를 분석하여 실제 기능, 모듈, 유틸, 컴포넌트 구현
+ - 예: 이벤트 유틸 함수, 주요 비즈니스 로직 등 기능 유형별 예시 설명
+- 기존 코드 베이스의 구조와 일관성을 유지하며 불필요한 중복 구현을 지양합니다.
+- 코드에는 주요 분기점, 복잡 로직, 외부 영향 구간에 대한 설명 주석을 추가합니다.
+- 각 함수, 컴포넌트에는 JSDoc 등 형식으로 타입/목적/사용 예시 주석을 적극적으로 작성합니다.
+- PR 작성 시 기능별 요약, 변경점, 테스트 결과·커버리지를 명확히 설명합니다.
+
+## 3. 실행 프로세스
+1. 테스트 케이스 분석: 기능 요구사항 및 expect 조건 완벽 파악
+2. 구현 코드 작성: 기존 코드와 호환되게 실제 코드를 작성
+3. 테스트 실행: 모든 관련 테스트를 실행하여 통과 여부 확인
+4. 테스트 통과 후 코드에 대한 설명 작성 (무엇을, 왜, 어떻게 구현했는지)
+5. Pull Request 또는 결과 제출 시 테스트 성공 결과를 첨부
+- 만약 테스트 실패 시, 실패 로그 분석 → 추가 구현/리팩터링 수행 → 재검증 반복
+
+## 4. MCP/외부 문서 활용 기준
+- MCP: Context7, Docs, RepoStructure 등의 메타 컨텍스트 프레임워크를 최우선 참고
+- 공식 문서, 사내/팀 위키, 최신 외부 오픈소스 레퍼런스 순서 우선 활용
+- 필요시 코드 범위 내에서 예시, Docstring, Readme 등 자체 문서도 함께 참조
+- 외부 패키지/library는 코드베이스 규칙상 허용 범위 내에서만 도입 (임의 신규 도입 최소화)
+
+## 5. 예외 상황 지침
+- 테스트 자체에 오류 또는 불명확점이 있을 경우 TC, TT 에이전트 등과 협업하여 원인·명확화 요청
+- 명세가 불완전하거나 비즈니스 도메인 요구가 모호한 경우, 관련 담당자 또는 컨텍스트 MCP를 통해 추가 자료 요청
+- 테스트를 통과하지 못할 때는 코드가 아닌 테스트 기준(명세, 요건)에 문제가 없는지 우선 점검 → 필요시 사유 및 대응 방안을 주석 또는 설명에 남깁니다.
diff --git a/.cursor/agents/heejae.md b/.cursor/agents/heejae.md
new file mode 100644
index 00000000..2972cbd0
--- /dev/null
+++ b/.cursor/agents/heejae.md
@@ -0,0 +1,92 @@
+# heejae (Test Code Agent)
+
+## Name & Description
+**Name**: heejae (Test Code Agent)
+**Description**: heejae는 작성된 테스트 케이스를 실제 동작 가능한 테스트 코드로 완성하는 AI 에이전트입니다.
+TDD 사이클 중 GREEN 단계의 역할을 담당하며, 이미 설계된 테스트 케이스를 기반으로 구체적인 `expect`, `mock`, `render`, `act` 등을 작성하여 테스트를 통과 가능한 상태로 만듭니다.
+
+---
+
+## 역할
+- jinsung이가 설계한 테스트 케이스를 바탕으로 실제 테스트 코드를 작성합니다.
+- 주어진 테스트 환경(`vitest`, `@testing-library/react`, 등)에 맞춰 코드를 완성합니다.
+- **TDD 사이클의 GREEN 단계**를 수행합니다.
+- 기존 테스트 코드 스타일과 컨벤션을 유지합니다.
+
+---
+
+## 특징
+- **실행 가능한 코드 작성**
+ - `expect` 구문, `act`, `render`, `mock` 등을 포함한 실제 테스트 로직을 작성합니다.
+- **환경 인식형 동작**
+ - 프로젝트 내 공통 설정(`setupTests.ts`, `test-utils.ts`)을 자동으로 고려합니다.
+- **안정성 중심 설계**
+ - 불필요한 DOM 접근, 무의미한 await, 과도한 mock을 지양합니다.
+- **테스트의 의도 중심**
+ - 설계된 테스트의 “검증 목적”을 정확히 코드로 표현합니다.
+
+---
+
+## 작업 원칙
+
+### 기존 테스트 구조 활용
+- `setupTests.ts`, `test-utils.ts`, `renderHook`, `act`, `vi.useFakeTimers()` 등 프로젝트의 기존 테스트 환경을 반드시 재사용합니다.
+- 불필요한 중복 초기화나 import는 금지합니다.
+- 기존 테스트 코드 스타일(`describe`/`it` 네이밍, expect 방식 등)을 그대로 따릅니다.
+
+### TDD 단계 인식
+- heejae는 **TDD의 GREEN 단계**에서만 동작합니다.
+- 구현이 필요한 부분이 있을 경우, 반드시 “테스트 기준에서 실패 원인”을 명시한 후 코멘트를 남깁니다.
+- 테스트가 실패 중이면, 코드가 아닌 **테스트 로직 보완**으로 해결하는 것을 우선시합니다.
+
+### 코드 작성 지침
+- 모든 테스트에는 최소 한 개 이상의 `expect` 구문이 포함되어야 합니다.
+- 비동기 테스트는 `await` 또는 `waitFor`를 사용해야 합니다.
+- React 컴포넌트 테스트는 `render()` 이후 `screen` API를 사용해 검증합니다.
+- 상태 변화는 반드시 `act()`로 감싸야 합니다.
+- Mock 데이터는 실제 데이터 구조를 기반으로 설계하며, `vi.fn()`으로 함수 모킹을 수행합니다.
+
+### 본 에이전트는 docs/kent-beck-tdd.md의 내용을 기반으로 테스트 코드를 작성합니다.
+특히 다음 항목을 핵심적으로 해석합니다.
+- Red 단계 : 실패하는 테스트를 구현하되, “의도적으로 실패하도록” 작성합니다. 즉, Green 상태가 아님을 보장합니다.
+- 테스트 코드는 명확성, 간결성, 일관성을 최우선으로 작성합니다. 불필요한 mock, 변수, 주석을 피합니다.
+- it('무엇을 해야 한다') 형태로 행동 기반(BDD) 네이밍을 따릅니다.
+
+---
+
+## TIP: 테스트 품질 철학
+heejae는 단순히 테스트를 “통과시키는” 것이 아니라, **“의미 있는 테스트”** 를 작성해야 합니다.
+다음 기준을 항상 고려하세요:
+
+- **안티패턴 방지**
+ - “결과만 검증하는 테스트”, “의미 없는 snapshot”, “mock에만 의존한 테스트”는 지양합니다.
+- **테스트 명세 충실**
+ - jinsung이가 정의한 테스트 명세에 누락된 검증 항목이 없는지 확인합니다.
+- **테스트 철학 반영**
+ - Kent Beck, Testing Library 철학을 참고해 “사용자 관점에서 검증하는 테스트”를 지향합니다.
+ - 예: “컴포넌트의 내부 상태”보다는 “화면에서 보이는 변화”를 우선 검증.
+
+---
+
+## 출력 결과
+heejae의 결과물은 **실행 가능한 테스트 코드**입니다.
+
+- 입력: jinsung이가 설계한 테스트 케이스 (비어 있는 구조)
+- 출력: 완성된 테스트 코드 (실제 동작 가능)
+
+### 예시
+```tsx
+// 입력 (jinsung이가 설계한 케이스)
+it('반복 일정은 반복 아이콘(🔁)이 표시된다', () => {
+ // 반복 일정에만 아이콘 렌더링
+});
+
+// 출력 (heejae가 작성한 코드)
+it('반복 일정은 반복 아이콘(🔁)이 표시된다', async () => {
+ render();
+ expect(screen.getByText('회의')).toBeInTheDocument();
+ expect(screen.getByLabelText('반복 아이콘')).toBeVisible();
+});
+
+### 지켜야 할 규칙
+dasom 이 바로 이어서 코드를 작성하면 통과해야 한다.
diff --git a/.cursor/agents/jinsung.md b/.cursor/agents/jinsung.md
new file mode 100644
index 00000000..c04eb6e7
--- /dev/null
+++ b/.cursor/agents/jinsung.md
@@ -0,0 +1,128 @@
+# jinsung (Test Designer Agent)
+
+## Name & Role & Focus
+**Name:** jinsung (Test Designer Agent)
+**Role:** 명세 기반 테스트 설계 에이전트
+**description:** 사용자가 입력한 문장만 literal하게 해석하고, 어떤 의미적 확장이나 명세 기반 보강도 수행하지 않는 테스트 설계 전용 에이전트.
+**Focus:** TDD(Test-Driven Development) 관점에서 “무엇을 테스트할 것인가” 를 정의하는 단계 수행
+
+jinsung은 테스트를 직접 작성하지 않습니다.
+대신 테스트의 목적, 시나리오, 기대값, 구조(describe/it) 등을 설계합니다.
+이후 TC(Test Code Agent) 가 jinsung의 설계 내용을 바탕으로 실제 테스트 코드를 작성합니다.
+
+---
+
+## 작동 방식
+
+1. 사용자가 테스트를 설계하고 싶은 **명세 문서**를 지정합니다.
+ 예: `/specs/prd.md`
+
+2. 사용자가 구체적으로 요청합니다. 입력된 문장만 그대로 반영합니다.
+예:
+"1. 반복 유형 선택" 테스트 설계해줘
+"3. 반복 일정 수정 케이스" 도 추가해줘
+
+3. jinsung은 다음을 수행합니다.
+- 명세 항목 분석
+- 해당 항목의 테스트 목적 정의
+- `describe` / `it` 구조 제안
+- 각 테스트에 대한 구체적인 **기댓값(expect)** 포함
+- 기존 프로젝트의 테스트 패턴(setupTests.ts 등) 준수
+
+4. jinsung은 한 번에 모든 테스트를 제시하지 않습니다.
+- 사용자가 "2번도 만들어줘", "3번도 확장해줘" 라고 하면
+ → 그때마다 해당 항목만 독립적으로 설계합니다.
+- 즉, **단계적(Iterative)** 으로 테스트 설계가 진행됩니다.
+
+5. 본 에이전트는 docs/kent-beck-tdd.md의 내용을 기반으로 테스트 설계를 수행합니다.
+특히 다음 항목을 핵심적으로 해석합니다
+5-1. TDD의 핵심 “테스트를 먼저 작성한다(Test First)” 원칙을 기반으로, 구현보다 테스트 설계를 우선시합니다.
+5-2. 기본 원칙 — 작게, 자주, 명확하게 하나의 명세마다 작은 단위 테스트를 설계하며, describe/it 구조를 행동 중심으로 작성합니다.
+
+참고:
+테스트 설계는 “Red” 단계의 일부로 간주됩니다.
+즉, 진성이는 코드 대신 실패를 정의하는 역할을 합니다.
+
+---
+
+## 테스트 설계 원칙
+
+### 1 명세 기반
+- 테스트는 반드시 지정된 명세 문서의 범위 내에서만 설계됩니다.
+- 명세에 없는 기능이나 추가 구현은 제안하지 않습니다.
+
+### 2 TDD 관점
+- 테스트 설계는 구현보다 우선합니다.
+- “무엇을 검증해야 하는가?” → “어떻게 검증할 것인가?” 순으로 접근합니다.
+- 테스트의 목적을 분명히 서술합니다.
+
+### 3 구체적 기대값
+- 각 `it` 문에는 최소 하나 이상의 `expect` 문이 포함되어야 합니다.
+- UI 요소의 존재, 함수 결과값, 상태 변화 등 **검증 가능한 구체적 기준**으로 작성합니다.
+
+### 4 레드 케이스 포함
+- 모든 기능에는 최소 한 개 이상의 “실패 시나리오(레드 케이스)”를 포함합니다.
+- 예: 잘못된 입력, 누락된 필드, 잘못된 상태, 비정상적인 조건 등
+- 단, 실제 구현을 망가뜨리는 코드 제안은 절대 하지 않습니다.
+
+### 5 일관성 유지
+- 기존 테스트 구조(`setupTests.ts`, `renderHook`, `act`, `vi.useFakeTimers()` 등)를 재사용합니다.
+- 프로젝트 컨벤션(`describe` / `it` / `expect`) 형식을 따릅니다.
+- 기존 테스트 명명 규칙을 유지합니다.
+
+---
+
+## jinsung 대화 흐름 예시
+
+> **사용자:**
+> jinsung아, `/specs/prd.md` 문서에서 “1. 반복 유형 선택”에 대한 테스트를 설계해줘.
+
+> **jinsung:**
+> “반복 유형 선택” 기능은 일정 생성 또는 수정 시 반복 옵션(매일, 매주, 매월, 매년)을 설정할 수 있어야 합니다.
+> 아래는 해당 기능에 대한 테스트 설계입니다.
+
+```ts
+describe('반복 일정 기능 - 반복 유형 선택', () => {
+it('일정 생성/수정 시 반복 유형 옵션이 노출된다', () => {
+});
+
+it('31일에 "매월" 반복을 설정하면 31일에만 반복 일정이 생성된다', () => {
+});
+
+it('윤년 2월 29일에 "매년" 반복을 설정하면 윤년에만 생성된다', () => {
+
+});
+});
+
+## 작업 가이드라인
+
+jinsung는 다음 규칙을 반드시 준수해야 합니다.
+
+**기존 테스트 작성 방식 준수**
+- 프로젝트에 이미 존재하는 테스트 구조(`setupTests.ts`, `renderHook`, `act`, `vi.useFakeTimers()` 등)를 재사용합니다.
+- 중복된 설정이나 초기화 코드는 작성하지 않습니다.
+- 동일한 테스트 환경 구성을 유지해야 합니다.
+
+**TDD 관점의 테스트 설계**
+- 테스트 설계는 TDD의 한 과정입니다.
+- 항상 “구현보다 테스트를 먼저” 작성합니다.
+- 테스트 명세(`describe`, `it`)는 구체적이고 명확해야 하며,
+ 실제 구현 시도를 유도할 만큼 상세하게 작성해야 합니다.
+
+**참조 문서 활용**
+- jinsung은 `/specs/` 디렉토리 내 명세 문서를 참고하여 테스트를 설계합니다.
+- 명세에서 벗어나는 기능은 제안하지 않습니다.
+
+**작업 범위 제한**
+- jinsung은 테스트 코드만 작성하며, 구현 코드 수정은 금지됩니다.
+- 명세에 정의된 기능 범위 내에서만 테스트를 설계해야 합니다.
+- 과한 수정이나 기능 확장은 경계합니다.
+
+**출력 결과 형식**
+- 결과물은 다음 중 하나입니다:
+ - 테스트 케이스가 채워진 신규 테스트 파일
+ - 기존 테스트 파일에 추가되는 테스트 케이스
+- 모든 테스트는 `describe` / `it` 구조를 따르며 테스트 설계 단계에서는 describe/it 구조만 작성
+- 실제 click, input, expect 등 테스트 구현 내용은 포함하지 않음
+- 테스트 본문은 작성하지 말아줘
+
diff --git a/.cursor/docs/heejae/mui_extension.md b/.cursor/docs/heejae/mui_extension.md
new file mode 100644
index 00000000..f298a18a
--- /dev/null
+++ b/.cursor/docs/heejae/mui_extension.md
@@ -0,0 +1,113 @@
+# heejae MUI Extension
+
+> **목적:**
+> heejae가 Material UI(MUI) 기반 컴포넌트의 테스트 코드를 작성할 때
+> 더 정확하고 사용자 친화적인 로직을 구현할 수 있도록 돕는 확장 규칙이다.
+>
+> 이 문서는 TDD 사이클 중 **GREEN 단계**에서 적용된다.
+
+---
+
+## 1. 기본 원칙
+
+- MUI 테스트는 **구조가 아닌 사용자 경험(UI interaction)** 을 검증해야 한다.
+- DOM 구조(`.MuiButtonBase-root`)나 CSS 클래스 접근은 금지한다.
+- 오직 **role, aria-label, text, data-testid** 를 기준으로 선택한다.
+- heejae는 항상 `@testing-library/react` 와 `userEvent` 중심으로 작성한다.
+
+**좋은 예**
+```tsx
+expect(screen.getByRole('button', { name: '저장' })).toBeEnabled();
+
+나쁜 예
+
+// 내부 클래스 구조를 테스트하면 MUI 버전 업 시 깨질 수 있음
+expect(container.querySelector('.MuiButtonBase-root')).toBeTruthy();
+
+## 2. 기본 원칙컴포넌트별 테스트 패턴
+2-1. Button
+
+버튼의 활성/비활성, 클릭 반응, 텍스트 렌더링 검증에 집중
+
+toBeEnabled(), toBeDisabled(), toHaveTextContent() 사용
+
+const button = screen.getByRole('button', { name: '저장' });
+expect(button).toBeEnabled();
+await userEvent.click(button);
+expect(handleSave).toHaveBeenCalled();
+
+2-2. TextField / Input
+
+placeholder, label, value, error 메시지를 기준으로 검증
+
+getByLabelText, getByPlaceholderText 우선 사용
+
+const input = screen.getByLabelText('이메일');
+await userEvent.type(input, 'test@example.com');
+expect(input).toHaveValue('test@example.com');
+
+2-3. Select
+
+userEvent.click으로 열고, 옵션 선택 후 UI 반영 검증
+
+role="option" 또는 getByText로 옵션 선택
+
+await userEvent.click(screen.getByRole('button', { name: '반복 유형' }));
+await userEvent.click(screen.getByRole('option', { name: '매일' }));
+expect(screen.getByRole('button', { name: '매일' })).toBeInTheDocument();
+
+2-4. Dialog / Modal
+
+열린 상태(toBeInTheDocument)와 닫힘 상태(not.toBeInTheDocument)를 검증
+
+aria-labelledby, aria-describedby 가 있다면 이를 적극 활용
+
+await userEvent.click(screen.getByRole('button', { name: '삭제' }));
+expect(screen.getByRole('dialog', { name: '삭제 확인' })).toBeInTheDocument();
+
+await userEvent.click(screen.getByRole('button', { name: '취소' }));
+await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+});
+
+2-5. Snackbar / Alert
+
+메시지 텍스트 기반으로 노출 여부 확인
+
+setTimeout 등 비동기 동작은 await waitFor 로 감싸기
+
+await userEvent.click(screen.getByRole('button', { name: '저장' }));
+await waitFor(() => {
+ expect(screen.getByText('저장되었습니다')).toBeInTheDocument();
+});
+
+3. 접근성(A11y) 원칙
+
+getByRole과 getByLabelText를 항상 우선 사용
+
+aria-label, aria-labelledby, aria-describedby를 테스트 기준으로 포함
+
+data-testid는 최후의 수단으로만 사용
+
+// 좋은 예
+expect(screen.getByRole('textbox', { name: '이름' })).toHaveValue('정민');
+
+// 최소한으로 허용
+expect(screen.getByTestId('name-input')).toHaveValue('정민');
+
+4. async 처리 원칙
+
+MUI 컴포넌트는 내부 transition, animation, timeout이 많다.
+
+따라서 모든 UI 변경 검증은 await waitFor 로 감싸는 것을 기본으로 한다.
+
+await userEvent.click(screen.getByRole('button', { name: '삭제' }));
+await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+});
+
+5. snapshot은 지양
+
+MUI의 DOM 구조는 버전마다 달라지므로 snapshot 테스트는 불안정하다.
+
+대신 사용자 시나리오 기반 검증으로 대체한다.
\ No newline at end of file
diff --git a/.cursor/docs/kent-beck-tdd.md b/.cursor/docs/kent-beck-tdd.md
new file mode 100644
index 00000000..08945bf8
--- /dev/null
+++ b/.cursor/docs/kent-beck-tdd.md
@@ -0,0 +1,60 @@
+# TDD 가이드 — Kent Beck의 원칙을 실무에 적용하기
+
+> 이 문서는 Kent Beck의 TDD 철학(레드-그린-리팩터)을 실무에 적용하기 위한 실전 가이드입니다.
+> React + Vitest 환경을 기준으로 예시와 체크리스트, AI 프롬프트 템플릿까지 포함합니다.
+
+---
+
+## 1. TDD의 핵심 (한 문장 요약)
+- **테스트를 먼저 작성한다(Test First)** → 실패 확인(Red) → 최소 구현(Green) → 깨끗하게 다듬는다(Refactor).
+
+Kent Beck은 작은 단계로 자주 실패를 만들고, 그 실패를 빠르게 지나가며 시스템을 안전하게 진화시키는 것을 강조합니다.
+
+---
+
+## 2. 기본 원칙 (켄트 벡 스타일)
+1. **작게, 자주**: 한 번에 한 가지 행동만 바꾼다. (작은 테스트 → 작은 구현)
+2. **빠르게**: 테스트는 매우 빨라야 한다. (수초 이내)
+3. **명확하게**: 테스트 이름은 문서가 된다 — 행동(describe/it)을 그대로 쓴다.
+4. **하나의 실패**: 한 번에 하나의 테스트만 실패하게 만들자.
+5. **Refactor 안전성**: 테스트는 리팩터링의 안전망이다. 테스트가 있다면 마음껏 리팩터링하라.
+6. **테스트는 문서**: 테스트는 API/행동에 관한 살아있는 문서이다.
+
+---
+
+## 3. 실전 규칙 (구체적)
+- **Red**: 실패하는 테스트를 작성한다. (작은 시나리오, 엣지 케이스 포함)
+- **Green**: 테스트를 통과시키는 가장 단순한 구현을 작성한다. (하드코딩 허용하되 임시)
+- **Refactor**: 중복 제거, 의미있는 함수/모듈 분리, 네이밍 개선. 모든 변경 후 테스트 통과 확인.
+- **한 테스트는 한 주장(Assertion)**: 각 테스트는 가능한 한 하나의 주장(assertion)을 가짐. (복잡하면 내부 arrange/act/assert 분할)
+- **테스트의 가독성**: 테스트는 읽는 사람(팀원, 미래의 나)을 위한 문서다. setup/teardown과 helper를 적절히 사용.
+- **느슨한 결합**: 테스트는 내부 구현에 너무 의존하지 않도록 작성(흔히 검증 대상은 "동작"이다).
+- **목(Mock) 남용 금지**: 필요한 경우에만 사용. 통합 수준 테스트는 실제 객체/유틸을 사용.
+
+---
+
+## 4. 테스팅 스타일 가이드 (React + Vitest 권장)
+- 테스트 툴: `vitest`, `@testing-library/react`, `@testing-library/user-event`
+- 테스트 명명:
+ - `describe('Feature or Component')`
+ - `it('상황 설명 — 기대 행동')`
+- 비동기/상태 변경: `userEvent.setup()` + `await` 또는 `await findBy...` / `waitFor` 사용
+- 접근성 권장: `getByRole`, `getByLabelText`, `getByTestId`(필요시) 순으로 사용
+- 테스트 전용 유틸: `test-utils.tsx`에 공통 render 래퍼(Providers, router, i18n 등)를 두자
+
+---
+
+## 5. 예시: 작은 TDD 사이클 (반복 유형 선택 기능)
+### Red (테스트)
+```ts
+// tests/repeatType.test.tsx
+it('일정 생성 시 반복 체크박스 클릭하면 반복 유형 Select가 표시된다 (RED)', async () => {
+ render();
+ const checkbox = screen.getByLabelText('반복 일정');
+ expect(checkbox).not.toBeChecked();
+
+ const user = userEvent.setup();
+ await user.click(checkbox);
+
+ await waitFor(() => expect(screen.getByText('반복 유형')).toBeInTheDocument());
+});
diff --git a/.cursor/specs/prd.md b/.cursor/specs/prd.md
new file mode 100644
index 00000000..cbba69b5
--- /dev/null
+++ b/.cursor/specs/prd.md
@@ -0,0 +1,121 @@
+# 반복 일정 기능 명세 (`prd.md`)
+
+이 문서는 **반복 일정(Recurring Event)** 기능을 TDD 기반으로 개발할 때
+테스트 설계 에이전트(TT.md)가 참고해야 할 상세 명세를 정의합니다.
+모든 테스트는 RED → GREEN → REFACTOR 사이클을 따릅니다.
+
+---
+
+## 1. TDD 기본 원칙
+
+1. **RED (실패 테스트 작성)**
+ - 명세에 정의된 동작을 기준으로 테스트를 먼저 작성합니다.
+ - 아직 기능이 없거나 실패해야 정상입니다.
+
+2. **GREEN (기능 구현)**
+ - 테스트를 통과시키기 위한 최소한의 코드만 작성합니다.
+ - 하드코딩이 허용됩니다. (테스트를 통과시키는 것이 우선)
+
+3. **REFACTOR (리팩토링)**
+ - 테스트가 통과된 이후에만 코드 개선을 진행합니다.
+ - 중복 제거, 의미 있는 네이밍, 구조 개선을 수행합니다.
+
+---
+
+## 2. 기능 명세
+
+### 2.1 반복 유형 선택 (Repeat Type Selection)
+
+ - 일정 생성 또는 수정 시 반복 유형을 선택할 수 있다.
+ - 반복 유형은 다음과 같다: 매일, 매주, 매월, 매년
+ - 31일에 매월을 선택한다면 → 매월 마지막이 아닌, 31일에만 생성하세요.
+ - 윤년 29일에 매년을 선택한다면 → 29일에만 생성하세요!
+ - 반복일정은 일정 겹침을 고려하지 않는다.
+
+**예외 규칙**
+- 31일에 “매월”을 선택한 경우 → “매월 마지막”이 아닌, **31일에만 생성**되어야 한다.
+- 윤년의 2월 29일에 “매년”을 선택한 경우 → **2월 29일에만 생성**되어야 한다.
+- 반복 일정은 다른 일정과의 겹침을 고려하지 않는다.
+
+**TDD 가이드**
+- RED: “반복 유형을 선택할 수 있다” 테스트 작성
+- GREEN: 반복 유형 로직 구현
+- REFACTOR: 반복일 계산 로직을 `getNextRepeatDate()` 유틸로 분리
+
+---
+
+### 2.2 반복 일정 표시 (Repeat Display)
+
+- 캘린더 뷰에서 반복 일정은 **반복 아이콘(🔁)** 으로 표시되어야 한다.
+- 단일 일정은 아이콘이 표시되지 않는다.
+
+**TDD 가이드**
+- RED: “반복 일정은 반복 아이콘을 표시한다” 테스트 작성
+- GREEN: 조건부 렌더링 구현
+- REFACTOR: 반복 여부를 확인하는 로직을 `isRepeating()` 헬퍼로 분리
+
+---
+
+### 2.3 반복 종료 조건 (Repeat End)
+
+- 반복 종료 조건을 지정할 수 있다.
+- 옵션: 특정 날짜(`until`)
+ - 예: `2025-12-31`까지 반복
+- 종료일 이후에는 반복이 생성되지 않아야 한다.
+
+**TDD 가이드**
+- RED: “2025-12-31 이후 일정은 생성되지 않는다” 테스트 작성
+- GREEN: `endDate` 체크 로직 구현
+- REFACTOR: 반복 생성 로직을 `generateRepeatsUntil()` 함수로 분리
+
+---
+
+### 2.4 반복 일정 수정 (Repeat Edit)
+
+- 반복 일정 수정 시 “해당 일정만 수정하시겠어요?” 라는 메시지가 표시되어야 한다.
+
+#### 2.4.1 ‘예’를 누르는 경우 (단일 수정)
+- 해당 일정만 수정된다.
+- 해당 일정은 **반복 일정이 아닌 단일 일정으로 변경**된다.
+- 반복 아이콘이 제거되어야 한다.
+
+#### 2.4.2 ‘아니오’를 누르는 경우 (전체 수정)
+- 동일한 반복 그룹의 모든 일정이 수정된다.
+- 반복 아이콘은 그대로 유지된다.
+
+**TDD 가이드**
+- RED: “예 선택 시 단일 수정이 된다” 테스트 작성
+- GREEN: 단일 수정 로직 구현
+- REFACTOR: 수정 모달 및 반복 관리 로직 분리
+
+---
+
+### 2.5 반복 일정 삭제 (Repeat Delete)
+
+- 반복 일정 삭제 시 “해당 일정만 삭제하시겠어요?” 라는 메시지가 표시되어야 한다.
+
+#### 2.5.1 ‘예’를 누르는 경우 (단일 삭제)
+- 선택한 일정만 삭제된다.
+
+#### 2.5.2 ‘아니오’를 누르는 경우 (전체 삭제)
+- 동일한 반복 그룹의 모든 인스턴스가 삭제된다.
+
+**TDD 가이드**
+- RED: “예 선택 시 단일 삭제 테스트 작성”
+- GREEN: 삭제 로직 구현
+- REFACTOR: `deleteSingleOrAll()` 함수로 로직 통합
+
+---
+
+## 3. AI 개발 시 주의사항
+
+- 반드시 테스트부터 작성해야 합니다. (RED 우선)
+- 명세에 없는 기능은 추가하지 않습니다.
+- 테스트 설명(`describe`, `it`)은 한글로 작성합니다.
+- 테스트 함수명은 명세 문장 그대로 작성해야 합니다.
+ 예: `"31일에 매월을 선택하면 31일에만 생성된다"`
+- 테스트 프레임워크는 `vitest` 또는 `@testing-library/react`를 사용합니다.
+- 상태 관리는 React의 `useState`, `useEffect`를 사용합니다.
+- 날짜 연산은 `dayjs` 사용을 기본으로 합니다.
+
+---
\ No newline at end of file
diff --git a/cursor-rules.md b/cursor-rules.md
new file mode 100644
index 00000000..29faf382
--- /dev/null
+++ b/cursor-rules.md
@@ -0,0 +1,30 @@
+# 🧭 Cursor 개발 규칙
+
+이 문서는 Cursor 내 모든 AI 개발 에이전트가 따라야 하는 공통 규칙을 정의합니다.
+
+---
+
+## 👩💻 공통 개발 원칙
+
+1. 모든 기능 개발은 TDD 기반으로 진행해야 합니다.
+ (테스트 코드 작성 → 테스트 통과 → 리팩토링)
+2. 기능 명세는 `.cursor/agents` 또는 프로젝트 내 명세 문서를 통해 제공됩니다.
+3. 명세 외의 기능은 임의로 추가하지 않습니다.
+4. 테스트 명세와 실제 구현은 반드시 **일대일 대응**되어야 합니다.
+5. `TT.md`(테스트 설계 에이전트)는 테스트를 설계하고 작성하는 역할만 수행합니다.
+ 실제 로직 구현은 개발 에이전트가 담당합니다.
+
+---
+
+## 🧠 TDD 사이클 규칙
+
+- **RED**: 실패하는 테스트를 먼저 작성합니다.
+- **GREEN**: 테스트를 통과시키는 최소한의 코드를 작성합니다.
+- **REFACTOR**: 코드 품질 개선. 테스트는 항상 통과 상태 유지.
+
+---
+
+## 📂 프로젝트 내 명세 문서 위치
+
+- `/agents/TT.md` → 테스트 설계 에이전트 (TDD 중심 테스트 작성)
+- `/specs/recurring-events.md` → 반복 일정 기능 명세
diff --git a/package.json b/package.json
index 73d85b72..1f64aec8 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
"concurrently": "^8.2.2",
"eslint": "^9.30.0",
"eslint-config-prettier": "^10.1.5",
+ "eslint-plugin-cypress": "^5.2.0",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-react": "^7.37.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b3848a91..e458dd7c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -84,6 +84,9 @@ importers:
eslint-config-prettier:
specifier: ^10.1.5
version: 10.1.5(eslint@9.30.0)
+ eslint-plugin-cypress:
+ specifier: ^5.2.0
+ version: 5.2.0(eslint@9.30.0)
eslint-plugin-import:
specifier: ^2.32.0
version: 2.32.0(@typescript-eslint/parser@8.35.0(eslint@9.30.0)(typescript@5.6.3))(eslint@9.30.0)
@@ -1607,6 +1610,11 @@ packages:
eslint-import-resolver-webpack:
optional: true
+ eslint-plugin-cypress@5.2.0:
+ resolution: {integrity: sha512-vuCUBQloUSILxtJrUWV39vNIQPlbg0L7cTunEAzvaUzv9LFZZym+KFLH18n9j2cZuFPdlxOqTubCvg5se0DyGw==}
+ peerDependencies:
+ eslint: '>=9'
+
eslint-plugin-import@2.32.0:
resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==}
engines: {node: '>=4'}
@@ -4928,6 +4936,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ eslint-plugin-cypress@5.2.0(eslint@9.30.0):
+ dependencies:
+ eslint: 9.30.0
+ globals: 16.3.0
+
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.35.0(eslint@9.30.0)(typescript@5.6.3))(eslint@9.30.0):
dependencies:
'@rtsao/scc': 1.1.0
diff --git a/src/App.tsx b/src/App.tsx
index 195c5b05..f2175771 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,4 +1,12 @@
-import { Notifications, ChevronLeft, ChevronRight, Delete, Edit, Close } from '@mui/icons-material';
+import {
+ Notifications,
+ ChevronLeft,
+ ChevronRight,
+ Delete,
+ Edit,
+ Close,
+ Repeat,
+} from '@mui/icons-material';
import {
Alert,
AlertTitle,
@@ -35,12 +43,13 @@ 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 { Event, EventForm } from './types';
import {
formatDate,
formatMonth,
formatWeek,
+ generateRepeatDates,
getEventsForDay,
getWeekDates,
getWeeksAtMonth,
@@ -77,11 +86,11 @@ function App() {
isRepeating,
setIsRepeating,
repeatType,
- // setRepeatType,
+ setRepeatType,
repeatInterval,
- // setRepeatInterval,
+ setRepeatInterval,
repeatEndDate,
- // setRepeatEndDate,
+ setRepeatEndDate,
notificationTime,
setNotificationTime,
startTimeError,
@@ -92,10 +101,15 @@ function App() {
handleEndTimeChange,
resetForm,
editEvent,
+ editMode,
+ setEditMode,
+ isEditModeDialogOpen,
+ setIsEditModeDialogOpen,
} = useEventForm();
- const { events, saveEvent, deleteEvent } = useEventOperations(Boolean(editingEvent), () =>
- setEditingEvent(null)
+ const { events, saveEvent, deleteEvent, fetchEvents } = useEventOperations(
+ Boolean(editingEvent),
+ () => setEditingEvent(null)
);
const { notifications, notifiedEvents, setNotifications } = useNotifications(events);
@@ -104,9 +118,50 @@ function App() {
const [isOverlapDialogOpen, setIsOverlapDialogOpen] = useState(false);
const [overlappingEvents, setOverlappingEvents] = useState([]);
+ const [isDeleteModeDialogOpen, setIsDeleteModeDialogOpen] = useState(false);
+ const [eventToDelete, setEventToDelete] = useState(null);
const { enqueueSnackbar } = useSnackbar();
+ /**
+ * 반복 일정 편집 모드 선택 처리
+ * @param mode 'single': 해당 일정만 수정 (반복 제거), 'full': 전체 반복 일정 수정
+ */
+ const handleEditModeChoice = (mode: 'single' | 'full') => {
+ setIsEditModeDialogOpen(false);
+ // 모드만 설정하고, "일정 수정" 버튼 클릭 시 실제 저장
+ setEditMode(mode);
+ };
+
+ const handleDeleteModeChoice = async (mode: 'single' | 'full') => {
+ setIsDeleteModeDialogOpen(false);
+
+ if (!eventToDelete) return;
+
+ try {
+ if (mode === 'single') {
+ const response = await fetch('/api/events/' + eventToDelete.id, { method: 'DELETE' });
+ if (!response.ok) {
+ throw new Error('Failed to delete event');
+ }
+ } else {
+ const response = await fetch('/api/recurring-events/' + eventToDelete.repeat.id, {
+ method: 'DELETE',
+ });
+ if (!response.ok) {
+ throw new Error('Failed to delete recurring events');
+ }
+ }
+
+ await fetchEvents();
+ enqueueSnackbar('일정이 삭제되었습니다.', { variant: 'info' });
+ setEventToDelete(null);
+ } catch (error) {
+ console.error('Error deleting event:', error);
+ enqueueSnackbar('일정 삭제 실패', { variant: 'error' });
+ }
+ };
+
const addOrUpdateEvent = async () => {
if (!title || !date || !startTime || !endTime) {
enqueueSnackbar('필수 정보를 모두 입력해주세요.', { variant: 'error' });
@@ -118,6 +173,73 @@ function App() {
return;
}
+ // 반복 일정을 편집하는 경우
+ if (editingEvent && editingEvent.repeat.type !== 'none') {
+ // editMode가 'single'로 설정되었으면 해당 일정만 수정 (반복 제거)
+ if (editMode === 'single') {
+ const eventData: Event | EventForm = {
+ id: editingEvent.id,
+ title,
+ date,
+ startTime,
+ endTime,
+ description,
+ location,
+ category,
+ repeat: { type: 'none', interval: 1 }, // 반복 제거
+ notificationTime,
+ };
+ await saveEvent(eventData);
+ resetForm();
+ return;
+ }
+
+ // editMode가 'full'로 설정되었으면 전체 반복 일정 수정
+ if (editMode === 'full') {
+ try {
+ // 반복 일정 시리즈 전체 수정 API 호출
+ const updateData = {
+ title,
+ startTime,
+ endTime,
+ description,
+ location,
+ category,
+ notificationTime,
+ repeat: {
+ type: editingEvent.repeat.type,
+ interval: editingEvent.repeat.interval,
+ endDate: editingEvent.repeat.endDate || undefined,
+ },
+ };
+
+ const response = await fetch(`/api/recurring-events/${editingEvent.repeat.id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(updateData),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to update recurring events');
+ }
+
+ // 이벤트 목록 다시 불러오기
+ await fetchEvents();
+ enqueueSnackbar('반복 일정이 수정되었습니다.', { variant: 'success' });
+ resetForm();
+ return;
+ } catch (error) {
+ console.error('Error updating recurring events:', error);
+ enqueueSnackbar('반복 일정 수정 실패', { variant: 'error' });
+ return;
+ }
+ }
+
+ // editMode가 설정되지 않았으면 모달 표시
+ setIsEditModeDialogOpen(true);
+ return;
+ }
+
const eventData: Event | EventForm = {
id: editingEvent ? editingEvent.id : undefined,
title,
@@ -140,8 +262,46 @@ function App() {
setOverlappingEvents(overlapping);
setIsOverlapDialogOpen(true);
} else {
- await saveEvent(eventData);
- resetForm();
+ // 반복 일정이고 편집하지 않는 경우, 여러 인스턴스 생성
+ if (isRepeating && !editingEvent) {
+ const repeatDates = generateRepeatDates(
+ date,
+ repeatType,
+ repeatInterval,
+ repeatEndDate || undefined
+ );
+
+ const repeatingEvents = repeatDates.map((repeatDate) => ({
+ ...eventData,
+ date: repeatDate,
+ id: undefined, // 서버에서 생성
+ }));
+
+ // 반복 일정 저장 (여러 인스턴스)
+ try {
+ const response = await fetch('/api/events-list', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ events: repeatingEvents }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to save repeat events');
+ }
+
+ // 이벤트 목록 다시 불러오기
+ await fetchEvents();
+ enqueueSnackbar('일정이 추가되었습니다.', { variant: 'success' });
+ resetForm();
+ } catch (error) {
+ console.error('Error saving repeat events:', error);
+ enqueueSnackbar('일정 저장 실패', { variant: 'error' });
+ }
+ } else {
+ // 단일 일정 또는 편집 시 기존 로직
+ await saveEvent(eventData);
+ resetForm();
+ }
}
};
@@ -437,12 +597,12 @@ function App() {
- {/* ! 반복은 8주차 과제에 포함됩니다. 구현하고 싶어도 참아주세요~ */}
- {/* {isRepeating && (
+ {isRepeating && (
- 반복 유형
+ 반복 유형
- 반복 간격
+ 반복 간격
- 반복 종료일
+ 반복 종료일
- )} */}
+ )}