diff --git a/backend/src/models/todo.py b/backend/src/models/todo.py index 88f8994..cae6ccb 100644 --- a/backend/src/models/todo.py +++ b/backend/src/models/todo.py @@ -22,11 +22,11 @@ class TodoItem(Base): status = Column(String(20), nullable=False, default="draft") # draft, scheduled, active, completed, delayed # 시간 관리 - created_at = Column(DateTime, nullable=False, default=datetime.utcnow) - start_date = Column(DateTime, nullable=True) # 시작 예정일 + created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow) + start_date = Column(DateTime(timezone=True), nullable=True) # 시작 예정일 estimated_minutes = Column(Integer, nullable=True) # 예상 소요시간 (분) - completed_at = Column(DateTime, nullable=True) - delayed_until = Column(DateTime, nullable=True) # 지연된 경우 새로운 시작일 + completed_at = Column(DateTime(timezone=True), nullable=True) + delayed_until = Column(DateTime(timezone=True), nullable=True) # 지연된 경우 새로운 시작일 # 분할 관리 parent_id = Column(UUID(as_uuid=True), ForeignKey("todo_items.id"), nullable=True) # 분할된 할일의 부모 @@ -52,8 +52,8 @@ class TodoComment(Base): user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) content = Column(Text, nullable=False) - created_at = Column(DateTime, nullable=False, default=datetime.utcnow) - updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow) + updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) # 관계 todo_item = relationship("TodoItem", back_populates="comments") diff --git a/frontend/static/js/todos.js b/frontend/static/js/todos.js index 943d37c..3750285 100644 --- a/frontend/static/js/todos.js +++ b/frontend/static/js/todos.js @@ -8,7 +8,7 @@ function todosApp() { return { // 상태 관리 loading: false, - activeTab: 'active', // draft, active, scheduled, completed + activeTab: 'todo', // draft, todo, completed // 할일 데이터 todos: [], @@ -57,7 +57,19 @@ function todosApp() { }, get activeTodos() { - return this.todos.filter(todo => todo.status === 'active'); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + return this.todos.filter(todo => { + // active 상태이거나, scheduled인데 날짜가 오늘이거나 지난 경우 + if (todo.status === 'active') return true; + if (todo.status === 'scheduled' && todo.start_date) { + const startDate = new Date(todo.start_date); + const startDay = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate()); + return startDay <= today; + } + return false; + }); }, get scheduledTodos() { @@ -133,21 +145,31 @@ function todosApp() { } }, - // 통계 로드 + // 통계 로드 async loadStats() { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const stats = { total_count: this.todos.length, draft_count: this.todos.filter(t => t.status === 'draft').length, - scheduled_count: this.todos.filter(t => t.status === 'scheduled').length, - active_count: this.todos.filter(t => t.status === 'active').length, - completed_count: this.todos.filter(t => t.status === 'completed').length, - delayed_count: this.todos.filter(t => t.status === 'delayed').length + todo_count: this.todos.filter(t => { + // active 상태이거나, scheduled인데 날짜가 오늘이거나 지난 경우 + if (t.status === 'active') return true; + if (t.status === 'scheduled' && t.start_date) { + const startDate = new Date(t.start_date); + const startDay = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate()); + return startDay <= today; + } + return false; + }).length, + completed_count: this.todos.filter(t => t.status === 'completed').length }; stats.completion_rate = stats.total_count > 0 ? Math.round((stats.completed_count / stats.total_count) * 100) : 0; - + this.stats = stats; }, @@ -182,7 +204,7 @@ function todosApp() { openScheduleModal(todo) { this.currentTodo = todo; this.scheduleForm = { - start_date: this.formatDateTimeLocal(new Date()), + start_date: this.formatDateLocal(new Date()), estimated_minutes: 30 }; this.showScheduleModal = true; @@ -199,8 +221,11 @@ function todosApp() { if (!this.currentTodo || !this.scheduleForm.start_date) return; try { + // 날짜만 사용하여 해당 날짜의 시작 시간으로 설정 + const startDate = new Date(this.scheduleForm.start_date + 'T00:00:00'); + const response = await window.api.post(`/todos/${this.currentTodo.id}/schedule`, { - start_date: new Date(this.scheduleForm.start_date).toISOString(), + start_date: startDate.toISOString(), estimated_minutes: parseInt(this.scheduleForm.estimated_minutes) }); @@ -410,10 +435,215 @@ function todosApp() { return date.toLocaleDateString('ko-KR'); }, - formatDateTimeLocal(date) { + formatDateLocal(date) { const d = new Date(date); - d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); - return d.toISOString().slice(0, 16); + return d.toISOString().slice(0, 10); + }, + + // 햅틱 피드백 (모바일) + hapticFeedback(element) { + // 진동 API 지원 확인 + if ('vibrate' in navigator) { + navigator.vibrate(50); // 50ms 진동 + } + + // 시각적 피드백 + if (element) { + element.classList.add('haptic-feedback'); + setTimeout(() => { + element.classList.remove('haptic-feedback'); + }, 100); + } + }, + + // 풀 투 리프레시 (모바일) + handlePullToRefresh() { + let startY = 0; + let currentY = 0; + let pullDistance = 0; + const threshold = 100; + + document.addEventListener('touchstart', (e) => { + if (window.scrollY === 0) { + startY = e.touches[0].clientY; + } + }); + + document.addEventListener('touchmove', (e) => { + if (startY > 0) { + currentY = e.touches[0].clientY; + pullDistance = currentY - startY; + + if (pullDistance > 0 && pullDistance < threshold) { + // 당기는 중 시각적 피드백 + const opacity = pullDistance / threshold; + document.body.style.background = `linear-gradient(to bottom, rgba(99, 102, 241, ${opacity * 0.1}) 0%, #f9fafb 100%)`; + } + } + }); + + document.addEventListener('touchend', async () => { + if (pullDistance > threshold) { + // 새로고침 실행 + await this.loadTodos(); + await this.loadActiveTodos(); + + // 햅틱 피드백 + if ('vibrate' in navigator) { + navigator.vibrate([100, 50, 100]); + } + } + + // 리셋 + startY = 0; + pullDistance = 0; + document.body.style.background = ''; + }); + }, + + // 할일 메모 로드 (인라인용) + async loadTodoMemos(todoId) { + try { + const response = await window.api.get(`/todos/${todoId}/comments`); + return response || []; + } catch (error) { + console.error('❌ 메모 로드 실패:', error); + return []; + } + }, + + // 할일 메모 추가 (인라인용) + async addTodoMemo(todoId, content) { + try { + const response = await window.api.post(`/todos/${todoId}/comments`, { + content: content.trim() + }); + + console.log('✅ 메모 추가 완료'); + return response; + + } catch (error) { + console.error('❌ 메모 추가 실패:', error); + alert('메모 추가 중 오류가 발생했습니다.'); + throw error; + } + } + }; +} + +// 모바일 감지 및 초기화 +function isMobile() { + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || + (navigator.maxTouchPoints && navigator.maxTouchPoints > 2 && /MacIntel/.test(navigator.platform)); +} + +// 모바일 최적화 초기화 +document.addEventListener('DOMContentLoaded', () => { + if (isMobile()) { + // 모바일 전용 스타일 추가 + document.body.classList.add('mobile-optimized'); + + // iOS Safari 주소창 숨김 대응 + const setVH = () => { + const vh = window.innerHeight * 0.01; + document.documentElement.style.setProperty('--vh', `${vh}px`); + }; + + setVH(); + window.addEventListener('resize', setVH); + window.addEventListener('orientationchange', setVH); + + // 터치 스크롤 개선 + document.body.style.webkitOverflowScrolling = 'touch'; + } +}); + +// 캘린더 컴포넌트 +function calendarComponent() { + return { + currentDate: new Date(), + + get calendarDays() { + const year = this.currentDate.getFullYear(); + const month = this.currentDate.getMonth(); + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + const startDate = new Date(firstDay); + startDate.setDate(startDate.getDate() - firstDay.getDay()); + + const days = []; + const today = new Date(); + const todayString = today.toISOString().slice(0, 10); + + for (let i = 0; i < 42; i++) { + const currentDay = new Date(startDate); + currentDay.setDate(startDate.getDate() + i); + + const dateString = currentDay.toISOString().slice(0, 10); + const isCurrentMonth = currentDay.getMonth() === month; + const isToday = dateString === todayString; + + // 해당 날짜의 할일들 찾기 + const dayTodos = this.$parent.todos.filter(todo => { + if (!todo.start_date) return false; + const todoDate = new Date(todo.start_date).toISOString().slice(0, 10); + return todoDate === dateString && (todo.status === 'scheduled' || todo.status === 'active'); + }); + + days.push({ + day: currentDay.getDate(), + dateString: dateString, + isCurrentMonth: isCurrentMonth, + isToday: isToday, + todos: dayTodos + }); + } + + return days; + }, + + formatMonthYear(date) { + return date.toLocaleDateString('ko-KR', { year: 'numeric', month: 'long' }); + }, + + formatSelectedDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { month: 'long', day: 'numeric', weekday: 'short' }); + }, + + previousMonth() { + this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() - 1, 1); + }, + + nextMonth() { + this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, 1); + }, + + selectDate(dateString) { + this.$parent.scheduleForm.start_date = dateString; + }, + + selectDelayDate(dateString) { + this.$parent.delayForm.delayed_until = dateString; + }, + + getSelectedDateTodos() { + if (!this.$parent.scheduleForm.start_date) return []; + return this.$parent.todos.filter(todo => { + if (!todo.start_date) return false; + const todoDate = new Date(todo.start_date).toISOString().slice(0, 10); + return todoDate === this.$parent.scheduleForm.start_date && (todo.status === 'scheduled' || todo.status === 'active'); + }); + }, + + getDelayDateTodos() { + if (!this.$parent.delayForm.delayed_until) return []; + return this.$parent.todos.filter(todo => { + if (!todo.start_date) return false; + const todoDate = new Date(todo.start_date).toISOString().slice(0, 10); + return todoDate === this.$parent.delayForm.delayed_until && (todo.status === 'scheduled' || todo.status === 'active'); + }); } }; } diff --git a/frontend/todos.html b/frontend/todos.html index 0866ed0..5b5af19 100644 --- a/frontend/todos.html +++ b/frontend/todos.html @@ -11,16 +11,26 @@ @@ -86,12 +327,12 @@
-
-

