Compare commits

..

9 Commits

20 changed files with 1702 additions and 49 deletions

View File

@@ -4,7 +4,7 @@
- 프로젝트명: 10 Minute Planner 웹 UI
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
- 현재 기준 버전: `v0.1.8`
- 현재 기준 버전: `v0.1.17`
- Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git`
## 기준 디자인
@@ -26,8 +26,13 @@
- 메인 화면 셸: `src/App.vue`
- 플래너 종이 레이아웃: `src/components/PlannerPage.vue`
- 우측 달력 컴포넌트: `src/components/MiniCalendar.vue`
- 프론트 인증 모달: `src/components/AuthDialog.vue`
- 프론트 인증 클라이언트: `src/lib/authClient.js`
- 백엔드 엔트리 포인트: `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 설정은 완료되어 있으며, 이 프로젝트의 스타일링 기준으로 유지한다.
- 현재 선택 날짜는 시스템 날짜 기준으로 시작한다.
- `COMMENT`, `TASKS`, `MEMO`는 화면에서 바로 편집할 수 있다.
@@ -45,6 +50,10 @@
- 상단 날짜 표시에서는 토요일의 `(토)`만 파란색, 일요일의 `(일)`만 빨간색으로 표시한다.
- 플래너 상태는 `localStorage`에 저장되며, 날짜별 기록과 선택 날짜, 달력 보고 있던 월까지 복원된다.
- `localStorage` 접근 로직은 `src/lib/plannerStorage.js`로 분리하기 시작했고, 이후 API/DB adapter로 교체하기 쉬운 구조로 정리 중이다.
- 프론트는 헤더에서 `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일 흐름, 최근 기록, 베스트 데이를 보여준다.
@@ -63,6 +72,11 @@
- `1-UP`은 세로 가운데 정렬을 없애고 상단 기준으로 붙여야 여백이 덜 커 보인다.
- 현재 `1-UP`은 프레임 자체를 A4 세로에 가깝게 키우고 배율을 크게 올려 빈 여백을 줄이는 방향으로 맞추고 있다.
- 백엔드 초안은 `Fastify + Drizzle + SQLite` 조합이며, 현재는 `/health`, `/api/meta` 정도의 기본 라우트만 있다.
- 백엔드에는 `/api/auth/signup`, `/api/auth/login`, `/api/auth/me`가 추가되었다.
- 백엔드에는 `/api/planner/:entryDate` 단건 조회/저장과 `/api/planner?from=...&to=...` 범위 조회가 추가되었다.
- 비밀번호는 Node 내장 `crypto.scrypt` 기반 해시로 저장하고, 세션 토큰은 `auth_sessions` 테이블에 해시 형태로 저장한다.
- 초기 실행 시 `backend/src/db/init.js`에서 테이블이 없으면 자동 생성하도록 맞춰두었다.
- 플래너 저장은 `planner_entries (user_id, entry_date)` 고유 키 기준으로 upsert 하도록 구성했다.
- 현재 샌드박스에서는 포트 바인딩 제한 때문에 백엔드 실제 리슨 확인이 막힐 수 있다. `listen EPERM 0.0.0.0:3001`은 코드 자체보다 실행 환경 제약에 가깝다.
## 확정된 결정사항
@@ -102,6 +116,21 @@
- DB는 기능 탐색 속도를 해치지 않는 선에서, 저장 레이어를 분리할 수 있는 적절한 시점에 붙이는 것이 좋다.
- 현재 기준 추천 백엔드 방향은 `Vue 프론트엔드 + Node.js API + SQLite 또는 PostgreSQL`이다.
- 현재는 SQLite로 시작하되, 확장 시 PostgreSQL로 옮길 수 있게 Drizzle 기반 스키마를 유지한다.
- 현재 인증 방식은 Bearer 토큰 기반의 간단한 세션 구조이며, 추후 쿠키/리프레시 토큰 전략으로 확장할 수 있다.
- 다음 프론트 단계에서는 `src/lib/plannerStorage.js`를 유지하되, 인증 이후 백엔드 저장소 adapter를 추가해서 `localStorage`와 전환 가능하게 만드는 흐름이 좋다.
- 현재 프론트는 인증만 연결된 상태이고, 플래너 저장은 아직 `localStorage` 기준이다.
- 로그인 상태에서는 플래너 수정 시 날짜별 서버 저장을 예약하고, 로그인 직후에는 서버 데이터를 먼저 가져오도록 연결하기 시작했다.
- 현재는 로컬 저장도 계속 유지하면서 서버 저장을 병행하는 과도기 구조다.
- 로그인 시 서버 플래너 데이터로 `plannerRecords`를 교체하고, 로그아웃 시에는 로컬 저장 기반 데이터로 다시 복귀하도록 정리했다.
- 이로 인해 다른 사용자 로그인 시 이전 로컬 데이터가 서버 계정 데이터와 섞일 위험을 줄였다.
- 로그인 상태에서 특정 날짜의 플래너 내용을 완전히 비우면, 서버 저장 대신 해당 날짜 엔트리를 삭제하도록 정리했다.
- 현재는 로그인 전 플래너 진입을 막고, 인증 후에만 실제 플래너/통계 화면을 사용하도록 변경했다.
- 클라우드 저장 상태는 헤더가 아니라 오른쪽 하단의 작은 토스트 형태로 표시되도록 변경했다.
- 저장 완료 토스트는 한 줄짜리의 작은 상태 문구로 줄여서 존재감을 낮췄다.
- D-DAY는 본문 직접 입력이 아니라, 날짜별로 선택한 대표 목표를 보여주는 구조로 실제 연결되기 시작했다.
- 오른쪽 패널에 `D-DAY 사용` 토글, 목표 검색, 목표 선택, 목표 생성 폼이 추가되었다.
- 목표가 없거나 `D-DAY 사용`이 꺼져 있으면 본문 D-DAY 블록은 숨긴다.
- 목표 데이터는 현재 사용자 기준으로 서버에서 관리되며, 플래너 레코드에는 목표 사용 여부와 선택한 목표 ID를 함께 저장한다.
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
- Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다.

24
TODO.md
View File

@@ -7,7 +7,7 @@
- 기본 레이아웃은 `1페이지 + 우측 정보 패널`을 유지한다.
- `2페이지 펼침 보기`는 비교용 보조 모드로 유지한다.
- 스타일은 Vue + TailwindCSS 기준으로 구현한다.
- D-DAY는 목표 관리 패널과 연결기능으로 추후 구현한다.
- D-DAY는 목표 관리 패널과 연결구조로 전환했고, 세부 확장은 계속 진행한다.
## 1단계: 플래너 핵심 상호작용
@@ -36,10 +36,15 @@
## 3단계: 목표와 회고 기능
- [ ] 목표 관리 패널 설계한다.
- [ ] 선택한 목표 기준으로 `D-DAY`가 자동 계산되게 한다.
- [x] 목표 관리 패널 기본 구조를 설계한다.
- [x] 선택한 목표 기준으로 `D-DAY`가 자동 계산되게 한다.
- [ ] 우측 요약 패널의 `PREV SNAPSHOT`, `READ NEXT`를 실제 데이터 기반으로 연결한다.
- [ ] 다음날 할 일 자동 제안 규칙을 정리한다.
- [x] 오른쪽 패널에 `D-DAY 사용` 토글과 목표 검색/선택 UI를 추가한다.
- [x] 목표를 여러 개 생성하고 날짜별 대표 목표를 선택할 수 있게 한다.
- [ ] 목표 완료 처리와 보관 상태를 구분한다.
- [ ] 목표 편집/삭제 UI를 추가한다.
- [ ] 목표 목록 정렬 규칙과 검색 UX를 다듬는다.
## 4단계: 데이터 구조와 저장
@@ -49,7 +54,9 @@
- [x] 입력 상태가 새로고침 후에도 유지되도록 만든다.
- [x] DB 전환 시점을 잡을 수 있도록 저장 레이어를 분리한다.
- [ ] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다.
- [x] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다.
- [ ] 사용자별 문서 저장/조회 흐름을 정리한다.
- [x] 사용자별 문서 저장/조회 흐름을 정리한다.
- [ ] 출력용 문서 포맷과 프린트 흐름을 고려한 데이터 구조를 정리한다.
## 추가 반영 메모
@@ -68,6 +75,7 @@
## 6단계: 계정 및 서비스 확장
- [ ] 회원 가입 / 로그인 방식 후보를 정리한다.
- [x] 회원 가입 / 로그인 방식 후보를 정리한다.
- [ ] 사용자별 문서 분리 저장 구조를 설계한다.
- [ ] 공유가 아닌 개인 보관용 서비스 흐름으로 요구사항을 정리한다.
- [x] 향후 출력 기능을 위한 인쇄 레이아웃 요구사항을 정리한다.
@@ -81,6 +89,8 @@
## 메모
- D-DAY는 현재 보류 상태다. 목표 패널 설계 후 연결한다.
- D-DAY는 본문에 직접 입력하는 방식보다, 별도 목표 목록에서 선택한 대표 목표를 보여주는 구조가 더 적합하다.
- 목표가 없는 경우 본문 D-DAY 영역은 숨기고, 오른쪽 패널의 `D-DAY 사용` 메뉴에서 검색/선택하도록 유도한다.
- `TIME TABLE` 드래그는 단순 사각형 선택이 아니라 시간 셀 단위의 연속 선택으로 해석한다.
- 현재는 `localStorage`로 개발을 진행하지만, 적절한 시점에 DB를 붙여 사용자별 저장 구조로 확장해야 한다.
- 현재 `localStorage` 저장 로직은 분리 가능한 형태로 정리 중이며, 이후 API/DB adapter로 교체하기 쉽게 유지한다.
@@ -89,4 +99,12 @@
- 최종 배포는 UGREEN NAS에서 Docker 기반으로 동작할 예정이며, 포트와 실제 서비스 구성은 추후 확정한다.
- 백엔드는 빠른 목업이면 PocketBase도 가능하지만, 현재 방향상 커스텀 로직과 확장성을 생각하면 전용 Node.js API + DB 조합을 우선 검토한다.
- 현재 백엔드는 `backend/` 폴더에 `Fastify + Drizzle + SQLite` 기준 초안이 추가되었다.
- 현재 백엔드는 회원가입, 로그인, 현재 사용자 확인용 기본 인증 API까지 포함한다.
- 현재 백엔드는 사용자별 플래너 단건 저장/조회와 범위 조회 API까지 포함한다.
- 현재 백엔드는 사용자별 목표 목록 조회와 목표 생성 API까지 포함한다.
- 프론트에는 로그인/회원가입 모달과 현재 사용자 상태 표시가 추가되었다.
- 로그인 상태일 때는 서버 저장을 우선 사용하는 흐름으로 전환 중이다.
- 로그인 전에는 플래너 본문을 사용하지 못하도록 막고, 인증 후 사용 흐름으로 정리했다.
- 현재는 각 날짜 플래너가 대표 목표 하나를 선택해 `D-DAY`에 연결하는 구조다.
- 목표가 선택되지 않았거나 `D-DAY 사용`이 꺼져 있으면 본문 `D-DAY` 영역은 숨긴다.
- 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다.

View File

@@ -12,6 +12,7 @@ const envSchema = z.object({
PORT: z.coerce.number().default(3001),
DB_FILE: z.string().default('./data/planner.sqlite'),
CORS_ORIGIN: z.string().default('http://localhost:5173'),
SESSION_TTL_DAYS: z.coerce.number().default(30),
})
export const env = envSchema.parse(process.env)

49
backend/src/db/init.js Normal file
View File

@@ -0,0 +1,49 @@
import { sqlite } from './client.js'
export function ensureDatabaseSchema() {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
nickname TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS auth_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS planner_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
entry_date TEXT NOT NULL,
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
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
);
`)
}

