From 4355185203388574a07e61fd248a29eda64cc468 Mon Sep 17 00:00:00 2001 From: zenn Date: Tue, 21 Apr 2026 18:37:06 +0900 Subject: [PATCH] =?UTF-8?q?v0.1.17=20-=20=EB=AA=A9=ED=91=9C=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EB=B0=8F=20D-DAY=20=EC=84=A0=ED=83=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HANDOFF.md | 10 +- TODO.md | 16 ++- backend/src/db/init.js | 13 ++ backend/src/db/schema.js | 12 ++ backend/src/routes/goals.js | 103 +++++++++++++++ backend/src/server.js | 3 + package-lock.json | 4 +- package.json | 2 +- src/App.vue | 228 ++++++++++++++++++++++++++++++++- src/components/PlannerPage.vue | 10 +- src/lib/goalsApi.js | 49 +++++++ 11 files changed, 429 insertions(+), 21 deletions(-) create mode 100644 backend/src/routes/goals.js create mode 100644 src/lib/goalsApi.js diff --git a/HANDOFF.md b/HANDOFF.md index eb419f2..cc3865d 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ - 프로젝트명: 10 Minute Planner 웹 UI - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript -- 현재 기준 버전: `v0.1.16` +- 현재 기준 버전: `v0.1.17` - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` ## 기준 디자인 @@ -31,6 +31,7 @@ - 백엔드 엔트리 포인트: `backend/src/server.js` - 백엔드 DB 스키마: `backend/src/db/schema.js` - 백엔드 인증 라우트: `backend/src/routes/auth.js` +- 백엔드 목표 라우트: `backend/src/routes/goals.js` - 백엔드 비밀번호/세션 유틸: `backend/src/lib/password.js` - Tailwind 설정은 완료되어 있으며, 이 프로젝트의 스타일링 기준으로 유지한다. - 현재 선택 날짜는 시스템 날짜 기준으로 시작한다. @@ -52,6 +53,7 @@ - 프론트는 헤더에서 `LOGIN` / `SIGN UP` 모달을 열 수 있고, 로그인 상태면 닉네임과 `LOGOUT` 버튼을 표시한다. - 인증 토큰과 현재 사용자 정보는 프론트 로컬 저장소에 따로 유지하고, 앱 시작 시 `/api/auth/me`로 세션 복원을 시도한다. - 프론트 플래너 API 클라이언트는 `src/lib/plannerApi.js`에 추가되었다. +- 프론트 목표 API 클라이언트는 `src/lib/goalsApi.js`에 추가되었다. - 루트에서 `npm run dev:backend`, `npm run db:generate`, `npm run db:migrate`로 백엔드 명령을 호출할 수 있다. - 상단 전환 버튼으로 `PLANNER / STATS` 화면을 오갈 수 있다. - 통계 화면에서는 전체 집중 시간, 평균 완료율, 기록 일수, 최근 7일 흐름, 최근 기록, 베스트 데이를 보여준다. @@ -125,8 +127,10 @@ - 현재는 로그인 전 플래너 진입을 막고, 인증 후에만 실제 플래너/통계 화면을 사용하도록 변경했다. - 클라우드 저장 상태는 헤더가 아니라 오른쪽 하단의 작은 토스트 형태로 표시되도록 변경했다. - 저장 완료 토스트는 한 줄짜리의 작은 상태 문구로 줄여서 존재감을 낮췄다. -- D-DAY는 본문 직접 입력보다 별도 목표 목록에서 선택한 대표 목표를 보여주는 방식이 적합하다는 방향으로 정리했다. -- 목표가 없을 때는 본문 D-DAY를 숨기고, 오른쪽 패널의 `D-DAY 사용` 메뉴에서 목표를 검색/선택하게 하는 UX가 현재 추천안이다. +- D-DAY는 본문 직접 입력이 아니라, 날짜별로 선택한 대표 목표를 보여주는 구조로 실제 연결되기 시작했다. +- 오른쪽 패널에 `D-DAY 사용` 토글, 목표 검색, 목표 선택, 목표 생성 폼이 추가되었다. +- 목표가 없거나 `D-DAY 사용`이 꺼져 있으면 본문 D-DAY 블록은 숨긴다. +- 목표 데이터는 현재 사용자 기준으로 서버에서 관리되며, 플래너 레코드에는 목표 사용 여부와 선택한 목표 ID를 함께 저장한다. - 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. - Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다. diff --git a/TODO.md b/TODO.md index 14b2ea0..bdcdc45 100644 --- a/TODO.md +++ b/TODO.md @@ -7,7 +7,7 @@ - 기본 레이아웃은 `1페이지 + 우측 정보 패널`을 유지한다. - `2페이지 펼침 보기`는 비교용 보조 모드로 유지한다. - 스타일은 Vue + TailwindCSS 기준으로 구현한다. -- D-DAY는 목표 관리 패널과 연결되는 기능으로 추후 구현한다. +- D-DAY는 목표 관리 패널과 연결하는 구조로 전환했고, 세부 확장은 계속 진행한다. ## 1단계: 플래너 핵심 상호작용 @@ -36,12 +36,15 @@ ## 3단계: 목표와 회고 기능 -- [ ] 목표 관리 패널을 설계한다. -- [ ] 선택한 목표 기준으로 `D-DAY`가 자동 계산되게 한다. +- [x] 목표 관리 패널 기본 구조를 설계한다. +- [x] 선택한 목표 기준으로 `D-DAY`가 자동 계산되게 한다. - [ ] 우측 요약 패널의 `PREV SNAPSHOT`, `READ NEXT`를 실제 데이터 기반으로 연결한다. - [ ] 다음날 할 일 자동 제안 규칙을 정리한다. -- [ ] 오른쪽 패널에 `D-DAY 사용` 토글과 목표 검색/선택 UI를 추가한다. -- [ ] 목표를 여러 개 생성하고 날짜별 대표 목표를 선택할 수 있게 한다. +- [x] 오른쪽 패널에 `D-DAY 사용` 토글과 목표 검색/선택 UI를 추가한다. +- [x] 목표를 여러 개 생성하고 날짜별 대표 목표를 선택할 수 있게 한다. +- [ ] 목표 완료 처리와 보관 상태를 구분한다. +- [ ] 목표 편집/삭제 UI를 추가한다. +- [ ] 목표 목록 정렬 규칙과 검색 UX를 다듬는다. ## 4단계: 데이터 구조와 저장 @@ -98,7 +101,10 @@ - 현재 백엔드는 `backend/` 폴더에 `Fastify + Drizzle + SQLite` 기준 초안이 추가되었다. - 현재 백엔드는 회원가입, 로그인, 현재 사용자 확인용 기본 인증 API까지 포함한다. - 현재 백엔드는 사용자별 플래너 단건 저장/조회와 범위 조회 API까지 포함한다. +- 현재 백엔드는 사용자별 목표 목록 조회와 목표 생성 API까지 포함한다. - 프론트에는 로그인/회원가입 모달과 현재 사용자 상태 표시가 추가되었다. - 로그인 상태일 때는 서버 저장을 우선 사용하는 흐름으로 전환 중이다. - 로그인 전에는 플래너 본문을 사용하지 못하도록 막고, 인증 후 사용 흐름으로 정리했다. +- 현재는 각 날짜 플래너가 대표 목표 하나를 선택해 `D-DAY`에 연결하는 구조다. +- 목표가 선택되지 않았거나 `D-DAY 사용`이 꺼져 있으면 본문 `D-DAY` 영역은 숨긴다. - 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다. diff --git a/backend/src/db/init.js b/backend/src/db/init.js index f2b8473..b84c25e 100644 --- a/backend/src/db/init.js +++ b/backend/src/db/init.js @@ -32,5 +32,18 @@ export function ensureDatabaseSchema() { CREATE UNIQUE INDEX IF NOT EXISTS planner_entries_user_date_unique ON planner_entries (user_id, entry_date); + + CREATE TABLE IF NOT EXISTS goals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + target_date TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + color TEXT NOT NULL DEFAULT '#1c1917', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + completed_at INTEGER, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); `) } diff --git a/backend/src/db/schema.js b/backend/src/db/schema.js index ea6408e..ee0bd84 100644 --- a/backend/src/db/schema.js +++ b/backend/src/db/schema.js @@ -31,3 +31,15 @@ export const plannerEntries = sqliteTable( userDateUnique: uniqueIndex('planner_entries_user_date_unique').on(table.userId, table.entryDate), }), ) + +export const goals = sqliteTable('goals', { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + targetDate: text('target_date').notNull(), + status: text('status').notNull().default('active'), + color: text('color').notNull().default('#1c1917'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), + completedAt: integer('completed_at', { mode: 'timestamp_ms' }), +}) diff --git a/backend/src/routes/goals.js b/backend/src/routes/goals.js new file mode 100644 index 0000000..87c2177 --- /dev/null +++ b/backend/src/routes/goals.js @@ -0,0 +1,103 @@ +import { and, asc, eq, like } from 'drizzle-orm' +import { z } from 'zod' +import { db } from '../db/client.js' +import { goals } from '../db/schema.js' +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}$/), + color: z.string().trim().min(4).max(32).optional(), +}) + +const goalQuerySchema = z.object({ + query: z.string().trim().optional(), + status: z.enum(['active', 'done', 'archived', 'all']).optional(), +}) + +async function requireAuthenticatedUser(request, reply) { + const user = await findAuthenticatedUser(request) + + if (!user) { + reply.code(401).send({ + message: '인증이 필요합니다.', + }) + return null + } + + return user +} + +export async function registerGoalRoutes(app) { + app.get('/api/goals', async (request, reply) => { + const user = await requireAuthenticatedUser(request, reply) + + if (!user) { + return + } + + const query = goalQuerySchema.safeParse(request.query ?? {}) + + if (!query.success) { + return reply.code(400).send({ + message: '목표 조회 조건이 올바르지 않습니다.', + issues: query.error.flatten(), + }) + } + + const filters = [eq(goals.userId, user.id)] + + if (query.data.status && query.data.status !== 'all') { + filters.push(eq(goals.status, query.data.status)) + } + + if (query.data.query) { + filters.push(like(goals.title, `%${query.data.query}%`)) + } + + const items = await db + .select() + .from(goals) + .where(and(...filters)) + .orderBy(asc(goals.targetDate), asc(goals.id)) + + return { goals: items } + }) + + app.post('/api/goals', async (request, reply) => { + const user = await requireAuthenticatedUser(request, reply) + + if (!user) { + return + } + + const payload = goalSchema.safeParse(request.body) + + if (!payload.success) { + return reply.code(400).send({ + message: '목표 입력값이 올바르지 않습니다.', + issues: payload.error.flatten(), + }) + } + + const now = new Date() + + const [goal] = await db + .insert(goals) + .values({ + userId: user.id, + title: payload.data.title, + targetDate: payload.data.targetDate, + color: payload.data.color ?? '#1c1917', + status: 'active', + createdAt: now, + updatedAt: now, + }) + .returning() + + return reply.code(201).send({ + message: '목표가 추가되었습니다.', + goal, + }) + }) +} diff --git a/backend/src/server.js b/backend/src/server.js index 97cc0ff..7f9e7a5 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -4,6 +4,7 @@ import { env } from './config.js' import { sqlite } from './db/client.js' import { ensureDatabaseSchema } from './db/init.js' import { registerAuthRoutes } from './routes/auth.js' +import { registerGoalRoutes } from './routes/goals.js' import { registerPlannerRoutes } from './routes/planner.js' const app = Fastify({ @@ -18,6 +19,7 @@ await app.register(cors, { }) await registerAuthRoutes(app) +await registerGoalRoutes(app) await registerPlannerRoutes(app) app.get('/health', async () => { @@ -39,6 +41,7 @@ app.get('/api/meta', async () => ({ orm: 'drizzle', notes: [ '회원가입, 로그인, 현재 사용자 확인 API가 준비되어 있습니다.', + '사용자별 목표 목록과 생성 API가 준비되어 있습니다.', '사용자별 플래너 저장 및 조회 API가 준비되어 있습니다.', ], })) diff --git a/package-lock.json b/package-lock.json index acf2e99..54ab75a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ten-minute-planner", - "version": "0.1.16", + "version": "0.1.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ten-minute-planner", - "version": "0.1.16", + "version": "0.1.17", "dependencies": { "vue": "^3.5.13" }, diff --git a/package.json b/package.json index 0fbf422..4bf24ac 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ten-minute-planner", "private": true, - "version": "0.1.16", + "version": "0.1.17", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.vue b/src/App.vue index a588517..6892a36 100644 --- a/src/App.vue +++ b/src/App.vue @@ -12,6 +12,7 @@ import { readAuthState, signup, } from './lib/authClient' +import { createGoal, fetchGoals } from './lib/goalsApi' import { deletePlannerEntry, fetchPlannerEntries, savePlannerEntry } from './lib/plannerApi' import { createInitialPlannerRecords, @@ -29,6 +30,10 @@ const authBusy = ref(false) const authMessage = ref('') const authToken = ref('') const currentUser = ref(null) +const goals = ref([]) +const goalQuery = ref('') +const goalBusy = ref(false) +const goalMessage = ref('') const syncStatus = ref('local') const syncMessage = ref('') const syncToastVisible = ref(false) @@ -41,6 +46,10 @@ const authForm = reactive({ email: '', password: '', }) +const goalForm = reactive({ + title: '', + targetDate: '', +}) const hours = [ '6', '7', '8', '9', '10', '11', '12', @@ -178,8 +187,9 @@ function startOfDay(date) { function buildFallbackRecord(date) { return { - dday: 'D-00 FOCUS', comment: '', + goalEnabled: false, + selectedGoalId: null, tasks: Array.from({ length: 15 }, (_, index) => ({ label: '', title: '', @@ -198,6 +208,8 @@ function buildFallbackRecord(date) { function normalizeRecord(record) { return { ...record, + goalEnabled: Boolean(record.goalEnabled), + selectedGoalId: record.selectedGoalId ?? null, tasks: record.tasks.map((task, index) => ({ label: task.label ?? task.id ?? '', title: task.title ?? '', @@ -300,6 +312,36 @@ const markedDateKeys = computed(() => ) const isAuthenticated = computed(() => Boolean(authToken.value && currentUser.value)) +const filteredGoals = computed(() => { + const query = goalQuery.value.trim().toLowerCase() + + if (!query) { + return goals.value + } + + return goals.value.filter((goal) => + goal.title.toLowerCase().includes(query), + ) +}) +const plannerGoal = computed(() => + goals.value.find((goal) => goal.id === planner.value.selectedGoalId) ?? null, +) +const plannerDday = computed(() => { + if (!planner.value.goalEnabled || !plannerGoal.value) { + return '' + } + + const targetDate = startOfDay(toDateValue(plannerGoal.value.targetDate)) + const currentDate = startOfDay(selectedDate.value) + const diffDays = Math.round((targetDate.getTime() - currentDate.getTime()) / (24 * 60 * 60 * 1000)) + const badge = + diffDays === 0 ? 'D-DAY' : diffDays > 0 ? `D-${diffDays}` : `D+${Math.abs(diffDays)}` + + return `${badge} ${plannerGoal.value.title}` +}) +const showPlannerDday = computed(() => + planner.value.goalEnabled && Boolean(plannerGoal.value), +) const filledTasks = computed(() => planner.value.tasks.filter((task) => task.title.trim()), @@ -368,6 +410,22 @@ function updateComment(record, value) { schedulePlannerSyncForRecord(record) } +function updateGoalEnabled(record, value) { + record.goalEnabled = value + + if (!value) { + record.selectedGoalId = null + } + + schedulePlannerSyncForRecord(record) +} + +function selectGoalForPlanner(record, goalId) { + record.goalEnabled = true + record.selectedGoalId = goalId + schedulePlannerSyncForRecord(record) +} + function updateTaskLabel(record, { index, value }) { record.tasks[index].label = value schedulePlannerSyncForRecord(record) @@ -609,6 +667,11 @@ function resetAuthForm() { authForm.password = '' } +function resetGoalForm() { + goalForm.title = '' + goalForm.targetDate = '' +} + function openAuthDialog(mode = 'login') { authMode.value = mode authMessage.value = '' @@ -633,6 +696,7 @@ async function applyAuthSuccess(data) { token: data.token, user: data.user, }) + await loadGoals() await hydratePlannerRecordsFromApi() closeAuthDialog() } @@ -680,6 +744,7 @@ async function restoreAuthSession() { token: savedAuth.token, user: result.user, }) + await loadGoals() await hydratePlannerRecordsFromApi() } catch (error) { authToken.value = '' @@ -695,6 +760,9 @@ function logout() { clearPendingSyncTimers() authToken.value = '' currentUser.value = null + goals.value = [] + goalQuery.value = '' + goalMessage.value = '' setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', { visible: false, }) @@ -702,6 +770,56 @@ function logout() { restoreLocalPlannerRecords() } +async function loadGoals() { + if (!authToken.value) { + return + } + + goalBusy.value = true + + try { + const result = await fetchGoals(authToken.value, { + status: 'active', + }) + + goals.value = result.goals + goalMessage.value = '' + } catch (error) { + goalMessage.value = error.message || '목표를 불러오지 못했습니다.' + } finally { + goalBusy.value = false + } +} + +async function submitGoal() { + if (!goalForm.title.trim() || !goalForm.targetDate) { + goalMessage.value = '목표 이름과 날짜를 입력해 주세요.' + return + } + + goalBusy.value = true + goalMessage.value = '' + + try { + const result = await createGoal(authToken.value, { + title: goalForm.title.trim(), + targetDate: goalForm.targetDate, + }) + + goals.value = [...goals.value, result.goal].sort((left, right) => + left.targetDate.localeCompare(right.targetDate), + ) + selectGoalForPlanner(planner.value, result.goal.id) + resetGoalForm() + goalQuery.value = '' + goalMessage.value = '목표가 추가되었습니다.' + } catch (error) { + goalMessage.value = error.message || '목표를 추가하지 못했습니다.' + } finally { + goalBusy.value = false + } +} + function replacePlannerRecords(nextRecords) { Object.keys(plannerRecords).forEach((key) => { delete plannerRecords[key] @@ -1065,7 +1183,8 @@ onMounted(() => { :date-main="selectedDateDisplay.main" :date-weekday="selectedDateDisplay.weekday" :date-weekday-tone="selectedDateDisplay.weekdayTone" - :dday="planner.dday" + :dday="plannerDday" + :show-dday="showPlannerDday" :comment="planner.comment" :total-time="formatTotalTime(planner)" :tasks="planner.tasks" @@ -1132,6 +1251,96 @@ onMounted(() => { +
+
+
+

D-DAY 사용

+

+ 목표를 검색해서 오늘의 대표 목표로 선택하면 본문 상단 D-DAY에 표시됩니다. +

+
+ +
+ +
+
+

현재 목표

+

{{ plannerGoal.title }}

+

+ 목표일 {{ plannerGoal.targetDate }} / {{ plannerDday }} +

+
+ +
+ +
+ +
+
+ +
+

+ 아직 목표가 없습니다. 아래에서 첫 목표를 추가해 주세요. +

+
+ +
+

새 목표 추가

+
+ + + +
+

+ {{ goalMessage }} +

+
+
+
+ { :date-main="selectedDateDisplay.main" :date-weekday="selectedDateDisplay.weekday" :date-weekday-tone="selectedDateDisplay.weekdayTone" - :dday="planner.dday" + :dday="plannerDday" + :show-dday="showPlannerDday" :comment="planner.comment" :total-time="formatTotalTime(planner)" :tasks="planner.tasks" @@ -1206,7 +1416,8 @@ onMounted(() => { :date-main="secondaryDateDisplay.main" :date-weekday="secondaryDateDisplay.weekday" :date-weekday-tone="secondaryDateDisplay.weekdayTone" - :dday="secondaryPlanner.dday" + :dday="''" + :show-dday="false" :comment="secondaryPlanner.comment" :total-time="formatTotalTime(secondaryPlanner)" :tasks="secondaryPlanner.tasks" @@ -1246,7 +1457,8 @@ onMounted(() => { :date-main="selectedDateDisplay.main" :date-weekday="selectedDateDisplay.weekday" :date-weekday-tone="selectedDateDisplay.weekdayTone" - :dday="planner.dday" + :dday="plannerDday" + :show-dday="showPlannerDday" :comment="planner.comment" :total-time="formatTotalTime(planner)" :tasks="planner.tasks" @@ -1270,7 +1482,8 @@ onMounted(() => { :date-main="selectedDateDisplay.main" :date-weekday="selectedDateDisplay.weekday" :date-weekday-tone="selectedDateDisplay.weekdayTone" - :dday="planner.dday" + :dday="plannerDday" + :show-dday="showPlannerDday" :comment="planner.comment" :total-time="formatTotalTime(planner)" :tasks="planner.tasks" @@ -1291,7 +1504,8 @@ onMounted(() => { :date-main="secondaryDateDisplay.main" :date-weekday="secondaryDateDisplay.weekday" :date-weekday-tone="secondaryDateDisplay.weekdayTone" - :dday="secondaryPlanner.dday" + :dday="''" + :show-dday="false" :comment="secondaryPlanner.comment" :total-time="formatTotalTime(secondaryPlanner)" :tasks="secondaryPlanner.tasks" diff --git a/src/components/PlannerPage.vue b/src/components/PlannerPage.vue index 702c93f..b574109 100644 --- a/src/components/PlannerPage.vue +++ b/src/components/PlannerPage.vue @@ -16,7 +16,11 @@ const props = defineProps({ }, dday: { type: String, - required: true, + default: '', + }, + showDday: { + type: Boolean, + default: true, }, comment: { type: String, @@ -119,14 +123,14 @@ onBeforeUnmount(() => { >
-
+
YEAR / MONTH / DAY

{{ dateMain }} {{ dateWeekday }}

-
+
D-DAY

{{ dday }}

diff --git a/src/lib/goalsApi.js b/src/lib/goalsApi.js new file mode 100644 index 0000000..7265aac --- /dev/null +++ b/src/lib/goalsApi.js @@ -0,0 +1,49 @@ +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001' + +function buildHeaders(token) { + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + } +} + +async function request(path, { method = 'GET', token, body } = {}) { + const response = await fetch(`${API_BASE_URL}${path}`, { + method, + headers: buildHeaders(token), + body: body ? JSON.stringify(body) : undefined, + }) + + const data = await response.json().catch(() => ({})) + + if (!response.ok) { + throw new Error(data.message || '목표 데이터를 처리하지 못했습니다.') + } + + return data +} + +export async function fetchGoals(token, { query = '', status = 'active' } = {}) { + const searchParams = new URLSearchParams() + + if (query) { + searchParams.set('query', query) + } + + if (status) { + searchParams.set('status', status) + } + + const queryString = searchParams.toString() + return request(`/api/goals${queryString ? `?${queryString}` : ''}`, { + token, + }) +} + +export async function createGoal(token, payload) { + return request('/api/goals', { + method: 'POST', + token, + body: payload, + }) +}