Compare commits

...

2 Commits

9 changed files with 128 additions and 20 deletions

View File

@@ -4,7 +4,7 @@
- 프로젝트명: 10 Minute Planner 웹 UI
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
- 현재 기준 버전: `v0.1.20`
- 현재 기준 버전: `v0.1.22` 준비 중
- Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git`
## 기준 디자인
@@ -149,10 +149,15 @@
- 목표 상태 개념은 제거했고, 목표는 기간이 있으면 곧바로 D-DAY 후보가 되는 단순 구조로 정리했다.
- GOALS 화면에서는 수정 중인 카드가 시각적으로 강조되고, 목표 삭제 버튼이 추가되었다.
- 목표 삭제 시 과거 날짜를 포함해 어떤 날짜에서도 해당 목표는 더 이상 표시되지 않는다.
- 우측 패널의 `PREV SNAPSHOT`은 선택 날짜 이전의 가장 최근 기록을 읽어서 날짜, 집중 시간, 완료 개수, 코멘트 또는 대표 작업을 보여준다.
- 우측 패널의 `READ NEXT`는 다음 날 첫 작업, 오늘의 미완료 작업, 오늘 코멘트를 기반으로 이어보기 문구를 자동 생성한다.
- 백엔드는 SQLite 파일 기반 구조에서 PostgreSQL 연결 구조로 교체되었다.
- `planner_entries.payload`는 문자열이 아니라 PostgreSQL `JSONB`로 저장되도록 바뀌었다.
- `docker-compose.yml` 기준으로 `postgres`, `backend`, `frontend(nginx)` 3개 서비스 초안이 추가되었다.
- 프론트는 nginx에서 `/api`를 백엔드로 프록시하는 구조라서, 배포 시 브라우저가 별도 API 포트를 직접 알 필요가 없다.
- 프론트 API 클라이언트는 `VITE_API_BASE_URL` 끝에 `/api`가 포함되어 있어도 `/api/api/...` 중복 주소가 생기지 않도록 정규화한다.
- 로그인 실패 시에는 내부 라우트 문자열이나 서버 경로를 그대로 노출하지 않고, 사용자용 안내 문구만 보여준다.
- 비로그인 상태에서는 왼쪽 사이드 내비게이션을 숨기고, 중앙 로그인 안내 화면만 보여주도록 정리했다.
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
- Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다.

View File

@@ -38,7 +38,7 @@
- [x] 목표 관리 패널 기본 구조를 설계한다.
- [x] 선택한 목표 기준으로 `D-DAY`가 자동 계산되게 한다.
- [ ] 우측 요약 패널의 `PREV SNAPSHOT`, `READ NEXT`를 실제 데이터 기반으로 연결한다.
- [x] 우측 요약 패널의 `PREV SNAPSHOT`, `READ NEXT`를 실제 데이터 기반으로 연결한다.
- [ ] 다음날 할 일 자동 제안 규칙을 정리한다.
- [x] 오른쪽 패널에 `D-DAY 사용` 토글과 목표 검색/선택 UI를 추가한다.
- [x] 목표를 여러 개 생성하고 날짜별 대표 목표를 선택할 수 있게 한다.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "ten-minute-planner",
"version": "0.1.20",
"version": "0.1.22",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ten-minute-planner",
"version": "0.1.20",
"version": "0.1.22",
"dependencies": {
"vue": "^3.5.13"
},

View File

@@ -1,7 +1,7 @@
{
"name": "ten-minute-planner",
"private": true,
"version": "0.1.20",
"version": "0.1.22",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -16,6 +16,7 @@ import {
updatePassword,
updateProfile,
} from './lib/authClient'
import { toUserFacingApiError } from './lib/apiBase'
import { createGoal, deleteGoal, fetchGoals, updateGoal } from './lib/goalsApi'
import { deletePlannerEntry, fetchPlannerEntries, savePlannerEntry } from './lib/plannerApi'
import {
@@ -636,6 +637,56 @@ const bestDay = computed(() => {
}
})
const previousRecordedEntry = computed(() => {
const selectedKey = toKey(selectedDate.value)
return [...plannerEntries.value]
.filter(([key]) => key < selectedKey)
.sort(([leftKey], [rightKey]) => rightKey.localeCompare(leftKey))[0] ?? null
})
const prevSnapshotItems = computed(() => {
if (!previousRecordedEntry.value) {
return [
'이전 기록 없음',
'첫 기록을 쌓아보세요.',
'오늘부터 흐름을 만들 수 있습니다.',
]
}
const [entryKey, entryRecord] = previousRecordedEntry.value
const completedCount = entryRecord.tasks.filter((task) => task.title.trim() && task.checked).length
const previousComment = entryRecord.comment.trim()
const previousTopTask = entryRecord.tasks.find((task) => task.title.trim())
return [
`${createDateLabel(entryKey)} 기록`,
`${formatTotalTime(entryRecord)} 집중 / 완료 ${completedCount}`,
previousComment || (previousTopTask ? `주요 작업: ${previousTopTask.title}` : '남겨진 코멘트 없음'),
]
})
const readNextItems = computed(() => {
const nextDayFirstTask = secondaryPlanner.value.tasks.find((task) => task.title.trim())
const incompleteTasks = planner.value.tasks.filter((task) => task.title.trim() && !task.checked)
const carryTask = incompleteTasks[0]?.title
const todayComment = planner.value.comment.trim()
return [
nextDayFirstTask
? `내일 첫 작업: ${nextDayFirstTask.title}`
: carryTask
? `내일 이어갈 첫 작업: ${carryTask}`
: '내일 첫 작업은 아직 비어 있습니다.',
incompleteTasks.length > 0
? `미완료 ${incompleteTasks.length}개를 이어서 볼 수 있습니다.`
: '오늘 미완료 작업은 없습니다.',
todayComment
? `오늘 코멘트 이어보기: ${todayComment}`
: '오늘 코멘트는 아직 비어 있습니다.',
]
})
watch(
[plannerRecords, selectedDate, calendarViewDate, statsRangeStart, statsRangeEnd],
() => {
@@ -799,7 +850,7 @@ async function submitAuthForm() {
await applyAuthSuccess(result)
} catch (error) {
authMessage.value = error.message || '인증 처리 중 문제가 발생했습니다.'
authMessage.value = toUserFacingApiError(error, '인증 처리 중 문제가 발생했습니다.')
} finally {
authBusy.value = false
}
@@ -1246,8 +1297,14 @@ onMounted(() => {
<template>
<main class="min-h-screen px-4 py-6 text-ink sm:px-6 lg:px-10 xl:h-screen xl:overflow-hidden">
<div class="print-root mx-auto flex max-w-[1760px] flex-col gap-6 xl:h-[calc(100vh-3rem)] xl:grid xl:grid-cols-[300px_minmax(0,1fr)] xl:items-start">
<aside class="scrollbar-hide print-hidden rounded-[28px] border border-white/60 bg-white/70 p-5 backdrop-blur sm:p-6 xl:h-full xl:overflow-y-auto">
<div
class="print-root mx-auto flex max-w-[1760px] flex-col gap-6 xl:h-[calc(100vh-3rem)] xl:items-start"
:class="isAuthenticated ? 'xl:grid xl:grid-cols-[300px_minmax(0,1fr)]' : ''"
>
<aside
v-if="isAuthenticated"
class="scrollbar-hide print-hidden rounded-[28px] border border-white/60 bg-white/70 p-5 backdrop-blur sm:p-6 xl:h-full xl:overflow-y-auto"
>
<div class="space-y-6">
<div class="space-y-2">
<p class="text-[11px] font-bold uppercase tracking-[0.28em] text-stone-500">10 Minute Planner</p>
@@ -1402,7 +1459,7 @@ onMounted(() => {
</div>
</aside>
<div class="min-w-0 space-y-6 xl:h-full xl:overflow-hidden">
<div class="min-w-0 space-y-6" :class="isAuthenticated ? 'xl:h-full xl:overflow-hidden' : ''">
<section
v-if="!isAuthenticated"
class="scrollbar-hide print-hidden rounded-[28px] border border-white/60 bg-white/65 p-6 shadow-[0_24px_80px_rgba(28,25,23,0.08)] sm:p-8 xl:h-full xl:overflow-y-auto"
@@ -1493,7 +1550,7 @@ onMounted(() => {
<p class="mb-4 text-[11px] font-bold tracking-[0.22em] text-ink">PREV SNAPSHOT</p>
<div class="space-y-3">
<p
v-for="item in planner.prevSummary"
v-for="item in prevSnapshotItems"
:key="item"
class="border-b border-stone-200 pb-3 text-[11px] font-semibold tracking-[0.08em] text-stone-700 last:border-b-0 last:pb-0"
>
@@ -1528,7 +1585,7 @@ onMounted(() => {
<p class="mb-4 text-[11px] font-bold tracking-[0.22em] text-ink">READ NEXT</p>
<div class="space-y-3">
<p
v-for="item in planner.nextFocus"
v-for="item in readNextItems"
:key="item"
class="border-b border-stone-200 pb-3 text-[11px] font-semibold tracking-[0.08em] text-stone-700 last:border-b-0 last:pb-0"
>

46
src/lib/apiBase.js Normal file
View File

@@ -0,0 +1,46 @@
const DEFAULT_API_BASE_URL = 'http://localhost:3001'
function normalizeBaseUrl(baseUrl) {
return (baseUrl || DEFAULT_API_BASE_URL).replace(/\/+$/, '')
}
function normalizePath(path) {
return path.startsWith('/') ? path : `/${path}`
}
export function buildApiUrl(path) {
const baseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL)
const normalizedPath = normalizePath(path)
if (baseUrl.endsWith('/api') && normalizedPath.startsWith('/api/')) {
return `${baseUrl}${normalizedPath.slice(4)}`
}
return `${baseUrl}${normalizedPath}`
}
export function toUserFacingApiError(error, fallbackMessage) {
const rawMessage = `${error?.message ?? ''}`.trim()
if (!rawMessage) {
return fallbackMessage
}
if (
rawMessage.includes('Failed to fetch') ||
rawMessage.includes('NetworkError') ||
rawMessage.includes('Load failed')
) {
return '서버에 연결하지 못했습니다. 잠시 후 다시 시도해 주세요.'
}
if (
rawMessage.includes('Route ') ||
rawMessage.includes('not found') ||
rawMessage.includes('/api/')
) {
return '로그인 요청을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.'
}
return rawMessage
}

View File

@@ -1,5 +1,5 @@
const AUTH_STORAGE_KEY = 'ten-minute-planner-auth'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001'
import { buildApiUrl, toUserFacingApiError } from './apiBase'
function buildHeaders(token, extraHeaders = {}) {
return {
@@ -10,7 +10,7 @@ function buildHeaders(token, extraHeaders = {}) {
}
async function request(path, { method = 'GET', token, body } = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, {
const response = await fetch(buildApiUrl(path), {
method,
headers: buildHeaders(token),
body: body ? JSON.stringify(body) : undefined,
@@ -19,7 +19,7 @@ async function request(path, { method = 'GET', token, body } = {}) {
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.message || '요청 처리 중 문제가 발생했습니다.')
throw new Error(toUserFacingApiError(data, '요청 처리 중 문제가 발생했습니다.'))
}
return data

View File

@@ -1,4 +1,4 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001'
import { buildApiUrl, toUserFacingApiError } from './apiBase'
function buildHeaders(token) {
return {
@@ -8,7 +8,7 @@ function buildHeaders(token) {
}
async function request(path, { method = 'GET', token, body } = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, {
const response = await fetch(buildApiUrl(path), {
method,
headers: buildHeaders(token),
body: body ? JSON.stringify(body) : undefined,
@@ -17,7 +17,7 @@ async function request(path, { method = 'GET', token, body } = {}) {
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.message || '목표 데이터를 처리하지 못했습니다.')
throw new Error(toUserFacingApiError(data, '목표 데이터를 처리하지 못했습니다.'))
}
return data

View File

@@ -1,4 +1,4 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001'
import { buildApiUrl, toUserFacingApiError } from './apiBase'
function buildHeaders(token) {
return {
@@ -8,7 +8,7 @@ function buildHeaders(token) {
}
async function request(path, { method = 'GET', token, body } = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, {
const response = await fetch(buildApiUrl(path), {
method,
headers: buildHeaders(token),
body: body ? JSON.stringify(body) : undefined,
@@ -17,7 +17,7 @@ async function request(path, { method = 'GET', token, body } = {}) {
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.message || '플래너 데이터를 처리하지 못했습니다.')
throw new Error(toUserFacingApiError(data, '플래너 데이터를 처리하지 못했습니다.'))
}
return data