diff --git a/HANDOFF.md b/HANDOFF.md index 41462ed..9e788e0 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ - 프로젝트명: 10 Minute Planner 웹 UI - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript -- 현재 기준 버전: `v0.0.8` +- 현재 기준 버전: `v0.0.9` ## 기준 디자인 @@ -32,6 +32,7 @@ - STATS 완료율은 전체 15칸이 아니라, 실제로 입력된 TASKS만 기준으로 계산한다. - `TIME TABLE`은 드래그로 10분 블록을 연속 선택할 수 있다. - `TIME TABLE`은 우클릭 드래그 시 선택된 블록을 지우는 방식으로도 편집할 수 있다. +- `TIME TABLE` 숫자 영역은 선택/드래그로 텍스트가 잡히지 않도록 막아두었다. - `TOTAL TIME`은 타임테이블에서 선택된 블록 수를 기준으로 자동 계산된다. - 달력은 연/월 이동이 가능하며, 현재 보이는 월과 선택된 날짜 상태를 분리해서 관리한다. - 달력 상단은 월 좌우 화살표, 클릭형 연도 선택, `TODAY` 버튼 구조로 동작한다. @@ -40,6 +41,8 @@ - 플래너 상태는 `localStorage`에 저장되며, 날짜별 기록과 선택 날짜, 달력 보고 있던 월까지 복원된다. - 상단 전환 버튼으로 `PLANNER / STATS` 화면을 오갈 수 있다. - 통계 화면에서는 전체 집중 시간, 평균 완료율, 기록 일수, 최근 7일 흐름, 최근 기록, 베스트 데이를 보여준다. +- 통계 화면은 시작일/종료일을 직접 선택해 그 기간 기준으로 지표를 다시 계산할 수 있다. +- `NEXT DAY` 영역의 요일도 본문 날짜와 같은 규칙으로 주말 색상이 적용된다. ## 확정된 결정사항 diff --git a/TODO.md b/TODO.md index 5ea001d..5be2301 100644 --- a/TODO.md +++ b/TODO.md @@ -62,6 +62,7 @@ - [x] 통계 페이지 요구사항을 정리한다. - [x] 통계 페이지 라우팅 또는 화면 전환 구조를 설계한다. - [x] 집중 시간, 완료율, 연속 기록 같은 핵심 지표를 정의한다. +- [x] 사용자가 시작일과 종료일을 선택해서 기간별 통계를 볼 수 있게 한다. - [ ] 사용자 개인 통계 화면 기준을 정리한다. ## 6단계: 계정 및 서비스 확장 diff --git a/package-lock.json b/package-lock.json index 79f923c..9a125cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ten-minute-planner", - "version": "0.0.8", + "version": "0.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ten-minute-planner", - "version": "0.0.8", + "version": "0.0.9", "dependencies": { "vue": "^3.5.13" }, diff --git a/package.json b/package.json index 7656413..0f92024 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ten-minute-planner", "private": true, - "version": "0.0.8", + "version": "0.0.9", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.vue b/src/App.vue index 8ba0fd5..9f5d3f6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -9,6 +9,8 @@ const screenMode = ref('planner') const viewMode = ref('focus') const selectedDate = ref(new Date()) const calendarViewDate = ref(new Date(selectedDate.value)) +const statsRangeStart = ref(toKey(new Date(new Date().setDate(new Date().getDate() - 6)))) +const statsRangeEnd = ref(toKey(new Date())) const hours = [ '6', '7', '8', '9', '10', '11', '12', @@ -136,6 +138,10 @@ function toDateValue(value, fallback = new Date()) { return Number.isNaN(nextDate.getTime()) ? new Date(fallback) : nextDate } +function startOfDay(date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()) +} + function buildFallbackRecord(date) { return { dday: 'D-00 FOCUS', @@ -221,10 +227,17 @@ function restoreDateState() { if (savedState.calendarViewDate) { calendarViewDate.value = toDateValue(savedState.calendarViewDate, selectedDate.value) - return + } else { + calendarViewDate.value = new Date(selectedDate.value) } - calendarViewDate.value = new Date(selectedDate.value) + if (savedState.statsRangeStart) { + statsRangeStart.value = savedState.statsRangeStart + } + + if (savedState.statsRangeEnd) { + statsRangeEnd.value = savedState.statsRangeEnd + } } catch (error) { console.warn('저장된 날짜 상태를 불러오지 못했습니다.', error) } @@ -404,19 +417,37 @@ const plannerEntries = computed(() => .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)), ) +const normalizedStatsRange = computed(() => { + const todayKey = toKey(new Date()) + const startKey = statsRangeStart.value || todayKey + const endKey = statsRangeEnd.value || todayKey + + if (startKey <= endKey) { + return { startKey, endKey } + } + + return { startKey: endKey, endKey: startKey } +}) + +const rangeEntries = computed(() => + plannerEntries.value.filter(([key]) => + key >= normalizedStatsRange.value.startKey && key <= normalizedStatsRange.value.endKey, + ), +) + const totalFocusedMinutes = computed(() => - plannerEntries.value.reduce((total, [, record]) => total + getFocusedMinutes(record), 0), + rangeEntries.value.reduce((total, [, record]) => total + getFocusedMinutes(record), 0), ) const totalRecordedTasks = computed(() => - plannerEntries.value.reduce( + rangeEntries.value.reduce( (total, [, record]) => total + record.tasks.filter((task) => task.title.trim()).length, 0, ), ) const completedRecordedTasks = computed(() => - plannerEntries.value.reduce( + rangeEntries.value.reduce( (total, [, record]) => total + record.tasks.filter((task) => task.title.trim() && task.checked).length, 0, ), @@ -443,7 +474,7 @@ const overviewCards = computed(() => [ }, { label: 'RECORDED DAYS', - value: `${plannerEntries.value.length}일`, + value: `${rangeEntries.value.length}일`, caption: '기록이 남아 있는 날짜 수', }, { @@ -466,21 +497,23 @@ function createDateLabel(dateKey) { } 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 entries = rangeEntries.value.map(([key, record]) => { + const date = toDateValue(key) const weekdayShort = ['일', '월', '화', '수', '목', '금', '토'][date.getDay()] + const focusedMinutes = getFocusedMinutes(record) return { - key: toKey(date), + key, weekday: weekdayShort, focusedMinutes, focusedTime: formatMinutes(focusedMinutes), } }) + if (entries.length === 0) { + return [] + } + const maxMinutes = Math.max(...entries.map((entry) => entry.focusedMinutes), 60) return entries.map((entry) => ({ @@ -490,7 +523,7 @@ const weeklyRecords = computed(() => { }) const recentRecords = computed(() => - [...plannerEntries.value] + [...rangeEntries.value] .sort(([leftKey], [rightKey]) => rightKey.localeCompare(leftKey)) .slice(0, 5) .map(([key, record]) => ({ @@ -503,11 +536,11 @@ const recentRecords = computed(() => ) const bestDay = computed(() => { - if (plannerEntries.value.length === 0) { + if (rangeEntries.value.length === 0) { return null } - const [bestKey, bestRecord] = [...plannerEntries.value].reduce((bestEntry, currentEntry) => { + const [bestKey, bestRecord] = [...rangeEntries.value].reduce((bestEntry, currentEntry) => { const [, bestRecordValue] = bestEntry const [, currentRecordValue] = currentEntry @@ -523,7 +556,7 @@ const bestDay = computed(() => { }) watch( - [plannerRecords, selectedDate, calendarViewDate], + [plannerRecords, selectedDate, calendarViewDate, statsRangeStart, statsRangeEnd], () => { if (typeof window === 'undefined') { return @@ -546,6 +579,8 @@ watch( JSON.stringify({ selectedDate: selectedDate.value.toISOString(), calendarViewDate: calendarViewDate.value.toISOString(), + statsRangeStart: normalizedStatsRange.value.startKey, + statsRangeEnd: normalizedStatsRange.value.endKey, records: serializableRecords, }), ) @@ -742,7 +777,8 @@ function clearTaskLabels(record) {

NEXT DAY

- {{ secondaryDateDisplay.main }} {{ secondaryDateDisplay.weekday }} + {{ secondaryDateDisplay.main }} + {{ secondaryDateDisplay.weekday }}

내일의 첫 작업은 "{{ secondaryPlanner.tasks[0]?.title || '새 작업 추가' }}" 로 시작합니다. @@ -806,6 +842,10 @@ function clearTaskLabels(record) { :recent-records="recentRecords" :best-day="bestDay" :selected-date-label="`${selectedDateDisplay.main} ${selectedDateDisplay.weekday}`" + :range-start="normalizedStatsRange.startKey" + :range-end="normalizedStatsRange.endKey" + @update:range-start="statsRangeStart = $event" + @update:range-end="statsRangeEnd = $event" />

diff --git a/src/components/PlannerPage.vue b/src/components/PlannerPage.vue index 79ca17c..0408ceb 100644 --- a/src/components/PlannerPage.vue +++ b/src/components/PlannerPage.vue @@ -230,7 +230,10 @@ onBeforeUnmount(() => { class="flex h-[30px] border-b" :class="index === hours.length - 1 ? 'border-ink' : 'border-line'" > -
+
{{ hour }}