interface Todo {
id: string; // UUID 또는 temp-{timestamp}
text: string; // 할일 제목
completed: boolean; // 완료 상태
date: Date; // 날짜
categoryId: string; // 카테고리 ID
subtasks?: Todo[]; // 하위 항목 (재귀 구조)
parentId?: string; // 부모 ID
startTime?: string; // 시작 시간 (HH:mm)
endTime?: string; // 종료 시간 (HH:mm)
recurrenceRule?: RecurrenceRule; // 반복 규칙
completedDates?: string[]; // 반복 일정 완료 날짜들 (YYYY-MM-DD)
skippedDates?: string[]; // 반복 일정 건너뛴 날짜들
googleEventId?: string; // 구글 캘린더 이벤트 ID
}interface RecurrenceRule {
frequency: 'daily' | 'weekly' | 'monthly'; // 반복 주기
interval: number; // 간격 (1=매일, 2=이틀마다...)
startDate?: Date; // 시작 날짜
endDate?: Date; // 종료 날짜
daysOfWeek?: number[]; // 요일 (1=월, 7=일)
}interface Category {
id: string; // UUID 또는 cat-{name}
name: string; // 카테고리 이름
color: string; // 색상 (#HEX)
}todos 테이블
CREATE TABLE todos (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
category_id UUID NULL, -- 기본 카테고리는 NULL
parent_id UUID NULL, -- 하위 항목의 부모 ID
text TEXT NOT NULL,
completed BOOLEAN DEFAULT FALSE,
date DATE NOT NULL,
start_time TIME NULL,
end_time TIME NULL,
recurrence_rule JSONB NULL, -- 반복 규칙
completed_dates TEXT[] NULL, -- 반복 일정 완료 날짜
skipped_dates TEXT[] NULL, -- 반복 일정 건너뛴 날짜
google_event_id TEXT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);categories 테이블
CREATE TABLE categories (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
name TEXT NOT NULL,
color TEXT NOT NULL,
order INT NOT NULL, -- 정렬 순서
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);┌─────────────────────────────────────────┐
│ UI Components Layer │
│ (TodoList, WeekCalendar, TodoBlock) │
└──────────────┬──────────────────────────┘
│
┌──────────────▼──────────────────────────┐
│ Context Layer │
│ (TodoContext, CategoryContext) │
└──────────────┬──────────────────────────┘
│
┌──────────────▼──────────────────────────┐
│ Hooks Layer │
│ (useTodos, useCategories, etc) │
└──────────────┬──────────────────────────┘
│
┌──────────────▼──────────────────────────┐
│ Supabase Queries │
│ (createTodo, updateTodo, etc) │
└──────────────┬──────────────────────────┘
│
┌──────────────▼──────────────────────────┐
│ Supabase Database │
│ (PostgreSQL + Realtime) │
└─────────────────────────────────────────┘
1. UI Component
└─> Context (onAddTodo)
└─> useTodos Hook
├─> 즉시 로컬 상태 업데이트 (임시 ID)
│ └─> UI 즉시 반영 ✨
└─> 백그라운드 DB 저장
├─ 성공 → 실제 ID로 교체
└─ 실패 → 롤백 (임시 Todo 제거)
1. UI Component
└─> Context (onEditTodo)
└─> useTodos Hook
├─> 즉시 로컬 상태 업데이트
│ └─> UI 즉시 반영 ✨
└─> 백그라운드 DB 업데이트
└─ 실패 → 로그 (롤백 복잡해서 생략)
1. UI Component
└─> Context (onDeleteTodo)
└─> useTodos Hook
├─> 백업 생성
├─> 즉시 로컬 상태에서 삭제
│ └─> UI 즉시 반영 ✨
└─> 백그라운드 DB 삭제
└─ 실패 → 롤백 (백업 복원)
TodoContext - 할일 관련 모든 액션
interface TodoContextType {
// 기본 CRUD
onAddTodo: (text, categoryId, date, parentId?, startTime?, endTime?) => void
onDeleteTodo: (id) => void
onToggleTodo: (id) => void
onEditTodo: (id, text) => void
// 시간/날짜 관리
onUpdateTodoTime: (id, startTime?, endTime?) => void
onUpdateTodoDateTime: (id, date, startTime?, endTime?) => void
onMoveTodoToDate: (id, newDate) => void
// 계층 구조 관리
onMoveTodo: (todoId, newCategoryId, newParentId?, newIndex?) => void
// 캘린더 전용
onAddTodoFromCalendar: (todo, callback?) => void
onUpdateTodo: (id, updates) => void
// 반복 일정 관리
onToggleRecurringInstance: (recurringId, date) => void
onSkipRecurringInstance: (recurringId, date) => void
onDeleteRecurringAfter: (recurringId, date) => void
onConvertRecurringToRegular: (recurringId, date, categoryId) => void
onConvertRegularToRecurring: (todoId, text, startTime, endTime, rule, categoryId) => void
}CategoryContext - 카테고리 관련 모든 액션
interface CategoryContextType {
// 기본 CRUD
onAddCategory: (name, color) => void
onEditCategory: (id, name) => void
onChangeColor: (id, color) => void
onDeleteCategory: (id) => void
// 정렬
onMoveCategory: (categoryId, newIndex) => void
// 반복 일정
onAddRecurring: (text, startTime, endTime, rule, categoryId) => void
onEditRecurring: (id, text, startTime, endTime, rule, categoryId) => void
onDeleteRecurring: (id) => void
}1. app/page.tsx 마운트
│
2. useEffect 실행
├─> fetchTodos() - Supabase에서 todos 가져오기
└─> fetchCategories() - Supabase에서 categories 가져오기
│
3. 데이터 로드 완료
├─> setInitialTodos(todosData)
└─> setInitialCategories(categoriesData)
│
4. useTodos(initialTodos) 실행
└─> useState로 로컬 상태 생성
│
5. useCategories(initialCategories, todos) 실행
└─> useState로 로컬 상태 생성
│
6. Context에 값 제공
└─> TodoProvider, CategoryProvider
└─> 모든 하위 컴포넌트에서 사용 가능
Optimistic Update: 서버 응답을 기다리지 않고 즉시 UI를 업데이트하여 사용자 경험을 향상시키는 기법
// 1. 임시 ID 생성
const tempId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// 2. 임시 객체 생성
const tempTodo: Todo = { id: tempId, ...data };
// 3. 즉시 로컬 상태 업데이트
setTodos(prev => [...prev, tempTodo]);
// 4. 백그라운드 DB 저장
const result = await createTodoDB(...);
// 5. 성공 시 임시 ID를 실제 ID로 교체
if (result.success && result.todo) {
setTodos(prev => prev.map(t =>
t.id === tempId ? result.todo! : t
));
} else {
// 6. 실패 시 롤백
setTodos(prev => prev.filter(t => t.id !== tempId));
}// 1. 즉시 로컬 상태 업데이트
setTodos(prev => updateTodoRecursively(prev, id, updates));
// 2. 백그라운드 DB 업데이트
const result = await updateTodoDB(id, updates);
// 3. 실패 시 로그 (롤백 생략)
if (!result.success) {
console.error('Failed to update:', result.error);
}// 1. 백업 생성
let backupTodos: Todo[] = [];
// 2. 즉시 로컬 상태에서 삭제
setTodos(prev => {
backupTodos = prev;
return deleteRecursively(prev, id);
});
// 3. 백그라운드 DB 삭제
const result = await deleteTodoDB(id);
// 4. 실패 시 롤백
if (!result.success) {
setTodos(backupTodos);
}임시 ID 패턴
- 일반 할일:
temp-{timestamp}-{random} - 캘린더 할일:
temp-calendar-{timestamp}-{random} - 반복 할일:
temp-recurring-{timestamp}-{random} - 변환 할일:
temp-convert-{timestamp}-{random}
임시 ID 감지
if (id.startsWith('temp-')) {
// DB 호출 건너뛰기
return;
}사용처: 할일 순서 변경, 카테고리 간 이동, 계층 구조
// 드래그 데이터 구조
{
type: 'todo', // 또는 'category'
id: 'todo-123',
categoryId: 'cat1',
parentId: undefined,
index: 2
}주요 기능
- 8px 활성화 거리 (클릭과 구분)
- 드래그 오버 시 카테고리 하이라이트
- Shift+드롭으로 하위 항목 만들기
- 드래그 중 opacity 0.5
사용처: 할일 → 캘린더 드래그, 캘린더 내 드래그
// 드래그 데이터
const dragData = {
id: todo.id,
text: todo.text,
startTime: todo.startTime,
endTime: todo.endTime,
categoryId: todo.categoryId,
};
e.dataTransfer.setData('text/plain', JSON.stringify(dragData));1. mousedown 이벤트
├─> 현재 위치 저장 (offsetY)
└─> draggingTodo 상태 설정
2. mousemove 이벤트 (requestAnimationFrame으로 60fps)
├─> 마우스 X 위치로 날짜 계산
├─> 마우스 Y 위치로 시간 계산 (15분 스냅)
├─> offsetY 고려하여 자연스러운 이동
└─> draggingTodo 상태 업데이트
3. mouseup 이벤트
├─> 날짜 변경 여부 확인
├─ YES → onUpdateTodoDateTime(id, newDate, startTime, endTime)
└─ NO → onEditTodo(id, { startTime, endTime })1. 상단/하단 핸들 mousedown
└─> resizingTodo 상태 설정 (type: 'top' 또는 'bottom')
2. mousemove 이벤트
├─> 마우스 Y 위치로 시간 계산 (15분 스냅)
├─ type === 'top'
│ └─> startTime 업데이트 (endTime보다 작아야 함)
└─ type === 'bottom'
└─> endTime 업데이트 (startTime보다 커야 함)
3. mouseup 이벤트
└─> onEditTodo(id, { startTime, endTime })// 1. 마우스 Y 좌표를 분 단위로 변환
const totalMinutes = Math.floor((mouseY / hourHeight) * 60);
// 2. 15분 단위로 반올림
const roundedMinutes = Math.round(totalMinutes / 15) * 15;
// 3. 시간으로 변환
const hours = Math.floor(roundedMinutes / 60);
const minutes = roundedMinutes % 60;
const time = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;const daysDiff = Math.floor(
(targetDate - startDate) / (1000 * 60 * 60 * 24)
);
shouldInclude = daysDiff >= 0 && daysDiff % interval === 0;// daysOfWeek가 있는 경우
if (daysOfWeek.includes(dayOfWeek)) {
const weeksDiff = Math.floor(daysDiff / 7);
shouldInclude = weeksDiff % interval === 0;
}
// daysOfWeek가 없는 경우 (시작 날짜 요일만)
shouldInclude =
targetDate.getDay() === startDate.getDay() &&
weeksDiff % interval === 0;const monthsDiff =
(targetYear - startYear) * 12 +
(targetMonth - startMonth);
shouldInclude =
targetDate.getDate() === startDate.getDate() &&
monthsDiff % interval === 0;// generateRecurringEvents(todo, weekDays)
function generateRecurringEvents(todo: Todo, weekDays: Date[]): Todo[] {
if (!todo.recurrenceRule) return [todo];
const events: Todo[] = [];
weekDays.forEach((day) => {
// 1. 반복 규칙 확인
let shouldInclude = checkRecurrenceRule(todo, day);
// 2. 종료일 확인
if (endDate && day > endDate) {
shouldInclude = false;
}
// 3. 건너뛴 날짜 확인
if (skippedDates.includes(formatDateKey(day))) {
shouldInclude = false;
}
// 4. 이벤트 생성
if (shouldInclude) {
events.push({
...todo,
id: `${todo.id}-${day.toISOString()}`, // 고유 ID
date: day,
});
}
});
return events;
}completedDates 배열
// 특정 날짜 완료 토글
const dateString = '2025-11-02';
const isCompleted = completedDates.includes(dateString);
newCompletedDates = isCompleted
? completedDates.filter(d => d !== dateString) // 제거
: [...completedDates, dateString]; // 추가
// DB 업데이트
updateTodoDB(recurringId, { completedDates: newCompletedDates });skippedDates 배열
// 특정 날짜 건너뛰기
const dateString = '2025-11-02';
const skippedDates = todo.skippedDates || [];
if (!skippedDates.includes(dateString)) {
newSkippedDates = [...skippedDates, dateString];
// DB 업데이트
updateTodoDB(recurringId, { skippedDates: newSkippedDates });
}
// 생성 시 필터링
if (skippedDates.includes(formatDateKey(day))) {
shouldInclude = false; // 이벤트 생성하지 않음
}// endDate를 오늘 -1일로 설정
const endDate = new Date(date);
endDate.setDate(endDate.getDate() - 1);
newRecurrenceRule = {
...recurrenceRule,
endDate // 오늘 이전까지만 반복
};
updateTodoDB(recurringId, { recurrenceRule: newRecurrenceRule });Google Calendar 스타일 알고리즘
function calculateEventLayout(todos: Todo[]): Layout {
// 1. 시작 시간과 길이로 정렬
const sorted = todos.sort((a, b) => {
if (startTime(a) !== startTime(b)) return startTime(a) - startTime(b);
return duration(b) - duration(a); // 긴 이벤트 우선
});
// 2. 컬럼 배정
const columns: Todo[][] = [];
sorted.forEach(event => {
// 겹치지 않는 첫 번째 컬럼 찾기
let placed = false;
for (let col of columns) {
if (!hasOverlap(event, col)) {
col.push(event);
placed = true;
break;
}
}
// 새 컬럼 생성
if (!placed) columns.push([event]);
});
// 3. width와 left 계산
return todos.map(event => {
const column = findColumn(event, columns);
const maxColumns = countOverlappingColumns(event, todos);
return {
width: 100 / maxColumns,
left: column * (100 / maxColumns)
};
});
}function getTodoBlockStyle(
startTime: string, // "09:00"
endTime: string, // "11:00"
hourHeight: number // 60px
): CSSProperties {
// 1. 분 단위로 변환
const startMinutes = timeToMinutes(startTime); // 540
const endMinutes = timeToMinutes(endTime); // 660
const duration = endMinutes - startMinutes; // 120
// 2. 픽셀 계산
const top = (startMinutes / 60) * hourHeight; // 540px
const height = (duration / 60) * hourHeight - 2; // 118px (2px gap)
return {
top: `${top}px`,
height: `${height}px`,
// 겹치는 경우
width: `calc(${width}% - 4px)`,
left: `${left}%`,
};
}// 1분마다 업데이트
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 60000);
return () => clearInterval(timer);
}, []);
// 현재 시간 라인 렌더링
const now = currentTime;
const minutes = now.getHours() * 60 + now.getMinutes();
const topPosition = (minutes / 60) * hourHeight;
<div style={{ top: `${topPosition}px` }} className="current-time-line" />- ✅ 할일 추가: Enter로 빠른 생성
- ✅ 할일 삭제: Backspace(빈 칸) 또는 삭제 버튼
- ✅ 할일 수정: 인라인 편집
- ✅ 완료 토글: 체크박스 클릭
- ✅ 들여쓰기: Tab 키
- ✅ 내어쓰기: Shift+Tab 키
- ✅ 하위 항목 추가: Shift+드롭
- ✅ 부모 완료 시: 하위 항목 일괄 완료
- ✅ 하위 항목 표시: 개수 뱃지
- ✅ 시간 추가: 호버 시 버튼 표시
- ✅ 시간 선택: TimePicker (15분 단위)
- ✅ 시간 표시: HH:mm 형식
- ✅ 시간 편집: 클릭하여 수정
- ✅ 순서 변경: 같은 카테고리 내
- ✅ 카테고리 이동: 다른 카테고리로
- ✅ 캘린더로 드래그: 캘린더 아이콘
- ✅ 반복 변환: 일반 ↔ 반복
- ✅ 카테고리 추가: + 버튼
- ✅ 색상 변경: 10가지 팔레트
- ✅ 이름 변경: 인라인 편집
- ✅ 순서 변경: 드래그앤드롭
- ✅ 카테고리 삭제: 확인 모달
- ✅ 반복: 최상단 고정, 삭제 불가
- ✅ 기타: 최하단 고정, 삭제 불가
- ✅ 자동 생성: 3개 기본 카테고리
- ✅ 매일: 1일마다, 2일마다...
- ✅ 매주: 요일 다중 선택
- ✅ 매월: 특정 날짜
- ✅ 시작일: 반복 시작
- ✅ 종료일: 반복 종료
- ✅ 건너뛰기: 특정 날짜 제외
- ✅ 날짜별 완료: completedDates 배열
- ✅ 오늘 완료: 체크박스 토글
- ✅ 완료 표시: 카테고리 색상
- ✅ 반복 일정 편집: 모든 인스턴스 적용
- ✅ 오늘만 건너뛰기: skippedDates 추가
- ✅ 이후 모두 삭제: endDate 설정
- ✅ 전체 삭제: 반복 일정 자체 삭제
- ✅ 일반 → 반복: 드래그 + 규칙 설정
- ✅ 반복 → 일반: 특정 날짜만
- ✅ 주간 뷰: 일~토 7일
- ✅ 타임라인: 0시~24시
- ✅ 시간 높이 조절: Zoom
- ✅ 현재 시간: 빨간 라인
- ✅ 공휴일 표시: API 연동
- ✅ 빈 공간 드래그: 새 이벤트
- ✅ 15분 스냅: 자동 정렬
- ✅ 인라인 입력: 즉시 제목 입력
- ✅ Enter 확정: 할일 생성
- ✅ Escape 취소: 생성 취소
- ✅ 블록 드래그: 날짜/시간 이동
- ✅ 상단 리사이즈: 시작 시간
- ✅ 하단 리사이즈: 종료 시간
- ✅ 더블클릭 편집: 제목 수정
- ✅ 체크박스: 완료 토글
- ✅ 우클릭 메뉴: 6가지 액션
- ✅ 카테고리 색상: 배경색
- ✅ 반복 아이콘: Repeat 표시
- ✅ 시간 표시: HH:mm - HH:mm
- ✅ 하위 항목: 개수 + 툴팁
- ✅ 과거 이벤트: 투명도 감소
- ✅ 겹치는 이벤트: Google 스타일 레이아웃
- ✅ 카테고리 필터: 다중 선택
- ✅ 완료 필터: 전체/완료/미완료
- ✅ React.memo: TodoBlock, TodoItem, CategorySection
- ✅ useMemo: 복잡한 계산 캐싱
- ✅ useCallback: 함수 메모이제이션
- ✅ 메모이제이션 비교: 세밀한 props 비교
- ✅ requestAnimationFrame: 60fps 드래그
- ✅ throttling: 과도한 이벤트 방지
- ✅ 애니메이션 취소: unmount 시
- ✅ Optimistic Update: 즉시 UI 반영
- ✅ 임시 ID: 서버 응답 전 사용
- ✅ 롤백 메커니즘: 실패 시 복원
- ✅ 백그라운드 저장: 비동기 처리
- ✅ Enter: 다음 할일 / 편집 완료
- ✅ Escape: 편집 취소
- ✅ Tab: 들여쓰기
- ✅ Shift+Tab: 내어쓰기
- ✅ Backspace: 빈 칸 삭제
- ✅ 8px 활성화: 클릭과 구분
- ✅ Opacity 0.5: 드래그 중
- ✅ DragOverlay: 커서 따라다님
- ✅ 카테고리 하이라이트: 드롭 가능 표시
- ✅ 밝기 변경: hover 시
- ✅ 버튼 표시: opacity 0 → 100
- ✅ 핸들 표시: 리사이즈 가능
- ✅ 툴팁 표시: 하위 항목
- ✅ Framer Motion: 부드러운 전환
- ✅ 색상 전환: transition 0.15s
- ✅ 스케일 효과: 버튼 hover
- ✅ 슬라이드: 모달 등장
- ✅ hourHeight: 캘린더 줌 레벨
- ✅ 카테고리 필터: 선택 상태
- ✅ 완료 필터: 선택 상태
- ✅ 자동 동기화: 변경 사항 실시간 반영
- ✅ 사용자 격리: user_id로 필터링
- ✅ RLS 정책: 보안
- ✅ 생성 실패: 임시 Todo 제거
- ✅ 삭제 실패: 백업 복원
- ✅ 수정 실패: 콘솔 로그
- ✅ 로그인 필요: 에러 메시지
- ✅ 권한 없음: 에러 메시지
- ✅ 네트워크 오류: 로그
Todal은 Optimistic Update, 드래그앤드롭, 반복 일정, 계층 구조 등 복잡한 기능들을 React Context, Custom Hooks, Supabase를 활용하여 깔끔하게 구현한 프로젝트입니다.
핵심 강점:
- ⚡ 빠른 UX: Optimistic Update로 즉시 반영
- 🎯 직관적: 드래그앤드롭으로 자연스러운 조작
- 🔄 강력한 반복: 복잡한 반복 규칙 지원
- 📊 확장 가능: 깔끔한 아키텍처
- 🎨 세밀한 최적화: React.memo, useMemo, useCallback
Phase 1 완성도: 85% (구글 캘린더 연동 제외)