View File

@@ -1,4 +1,4 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
@@ -9,11 +9,37 @@ export const users = sqliteTable('users', {
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
})
export const plannerEntries = sqliteTable('planner_entries', {
export const authSessions = sqliteTable('auth_sessions', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
entryDate: text('entry_date').notNull(),
payload: text('payload').notNull(),
tokenHash: text('token_hash').notNull().unique(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
})
export const plannerEntries = sqliteTable(
'planner_entries',
{
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
entryDate: text('entry_date').notNull(),
payload: text('payload').notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
},
(table) => ({
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' }),
})

View File

@@ -0,0 +1,65 @@
import { eq } from 'drizzle-orm'
import { env } from '../config.js'
import { db } from '../db/client.js'
import { authSessions, users } from '../db/schema.js'
import { createSessionToken, hashSessionToken } from './password.js'
function getBearerToken(request) {
const authorization = request.headers.authorization
if (!authorization?.startsWith('Bearer ')) {
return null
}
return authorization.slice('Bearer '.length).trim()
}
export async function createSession(userId) {
const token = createSessionToken()
const tokenHash = hashSessionToken(token)
const now = Date.now()
const expiresAt = now + env.SESSION_TTL_DAYS * 24 * 60 * 60 * 1000
const [session] = await db
.insert(authSessions)
.values({
userId,
tokenHash,
expiresAt: new Date(expiresAt),
createdAt: new Date(now),
})
.returning()
return {
token,
session,
}
}
export async function findAuthenticatedUser(request) {
const token = getBearerToken(request)
if (!token) {
return null
}
const tokenHash = hashSessionToken(token)
const [session] = await db
.select()
.from(authSessions)
.where(eq(authSessions.tokenHash, tokenHash))
.limit(1)
if (!session || new Date(session.expiresAt).getTime() <= Date.now()) {
return null
}
const [user] = await db
.select()
.from(users)
.where(eq(users.id, session.userId))
.limit(1)
return user ?? null
}

View File

@@ -0,0 +1,47 @@
import crypto from 'node:crypto'
const SCRYPT_KEY_LENGTH = 64
function scryptAsync(password, salt) {
return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, SCRYPT_KEY_LENGTH, (error, derivedKey) => {
if (error) {
reject(error)
return
}
resolve(derivedKey)
})
})
}
export async function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('hex')
const derivedKey = await scryptAsync(password, salt)
return `${salt}:${derivedKey.toString('hex')}`
}
export async function verifyPassword(password, storedHash) {
const [salt, originalHash] = storedHash.split(':')
if (!salt || !originalHash) {
return false
}
const derivedKey = await scryptAsync(password, salt)
const originalBuffer = Buffer.from(originalHash, 'hex')
if (originalBuffer.length !== derivedKey.length) {
return false
}
return crypto.timingSafeEqual(originalBuffer, derivedKey)
}
export function createSessionToken() {
return crypto.randomBytes(32).toString('hex')
}
export function hashSessionToken(token) {
return crypto.createHash('sha256').update(token).digest('hex')
}

