diff --git a/HANDOFF.md b/HANDOFF.md index 47e13a4..a4b649e 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -228,6 +228,10 @@ - 로그인 모달에 `로그인 유지` 체크박스를 추가했다. 기본값은 OFF이며, OFF 상태에서는 인증 토큰을 `sessionStorage`에 저장해 브라우저 세션이 끝나면 사라지고, ON 상태에서만 `localStorage`에 저장한다. - 현재 로그아웃은 프론트 저장 토큰을 지우는 수준이다. 개인 기록 서비스 성격을 고려하면 다음 단계에서 서버 세션 폐기 API와 미사용 자동 로그아웃 옵션을 추가하는 편이 좋다. - 비로그인 랜딩에 `DEMO VIEW`를 추가했다. 데모는 실제 저장/로그인 상태와 분리된 읽기 전용 샘플이며, 어제/오늘/내일 3일치 플래너를 전환해서 제품 감각을 먼저 볼 수 있다. +- 플래너 본문 `MEMO`와 `TIME TABLE` 하단 높이를 맞추기 위해 TASK/MEMO 리스트 간격과 행 높이를 조정했다. TASK 드래그 선택 피드백은 레이아웃 흔들림을 줄이도록 ring 대신 배경색만 사용한다. +- 이월된 할 일은 `carryoverFrom` 날짜를 가진다. TASK 본문에는 `이월` 배지를 표시하고, 클릭하면 오른쪽 `READ NEXT` 영역에 원래 시작 날짜를 안내한다. +- 이월된 할 일을 완료할 때는 이전 날짜의 같은 이월 항목까지 모두 체크할지, 현재 날짜만 체크할지 선택한다. 기본값은 `항상 물어보기`이며, SETTINGS의 `CARRYOVER CHECK`에서 `항상 이전까지 체크` / `항상 오늘만 체크`로 바꿀 수 있다. +- 오른쪽 플래너 사이드바의 중복 `STATS` 카드는 제거했다. 미완료 항목 이월 버튼은 `READ NEXT` 카드 아래로 이동했다. ## 갱신 규칙 diff --git a/TODO.md b/TODO.md index 5c2d150..72b1a6b 100644 --- a/TODO.md +++ b/TODO.md @@ -92,6 +92,8 @@ - [x] 로그인 화면 문구와 관리자 정보 노출 지점을 일반 사용자 기준으로 정리한다. - [x] 비로그인 사용자가 저장 없이 볼 수 있는 3일치 샘플 데모 화면을 추가한다. - [x] 미완료 항목을 다음 날짜 빈칸으로 이월하는 버튼을 추가한다. +- [x] 이월된 할 일에 배지를 표시하고 원래 시작 날짜를 확인할 수 있게 한다. +- [x] 이월된 할 일을 체크할 때 이전 날짜까지 함께 완료할지 선택하는 정책을 추가한다. - [ ] 이메일 인증 플로우를 설계하고 구현한다. - [ ] 비밀번호 찾기 / 재설정 토큰 흐름을 추가한다. - [ ] 로그인 및 인증 관련 rate limit / 잠금 정책을 추가한다. diff --git a/src/App.vue b/src/App.vue index 3bbd408..b844297 100644 --- a/src/App.vue +++ b/src/App.vue @@ -31,6 +31,8 @@ import { const GUIDE_TOOLTIP_STORAGE_KEY = 'ten-minute-guide-tooltips-hidden' const DDAY_DISABLED_STORAGE_KEY = 'ten-minute-dday-disabled-dates' +const CARRYOVER_CHECK_POLICY_STORAGE_KEY = 'ten-minute-carryover-check-policy' +const CARRYOVER_CHECK_POLICIES = ['ask', 'all', 'current'] const screenMode = ref('planner') const viewMode = ref('focus') const printLayout = ref('single') @@ -83,6 +85,9 @@ const passwordBusy = ref(false) const profileMessage = ref('') const passwordMessage = ref('') const carryoverMessage = ref('') +const carryoverInspectMessage = ref('') +const carryoverCheckPolicy = ref(readCarryoverCheckPolicy()) +const carryoverCheckPrompt = ref(null) const guideTooltipResetMessage = ref('') const hiddenGuideTooltips = ref(readHiddenGuideTooltips()) const ddayDisabledDateKeys = ref(readDdayDisabledDateKeys()) @@ -154,6 +159,23 @@ function resetGuideTooltips() { persistHiddenGuideTooltips() } +function readCarryoverCheckPolicy() { + if (typeof window === 'undefined') { + return 'ask' + } + + const value = window.localStorage.getItem(CARRYOVER_CHECK_POLICY_STORAGE_KEY) + return CARRYOVER_CHECK_POLICIES.includes(value) ? value : 'ask' +} + +function updateCarryoverCheckPolicy(value) { + carryoverCheckPolicy.value = CARRYOVER_CHECK_POLICIES.includes(value) ? value : 'ask' + + if (typeof window !== 'undefined') { + window.localStorage.setItem(CARRYOVER_CHECK_POLICY_STORAGE_KEY, carryoverCheckPolicy.value) + } +} + function readDdayDisabledDateKeys() { if (typeof window === 'undefined') { return [] @@ -421,6 +443,7 @@ function normalizeRecord(record) { label: task.label ?? task.id ?? '', title: task.title ?? '', checked: Boolean(task.checked), + carryoverFrom: task.carryoverFrom ?? null, })), memo: record.memo.map((item) => { if (typeof item === 'string') { @@ -642,6 +665,7 @@ function shiftDate(amount) { selectedDate.value = next calendarViewDate.value = new Date(next) carryoverMessage.value = '' + carryoverInspectMessage.value = '' } function shiftCalendarMonth(amount) { @@ -660,6 +684,7 @@ function selectDate(date) { selectedDate.value = new Date(date) calendarViewDate.value = new Date(date) carryoverMessage.value = '' + carryoverInspectMessage.value = '' } function updateComment(record, value) { @@ -687,14 +712,97 @@ function updateTaskLabel(record, { index, value }) { function updateTaskTitle(record, { index, value }) { record.tasks[index].title = value + + if (!value.trim()) { + record.tasks[index].carryoverFrom = null + } + schedulePlannerSyncForRecord(record) } function toggleTask(record, index) { - record.tasks[index].checked = !record.tasks[index].checked + const task = record.tasks[index] + const nextChecked = !task.checked + + if (nextChecked && task.carryoverFrom) { + if (carryoverCheckPolicy.value === 'all') { + completeCarryoverTaskChain(record, index) + return + } + + if (carryoverCheckPolicy.value === 'ask') { + carryoverCheckPrompt.value = { + record, + index, + taskTitle: task.title, + originKey: task.carryoverFrom, + } + return + } + } + + task.checked = nextChecked schedulePlannerSyncForRecord(record) } +function completeCarryoverTaskCurrent(record, index) { + record.tasks[index].checked = true + schedulePlannerSyncForRecord(record) +} + +function completeCarryoverTaskChain(record, index) { + const task = record.tasks[index] + const originKey = task.carryoverFrom + const recordKey = findRecordKey(record) + + task.checked = true + schedulePlannerSyncForRecord(record) + + if (!originKey || !recordKey) { + return + } + + Object.entries(plannerRecords).forEach(([key, candidateRecord]) => { + if (key < originKey || key >= recordKey) { + return + } + + const matchingTask = candidateRecord.tasks.find((candidateTask) => + candidateTask.title === task.title + && (candidateTask.carryoverFrom === originKey || key === originKey), + ) + + if (matchingTask) { + matchingTask.checked = true + schedulePlannerSync(key) + } + }) +} + +function answerCarryoverCheckPrompt(scope, rememberPolicy = null) { + const prompt = carryoverCheckPrompt.value + + if (!prompt) { + return + } + + if (rememberPolicy) { + updateCarryoverCheckPolicy(rememberPolicy) + } + + if (scope === 'all') { + completeCarryoverTaskChain(prompt.record, prompt.index) + } else { + completeCarryoverTaskCurrent(prompt.record, prompt.index) + } + + carryoverCheckPrompt.value = null +} + +function closeCarryoverCheckPrompt() { + carryoverCheckPrompt.value = null +} + function clearTasks(record, indexes) { indexes.forEach((index) => { if (!record.tasks[index]) { @@ -703,6 +811,7 @@ function clearTasks(record, indexes) { record.tasks[index].title = '' record.tasks[index].checked = false + record.tasks[index].carryoverFrom = null }) schedulePlannerSyncForRecord(record) } @@ -712,6 +821,7 @@ function carryIncompleteTasksToNextDay() { label: task.label, title: task.title, checked: false, + carryoverFrom: task.carryoverFrom ?? selectedDateKey.value, })) if (tasksToCarry.length === 0) { @@ -748,6 +858,15 @@ function carryIncompleteTasksToNextDay() { : `${nextDateLabel} 빈칸 ${copyCount}개까지만 이월했습니다.` } +function inspectCarryoverTask(task) { + if (!task.carryoverFrom) { + carryoverInspectMessage.value = '' + return + } + + carryoverInspectMessage.value = `"${task.title}" 항목은 ${createDateLabel(task.carryoverFrom)}부터 이월된 할 일입니다.` +} + function updateMemo(record, { index, value }) { record.memo[index].text = value schedulePlannerSyncForRecord(record) @@ -2122,7 +2241,7 @@ onBeforeUnmount(() => { 10 MINITES PLANNER

