From 282d51daf6854af8b56580ba0dd5b4d28e866831 Mon Sep 17 00:00:00 2001 From: zenn Date: Tue, 21 Apr 2026 18:13:58 +0900 Subject: [PATCH] =?UTF-8?q?v0.1.12=20-=20=EC=84=9C=EB=B2=84=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=97=B0=EA=B2=B0=20=EC=8B=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HANDOFF.md | 6 +- TODO.md | 1 + package-lock.json | 4 +- package.json | 2 +- src/App.vue | 144 +++++++++++++++++++++++++++++++++++++++++- src/lib/plannerApi.js | 51 +++++++++++++++ 6 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 src/lib/plannerApi.js diff --git a/HANDOFF.md b/HANDOFF.md index bb97930..4752607 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ - 프로젝트명: 10 Minute Planner 웹 UI - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript -- 현재 기준 버전: `v0.1.11` +- 현재 기준 버전: `v0.1.12` - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` ## 기준 디자인 @@ -51,6 +51,7 @@ - `localStorage` 접근 로직은 `src/lib/plannerStorage.js`로 분리하기 시작했고, 이후 API/DB adapter로 교체하기 쉬운 구조로 정리 중이다. - 프론트는 헤더에서 `LOGIN` / `SIGN UP` 모달을 열 수 있고, 로그인 상태면 닉네임과 `LOGOUT` 버튼을 표시한다. - 인증 토큰과 현재 사용자 정보는 프론트 로컬 저장소에 따로 유지하고, 앱 시작 시 `/api/auth/me`로 세션 복원을 시도한다. +- 프론트 플래너 API 클라이언트는 `src/lib/plannerApi.js`에 추가되었다. - 루트에서 `npm run dev:backend`, `npm run db:generate`, `npm run db:migrate`로 백엔드 명령을 호출할 수 있다. - 상단 전환 버튼으로 `PLANNER / STATS` 화면을 오갈 수 있다. - 통계 화면에서는 전체 집중 시간, 평균 완료율, 기록 일수, 최근 7일 흐름, 최근 기록, 베스트 데이를 보여준다. @@ -116,7 +117,8 @@ - 현재 인증 방식은 Bearer 토큰 기반의 간단한 세션 구조이며, 추후 쿠키/리프레시 토큰 전략으로 확장할 수 있다. - 다음 프론트 단계에서는 `src/lib/plannerStorage.js`를 유지하되, 인증 이후 백엔드 저장소 adapter를 추가해서 `localStorage`와 전환 가능하게 만드는 흐름이 좋다. - 현재 프론트는 인증만 연결된 상태이고, 플래너 저장은 아직 `localStorage` 기준이다. -- 다음 단계는 `plannerStorage`에 백엔드 adapter를 추가해서 로그인 시 서버 저장을 우선 사용하도록 연결하는 것이다. +- 로그인 상태에서는 플래너 수정 시 날짜별 서버 저장을 예약하고, 로그인 직후에는 서버 데이터를 먼저 가져오도록 연결하기 시작했다. +- 현재는 로컬 저장도 계속 유지하면서 서버 저장을 병행하는 과도기 구조다. - 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. - Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다. diff --git a/TODO.md b/TODO.md index ab2a203..c5cfa8c 100644 --- a/TODO.md +++ b/TODO.md @@ -95,4 +95,5 @@ - 현재 백엔드는 회원가입, 로그인, 현재 사용자 확인용 기본 인증 API까지 포함한다. - 현재 백엔드는 사용자별 플래너 단건 저장/조회와 범위 조회 API까지 포함한다. - 프론트에는 로그인/회원가입 모달과 현재 사용자 상태 표시가 추가되었다. +- 로그인 상태일 때는 서버 저장을 우선 사용하는 흐름으로 전환 중이다. - 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다. diff --git a/package-lock.json b/package-lock.json index 73d681d..771014b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ten-minute-planner", - "version": "0.1.11", + "version": "0.1.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ten-minute-planner", - "version": "0.1.11", + "version": "0.1.12", "dependencies": { "vue": "^3.5.13" }, diff --git a/package.json b/package.json index f995b0d..1a67901 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ten-minute-planner", "private": true, - "version": "0.1.11", + "version": "0.1.12", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.vue b/src/App.vue index 24b0756..e94d8f4 100644 --- a/src/App.vue +++ b/src/App.vue @@ -12,6 +12,7 @@ import { readAuthState, signup, } from './lib/authClient' +import { fetchPlannerEntries, savePlannerEntry } from './lib/plannerApi' import { createInitialPlannerRecords, persistPlannerState, @@ -27,6 +28,8 @@ const authBusy = ref(false) const authMessage = ref('') const authToken = ref('') const currentUser = ref(null) +const syncStatus = ref('local') +const syncMessage = ref('') 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)))) @@ -46,6 +49,8 @@ const hours = [ const timetableCellCount = hours.length * 6 let printPageStyleElement = null +let isHydratingRemoteRecords = false +const syncTimers = new Map() function createEmptyTimetable() { return Array.from({ length: timetableCellCount }, () => false) @@ -357,30 +362,37 @@ function selectDate(date) { function updateComment(record, value) { record.comment = value + schedulePlannerSyncForRecord(record) } function updateTaskLabel(record, { index, value }) { record.tasks[index].label = value + schedulePlannerSyncForRecord(record) } function updateTaskTitle(record, { index, value }) { record.tasks[index].title = value + schedulePlannerSyncForRecord(record) } function toggleTask(record, index) { record.tasks[index].checked = !record.tasks[index].checked + schedulePlannerSyncForRecord(record) } function updateMemo(record, { index, value }) { record.memo[index].text = value + schedulePlannerSyncForRecord(record) } function updateMemoLabel(record, { index, value }) { record.memo[index].label = value + schedulePlannerSyncForRecord(record) } function updateTimetable(record, nextTimetable) { record.timetable = nextTimetable + schedulePlannerSyncForRecord(record) } function hasPlannerContent(record) { @@ -560,6 +572,7 @@ function clearTaskLabels(record) { record.tasks.forEach((task) => { task.label = '' }) + schedulePlannerSyncForRecord(record) } function resetAuthForm() { @@ -584,13 +597,17 @@ function updateAuthField({ field, value }) { authForm[field] = value } -function applyAuthSuccess(data) { +async function applyAuthSuccess(data) { authToken.value = data.token currentUser.value = data.user + syncStatus.value = 'cloud' + syncMessage.value = '클라우드 동기화 연결됨' persistAuthState({ token: data.token, user: data.user, }) + await hydratePlannerRecordsFromApi() + queueSyncAllPlannerRecords() closeAuthDialog() } @@ -611,7 +628,7 @@ async function submitAuthForm() { password: authForm.password, }) - applyAuthSuccess(result) + await applyAuthSuccess(result) } catch (error) { authMessage.value = error.message || '인증 처리 중 문제가 발생했습니다.' } finally { @@ -632,23 +649,132 @@ async function restoreAuthSession() { try { const result = await fetchCurrentUser(savedAuth.token) currentUser.value = result.user + syncStatus.value = 'cloud' + syncMessage.value = '클라우드 동기화 연결됨' persistAuthState({ token: savedAuth.token, user: result.user, }) + await hydratePlannerRecordsFromApi() } catch (error) { authToken.value = '' currentUser.value = null + syncStatus.value = 'local' + syncMessage.value = '로컬 저장 모드' clearAuthState() } } function logout() { + clearPendingSyncTimers() authToken.value = '' currentUser.value = null + syncStatus.value = 'local' + syncMessage.value = '로컬 저장 모드' clearAuthState() } +function findRecordKey(record) { + return Object.entries(plannerRecords).find(([, value]) => value === record)?.[0] ?? null +} + +function clearPendingSyncTimers() { + syncTimers.forEach((timerId) => { + window.clearTimeout(timerId) + }) + syncTimers.clear() +} + +function queueSyncAllPlannerRecords() { + Object.entries(plannerRecords).forEach(([key, record]) => { + if (hasPlannerContent(record)) { + schedulePlannerSync(key) + } + }) +} + +function schedulePlannerSyncForRecord(record) { + const recordKey = findRecordKey(record) + + if (!recordKey) { + return + } + + schedulePlannerSync(recordKey) +} + +function schedulePlannerSync(recordKey) { + if (!isAuthenticated.value || isHydratingRemoteRecords) { + return + } + + if (syncTimers.has(recordKey)) { + window.clearTimeout(syncTimers.get(recordKey)) + } + + syncStatus.value = 'syncing' + syncMessage.value = '클라우드 저장 중...' + + const timerId = window.setTimeout(async () => { + syncTimers.delete(recordKey) + + try { + const record = plannerRecords[recordKey] + + if (!record || !hasPlannerContent(record)) { + if (syncTimers.size === 0) { + syncStatus.value = 'cloud' + syncMessage.value = '클라우드 동기화 연결됨' + } + return + } + + await savePlannerEntry(authToken.value, recordKey, { + ...record, + tasks: record.tasks.map((task) => ({ ...task })), + memo: record.memo.map((item) => ({ ...item })), + timetable: [...record.timetable], + }) + + if (syncTimers.size === 0) { + syncStatus.value = 'cloud' + syncMessage.value = '클라우드에 저장됨' + } + } catch (error) { + syncStatus.value = 'error' + syncMessage.value = error.message || '클라우드 저장에 실패했습니다.' + } + }, 500) + + syncTimers.set(recordKey, timerId) +} + +async function hydratePlannerRecordsFromApi() { + if (!authToken.value) { + return + } + + isHydratingRemoteRecords = true + syncStatus.value = 'syncing' + syncMessage.value = '클라우드 데이터를 불러오는 중...' + + try { + const result = await fetchPlannerEntries(authToken.value) + + result.entries.forEach((entry) => { + plannerRecords[entry.entryDate] = normalizeRecord(entry.payload) + }) + + syncStatus.value = 'cloud' + syncMessage.value = '클라우드 동기화 연결됨' + } catch (error) { + syncStatus.value = 'error' + syncMessage.value = error.message || '클라우드 데이터를 불러오지 못했습니다.' + } finally { + isHydratingRemoteRecords = false + } +} + function applyPrintPageStyle(layout) { if (typeof document === 'undefined') { return @@ -677,6 +803,7 @@ async function printSelectedPlanner(layout = 'single') { } onMounted(() => { + syncMessage.value = '로컬 저장 모드' restoreAuthSession() }) @@ -703,6 +830,12 @@ onMounted(() => {

{{ currentUser.nickname }}

+

+ {{ syncMessage }} +