132
backend/src/routes/auth.js Normal file
View File

@@ -0,0 +1,132 @@
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { db } from '../db/client.js'
import { users } from '../db/schema.js'
import { hashPassword, verifyPassword } from '../lib/password.js'
import { createSession, findAuthenticatedUser } from '../lib/authSession.js'
const signupSchema = z.object({
email: z.string().trim().email(),
password: z.string().min(8).max(72),
nickname: z.string().trim().min(2).max(30),
})
const loginSchema = z.object({
email: z.string().trim().email(),
password: z.string().min(1).max(72),
})
function sanitizeUser(user) {
return {
id: user.id,
email: user.email,
nickname: user.nickname,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
}
}
export async function registerAuthRoutes(app) {
app.post('/api/auth/signup', async (request, reply) => {
const payload = signupSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({
message: '회원가입 입력값이 올바르지 않습니다.',
issues: payload.error.flatten(),
})
}
const { email, password, nickname } = payload.data
const normalizedEmail = email.toLowerCase()
const [existingUser] = await db
.select()
.from(users)
.where(eq(users.email, normalizedEmail))
.limit(1)
if (existingUser) {
return reply.code(409).send({
message: '이미 사용 중인 이메일입니다.',
})
}
const now = new Date()
const passwordHash = await hashPassword(password)
const [user] = await db
.insert(users)
.values({
email: normalizedEmail,
passwordHash,
nickname,
createdAt: now,
updatedAt: now,
})
.returning()
const { token } = await createSession(user.id)
return reply.code(201).send({
message: '회원가입이 완료되었습니다.',
token,
user: sanitizeUser(user),
})
})
app.post('/api/auth/login', async (request, reply) => {
const payload = loginSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({
message: '로그인 입력값이 올바르지 않습니다.',
issues: payload.error.flatten(),
})
}
const normalizedEmail = payload.data.email.toLowerCase()
const [user] = await db
.select()
.from(users)
.where(eq(users.email, normalizedEmail))
.limit(1)
if (!user) {
return reply.code(401).send({
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
})
}
const passwordMatches = await verifyPassword(payload.data.password, user.passwordHash)
if (!passwordMatches) {
return reply.code(401).send({
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
})
}
const { token } = await createSession(user.id)
return {
message: '로그인에 성공했습니다.',
token,
user: sanitizeUser(user),
}
})
app.get('/api/auth/me', async (request, reply) => {
const user = await findAuthenticatedUser(request)
if (!user) {
return reply.code(401).send({
message: '인증이 필요합니다.',
})
}
return {
user: sanitizeUser(user),
}
})
}

103
backend/src/routes/goals.js Normal file
View File

@@ -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,
})
})
}

View File