- 할 일, 집중 시간, 짧은 코멘트를 한 장의 다이어리로 남기고 내일의 첫 작업까지 이어가세요. + 장기 목표, 할 일, 집중 시간, 짧은 코멘트를 한 장의 다이어리로 남기고 내일의 첫 작업까지 이어가세요.

@@ -2282,6 +2401,7 @@ onBeforeUnmount(() => { @update:task-title="updateTaskTitle(planner, $event)" @toggle:task="toggleTask(planner, $event)" @clear:tasks="clearTasks(planner, $event)" + @inspect:carryover="inspectCarryoverTask" @update:memo-label="updateMemoLabel(planner, $event)" @update:memo="updateMemo(planner, $event)" @update:timetable="updateTimetable(planner, $event)" @@ -2371,50 +2491,9 @@ onBeforeUnmount(() => { -
-
-
-

미완료 항목 이월

-

- 체크하지 못한 할 일을 다음 날짜의 빈칸에 순서대로 복사합니다. -

-
- -

- {{ carryoverMessage }} -

-
-
-
-
-
-

STATS

-
-
-

{{ completionRate }}%

-

TASK COMPLETION

-
-
-

{{ formatTotalTimeKorean(planner) }}

-

FOCUSED TIME

-
-
-
-
-

NEXT DAY

@@ -2439,6 +2518,26 @@ onBeforeUnmount(() => { {{ item || ' ' }}

+ +

+ {{ carryoverMessage }} +

+

+ {{ carryoverInspectMessage }} +

@@ -2542,50 +2641,9 @@ onBeforeUnmount(() => {
-
-
-
-

미완료 항목 이월

-

- 체크하지 못한 할 일을 다음 날짜의 빈칸에 순서대로 복사합니다. -

-
- -

- {{ carryoverMessage }} -

-
-
-
-
-
-

STATS

-
-
-

{{ completionRate }}%

-

TASK COMPLETION

-
-
-

{{ formatTotalTimeKorean(planner) }}

-

FOCUSED TIME

-
-
-
-
-

NEXT DAY

@@ -2610,6 +2668,26 @@ onBeforeUnmount(() => { {{ item || ' ' }}

+ +

+ {{ carryoverMessage }} +

+

+ {{ carryoverInspectMessage }} +

@@ -2656,6 +2734,7 @@ onBeforeUnmount(() => { @update:task-title="updateTaskTitle(planner, $event)" @toggle:task="toggleTask(planner, $event)" @clear:tasks="clearTasks(planner, $event)" + @inspect:carryover="inspectCarryoverTask" @update:memo-label="updateMemoLabel(planner, $event)" @update:memo="updateMemo(planner, $event)" @update:timetable="updateTimetable(planner, $event)" @@ -2680,6 +2759,7 @@ onBeforeUnmount(() => { @update:task-title="updateTaskTitle(secondaryPlanner, $event)" @toggle:task="toggleTask(secondaryPlanner, $event)" @clear:tasks="clearTasks(secondaryPlanner, $event)" + @inspect:carryover="inspectCarryoverTask" @update:memo-label="updateMemoLabel(secondaryPlanner, $event)" @update:memo="updateMemo(secondaryPlanner, $event)" @update:timetable="updateTimetable(secondaryPlanner, $event)" @@ -2718,8 +2798,10 @@ onBeforeUnmount(() => { :profile-message="profileMessage" :password-message="passwordMessage" :guide-tooltip-reset-message="guideTooltipResetMessage" + :carryover-check-policy="carryoverCheckPolicy" @update:profile-field="updateProfileField" @update:password-field="updatePasswordField" + @update:carryover-check-policy="updateCarryoverCheckPolicy" @submit:profile="submitProfileForm" @submit:password="submitPasswordForm" @reset-guide-tooltips="resetGuideTooltips" @@ -2769,6 +2851,7 @@ onBeforeUnmount(() => { @update:task-title="updateTaskTitle(planner, $event)" @toggle:task="toggleTask(planner, $event)" @clear:tasks="clearTasks(planner, $event)" + @inspect:carryover="inspectCarryoverTask" @update:memo-label="updateMemoLabel(planner, $event)" @update:memo="updateMemo(planner, $event)" @update:timetable="updateTimetable(planner, $event)" @@ -2795,6 +2878,7 @@ onBeforeUnmount(() => { @update:task-title="updateTaskTitle(planner, $event)" @toggle:task="toggleTask(planner, $event)" @clear:tasks="clearTasks(planner, $event)" + @inspect:carryover="inspectCarryoverTask" @update:memo-label="updateMemoLabel(planner, $event)" @update:memo="updateMemo(planner, $event)" @update:timetable="updateTimetable(planner, $event)" @@ -2818,6 +2902,7 @@ onBeforeUnmount(() => { @update:task-title="updateTaskTitle(secondaryPlanner, $event)" @toggle:task="toggleTask(secondaryPlanner, $event)" @clear:tasks="clearTasks(secondaryPlanner, $event)" + @inspect:carryover="inspectCarryoverTask" @update:memo-label="updateMemoLabel(secondaryPlanner, $event)" @update:memo="updateMemo(secondaryPlanner, $event)" @update:timetable="updateTimetable(secondaryPlanner, $event)" @@ -2840,6 +2925,63 @@ onBeforeUnmount(() => { @update:field="updateAuthField" /> +
+
+

Carryover Task

+

+ 이월된 할 일을 완료할까요? +

+

+ "{{ carryoverCheckPrompt.taskTitle }}" 항목은 {{ createDateLabel(carryoverCheckPrompt.originKey) }}부터 이월되었습니다. + 이전 날짜의 같은 항목도 함께 완료 처리할 수 있습니다. +

+ +
+ + +
+ +
+ + + +
+
+
+ - - 로그인 유지 - - 체크하지 않으면 브라우저를 닫을 때 로그인 정보가 사라집니다. - - + 로그인 상태 유지

{

-
+
TASKS @@ -320,10 +321,10 @@ onBeforeUnmount(() => { v-for="(task, index) in tasks" :key="task.id ?? index" :data-task-index="index" - class="flex min-h-[38px] select-none items-center border-b transition-colors" + class="flex h-[38px] select-none items-center border-b transition-colors" :class="[ index % 5 === 4 || index === tasks.length - 1 ? 'border-ink' : 'border-line', - isTaskSelected(index) ? 'bg-amber-100/55 ring-1 ring-inset ring-amber-500/40' : '', + isTaskSelected(index) ? 'bg-amber-100/55' : '', ]" @pointerdown="startTaskSelection(index, $event)" @pointerenter="moveTaskSelection(index)" @@ -338,7 +339,7 @@ onBeforeUnmount(() => { @input="emit('update:task-label', { index, value: $event.target.value })" />
-
+
{ @focus="clearTaskSelectionOnFocus" @input="emit('update:task-title', { index, value: $event.target.value })" /> +
+
+