From 9b788406eab7421ba30f1298e1013651e5c1fb7b Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 22 Apr 2026 10:19:23 +0900 Subject: [PATCH] =?UTF-8?q?v0.1.19=20-=20=EC=82=AC=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=EA=B3=BC=20D-DAY?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=20=EB=B0=A9=EC=A7=80=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HANDOFF.md | 9 ++- TODO.md | 6 ++ backend/src/routes/goals.js | 88 +++++++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- src/App.vue | 140 +++++++++++++++++++++++++----------- 6 files changed, 204 insertions(+), 45 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 7b27305..2a941c8 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ - 프로젝트명: 10 Minute Planner 웹 UI - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript -- 현재 기준 버전: `v0.1.18` +- 현재 기준 버전: `v0.1.19` - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` ## 기준 디자인 @@ -56,6 +56,7 @@ - 프론트 목표 API 클라이언트는 `src/lib/goalsApi.js`에 추가되었다. - 루트에서 `npm run dev:backend`, `npm run db:generate`, `npm run db:migrate`로 백엔드 명령을 호출할 수 있다. - 화면 탭은 `PLANNER / STATS / GOALS / SETTINGS` 기준으로 확장되었다. +- 기존 상단 헤더는 왼쪽 사이드 내비게이션으로 재구성되었고, A5 본문이 더 넓게 보이도록 조정되었다. - 상단 전환 버튼으로 `PLANNER / STATS` 화면을 오갈 수 있다. - 통계 화면에서는 전체 집중 시간, 평균 완료율, 기록 일수, 최근 7일 흐름, 최근 기록, 베스트 데이를 보여준다. - 통계 화면은 시작일/종료일을 직접 선택해 그 기간 기준으로 지표를 다시 계산할 수 있다. @@ -134,6 +135,12 @@ - TASK LABELS도 별도 버튼 2개 대신 동일한 토글 UI로 단순화했다. ON이면 01~15를 채우고 OFF이면 비운다. - SETTINGS 화면이 추가되어 닉네임, 이메일, 비밀번호 변경을 분리해서 관리할 수 있다. - 백엔드에는 `/api/auth/profile`, `/api/auth/password`, `/api/goals/:goalId` 수정 API가 추가되었다. +- 왼쪽 사이드, 플래너 본문 래퍼, 오른쪽 정보 패널 모두 둥근 카드 톤으로 맞춰서 화면 전체의 통일감을 높였다. +- 플래너 집중 보기에서는 본문과 오른쪽 패널이 각각 독립 스크롤되도록 바뀌어서 동시에 참조하기 쉽다. +- TASK LABELS, D-DAY 토글은 공통 사이즈의 스위치로 통일했고, `translate` 기반 애니메이션으로 부드럽게 움직이게 했다. +- 목표 생성 폼은 기본적으로 `표시 시작일 = 오늘`, `표시 종료일 = 목표일` 흐름으로 자동 채워진다. +- D-DAY 기간은 서로 겹칠 수 없고, 프론트와 백엔드 모두 중복 기간을 감지하면 저장을 막는다. +- 현재 날짜에 적용된 목표가 있는 경우 D-DAY는 기본적으로 보이고, 해당 날짜에서만 토글로 숨길 수 있다. - 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. - Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다. diff --git a/TODO.md b/TODO.md index 91728fe..29b4c5d 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,7 @@ - [x] 목표를 여러 개 생성하고 날짜별 대표 목표를 선택할 수 있게 한다. - [x] 목표 관리 화면을 별도로 분리하고, 플래너에서는 D-DAY 표시 ON/OFF만 제어한다. - [x] 목표별로 D-DAY 표시 시작일과 종료일을 설정할 수 있게 한다. +- [x] 목표 표시 기간이 서로 겹치면 저장되지 않도록 막는다. - [ ] 목표 완료 처리와 보관 상태를 구분한다. - [ ] 목표 편집/삭제 UI를 추가한다. - [ ] 목표 목록 정렬 규칙과 검색 UX를 다듬는다. @@ -79,6 +80,8 @@ - [ ] 회원 가입 / 로그인 방식 후보를 정리한다. - [x] 회원 가입 / 로그인 방식 후보를 정리한다. - [x] 사용자 설정 화면에서 닉네임 / 이메일 / 비밀번호 수정 흐름을 분리한다. +- [x] 상단 헤더를 왼쪽 사이드 내비게이션 구조로 재배치한다. +- [x] 본문과 오른쪽 패널이 각각 독립 스크롤되도록 조정한다. - [ ] 사용자별 문서 분리 저장 구조를 설계한다. - [ ] 공유가 아닌 개인 보관용 서비스 흐름으로 요구사항을 정리한다. - [x] 향후 출력 기능을 위한 인쇄 레이아웃 요구사항을 정리한다. @@ -111,5 +114,8 @@ - 현재는 각 날짜 플래너가 대표 목표 하나를 선택해 `D-DAY`에 연결하는 구조다. - 목표는 별도 GOALS 화면에서 검색/생성/기간 설정을 관리하고, 플래너에서는 표시 ON/OFF만 다룬다. - 목표가 현재 날짜에 적용되지 않았거나 `D-DAY 사용`이 꺼져 있으면 본문 `D-DAY` 영역은 숨긴다. +- 현재 날짜에 적용된 목표가 있으면 D-DAY는 기본적으로 보이고, 사용자가 해당 날짜에서만 OFF로 끌 수 있다. +- 목표 생성 시 표시 시작일 기본값은 오늘, 표시 종료일 기본값은 목표일로 맞춘다. +- 표시 기간이 다른 진행 중 목표와 겹치면 프론트와 백엔드 모두 저장을 막는다. - TASK LABELS는 버튼 묶음 대신 동일한 토글 패턴으로 단순화했다. - 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다. diff --git a/backend/src/routes/goals.js b/backend/src/routes/goals.js index 1e8622e..b04f69f 100644 --- a/backend/src/routes/goals.js +++ b/backend/src/routes/goals.js @@ -40,6 +40,39 @@ async function requireAuthenticatedUser(request, reply) { return user } +function hasGoalRangeOverlap(leftStart, leftEnd, rightStart, rightEnd) { + return leftStart <= rightEnd && leftEnd >= rightStart +} + +async function validateGoalSchedule({ + userId, + activeFrom, + activeUntil, + status, + excludeGoalId = null, +}) { + if (!activeFrom || !activeUntil || status !== 'active') { + return null + } + + const existingGoals = await db + .select() + .from(goals) + .where(eq(goals.userId, userId)) + + return existingGoals.find((goal) => { + if (excludeGoalId && goal.id === excludeGoalId) { + return false + } + + if (goal.status !== 'active' || !goal.activeFrom || !goal.activeUntil) { + return false + } + + return hasGoalRangeOverlap(activeFrom, activeUntil, goal.activeFrom, goal.activeUntil) + }) ?? null +} + export async function registerGoalRoutes(app) { app.get('/api/goals', async (request, reply) => { const user = await requireAuthenticatedUser(request, reply) @@ -92,6 +125,31 @@ export async function registerGoalRoutes(app) { }) } + if ((payload.data.activeFrom && !payload.data.activeUntil) || (!payload.data.activeFrom && payload.data.activeUntil)) { + return reply.code(400).send({ + message: '표시 시작일과 종료일은 함께 입력해 주세요.', + }) + } + + if (payload.data.activeFrom && payload.data.activeUntil && payload.data.activeFrom > payload.data.activeUntil) { + return reply.code(400).send({ + message: '표시 종료일은 시작일보다 빠를 수 없습니다.', + }) + } + + const overlappedGoal = await validateGoalSchedule({ + userId: user.id, + activeFrom: payload.data.activeFrom ?? null, + activeUntil: payload.data.activeUntil ?? null, + status: payload.data.status ?? 'active', + }) + + if (overlappedGoal) { + return reply.code(409).send({ + message: `표시 기간이 "${overlappedGoal.title}" 목표와 겹칩니다. D-DAY 기간은 하나만 설정할 수 있습니다.`, + }) + } + const now = new Date() const [goal] = await db @@ -154,6 +212,36 @@ export async function registerGoalRoutes(app) { }) } + const nextActiveFrom = payload.data.activeFrom !== undefined ? payload.data.activeFrom : existingGoal.activeFrom + const nextActiveUntil = payload.data.activeUntil !== undefined ? payload.data.activeUntil : existingGoal.activeUntil + const nextStatus = payload.data.status ?? existingGoal.status + + if ((nextActiveFrom && !nextActiveUntil) || (!nextActiveFrom && nextActiveUntil)) { + return reply.code(400).send({ + message: '표시 시작일과 종료일은 함께 입력해 주세요.', + }) + } + + if (nextActiveFrom && nextActiveUntil && nextActiveFrom > nextActiveUntil) { + return reply.code(400).send({ + message: '표시 종료일은 시작일보다 빠를 수 없습니다.', + }) + } + + const overlappedGoal = await validateGoalSchedule({ + userId: user.id, + activeFrom: nextActiveFrom, + activeUntil: nextActiveUntil, + status: nextStatus, + excludeGoalId: existingGoal.id, + }) + + if (overlappedGoal) { + return reply.code(409).send({ + message: `표시 기간이 "${overlappedGoal.title}" 목표와 겹칩니다. D-DAY 기간은 하나만 설정할 수 있습니다.`, + }) + } + const nextValues = { updatedAt: new Date(), } diff --git a/package-lock.json b/package-lock.json index b50f777..2b055a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ten-minute-planner", - "version": "0.1.18", + "version": "0.1.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ten-minute-planner", - "version": "0.1.18", + "version": "0.1.19", "dependencies": { "vue": "^3.5.13" }, diff --git a/package.json b/package.json index 619cecf..3af0bfb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ten-minute-planner", "private": true, - "version": "0.1.18", + "version": "0.1.19", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.vue b/src/App.vue index 61daf73..eb304d1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -210,7 +210,7 @@ function startOfDay(date) { function buildFallbackRecord(date) { return { comment: '', - goalEnabled: false, + goalEnabled: true, selectedGoalId: null, tasks: Array.from({ length: 15 }, (_, index) => ({ label: '', @@ -230,7 +230,7 @@ function buildFallbackRecord(date) { function normalizeRecord(record) { return { ...record, - goalEnabled: Boolean(record.goalEnabled), + goalEnabled: record.goalEnabled !== false, selectedGoalId: record.selectedGoalId ?? null, tasks: record.tasks.map((task, index) => ({ label: task.label ?? task.id ?? '', @@ -709,6 +709,28 @@ function setSyncFeedback(status, message, options = {}) { } } +function getTodayKey() { + return toKey(new Date()) +} + +function findOverlappingGoal({ activeFrom, activeUntil, status, excludeGoalId = null }) { + if (!activeFrom || !activeUntil || status !== 'active') { + return null + } + + return goals.value.find((goal) => { + if (excludeGoalId && goal.id === excludeGoalId) { + return false + } + + if (goal.status !== 'active' || !goal.activeFrom || !goal.activeUntil) { + return false + } + + return activeFrom <= goal.activeUntil && activeUntil >= goal.activeFrom + }) ?? null +} + function resetAuthForm() { authForm.nickname = '' authForm.email = '' @@ -718,7 +740,7 @@ function resetAuthForm() { function resetGoalForm() { goalForm.title = '' goalForm.targetDate = '' - goalForm.activeFrom = '' + goalForm.activeFrom = getTodayKey() goalForm.activeUntil = '' goalForm.status = 'active' editingGoalId.value = null @@ -875,6 +897,17 @@ async function submitGoal() { return } + const overlappedGoal = findOverlappingGoal({ + activeFrom: goalForm.activeFrom || null, + activeUntil: goalForm.activeUntil || null, + status: goalForm.status, + }) + + if (overlappedGoal) { + goalMessage.value = `"${overlappedGoal.title}" 목표와 표시 기간이 겹칩니다. D-DAY 기간은 하나만 설정할 수 있습니다.` + return + } + goalBusy.value = true goalMessage.value = '' @@ -900,6 +933,14 @@ async function submitGoal() { function updateGoalFormField({ field, value }) { goalForm[field] = value + + if (field === 'targetDate' && !editingGoalId.value) { + if (!goalForm.activeFrom) { + goalForm.activeFrom = getTodayKey() + } + + goalForm.activeUntil = value + } } function startGoalEdit(goal) { @@ -932,6 +973,18 @@ async function saveGoalEdit() { return } + const overlappedGoal = findOverlappingGoal({ + activeFrom: goalForm.activeFrom || null, + activeUntil: goalForm.activeUntil || null, + status: goalForm.status, + excludeGoalId: editingGoalId.value, + }) + + if (overlappedGoal) { + goalMessage.value = `"${overlappedGoal.title}" 목표와 표시 기간이 겹칩니다. D-DAY 기간은 하나만 설정할 수 있습니다.` + return + } + goalBusy.value = true goalMessage.value = '' @@ -1172,6 +1225,7 @@ async function printSelectedPlanner(layout = 'single') { } onMounted(() => { + resetGoalForm() setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', { visible: false, }) @@ -1180,9 +1234,9 @@ onMounted(() => { -
+

NAVIGATION

-
+
@@ -1333,10 +1391,10 @@ onMounted(() => { -
+