@@ -0,0 +1,196 @@
import { and, asc, eq, gte, lte } from 'drizzle-orm'
import { z } from 'zod'
import { db } from '../db/client.js'
import { plannerEntries } from '../db/schema.js'
import { findAuthenticatedUser } from '../lib/authSession.js'
const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
const plannerPayloadSchema = z.object({
payload: z.record(z.any()),
})
const plannerRangeQuerySchema = z.object({
from: dateSchema.optional(),
to: dateSchema.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 registerPlannerRoutes(app) {
app.get('/api/planner', async (request, reply) => {
const user = await requireAuthenticatedUser(request, reply)
if (!user) {
return
}
const query = plannerRangeQuerySchema.safeParse(request.query ?? {})
if (!query.success) {
return reply.code(400).send({
message: '조회 범위가 올바르지 않습니다.',
issues: query.error.flatten(),
})
}
const filters = [eq(plannerEntries.userId, user.id)]
if (query.data.from) {
filters.push(gte(plannerEntries.entryDate, query.data.from))
}
if (query.data.to) {
filters.push(lte(plannerEntries.entryDate, query.data.to))
}
const entries = await db
.select({
entryDate: plannerEntries.entryDate,
payload: plannerEntries.payload,
createdAt: plannerEntries.createdAt,
updatedAt: plannerEntries.updatedAt,
})
.from(plannerEntries)
.where(and(...filters))
.orderBy(asc(plannerEntries.entryDate))
return {
entries: entries.map((entry) => ({
...entry,
payload: JSON.parse(entry.payload),
})),
}
})
app.get('/api/planner/:entryDate', async (request, reply) => {
const user = await requireAuthenticatedUser(request, reply)
if (!user) {
return
}
const dateResult = dateSchema.safeParse(request.params.entryDate)
if (!dateResult.success) {
return reply.code(400).send({
message: '날짜 형식이 올바르지 않습니다.',
})
}
const [entry] = await db
.select()
.from(plannerEntries)
.where(
and(
eq(plannerEntries.userId, user.id),
eq(plannerEntries.entryDate, dateResult.data),
),
)
.limit(1)
return {
entry: entry
? {
...entry,
payload: JSON.parse(entry.payload),
}
: null,
}
})
app.put('/api/planner/:entryDate', async (request, reply) => {
const user = await requireAuthenticatedUser(request, reply)
if (!user) {
return
}
const dateResult = dateSchema.safeParse(request.params.entryDate)
if (!dateResult.success) {
return reply.code(400).send({
message: '날짜 형식이 올바르지 않습니다.',
})
}
const payloadResult = plannerPayloadSchema.safeParse(request.body)
if (!payloadResult.success) {
return reply.code(400).send({
message: '플래너 저장 데이터가 올바르지 않습니다.',
issues: payloadResult.error.flatten(),
})
}
const now = new Date()
const [entry] = await db
.insert(plannerEntries)
.values({
userId: user.id,
entryDate: dateResult.data,
payload: JSON.stringify(payloadResult.data.payload),
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [plannerEntries.userId, plannerEntries.entryDate],
set: {
payload: JSON.stringify(payloadResult.data.payload),
updatedAt: now,
},
})
.returning()
return {
message: '플래너가 저장되었습니다.',
entry: {
...entry,
payload: JSON.parse(entry.payload),
},
}
})
app.delete('/api/planner/:entryDate', async (request, reply) => {
const user = await requireAuthenticatedUser(request, reply)
if (!user) {
return
}
const dateResult = dateSchema.safeParse(request.params.entryDate)
if (!dateResult.success) {
return reply.code(400).send({
message: '날짜 형식이 올바르지 않습니다.',
})
}
const deletedEntries = await db
.delete(plannerEntries)
.where(
and(
eq(plannerEntries.userId, user.id),
eq(plannerEntries.entryDate, dateResult.data),
),
)
.returning()
return {
message: deletedEntries.length > 0 ? '플래너가 삭제되었습니다.' : '삭제할 플래너가 없습니다.',
deleted: deletedEntries.length > 0,
}
})
}

View File

@@ -2,16 +2,26 @@ import Fastify from 'fastify'
import cors from '@fastify/cors'
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({
logger: true,
})
ensureDatabaseSchema()
await app.register(cors, {
origin: env.CORS_ORIGIN,
credentials: true,
})
await registerAuthRoutes(app)
await registerGoalRoutes(app)
await registerPlannerRoutes(app)
app.get('/health', async () => {
const version = sqlite.prepare('select sqlite_version() as version').get()
@@ -26,12 +36,13 @@ app.get('/health', async () => {
})
app.get('/api/meta', async () => ({
auth: 'planned',
auth: 'active',
storage: 'sqlite',
orm: 'drizzle',
notes: [
'회원가입 로그인 API는 다음 단계에서 추가 예정',
'플래너 저장 API는 로컬 저장 레이어 분리 이후 연결 예정',
'회원가입, 로그인, 현재 사용자 확인 API가 준비되어 있습니다.',
'사용자별 목표 목록과 생성 API가 준비되어 있습니다.',
'사용자별 플래너 저장 및 조회 API가 준비되어 있습니다.',
],
}))

4
package-lock.json generated
View File

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

View File

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

View File

@@ -1,21 +1,55 @@
<script setup>
import { computed, reactive, ref, watch, nextTick } from 'vue'
import { computed, onMounted, reactive, ref, watch, nextTick } from 'vue'
import AuthDialog from './components/AuthDialog.vue'
import MiniCalendar from './components/MiniCalendar.vue'
import PlannerPage from './components/PlannerPage.vue'
import StatsDashboard from './components/StatsDashboard.vue'
import {
clearAuthState,
fetchCurrentUser,
login,
persistAuthState,
readAuthState,
signup,
} from './lib/authClient'
import { createGoal, fetchGoals } from './lib/goalsApi'
import { deletePlannerEntry, fetchPlannerEntries, savePlannerEntry } from './lib/plannerApi'
import {
createInitialPlannerRecords,
persistPlannerState,
readPlannerStorageState,
restorePlannerUiState,
} from './lib/plannerStorage'
const screenMode = ref('planner')
const viewMode = ref('focus')
const printLayout = ref('single')
const authDialogOpen = ref(false)
const authMode = ref('login')
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)
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))))
const statsRangeEnd = ref(toKey(new Date()))
const authForm = reactive({
nickname: '',
email: '',
password: '',
})
const goalForm = reactive({
title: '',
targetDate: '',
})
const hours = [
'6', '7', '8', '9', '10', '11', '12',
@@ -26,6 +60,9 @@ const hours = [
const timetableCellCount = hours.length * 6
let printPageStyleElement = null
let isHydratingRemoteRecords = false
const syncTimers = new Map()
let syncToastTimer = null
function createEmptyTimetable() {
return Array.from({ length: timetableCellCount }, () => false)
@@ -150,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: '',
@@ -170,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 ?? '',
@@ -271,6 +311,38 @@ const markedDateKeys = computed(() =>
.map(([key]) => key),
)
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()),
)
@@ -335,30 +407,53 @@ function selectDate(date) {
function updateComment(record, value) {
record.comment = 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)
}
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) {
@@ -523,6 +618,7 @@ watch(
calendarViewDate: calendarViewDate.value,
statsRangeStart: normalizedStatsRange.value.startKey,
statsRangeEnd: normalizedStatsRange.value.endKey,
includeRecords: !isAuthenticated.value,
})
},
{ deep: true },
@@ -538,6 +634,320 @@ function clearTaskLabels(record) {
record.tasks.forEach((task) => {
task.label = ''
})
schedulePlannerSyncForRecord(record)
}
function setSyncFeedback(status, message, options = {}) {
const {
visible = true,
duration = 1600,
sticky = false,
} = options
syncStatus.value = status
syncMessage.value = message
if (syncToastTimer) {
window.clearTimeout(syncToastTimer)
syncToastTimer = null
}
syncToastVisible.value = visible
if (visible && !sticky) {
syncToastTimer = window.setTimeout(() => {
syncToastVisible.value = false
}, duration)
}
}
function resetAuthForm() {
authForm.nickname = ''
authForm.email = ''
authForm.password = ''
}
function resetGoalForm() {
goalForm.title = ''
goalForm.targetDate = ''
}
function openAuthDialog(mode = 'login') {
authMode.value = mode
authMessage.value = ''
authDialogOpen.value = true
}
function closeAuthDialog() {
authDialogOpen.value = false
authMessage.value = ''
resetAuthForm()
}
function updateAuthField({ field, value }) {
authForm[field] = value
}
async function applyAuthSuccess(data) {
authToken.value = data.token
currentUser.value = data.user
setSyncFeedback('cloud', '클라우드 동기화 연결됨')
persistAuthState({
token: data.token,
user: data.user,
})
await loadGoals()
await hydratePlannerRecordsFromApi()
closeAuthDialog()
}
async function submitAuthForm() {
authBusy.value = true
authMessage.value = ''
try {
const result =
authMode.value === 'login'
? await login({
email: authForm.email,
password: authForm.password,
})
: await signup({
nickname: authForm.nickname,
email: authForm.email,
password: authForm.password,
})
await applyAuthSuccess(result)
} catch (error) {
authMessage.value = error.message || '인증 처리 중 문제가 발생했습니다.'
} finally {
authBusy.value = false
}
}
async function restoreAuthSession() {
const savedAuth = readAuthState()
if (!savedAuth.token) {
return
}
authToken.value = savedAuth.token
currentUser.value = savedAuth.user ?? null
try {
const result = await fetchCurrentUser(savedAuth.token)
currentUser.value = result.user
setSyncFeedback('cloud', '클라우드 동기화 연결됨')
persistAuthState({
token: savedAuth.token,
user: result.user,
})
await loadGoals()
await hydratePlannerRecordsFromApi()
} catch (error) {
authToken.value = ''
currentUser.value = null
setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', {
visible: false,
})
clearAuthState()
}
}
function logout() {
clearPendingSyncTimers()
authToken.value = ''
currentUser.value = null
goals.value = []
goalQuery.value = ''
goalMessage.value = ''
setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', {
visible: false,
})
clearAuthState()
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]
})
Object.entries(nextRecords).forEach(([key, record]) => {
plannerRecords[key] = record
})
}
function restoreLocalPlannerRecords() {
replacePlannerRecords(createInitialPlannerRecords(plannerSeed, normalizeRecord))
const savedState = readPlannerStorageState()
if (savedState.selectedDate) {
selectedDate.value = toDateValue(savedState.selectedDate, selectedDate.value)
}
if (savedState.calendarViewDate) {
calendarViewDate.value = toDateValue(savedState.calendarViewDate, selectedDate.value)
}
}
function findRecordKey(record) {
return Object.entries(plannerRecords).find(([, value]) => value === record)?.[0] ?? null
}
function clearPendingSyncTimers() {
syncTimers.forEach((timerId) => {
window.clearTimeout(timerId)
})
syncTimers.clear()
}
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))
}
setSyncFeedback('syncing', '클라우드 저장 중...', {
sticky: true,
})
const timerId = window.setTimeout(async () => {
syncTimers.delete(recordKey)
try {
const record = plannerRecords[recordKey]
if (!record) {
if (syncTimers.size === 0) {
setSyncFeedback('cloud', '클라우드 동기화 연결됨')
}
return
}
if (!hasPlannerContent(record)) {
await deletePlannerEntry(authToken.value, recordKey)
if (syncTimers.size === 0) {
setSyncFeedback('cloud', '클라우드에서 삭제됨')
}
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) {
setSyncFeedback('cloud', '클라우드에 저장됨')
}
} catch (error) {
setSyncFeedback('error', error.message || '클라우드 저장에 실패했습니다.', {
duration: 3200,
})
}
}, 500)
syncTimers.set(recordKey, timerId)
}
async function hydratePlannerRecordsFromApi() {
if (!authToken.value) {
return
}
isHydratingRemoteRecords = true
setSyncFeedback('syncing', '클라우드 데이터를 불러오는 중...', {
sticky: true,
})
try {
const result = await fetchPlannerEntries(authToken.value)
const remoteRecords = {}
result.entries.forEach((entry) => {
remoteRecords[entry.entryDate] = normalizeRecord(entry.payload)
})
replacePlannerRecords(remoteRecords)
setSyncFeedback('cloud', '클라우드 동기화 연결됨')
} catch (error) {
setSyncFeedback('error', error.message || '클라우드 데이터를 불러오지 못했습니다.', {
duration: 3200,
})
} finally {
isHydratingRemoteRecords = false
}
}
function applyPrintPageStyle(layout) {
@@ -566,6 +976,13 @@ async function printSelectedPlanner(layout = 'single') {
await nextTick()
window.print()
}
onMounted(() => {
setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', {
visible: false,
})
restoreAuthSession()
})
</script>
<template>
@@ -583,7 +1000,43 @@ async function printSelectedPlanner(layout = 'single') {
</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<div class="inline-flex rounded-full border border-stone-200 bg-stone-100 p-1">
<div class="inline-flex items-center gap-2 rounded-full border border-stone-200 bg-white px-2 py-2">
<template v-if="isAuthenticated">
<div class="px-2">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">SIGNED IN</p>
<p class="text-sm font-semibold tracking-[0.02em] text-stone-900">
{{ currentUser.nickname }}
</p>
</div>
<button
type="button"
class="rounded-full border border-stone-200 px-3 py-2 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
@click="logout"
>
LOGOUT
</button>
</template>
<template v-else>
<button
type="button"
class="rounded-full border border-stone-200 px-3 py-2 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
@click="openAuthDialog('login')"
>
LOGIN
</button>
<button
type="button"
class="rounded-full border border-stone-900 bg-stone-900 px-3 py-2 text-xs font-bold tracking-[0.14em] text-white transition hover:bg-stone-700"
@click="openAuthDialog('signup')"
>
SIGN UP
</button>
</template>
</div>
<div
v-if="isAuthenticated"
class="inline-flex rounded-full border border-stone-200 bg-stone-100 p-1"
>
<button
type="button"
class="rounded-full px-4 py-2 text-xs font-bold tracking-[0.14em] transition"
@@ -601,7 +1054,10 @@ async function printSelectedPlanner(layout = 'single') {
STATS
</button>
</div>
<div class="inline-flex rounded-full border border-stone-200 bg-stone-100 p-1">
<div
v-if="isAuthenticated"
class="inline-flex rounded-full border border-stone-200 bg-stone-100 p-1"
>
<button
type="button"
class="rounded-full px-4 py-2 text-xs font-bold tracking-[0.14em] transition"
@@ -619,7 +1075,10 @@ async function printSelectedPlanner(layout = 'single') {
2 PAGE SPREAD
</button>
</div>
<div class="inline-flex items-center gap-2 rounded-full border border-stone-200 bg-white px-2 py-2">
<div
v-if="isAuthenticated"
class="inline-flex items-center gap-2 rounded-full border border-stone-200 bg-white px-2 py-2"
>
<button
type="button"
class="rounded-full border border-stone-200 px-3 py-2 text-xs font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
@@ -636,7 +1095,7 @@ async function printSelectedPlanner(layout = 'single') {
</button>
</div>
<div
v-if="screenMode === 'planner'"
v-if="isAuthenticated && screenMode === 'planner'"
class="inline-flex items-center gap-2 rounded-full border border-stone-200 bg-white px-2 py-2"
>
<button
@@ -659,7 +1118,64 @@ async function printSelectedPlanner(layout = 'single') {
</header>
<section
v-if="screenMode === 'planner' && viewMode === 'focus'"
v-if="!isAuthenticated"
class="print-hidden rounded-[32px] border border-white/60 bg-white/65 p-6 shadow-[0_24px_80px_rgba(28,25,23,0.08)] sm:p-8"
>
<div class="mx-auto flex max-w-3xl flex-col gap-6 text-center">
<div class="space-y-3">
<p class="text-[11px] font-bold uppercase tracking-[0.28em] text-stone-500">Members Only</p>
<h2 class="text-3xl font-semibold tracking-[-0.05em] text-stone-900 sm:text-4xl">
로그인 플래너를 작성하고<br>
클라우드에 안전하게 저장하세요
</h2>
<p class="mx-auto max-w-2xl text-sm leading-7 text-stone-600 sm:text-base">
이제 플래너는 사용자 계정 기준으로 문서와 통계가 연결됩니다. 로그인하지 않은 상태에서는 데이터가 섞일 있어
작성 화면을 열지 않도록 변경했습니다.
</p>
</div>
<div class="grid gap-4 rounded-[28px] border border-stone-200 bg-[#fbf7f0] p-5 text-left sm:grid-cols-3">
<article class="rounded-2xl border border-stone-200 bg-white px-4 py-5">
<p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">ACCOUNT</p>
<p class="mt-3 text-sm font-semibold leading-6 text-stone-800">
회원가입 사용자별 플래너와 통계를 분리해서 관리합니다.
</p>
</article>
<article class="rounded-2xl border border-stone-200 bg-white px-4 py-5">
<p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">CLOUD SAVE</p>
<p class="mt-3 text-sm font-semibold leading-6 text-stone-800">
작성 내용은 날짜별로 서버에 저장되고, 다른 기기에서도 이어볼 있습니다.
</p>
</article>
<article class="rounded-2xl border border-stone-200 bg-white px-4 py-5">
<p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">PRINT & STATS</p>
<p class="mt-3 text-sm font-semibold leading-6 text-stone-800">
로그인 기반 데이터로 통계와 출력 흐름을 안정적으로 맞춰갑니다.
</p>
</article>
</div>
<div class="flex flex-col items-center justify-center gap-3 sm:flex-row">
<button
type="button"
class="rounded-full border border-stone-300 bg-white px-6 py-3 text-xs font-bold tracking-[0.18em] text-stone-700 transition hover:border-stone-500 hover:text-stone-900"
@click="openAuthDialog('login')"
>
LOGIN
</button>
<button
type="button"
class="rounded-full bg-stone-900 px-6 py-3 text-xs font-bold tracking-[0.18em] text-white transition hover:bg-stone-700"
@click="openAuthDialog('signup')"
>
SIGN UP
</button>
</div>
</div>
</section>
<section
v-else-if="screenMode === 'planner' && viewMode === 'focus'"
class="print-hidden grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]"
>
<div class="print-target">
@@ -667,7 +1183,8 @@ async function printSelectedPlanner(layout = 'single') {
: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"
@@ -734,6 +1251,96 @@ async function printSelectedPlanner(layout = 'single') {
</div>
</section>
<section class="border border-stone-200 bg-white/80 p-5">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">D-DAY 사용</p>
<p class="mt-2 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600">
목표를 검색해서 오늘의 대표 목표로 선택하면 본문 상단 D-DAY에 표시됩니다.
</p>
</div>
<button
type="button"
class="rounded-full border px-3 py-2 text-[10px] font-bold tracking-[0.16em] transition"
:class="planner.goalEnabled ? 'border-stone-900 bg-stone-900 text-white' : 'border-stone-300 text-stone-500'"
@click="updateGoalEnabled(planner, !planner.goalEnabled)"
>
{{ planner.goalEnabled ? 'ON' : 'OFF' }}
</button>
</div>
<div class="mt-4 space-y-3">
<div v-if="planner.goalEnabled && plannerGoal" class="rounded-2xl border border-stone-200 bg-white px-4 py-3">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">현재 목표</p>
<p class="mt-2 text-sm font-semibold tracking-[0.02em] text-stone-900">{{ plannerGoal.title }}</p>
<p class="mt-1 text-[11px] font-semibold tracking-[0.06em] text-stone-500">
목표일 {{ plannerGoal.targetDate }} / {{ plannerDday }}
</p>
</div>
<div v-if="goals.length > 0" class="space-y-2">
<input
v-model="goalQuery"
type="text"
class="w-full rounded-2xl border border-stone-300 bg-white px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
placeholder="목표 검색"
/>
<div class="max-h-52 space-y-2 overflow-y-auto pr-1">
<button
v-for="goal in filteredGoals"
:key="goal.id"
type="button"
class="w-full rounded-2xl border px-4 py-3 text-left transition"
:class="planner.selectedGoalId === goal.id ? 'border-stone-900 bg-stone-900 text-white' : 'border-stone-200 bg-white text-stone-800 hover:border-stone-400'"
@click="selectGoalForPlanner(planner, goal.id)"
>
<p class="text-sm font-semibold tracking-[0.02em]">{{ goal.title }}</p>
<p
class="mt-1 text-[11px] font-semibold tracking-[0.06em]"
:class="planner.selectedGoalId === goal.id ? 'text-stone-200' : 'text-stone-500'"
>
목표일 {{ goal.targetDate }}
</p>
</button>
</div>
</div>
<div v-else class="rounded-2xl border border-dashed border-stone-300 bg-white/70 px-4 py-4">
<p class="text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-500">
아직 목표가 없습니다. 아래에서 목표를 추가해 주세요.
</p>
</div>
<div class="rounded-2xl border border-stone-200 bg-[#fbf7f0] p-4">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500"> 목표 추가</p>
<div class="mt-3 space-y-2">
<input
v-model="goalForm.title"
type="text"
class="w-full rounded-2xl border border-stone-300 bg-white px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
placeholder="예: 자격증 시험 / 런칭 / 프로젝트 마감"
/>
<input
v-model="goalForm.targetDate"
type="date"
class="w-full rounded-2xl border border-stone-300 bg-white px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
/>
<button
type="button"
class="w-full rounded-full bg-stone-900 px-4 py-3 text-[10px] font-bold tracking-[0.18em] text-white transition hover:bg-stone-700 disabled:cursor-not-allowed disabled:bg-stone-400"
:disabled="goalBusy"
@click="submitGoal"
>
{{ goalBusy ? '추가 중...' : 'GOAL ADD' }}
</button>
</div>
<p v-if="goalMessage" class="mt-3 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600">
{{ goalMessage }}
</p>
</div>
</div>
</section>
<MiniCalendar
:month-label="monthLabel"
:year-label="yearLabel"
@@ -787,7 +1394,8 @@ async function printSelectedPlanner(layout = 'single') {
: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"
@@ -808,7 +1416,8 @@ async function printSelectedPlanner(layout = 'single') {
: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"
@@ -841,14 +1450,15 @@ async function printSelectedPlanner(layout = 'single') {
@update:range-end="statsRangeEnd = $event"
/>
<section class="print-only">
<section v-if="isAuthenticated" class="print-only">
<div v-if="printLayout === 'single'" class="print-paper print-paper--single">
<div class="print-sheet-frame">
<PlannerPage
: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"
@@ -872,7 +1482,8 @@ async function printSelectedPlanner(layout = 'single') {
: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"
@@ -893,7 +1504,8 @@ async function printSelectedPlanner(layout = 'single') {
: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"
@@ -912,5 +1524,38 @@ async function printSelectedPlanner(layout = 'single') {
</div>
</section>
</div>
<AuthDialog
:open="authDialogOpen"
:mode="authMode"
:form="authForm"
:busy="authBusy"
:message="authMessage"
@close="closeAuthDialog"
@submit="submitAuthForm"
@switch-mode="authMode = $event; authMessage = ''"
@update:field="updateAuthField"
/>
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-2 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-2 opacity-0"
>
<div
v-if="isAuthenticated && syncToastVisible"
class="pointer-events-none fixed bottom-5 right-5 z-40 max-w-[240px] rounded-full border border-stone-200/70 bg-white/80 px-3 py-2 shadow-[0_10px_24px_rgba(28,25,23,0.08)] backdrop-blur"
>
<p
class="text-[11px] font-semibold tracking-[0.04em]"
:class="syncStatus === 'error' ? 'text-red-500' : syncStatus === 'syncing' ? 'text-blue-500' : 'text-stone-500'"
>
{{ syncMessage }}
</p>
</div>
</transition>
</main>
</template>

View File

@@ -0,0 +1,129 @@
<script setup>
const props = defineProps({
open: {
type: Boolean,
default: false,
},
mode: {
type: String,
default: 'login',
},
form: {
type: Object,
required: true,
},
busy: {
type: Boolean,
default: false,
},
message: {
type: String,
default: '',
},
})
const emit = defineEmits([
'close',
'submit',
'switch-mode',
'update:field',
])
function updateField(field, event) {
emit('update:field', {
field,
value: event.target.value,
})
}
</script>
<template>
<div
v-if="open"
class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/45 px-4 py-8 backdrop-blur-sm"
>
<div class="w-full max-w-md rounded-[28px] border border-white/60 bg-[#f6f1e8] p-6 shadow-2xl sm:p-7">
<div class="flex items-start justify-between gap-4">
<div class="space-y-2">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Account</p>
<h2 class="text-2xl font-semibold tracking-[-0.04em] text-stone-900">
{{ mode === 'login' ? '로그인' : '회원가입' }}
</h2>
<p class="text-sm leading-6 text-stone-600">
{{ mode === 'login' ? '저장된 플래너를 다시 이어서 볼 수 있습니다.' : '사용자별 기록과 통계를 연결하기 위한 계정을 만듭니다.' }}
</p>
</div>
<button
type="button"
class="rounded-full border border-stone-300 px-3 py-2 text-[10px] font-bold tracking-[0.16em] text-stone-500 transition hover:border-stone-500 hover:text-stone-900"
@click="emit('close')"
>
CLOSE
</button>
</div>
<form class="mt-6 space-y-4" @submit.prevent="emit('submit')">
<div v-if="mode === 'signup'" class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">닉네임</label>
<input
:value="form.nickname"
type="text"
class="w-full rounded-2xl border border-stone-300 bg-white px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
placeholder="닉네임을 입력해 주세요."
@input="updateField('nickname', $event)"
/>
</div>
<div class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">이메일</label>
<input
:value="form.email"
type="email"
class="w-full rounded-2xl border border-stone-300 bg-white px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
placeholder="zenn@example.com"
@input="updateField('email', $event)"
/>
</div>
<div class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">비밀번호</label>
<input
:value="form.password"
type="password"
class="w-full rounded-2xl border border-stone-300 bg-white px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
placeholder="8자 이상 입력해 주세요."
@input="updateField('password', $event)"
/>
</div>
<p
v-if="message"
class="rounded-2xl border border-stone-300 bg-white/80 px-4 py-3 text-sm font-semibold leading-6 text-stone-700"
>
{{ message }}
</p>
<button
type="submit"
class="w-full rounded-full bg-stone-900 px-5 py-3 text-xs font-bold tracking-[0.18em] text-white transition hover:bg-stone-700 disabled:cursor-not-allowed disabled:bg-stone-400"
:disabled="busy"
>
{{ busy ? '처리 중...' : mode === 'login' ? 'LOGIN' : 'SIGN UP' }}
</button>
</form>
<div class="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-stone-300 bg-white/70 px-4 py-3">
<p class="text-sm font-semibold text-stone-600">
{{ mode === 'login' ? '아직 계정이 없나요?' : '이미 계정이 있나요?' }}
</p>
<button
type="button"
class="text-xs font-bold tracking-[0.16em] text-stone-900 underline underline-offset-4"
@click="emit('switch-mode', mode === 'login' ? 'signup' : 'login')"
>
{{ mode === 'login' ? '회원가입' : '로그인' }}
</button>
</div>
</div>
</div>
</template>

View File

@@ -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(() => {
>
<div class="flex flex-col gap-4 py-[18px]">
<div class="flex gap-4">
<div class="relative h-[90px] w-[394px] flex-1 border-t border-ink px-[10px] pt-[10px]">
<div class="relative h-[90px] border-t border-ink px-[10px] pt-[10px]" :class="props.showDday ? 'w-[394px] flex-1' : 'w-full flex-1'">
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">YEAR / MONTH / DAY</span>
<p class="pt-6 text-xs tracking-[0.24em] text-ink sm:text-sm">
<span>{{ dateMain }}</span>
<span class="ml-1" :class="dateWeekdayTone">{{ dateWeekday }}</span>
</p>
</div>
<div class="relative h-[90px] w-[210px] border-t border-ink px-[10px] pt-[10px]">
<div v-if="props.showDday" class="relative h-[90px] w-[210px] border-t border-ink px-[10px] pt-[10px]">
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">D-DAY</span>
<p class="pt-6 text-xs tracking-[0.24em] text-ink sm:text-sm">{{ dday }}</p>
</div>

81
src/lib/authClient.js Normal file
View File

@@ -0,0 +1,81 @@
const AUTH_STORAGE_KEY = 'ten-minute-planner-auth'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001'
function buildHeaders(token, extraHeaders = {}) {
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...extraHeaders,
}
}
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 function readAuthState() {
if (typeof window === 'undefined') {
return { token: '', user: null }
}
try {
return JSON.parse(window.localStorage.getItem(AUTH_STORAGE_KEY) ?? '{"token":"","user":null}')
} catch (error) {
console.warn('저장된 인증 상태를 불러오지 못했습니다.', error)
return { token: '', user: null }
}
}
export function persistAuthState({ token, user }) {
if (typeof window === 'undefined') {
return
}
window.localStorage.setItem(
AUTH_STORAGE_KEY,
JSON.stringify({
token,
user,
}),
)
}
export function clearAuthState() {
if (typeof window === 'undefined') {
return
}
window.localStorage.removeItem(AUTH_STORAGE_KEY)
}
export async function signup({ email, password, nickname }) {
return request('/api/auth/signup', {
method: 'POST',
body: { email, password, nickname },
})
}
export async function login({ email, password }) {
return request('/api/auth/login', {
method: 'POST',
body: { email, password },
})
}
export async function fetchCurrentUser(token) {
return request('/api/auth/me', {
token,
})
}

49
src/lib/goalsApi.js Normal file
View File

@@ -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,
})
}

58
src/lib/plannerApi.js Normal file
View File

@@ -0,0 +1,58 @@
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 fetchPlannerEntries(token, range = {}) {
const searchParams = new URLSearchParams()
if (range.from) {
searchParams.set('from', range.from)
}
if (range.to) {
searchParams.set('to', range.to)
}
const query = searchParams.toString()
return request(`/api/planner${query ? `?${query}` : ''}`, {
token,
})
}
export async function savePlannerEntry(token, entryDate, payload) {
return request(`/api/planner/${entryDate}`, {
method: 'PUT',
token,
body: {
payload,
},
})
}
export async function deletePlannerEntry(token, entryDate) {
return request(`/api/planner/${entryDate}`, {
method: 'DELETE',
token,
})
}

View File

@@ -13,6 +13,10 @@ function readStorageState() {
}
}
export function readPlannerStorageState() {
return readStorageState()
}
export function createInitialPlannerRecords(seedRecords, normalizeRecord) {
const baseRecords = Object.fromEntries(
Object.entries(seedRecords).map(([key, record]) => [key, normalizeRecord(record)]),
@@ -62,31 +66,37 @@ export function persistPlannerState({
calendarViewDate,
statsRangeStart,
statsRangeEnd,
includeRecords = true,
}) {
if (typeof window === 'undefined') {
return
}
const serializableRecords = Object.fromEntries(
Object.entries(plannerRecords).map(([key, record]) => [
key,
{
...record,
tasks: record.tasks.map((task) => ({ ...task })),
memo: record.memo.map((item) => ({ ...item })),
timetable: [...record.timetable],
},
]),
)
const previousState = readStorageState()
const nextState = {
...previousState,
selectedDate: selectedDate.toISOString(),
calendarViewDate: calendarViewDate.toISOString(),
statsRangeStart,
statsRangeEnd,
}
if (includeRecords) {
nextState.records = Object.fromEntries(
Object.entries(plannerRecords).map(([key, record]) => [
key,
{
...record,
tasks: record.tasks.map((task) => ({ ...task })),
memo: record.memo.map((item) => ({ ...item })),
timetable: [...record.timetable],
},
]),
)
}
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
selectedDate: selectedDate.toISOString(),
calendarViewDate: calendarViewDate.toISOString(),
statsRangeStart,
statsRangeEnd,
records: serializableRecords,
}),
JSON.stringify(nextState),
)
}