{{ card.label }}
+{{ card.value }}
+{{ card.caption }}
+diff --git a/HANDOFF.md b/HANDOFF.md index a38ebb9..41462ed 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ - 프로젝트명: 10 Minute Planner 웹 UI - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript -- 현재 기준 버전: `v0.0.7` +- 현재 기준 버전: `v0.0.8` ## 기준 디자인 @@ -16,6 +16,7 @@ - 기본 UX 방향은 `1페이지 + 추가 정보 패널`이다. - `2페이지 펼침 보기`는 비교용 보조 모드로 함께 유지한다. - 화면 인상은 종이 다이어리 같아야 하지만, 상호작용은 웹앱처럼 빠르고 자연스러워야 한다. +- 현재는 개인용 단일 브라우저 흐름으로 개발 중이지만, 이후 회원 기반 개인 문서 관리 서비스로 확장해야 한다. ## 현재 구현 상태 @@ -37,6 +38,8 @@ - 입력 내용이 있는 날짜는 달력 하단에 빨간 점으로 표시된다. - 상단 날짜 표시에서는 토요일의 `(토)`만 파란색, 일요일의 `(일)`만 빨간색으로 표시한다. - 플래너 상태는 `localStorage`에 저장되며, 날짜별 기록과 선택 날짜, 달력 보고 있던 월까지 복원된다. +- 상단 전환 버튼으로 `PLANNER / STATS` 화면을 오갈 수 있다. +- 통계 화면에서는 전체 집중 시간, 평균 완료율, 기록 일수, 최근 7일 흐름, 최근 기록, 베스트 데이를 보여준다. ## 확정된 결정사항 @@ -44,6 +47,7 @@ 날짜 전환, 모드 토글, 사이드 패널 요약, 이후 저장 기능 등 상태 기반 상호작용이 많기 때문이다. - TailwindCSS는 Vue를 사용하더라도 반드시 유지해야 하는 스타일링 방식이다. - 현재 데이터는 레이아웃과 상호작용 검증을 위한 목업 데이터다. +- 현재 저장은 `localStorage`를 사용하지만, 적절한 시점에 DB를 붙여 사용자별 저장 구조로 전환해야 한다. - 상단 날짜는 시스템 날짜 또는 현재 선택된 플래너 날짜 기준으로 자동 표시되어야 한다. - `D-DAY`는 지금은 보류이며, 이후 별도의 목표 관리 패널과 연결해서 계산한다. - `COMMENT`, `TASKS`, `MEMO`는 모두 입력 가능한 필드가 되어야 한다. @@ -55,12 +59,16 @@ - 달력에는 연/월 이동 기능이 필요하다. - 내용이 저장된 날짜에는 달력에 빨간 점 표시가 필요하다. - 현재 단계의 저장 방식은 `localStorage`이며, 이후 외부 저장소 도입 전까지 기본 저장 경로로 사용한다. +- 장기적으로는 회원 가입 후 사용자별로 각자 문서를 작성/관리할 수 있어야 한다. +- 공유 문서 서비스가 아니라, 사용자 개인 보관과 회고 중심의 서비스 구조를 목표로 한다. +- 사용자는 스스로 통계를 확인할 수 있어야 하고, 특정 날짜에 작성한 문서는 출력 가능해야 한다. ## 다음 권장 작업 - `TODO.md` 기준으로 작은 단위씩 구현을 진행한다. - 목표나 통계 기능보다 먼저, 플래너 본문의 입력과 상호작용을 우선 구현한다. -- 달력 표시와 통계 기능을 본격화하기 전에 저장 구조를 먼저 정리하는 것이 좋다. +- 통계 화면 구현은 현재 `localStorage` 기반으로 먼저 진행해도 된다. +- DB는 기능 탐색 속도를 해치지 않는 선에서, 저장 레이어를 분리할 수 있는 적절한 시점에 붙이는 것이 좋다. ## 갱신 규칙 diff --git a/TODO.md b/TODO.md index cb61435..5ea001d 100644 --- a/TODO.md +++ b/TODO.md @@ -47,6 +47,10 @@ - [x] 입력 데이터의 저장 위치를 결정한다. - [x] 로컬 저장 또는 외부 저장 방식 중 우선 구현 방식을 정한다. - [x] 입력 상태가 새로고침 후에도 유지되도록 만든다. +- [ ] DB 전환 시점을 잡을 수 있도록 저장 레이어를 분리한다. +- [ ] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다. +- [ ] 사용자별 문서 저장/조회 흐름을 정리한다. +- [ ] 출력용 문서 포맷과 프린트 흐름을 고려한 데이터 구조를 정리한다. ## 추가 반영 메모 @@ -55,12 +59,22 @@ ## 5단계: 확장 화면 -- [ ] 통계 페이지 요구사항을 정리한다. -- [ ] 통계 페이지 라우팅 또는 화면 전환 구조를 설계한다. -- [ ] 집중 시간, 완료율, 연속 기록 같은 핵심 지표를 정의한다. +- [x] 통계 페이지 요구사항을 정리한다. +- [x] 통계 페이지 라우팅 또는 화면 전환 구조를 설계한다. +- [x] 집중 시간, 완료율, 연속 기록 같은 핵심 지표를 정의한다. +- [ ] 사용자 개인 통계 화면 기준을 정리한다. + +## 6단계: 계정 및 서비스 확장 + +- [ ] 회원 가입 / 로그인 방식 후보를 정리한다. +- [ ] 사용자별 문서 분리 저장 구조를 설계한다. +- [ ] 공유가 아닌 개인 보관용 서비스 흐름으로 요구사항을 정리한다. +- [ ] 향후 출력 기능을 위한 인쇄 레이아웃 요구사항을 정리한다. ## 메모 - D-DAY는 현재 보류 상태다. 목표 패널 설계 후 연결한다. - `TIME TABLE` 드래그는 단순 사각형 선택이 아니라 시간 셀 단위의 연속 선택으로 해석한다. +- 현재는 `localStorage`로 개발을 진행하지만, 적절한 시점에 DB를 붙여 사용자별 저장 구조로 확장해야 한다. +- 최종적으로는 회원 가입 후 각자 자신의 문서를 작성/관리하고, 개인 통계를 확인하며, 특정 날짜 문서를 출력할 수 있어야 한다. - 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다. diff --git a/package-lock.json b/package-lock.json index ceb7408..79f923c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ten-minute-planner", - "version": "0.0.7", + "version": "0.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ten-minute-planner", - "version": "0.0.7", + "version": "0.0.8", "dependencies": { "vue": "^3.5.13" }, diff --git a/package.json b/package.json index e053983..7656413 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ten-minute-planner", "private": true, - "version": "0.0.7", + "version": "0.0.8", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.vue b/src/App.vue index 1d02082..8ba0fd5 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,8 +2,10 @@ import { computed, reactive, ref, watch } from 'vue' import MiniCalendar from './components/MiniCalendar.vue' import PlannerPage from './components/PlannerPage.vue' +import StatsDashboard from './components/StatsDashboard.vue' const STORAGE_KEY = 'ten-minute-planner-state' +const screenMode = ref('planner') const viewMode = ref('focus') const selectedDate = ref(new Date()) const calendarViewDate = ref(new Date(selectedDate.value)) @@ -320,6 +322,21 @@ function formatTotalTime(record) { return `${hoursPart}H ${minutesPart}M` } +function getFocusedMinutes(record) { + return record.timetable.filter(Boolean).length * 10 +} + +function getCompletionRate(record) { + const activeTasks = record.tasks.filter((task) => task.title.trim()) + + if (activeTasks.length === 0) { + return 0 + } + + const doneTasks = activeTasks.filter((task) => task.checked).length + return Math.round((doneTasks / activeTasks.length) * 100) +} + function shiftDate(amount) { const next = new Date(selectedDate.value) next.setDate(next.getDate() + amount) @@ -381,6 +398,130 @@ function hasPlannerContent(record) { ) } +const plannerEntries = computed(() => + Object.entries(plannerRecords) + .filter(([, record]) => hasPlannerContent(record)) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)), +) + +const totalFocusedMinutes = computed(() => + plannerEntries.value.reduce((total, [, record]) => total + getFocusedMinutes(record), 0), +) + +const totalRecordedTasks = computed(() => + plannerEntries.value.reduce( + (total, [, record]) => total + record.tasks.filter((task) => task.title.trim()).length, + 0, + ), +) + +const completedRecordedTasks = computed(() => + plannerEntries.value.reduce( + (total, [, record]) => total + record.tasks.filter((task) => task.title.trim() && task.checked).length, + 0, + ), +) + +const aggregateCompletionRate = computed(() => { + if (totalRecordedTasks.value === 0) { + return 0 + } + + return Math.round((completedRecordedTasks.value / totalRecordedTasks.value) * 100) +}) + +const overviewCards = computed(() => [ + { + label: 'TOTAL FOCUSED', + value: formatMinutes(totalFocusedMinutes.value), + caption: '지금까지 기록된 전체 집중 시간', + }, + { + label: 'AVERAGE COMPLETION', + value: `${aggregateCompletionRate.value}%`, + caption: '입력된 할 일 기준 전체 평균 완료율', + }, + { + label: 'RECORDED DAYS', + value: `${plannerEntries.value.length}일`, + caption: '기록이 남아 있는 날짜 수', + }, + { + label: 'COMPLETED TASKS', + value: `${completedRecordedTasks.value}/${totalRecordedTasks.value || 0}`, + caption: '완료된 할 일과 전체 입력된 할 일 수', + }, +]) + +function formatMinutes(totalMinutes) { + const hoursPart = Math.floor(totalMinutes / 60) + const minutesPart = totalMinutes % 60 + return `${hoursPart}H ${`${minutesPart}`.padStart(2, '0')}M` +} + +function createDateLabel(dateKey) { + const date = toDateValue(dateKey) + const display = getDateDisplay(date) + return `${display.main} ${display.weekday}` +} + +const weeklyRecords = computed(() => { + const entries = Array.from({ length: 7 }, (_, index) => { + const date = new Date(selectedDate.value) + date.setDate(selectedDate.value.getDate() - (6 - index)) + const record = getPlannerRecord(date) + const focusedMinutes = getFocusedMinutes(record) + const weekdayShort = ['일', '월', '화', '수', '목', '금', '토'][date.getDay()] + + return { + key: toKey(date), + weekday: weekdayShort, + focusedMinutes, + focusedTime: formatMinutes(focusedMinutes), + } + }) + + const maxMinutes = Math.max(...entries.map((entry) => entry.focusedMinutes), 60) + + return entries.map((entry) => ({ + ...entry, + barHeight: Math.max(12, Math.round((entry.focusedMinutes / maxMinutes) * 100)), + })) +}) + +const recentRecords = computed(() => + [...plannerEntries.value] + .sort(([leftKey], [rightKey]) => rightKey.localeCompare(leftKey)) + .slice(0, 5) + .map(([key, record]) => ({ + key, + dateLabel: createDateLabel(key), + comment: record.comment.trim(), + focusedTime: formatTotalTime(record), + completionRate: getCompletionRate(record), + })), +) + +const bestDay = computed(() => { + if (plannerEntries.value.length === 0) { + return null + } + + const [bestKey, bestRecord] = [...plannerEntries.value].reduce((bestEntry, currentEntry) => { + const [, bestRecordValue] = bestEntry + const [, currentRecordValue] = currentEntry + + return getFocusedMinutes(currentRecordValue) > getFocusedMinutes(bestRecordValue) ? currentEntry : bestEntry + }) + + return { + dateLabel: createDateLabel(bestKey), + summary: `${formatTotalTime(bestRecord)} 집중, 완료율 ${getCompletionRate(bestRecord)}%, 코멘트 "${ + bestRecord.comment.trim() || '없음' + }"`, + } +}) + watch( [plannerRecords, selectedDate, calendarViewDate], () => { @@ -440,6 +581,24 @@ function clearTaskLabels(record) {
{{ card.label }}
+{{ card.value }}
+{{ card.caption }}
+WEEKLY FLOW
++ 기준 날짜: {{ selectedDateLabel }} +
+{{ record.weekday }}
+{{ record.focusedTime }}
+BEST DAY
++ {{ bestDay.summary }} +
++ 아직 통계를 보여줄 기록이 충분하지 않습니다. +
+RECENT RECORDS
+{{ record.dateLabel }}
++ {{ record.comment || '코멘트 없음' }} +
+{{ record.focusedTime }}
+{{ record.completionRate }}%
+