- +
+

+ 할일관리

-

효율적인 할일 관리로 생산성을 높여보세요

+

간편한 할일 관리

@@ -113,13 +354,15 @@

@@ -127,40 +370,49 @@ -
-
- - - - +
+ +
+ + 탭을 눌러서 전환하세요 +
+ +
+
+ + + +
@@ -202,10 +454,10 @@
- -
+ +
-
- -

오늘 할 일이 없습니다

+
+ +

할 일이 없습니다

+

검토필요에서 일정을 설정해보세요

@@ -306,48 +581,119 @@
-
+

일정 설정

+

캘린더에서 날짜를 선택하고 예정된 할일을 확인하세요

-
- - -
- -
- - -

2시간 이상의 작업은 분할하는 것을 권장합니다

-
- -
- - +
+ +
+
+ +

+ +
+ +
+
+
+
+
+
+
+
+
+ +
+ +
+
+ + +
+
+ + +
+ +
+ + +

2시간 이상의 작업은 분할하는 것을 권장합니다

+
+ + +
+
+ 의 예정된 할일 +
+
+ +
+ 이 날짜에는 예정된 할일이 없습니다 +
+
+
+ +
+ + +
+
@@ -355,35 +701,106 @@
-
+

할일 지연

+

새로운 날짜를 선택하고 예정된 할일을 확인하세요

-
- - -
- -
- - +
+ +
+
+ +

+ +
+ +
+
+
+
+
+
+
+
+
+ +
+ +
+
+ + +
+
+ + +
+ + +
+
+ 의 예정된 할일 +
+
+ +
+ 이 날짜에는 예정된 할일이 없습니다 +
+
+
+ +
+ + +
+