From fe538fc88b2c0d0bece0915add23b132a20842ad Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 22 Apr 2026 09:47:04 +0900 Subject: [PATCH] =?UTF-8?q?v0.1.18=20-=20=EC=84=A4=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EA=B3=BC=20=EA=B8=B0=EA=B0=84=ED=98=95=20D-DAY=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HANDOFF.md | 13 +- TODO.md | 7 +- backend/src/db/init.js | 14 + backend/src/db/schema.js | 2 + backend/src/routes/auth.js | 99 +++++ backend/src/routes/goals.js | 107 +++++- package-lock.json | 4 +- package.json | 2 +- src/App.vue | 530 +++++++++++++++++++-------- src/components/GoalsDashboard.vue | 233 ++++++++++++ src/components/SettingsDashboard.vue | 171 +++++++++ src/lib/authClient.js | 16 + src/lib/goalsApi.js | 8 + 13 files changed, 1033 insertions(+), 173 deletions(-) create mode 100644 src/components/GoalsDashboard.vue create mode 100644 src/components/SettingsDashboard.vue diff --git a/HANDOFF.md b/HANDOFF.md index cc3865d..7b27305 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ - 프로젝트명: 10 Minute Planner 웹 UI - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript -- 현재 기준 버전: `v0.1.17` +- 현재 기준 버전: `v0.1.18` - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` ## 기준 디자인 @@ -55,6 +55,7 @@ - 프론트 플래너 API 클라이언트는 `src/lib/plannerApi.js`에 추가되었다. - 프론트 목표 API 클라이언트는 `src/lib/goalsApi.js`에 추가되었다. - 루트에서 `npm run dev:backend`, `npm run db:generate`, `npm run db:migrate`로 백엔드 명령을 호출할 수 있다. +- 화면 탭은 `PLANNER / STATS / GOALS / SETTINGS` 기준으로 확장되었다. - 상단 전환 버튼으로 `PLANNER / STATS` 화면을 오갈 수 있다. - 통계 화면에서는 전체 집중 시간, 평균 완료율, 기록 일수, 최근 7일 흐름, 최근 기록, 베스트 데이를 보여준다. - 통계 화면은 시작일/종료일을 직접 선택해 그 기간 기준으로 지표를 다시 계산할 수 있다. @@ -127,10 +128,12 @@ - 현재는 로그인 전 플래너 진입을 막고, 인증 후에만 실제 플래너/통계 화면을 사용하도록 변경했다. - 클라우드 저장 상태는 헤더가 아니라 오른쪽 하단의 작은 토스트 형태로 표시되도록 변경했다. - 저장 완료 토스트는 한 줄짜리의 작은 상태 문구로 줄여서 존재감을 낮췄다. -- D-DAY는 본문 직접 입력이 아니라, 날짜별로 선택한 대표 목표를 보여주는 구조로 실제 연결되기 시작했다. -- 오른쪽 패널에 `D-DAY 사용` 토글, 목표 검색, 목표 선택, 목표 생성 폼이 추가되었다. -- 목표가 없거나 `D-DAY 사용`이 꺼져 있으면 본문 D-DAY 블록은 숨긴다. -- 목표 데이터는 현재 사용자 기준으로 서버에서 관리되며, 플래너 레코드에는 목표 사용 여부와 선택한 목표 ID를 함께 저장한다. +- D-DAY는 플래너 안에서 목표를 고르는 구조가 아니라, GOALS 화면에서 목표와 표시 기간을 먼저 설정하는 구조로 정리했다. +- 플래너 화면 오른쪽 패널에서는 현재 날짜에 적용된 목표가 있을 때만 D-DAY 토글을 켤 수 있고, 목표 검색 UI는 제거했다. +- 목표는 `active_from`, `active_until` 기간을 가질 수 있고, 현재 날짜가 그 범위에 들어올 때만 플래너 본문 D-DAY 후보가 된다. +- TASK LABELS도 별도 버튼 2개 대신 동일한 토글 UI로 단순화했다. ON이면 01~15를 채우고 OFF이면 비운다. +- SETTINGS 화면이 추가되어 닉네임, 이메일, 비밀번호 변경을 분리해서 관리할 수 있다. +- 백엔드에는 `/api/auth/profile`, `/api/auth/password`, `/api/goals/:goalId` 수정 API가 추가되었다. - 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. - Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다. diff --git a/TODO.md b/TODO.md index bdcdc45..91728fe 100644 --- a/TODO.md +++ b/TODO.md @@ -42,6 +42,8 @@ - [ ] 다음날 할 일 자동 제안 규칙을 정리한다. - [x] 오른쪽 패널에 `D-DAY 사용` 토글과 목표 검색/선택 UI를 추가한다. - [x] 목표를 여러 개 생성하고 날짜별 대표 목표를 선택할 수 있게 한다. +- [x] 목표 관리 화면을 별도로 분리하고, 플래너에서는 D-DAY 표시 ON/OFF만 제어한다. +- [x] 목표별로 D-DAY 표시 시작일과 종료일을 설정할 수 있게 한다. - [ ] 목표 완료 처리와 보관 상태를 구분한다. - [ ] 목표 편집/삭제 UI를 추가한다. - [ ] 목표 목록 정렬 규칙과 검색 UX를 다듬는다. @@ -76,6 +78,7 @@ - [ ] 회원 가입 / 로그인 방식 후보를 정리한다. - [x] 회원 가입 / 로그인 방식 후보를 정리한다. +- [x] 사용자 설정 화면에서 닉네임 / 이메일 / 비밀번호 수정 흐름을 분리한다. - [ ] 사용자별 문서 분리 저장 구조를 설계한다. - [ ] 공유가 아닌 개인 보관용 서비스 흐름으로 요구사항을 정리한다. - [x] 향후 출력 기능을 위한 인쇄 레이아웃 요구사항을 정리한다. @@ -106,5 +109,7 @@ - 로그인 상태일 때는 서버 저장을 우선 사용하는 흐름으로 전환 중이다. - 로그인 전에는 플래너 본문을 사용하지 못하도록 막고, 인증 후 사용 흐름으로 정리했다. - 현재는 각 날짜 플래너가 대표 목표 하나를 선택해 `D-DAY`에 연결하는 구조다. -- 목표가 선택되지 않았거나 `D-DAY 사용`이 꺼져 있으면 본문 `D-DAY` 영역은 숨긴다. +- 목표는 별도 GOALS 화면에서 검색/생성/기간 설정을 관리하고, 플래너에서는 표시 ON/OFF만 다룬다. +- 목표가 현재 날짜에 적용되지 않았거나 `D-DAY 사용`이 꺼져 있으면 본문 `D-DAY` 영역은 숨긴다. +- TASK LABELS는 버튼 묶음 대신 동일한 토글 패턴으로 단순화했다. - 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다. diff --git a/backend/src/db/init.js b/backend/src/db/init.js index b84c25e..24a0774 100644 --- a/backend/src/db/init.js +++ b/backend/src/db/init.js @@ -1,5 +1,14 @@ import { sqlite } from './client.js' +function ensureColumn(tableName, columnName, definition) { + const columns = sqlite.prepare(`PRAGMA table_info(${tableName})`).all() + const hasColumn = columns.some((column) => column.name === columnName) + + if (!hasColumn) { + sqlite.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`) + } +} + export function ensureDatabaseSchema() { sqlite.exec(` CREATE TABLE IF NOT EXISTS users ( @@ -38,6 +47,8 @@ export function ensureDatabaseSchema() { user_id INTEGER NOT NULL, title TEXT NOT NULL, target_date TEXT NOT NULL, + active_from TEXT, + active_until TEXT, status TEXT NOT NULL DEFAULT 'active', color TEXT NOT NULL DEFAULT '#1c1917', created_at INTEGER NOT NULL, @@ -46,4 +57,7 @@ export function ensureDatabaseSchema() { FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); `) + + ensureColumn('goals', 'active_from', 'TEXT') + ensureColumn('goals', 'active_until', 'TEXT') } diff --git a/backend/src/db/schema.js b/backend/src/db/schema.js index ee0bd84..afce88c 100644 --- a/backend/src/db/schema.js +++ b/backend/src/db/schema.js @@ -37,6 +37,8 @@ export const goals = sqliteTable('goals', { userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), title: text('title').notNull(), targetDate: text('target_date').notNull(), + activeFrom: text('active_from'), + activeUntil: text('active_until'), status: text('status').notNull().default('active'), color: text('color').notNull().default('#1c1917'), createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index ac2f499..303673b 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -16,6 +16,16 @@ const loginSchema = z.object({ password: z.string().min(1).max(72), }) +const profileSchema = z.object({ + email: z.string().trim().email(), + nickname: z.string().trim().min(2).max(30), +}) + +const passwordSchema = z.object({ + currentPassword: z.string().min(1).max(72), + newPassword: z.string().min(8).max(72), +}) + function sanitizeUser(user) { return { id: user.id, @@ -129,4 +139,93 @@ export async function registerAuthRoutes(app) { user: sanitizeUser(user), } }) + + app.put('/api/auth/profile', async (request, reply) => { + const user = await findAuthenticatedUser(request) + + if (!user) { + return reply.code(401).send({ + message: '인증이 필요합니다.', + }) + } + + const payload = profileSchema.safeParse(request.body) + + if (!payload.success) { + return reply.code(400).send({ + message: '프로필 입력값이 올바르지 않습니다.', + issues: payload.error.flatten(), + }) + } + + const normalizedEmail = payload.data.email.toLowerCase() + + const [existingUser] = await db + .select() + .from(users) + .where(eq(users.email, normalizedEmail)) + .limit(1) + + if (existingUser && existingUser.id !== user.id) { + return reply.code(409).send({ + message: '이미 사용 중인 이메일입니다.', + }) + } + + const [updatedUser] = await db + .update(users) + .set({ + email: normalizedEmail, + nickname: payload.data.nickname, + updatedAt: new Date(), + }) + .where(eq(users.id, user.id)) + .returning() + + return { + message: '프로필이 수정되었습니다.', + user: sanitizeUser(updatedUser), + } + }) + + app.put('/api/auth/password', async (request, reply) => { + const user = await findAuthenticatedUser(request) + + if (!user) { + return reply.code(401).send({ + message: '인증이 필요합니다.', + }) + } + + const payload = passwordSchema.safeParse(request.body) + + if (!payload.success) { + return reply.code(400).send({ + message: '비밀번호 입력값이 올바르지 않습니다.', + issues: payload.error.flatten(), + }) + } + + const passwordMatches = await verifyPassword(payload.data.currentPassword, user.passwordHash) + + if (!passwordMatches) { + return reply.code(401).send({ + message: '현재 비밀번호가 올바르지 않습니다.', + }) + } + + const passwordHash = await hashPassword(payload.data.newPassword) + + await db + .update(users) + .set({ + passwordHash, + updatedAt: new Date(), + }) + .where(eq(users.id, user.id)) + + return { + message: '비밀번호가 변경되었습니다.', + } + }) } diff --git a/backend/src/routes/goals.js b/backend/src/routes/goals.js index 87c2177..1e8622e 100644 --- a/backend/src/routes/goals.js +++ b/backend/src/routes/goals.js @@ -1,4 +1,4 @@ -import { and, asc, eq, like } from 'drizzle-orm' +import { and, asc, desc, eq, like } from 'drizzle-orm' import { z } from 'zod' import { db } from '../db/client.js' import { goals } from '../db/schema.js' @@ -7,6 +7,18 @@ import { findAuthenticatedUser } from '../lib/authSession.js' const goalSchema = z.object({ title: z.string().trim().min(1).max(80), targetDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + activeFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), + activeUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), + status: z.enum(['active', 'done', 'archived']).optional(), + color: z.string().trim().min(4).max(32).optional(), +}) + +const goalUpdateSchema = z.object({ + title: z.string().trim().min(1).max(80).optional(), + targetDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + activeFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), + activeUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), + status: z.enum(['active', 'done', 'archived']).optional(), color: z.string().trim().min(4).max(32).optional(), }) @@ -59,7 +71,7 @@ export async function registerGoalRoutes(app) { .select() .from(goals) .where(and(...filters)) - .orderBy(asc(goals.targetDate), asc(goals.id)) + .orderBy(desc(goals.updatedAt), asc(goals.targetDate), asc(goals.id)) return { goals: items } }) @@ -88,10 +100,13 @@ export async function registerGoalRoutes(app) { userId: user.id, title: payload.data.title, targetDate: payload.data.targetDate, + activeFrom: payload.data.activeFrom ?? null, + activeUntil: payload.data.activeUntil ?? null, color: payload.data.color ?? '#1c1917', - status: 'active', + status: payload.data.status ?? 'active', createdAt: now, updatedAt: now, + completedAt: payload.data.status === 'done' ? now : null, }) .returning() @@ -100,4 +115,90 @@ export async function registerGoalRoutes(app) { goal, }) }) + + app.patch('/api/goals/:goalId', async (request, reply) => { + const user = await requireAuthenticatedUser(request, reply) + + if (!user) { + return + } + + const params = z.object({ + goalId: z.coerce.number().int().positive(), + }).safeParse(request.params) + + if (!params.success) { + return reply.code(400).send({ + message: '목표 식별자가 올바르지 않습니다.', + }) + } + + const payload = goalUpdateSchema.safeParse(request.body) + + if (!payload.success) { + return reply.code(400).send({ + message: '목표 수정값이 올바르지 않습니다.', + issues: payload.error.flatten(), + }) + } + + const [existingGoal] = await db + .select() + .from(goals) + .where(and(eq(goals.id, params.data.goalId), eq(goals.userId, user.id))) + .limit(1) + + if (!existingGoal) { + return reply.code(404).send({ + message: '목표를 찾을 수 없습니다.', + }) + } + + const nextValues = { + updatedAt: new Date(), + } + + if (payload.data.title !== undefined) { + nextValues.title = payload.data.title + } + + if (payload.data.targetDate !== undefined) { + nextValues.targetDate = payload.data.targetDate + } + + if (payload.data.activeFrom !== undefined) { + nextValues.activeFrom = payload.data.activeFrom + } + + if (payload.data.activeUntil !== undefined) { + nextValues.activeUntil = payload.data.activeUntil + } + + if (payload.data.status !== undefined) { + nextValues.status = payload.data.status + } + + if (payload.data.color !== undefined) { + nextValues.color = payload.data.color + } + + if (payload.data.status === 'done' && !existingGoal.completedAt) { + nextValues.completedAt = new Date() + } + + if (payload.data.status && payload.data.status !== 'done') { + nextValues.completedAt = null + } + + const [goal] = await db + .update(goals) + .set(nextValues) + .where(and(eq(goals.id, params.data.goalId), eq(goals.userId, user.id))) + .returning() + + return { + message: '목표가 수정되었습니다.', + goal, + } + }) } diff --git a/package-lock.json b/package-lock.json index 54ab75a..b50f777 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ten-minute-planner", - "version": "0.1.17", + "version": "0.1.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ten-minute-planner", - "version": "0.1.17", + "version": "0.1.18", "dependencies": { "vue": "^3.5.13" }, diff --git a/package.json b/package.json index 4bf24ac..619cecf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ten-minute-planner", "private": true, - "version": "0.1.17", + "version": "0.1.18", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.vue b/src/App.vue index 6892a36..61daf73 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,8 +1,10 @@ + + diff --git a/src/components/SettingsDashboard.vue b/src/components/SettingsDashboard.vue new file mode 100644 index 0000000..b4b4c82 --- /dev/null +++ b/src/components/SettingsDashboard.vue @@ -0,0 +1,171 @@ + + + diff --git a/src/lib/authClient.js b/src/lib/authClient.js index 91e8945..7f135a2 100644 --- a/src/lib/authClient.js +++ b/src/lib/authClient.js @@ -79,3 +79,19 @@ export async function fetchCurrentUser(token) { token, }) } + +export async function updateProfile(token, { email, nickname }) { + return request('/api/auth/profile', { + method: 'PUT', + token, + body: { email, nickname }, + }) +} + +export async function updatePassword(token, { currentPassword, newPassword }) { + return request('/api/auth/password', { + method: 'PUT', + token, + body: { currentPassword, newPassword }, + }) +} diff --git a/src/lib/goalsApi.js b/src/lib/goalsApi.js index 7265aac..66ed7e9 100644 --- a/src/lib/goalsApi.js +++ b/src/lib/goalsApi.js @@ -47,3 +47,11 @@ export async function createGoal(token, payload) { body: payload, }) } + +export async function updateGoal(token, goalId, payload) { + return request(`/api/goals/${goalId}`, { + method: 'PATCH', + token, + body: payload, + }) +}