Compare commits

...

10 Commits

31 changed files with 1160 additions and 419 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
dist
.git
.gitignore
backend/node_modules
backend/data
backend/drizzle
*.log

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG VITE_API_BASE_URL=/api
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm run build
FROM nginx:1.27-alpine
COPY deploy/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

View File

@@ -4,7 +4,7 @@
- 프로젝트명: 10 Minute Planner 웹 UI - 프로젝트명: 10 Minute Planner 웹 UI
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
- 현재 기준 버전: `v0.1.19` - 현재 기준 버전: `v0.1.29` 준비 중
- Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git`
## 기준 디자인 ## 기준 디자인
@@ -33,6 +33,11 @@
- 백엔드 인증 라우트: `backend/src/routes/auth.js` - 백엔드 인증 라우트: `backend/src/routes/auth.js`
- 백엔드 목표 라우트: `backend/src/routes/goals.js` - 백엔드 목표 라우트: `backend/src/routes/goals.js`
- 백엔드 비밀번호/세션 유틸: `backend/src/lib/password.js` - 백엔드 비밀번호/세션 유틸: `backend/src/lib/password.js`
- Docker Compose 진입점: `docker-compose.yml`
- 프론트 Dockerfile: `Dockerfile`
- 백엔드 Dockerfile: `backend/Dockerfile`
- nginx 프록시 설정: `deploy/nginx/default.conf`
- 실행 가이드 문서: `README.md`
- Tailwind 설정은 완료되어 있으며, 이 프로젝트의 스타일링 기준으로 유지한다. - Tailwind 설정은 완료되어 있으며, 이 프로젝트의 스타일링 기준으로 유지한다.
- 현재 선택 날짜는 시스템 날짜 기준으로 시작한다. - 현재 선택 날짜는 시스템 날짜 기준으로 시작한다.
- `COMMENT`, `TASKS`, `MEMO`는 화면에서 바로 편집할 수 있다. - `COMMENT`, `TASKS`, `MEMO`는 화면에서 바로 편집할 수 있다.
@@ -73,7 +78,7 @@
- `1-UP`은 여백이 과하지 않도록 다시 확대했고, `2-UP`은 한 페이지 고정 안정성을 위해 가로 폭과 세로 높이를 조금 더 보수적으로 조정했다. - `1-UP`은 여백이 과하지 않도록 다시 확대했고, `2-UP`은 한 페이지 고정 안정성을 위해 가로 폭과 세로 높이를 조금 더 보수적으로 조정했다.
- `1-UP`은 세로 가운데 정렬을 없애고 상단 기준으로 붙여야 여백이 덜 커 보인다. - `1-UP`은 세로 가운데 정렬을 없애고 상단 기준으로 붙여야 여백이 덜 커 보인다.
- 현재 `1-UP`은 프레임 자체를 A4 세로에 가깝게 키우고 배율을 크게 올려 빈 여백을 줄이는 방향으로 맞추고 있다. - 현재 `1-UP`은 프레임 자체를 A4 세로에 가깝게 키우고 배율을 크게 올려 빈 여백을 줄이는 방향으로 맞추고 있다.
- 백엔드 초안은 `Fastify + Drizzle + SQLite` 조합이며, 현재는 `/health`, `/api/meta` 정도의 기본 라우트만 있다. - 백엔드 초안은 `Fastify + Drizzle + PostgreSQL` 조합으로 전환되었다.
- 백엔드에는 `/api/auth/signup`, `/api/auth/login`, `/api/auth/me`가 추가되었다. - 백엔드에는 `/api/auth/signup`, `/api/auth/login`, `/api/auth/me`가 추가되었다.
- 백엔드에는 `/api/planner/:entryDate` 단건 조회/저장과 `/api/planner?from=...&to=...` 범위 조회가 추가되었다. - 백엔드에는 `/api/planner/:entryDate` 단건 조회/저장과 `/api/planner?from=...&to=...` 범위 조회가 추가되었다.
- 비밀번호는 Node 내장 `crypto.scrypt` 기반 해시로 저장하고, 세션 토큰은 `auth_sessions` 테이블에 해시 형태로 저장한다. - 비밀번호는 Node 내장 `crypto.scrypt` 기반 해시로 저장하고, 세션 토큰은 `auth_sessions` 테이블에 해시 형태로 저장한다.
@@ -107,8 +112,8 @@
- 공유를 위해 나중에 이미지 저장 기능도 필요하지만, 실제 출력 품질과 텍스트 선명도는 HTML/CSS 인쇄 레이아웃을 우선 유지하는 편이 좋다. - 공유를 위해 나중에 이미지 저장 기능도 필요하지만, 실제 출력 품질과 텍스트 선명도는 HTML/CSS 인쇄 레이아웃을 우선 유지하는 편이 좋다.
- 원격 저장소 `origin``https://git.sori.studio/zenn/planner.sori.studio.git`로 연결되어 있다. - 원격 저장소 `origin``https://git.sori.studio/zenn/planner.sori.studio.git`로 연결되어 있다.
- 앞으로 버전 체크포인트 커밋은 `v0.1.7 - 작업 요약`처럼 버전 뒤에 짧은 작업 설명을 함께 남기는 형식으로 통일한다. - 앞으로 버전 체크포인트 커밋은 `v0.1.7 - 작업 요약`처럼 버전 뒤에 짧은 작업 설명을 함께 남기는 형식으로 통일한다.
- 이후 배포 단계에서는 `docker-compose.yml`도 함께 작성해야 하며, 포트 번호와 서비스 구성은 추후 사용자와 확정한다. - `docker-compose.yml` 초안은 이미 추가되었고, 포트 번호와 실제 외부 공개 범위는 NAS 배포 단계에서 다시 확정하면 된다.
- `backend/.env.example`에는 기본 `PORT`, `DB_FILE`, `CORS_ORIGIN` 예시가 들어 있다. - `backend/.env.example`에는 기본 `PORT`, `DATABASE_URL`, `CORS_ORIGIN` 예시가 들어 있다.
## 다음 권장 작업 ## 다음 권장 작업
@@ -116,8 +121,8 @@
- 목표나 통계 기능보다 먼저, 플래너 본문의 입력과 상호작용을 우선 구현한다. - 목표나 통계 기능보다 먼저, 플래너 본문의 입력과 상호작용을 우선 구현한다.
- 통계 화면 구현은 현재 `localStorage` 기반으로 먼저 진행해도 된다. - 통계 화면 구현은 현재 `localStorage` 기반으로 먼저 진행해도 된다.
- DB는 기능 탐색 속도를 해치지 않는 선에서, 저장 레이어를 분리할 수 있는 적절한 시점에 붙이는 것이 좋다. - DB는 기능 탐색 속도를 해치지 않는 선에서, 저장 레이어를 분리할 수 있는 적절한 시점에 붙이는 것이 좋다.
- 현재 기준 추천 백엔드 방향은 `Vue 프론트엔드 + Node.js API + SQLite 또는 PostgreSQL`이다. - 현재 기준 추천 백엔드 방향은 `Vue 프론트엔드 + Node.js API + PostgreSQL`이다.
- 현재는 SQLite로 시작하되, 확장 시 PostgreSQL로 옮길 수 있게 Drizzle 기반 스키마를 유지한다. - Docker 배포를 시작하는 시점이므로 SQLite보다 PostgreSQL을 기본 저장소로 유지하는 편이 낫다.
- 현재 인증 방식은 Bearer 토큰 기반의 간단한 세션 구조이며, 추후 쿠키/리프레시 토큰 전략으로 확장할 수 있다. - 현재 인증 방식은 Bearer 토큰 기반의 간단한 세션 구조이며, 추후 쿠키/리프레시 토큰 전략으로 확장할 수 있다.
- 다음 프론트 단계에서는 `src/lib/plannerStorage.js`를 유지하되, 인증 이후 백엔드 저장소 adapter를 추가해서 `localStorage`와 전환 가능하게 만드는 흐름이 좋다. - 다음 프론트 단계에서는 `src/lib/plannerStorage.js`를 유지하되, 인증 이후 백엔드 저장소 adapter를 추가해서 `localStorage`와 전환 가능하게 만드는 흐름이 좋다.
- 현재 프론트는 인증만 연결된 상태이고, 플래너 저장은 아직 `localStorage` 기준이다. - 현재 프론트는 인증만 연결된 상태이고, 플래너 저장은 아직 `localStorage` 기준이다.
@@ -135,12 +140,41 @@
- TASK LABELS도 별도 버튼 2개 대신 동일한 토글 UI로 단순화했다. ON이면 01~15를 채우고 OFF이면 비운다. - TASK LABELS도 별도 버튼 2개 대신 동일한 토글 UI로 단순화했다. ON이면 01~15를 채우고 OFF이면 비운다.
- SETTINGS 화면이 추가되어 닉네임, 이메일, 비밀번호 변경을 분리해서 관리할 수 있다. - SETTINGS 화면이 추가되어 닉네임, 이메일, 비밀번호 변경을 분리해서 관리할 수 있다.
- 백엔드에는 `/api/auth/profile`, `/api/auth/password`, `/api/goals/:goalId` 수정 API가 추가되었다. - 백엔드에는 `/api/auth/profile`, `/api/auth/password`, `/api/goals/:goalId` 수정 API가 추가되었다.
- 백엔드에는 `/api/goals/:goalId` 삭제 API도 추가되었다.
- 왼쪽 사이드, 플래너 본문 래퍼, 오른쪽 정보 패널 모두 둥근 카드 톤으로 맞춰서 화면 전체의 통일감을 높였다. - 왼쪽 사이드, 플래너 본문 래퍼, 오른쪽 정보 패널 모두 둥근 카드 톤으로 맞춰서 화면 전체의 통일감을 높였다.
- 플래너 집중 보기에서는 본문과 오른쪽 패널이 각각 독립 스크롤되도록 바뀌어서 동시에 참조하기 쉽다. - 플래너 집중 보기에서는 본문과 오른쪽 패널이 각각 독립 스크롤되도록 바뀌어서 동시에 참조하기 쉽다.
- TASK LABELS, D-DAY 토글은 공통 사이즈의 스위치로 통일했고, `translate` 기반 애니메이션으로 부드럽게 움직이게 했다. - TASK LABELS, D-DAY 토글은 공통 사이즈의 스위치로 통일했고, `translate` 기반 애니메이션으로 부드럽게 움직이게 했다.
- 목표 생성 폼은 기본적으로 `표시 시작일 = 오늘`, `표시 종료일 = 목표일` 흐름으로 자동 채워진다. - 목표 생성 폼은 기본적으로 `표시 시작일 = 오늘`, `표시 종료일 = 목표일` 흐름으로 자동 채워진다.
- D-DAY 기간은 서로 겹칠 수 없고, 프론트와 백엔드 모두 중복 기간을 감지하면 저장을 막는다. - D-DAY 기간은 서로 겹칠 수 없고, 프론트와 백엔드 모두 중복 기간을 감지하면 저장을 막는다.
- 현재 날짜에 적용된 목표가 있는 경우 D-DAY는 기본적으로 보이고, 해당 날짜에서만 토글로 숨길 수 있다. - 현재 날짜에 적용된 목표가 있는 경우 D-DAY는 기본적으로 보이고, 해당 날짜에서만 토글로 숨길 수 있다.
- 목표 상태 개념은 제거했고, 목표는 기간이 있으면 곧바로 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-compose.yml`과 별도로 개발용 `docker-compose.dev.yml`을 추가했다.
- 개발용 compose는 프론트 `5173`, 백엔드 `3001`, PostgreSQL `5432`를 열고, 소스 마운트 + Vite HMR + Node watch 기반으로 자동 반영된다.
- 개발/배포 실행 방법은 루트 `README.md`에 먼저 적고, `HANDOFF.md`에는 변경 이유와 주의사항 위주로 남긴다.
- API 클라이언트는 body가 없는 `GET`, `DELETE` 요청에 `Content-Type: application/json`을 붙이지 않도록 수정했다. 날짜 선택 시 발생하던 `Body cannot be empty...` 오류는 이 문제였다.
- 날짜 이동 중 목표 자동 보정으로 `goalEnabled`만 꺼지는 경우에는, 실제 내용이 없는 기록이라면 클라우드 동기화 토스트를 띄우지 않도록 정리했다.
- 집중 보기에서 오른쪽 패널 폭을 넓혀 `1920x1080` 기준 활용도를 높였고, `TASK LABELS / D-DAY / CALENDAR`는 상단 고정 영역으로 올렸다.
- `STATS``NEXT DAY`는 반반 카드가 아니라 각각 한 줄씩 쓰도록 바꿔서 날짜 길이에 따라 높이가 흔들리는 문제를 줄였다.
- 미니 달력은 42칸 기준으로 렌더링하고, 요일 헤더를 `SUN ~ SAT` 순서로 고정해서 일요일 시작 달력 기준을 더 명확하게 맞췄다.
- 집중 보기 오른쪽 패널은 다시 정리해서 `왼쪽 컬럼: CALENDAR / TASK LABELS / D-DAY`, `오른쪽 컬럼: STATS / NEXT DAY`, `하단 전체 폭: READ NEXT / PREV SNAPSHOT` 구조로 맞췄다.
- 달력은 과하게 키우지 않고 컴팩트한 크기를 유지한 채, 우측 정보 패널 내부 배치만 다시 조정하는 쪽으로 방향을 잡았다.
- `PRINT 2-UP`은 브라우저/프린터 기본 여백 차이로 오른쪽 페이지가 잘릴 수 있어서, 프레임 폭과 스케일을 조금 더 보수적으로 줄여 안정성을 높였다.
- 플래너 집중 보기 반응형 기준은 `1620px 이상: 우측 패널 2열`, `1280px 이상 ~ 1619px 이하: 우측 패널 1열 + 최대 360px`, `1280px 미만: 우측 패널 오버레이` 구조로 다시 정리했다.
- 오버레이 구간에서는 본문 오른쪽 위의 `OPEN SIDE PANEL` 버튼으로 패널을 열고, 배경 클릭이나 `CLOSE` 버튼으로 닫는다.
- `2 PAGE SPREAD`는 화면 폭을 기준으로 배율을 자동 계산해서 오른쪽 페이지가 잘리는 현상을 줄이는 방향으로 조정했다.
- 비로그인 랜딩 카드는 상단 고정이 아니라 화면 중앙에 오도록 정렬을 수정했다.
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. - 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
- Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다. - Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다.

57
README.md Normal file
View File

@@ -0,0 +1,57 @@
# 10 Minute Planner
Vue 3 + TailwindCSS + Fastify + PostgreSQL 기반의 `10분 플래너 다이어리` 프로젝트다.
## 실행 방법
### 개발용
코드를 수정하면서 자동 새로고침까지 보려면 개발용 compose를 사용한다.
```bash
docker compose -f docker-compose.dev.yml up
```
개발용 포트:
- 프론트엔드: `http://localhost:5173`
- 백엔드 API: `http://localhost:3001`
- PostgreSQL: `localhost:5432`
개발용 특징:
- 프론트는 Vite HMR로 저장 즉시 화면이 반영된다.
- 백엔드는 `node --watch`로 파일 변경 시 자동 재시작된다.
- 즉, 개발 중에는 매번 새로 빌드할 필요 없이 `docker compose -f docker-compose.dev.yml up`만 켜두면 된다.
### 배포용
실서비스나 최종 확인용으로는 배포용 compose를 사용한다.
```bash
docker compose up -d --build
```
배포용 포트:
- 프론트엔드: `http://localhost:8080`
- PostgreSQL: `localhost:5432`
배포용 특징:
- 프론트는 빌드 결과물을 nginx로 서빙한다.
- 브라우저에서는 `/api` 경로로 백엔드에 접근한다.
- 수정 사항 반영 시에는 다시 빌드가 필요하다.
## 문서
- 작업 규칙: [`AGENTS.md`](./AGENTS.md)
- 진행 상태 / 체크리스트: [`TODO.md`](./TODO.md)
- 인수인계 메모: [`HANDOFF.md`](./HANDOFF.md)
## 현재 방향
- 기본 UX는 `1페이지 + 우측 정보 패널`
- 보조 모드는 `2페이지 펼침 보기`
- 스타일링은 Vue + TailwindCSS
- 장기적으로는 Docker 기반으로 UGREEN NAS 배포 예정

11
TODO.md
View File

@@ -38,7 +38,7 @@
- [x] 목표 관리 패널 기본 구조를 설계한다. - [x] 목표 관리 패널 기본 구조를 설계한다.
- [x] 선택한 목표 기준으로 `D-DAY`가 자동 계산되게 한다. - [x] 선택한 목표 기준으로 `D-DAY`가 자동 계산되게 한다.
- [ ] 우측 요약 패널의 `PREV SNAPSHOT`, `READ NEXT`를 실제 데이터 기반으로 연결한다. - [x] 우측 요약 패널의 `PREV SNAPSHOT`, `READ NEXT`를 실제 데이터 기반으로 연결한다.
- [ ] 다음날 할 일 자동 제안 규칙을 정리한다. - [ ] 다음날 할 일 자동 제안 규칙을 정리한다.
- [x] 오른쪽 패널에 `D-DAY 사용` 토글과 목표 검색/선택 UI를 추가한다. - [x] 오른쪽 패널에 `D-DAY 사용` 토글과 목표 검색/선택 UI를 추가한다.
- [x] 목표를 여러 개 생성하고 날짜별 대표 목표를 선택할 수 있게 한다. - [x] 목표를 여러 개 생성하고 날짜별 대표 목표를 선택할 수 있게 한다.
@@ -88,9 +88,10 @@
- [x] A4 가로 기준 2장 출력 모드를 지원한다. - [x] A4 가로 기준 2장 출력 모드를 지원한다.
- [x] `1-UP` 세로 인쇄 / `2-UP` 가로 인쇄 기준을 분리한다. - [x] `1-UP` 세로 인쇄 / `2-UP` 가로 인쇄 기준을 분리한다.
- [ ] 공유를 위한 이미지 저장 기능을 추가한다. - [ ] 공유를 위한 이미지 저장 기능을 추가한다.
- [ ] Docker 배포 구조를 정리한다. - [x] Docker 배포 구조를 정리한다.
- [ ] UGREEN NAS 기준 `docker-compose.yml` 초안을 작성한다. - [x] UGREEN NAS 기준 `docker-compose.yml` 초안을 작성한다.
- [x] 백엔드 기본 스캐폴딩을 추가한다. - [x] 백엔드 기본 스캐폴딩을 추가한다.
- [x] PostgreSQL 전환 초안을 적용한다.
## 메모 ## 메모
@@ -104,10 +105,12 @@
- 실제 인쇄는 HTML/CSS 기반 프린트 레이아웃으로 유지하고, 공유용으로는 별도의 이미지 저장 기능을 추가하는 방향이 적합하다. - 실제 인쇄는 HTML/CSS 기반 프린트 레이아웃으로 유지하고, 공유용으로는 별도의 이미지 저장 기능을 추가하는 방향이 적합하다.
- 최종 배포는 UGREEN NAS에서 Docker 기반으로 동작할 예정이며, 포트와 실제 서비스 구성은 추후 확정한다. - 최종 배포는 UGREEN NAS에서 Docker 기반으로 동작할 예정이며, 포트와 실제 서비스 구성은 추후 확정한다.
- 백엔드는 빠른 목업이면 PocketBase도 가능하지만, 현재 방향상 커스텀 로직과 확장성을 생각하면 전용 Node.js API + DB 조합을 우선 검토한다. - 백엔드는 빠른 목업이면 PocketBase도 가능하지만, 현재 방향상 커스텀 로직과 확장성을 생각하면 전용 Node.js API + DB 조합을 우선 검토한다.
- 현재 백엔드는 `backend/` 폴더에 `Fastify + Drizzle + SQLite` 기준 초안이 추가되었다. - 현재 백엔드는 `backend/` 폴더에 `Fastify + Drizzle + PostgreSQL` 기준으로 전환 중이다.
- 현재 백엔드는 회원가입, 로그인, 현재 사용자 확인용 기본 인증 API까지 포함한다. - 현재 백엔드는 회원가입, 로그인, 현재 사용자 확인용 기본 인증 API까지 포함한다.
- 현재 백엔드는 사용자별 플래너 단건 저장/조회와 범위 조회 API까지 포함한다. - 현재 백엔드는 사용자별 플래너 단건 저장/조회와 범위 조회 API까지 포함한다.
- 현재 백엔드는 사용자별 목표 목록 조회와 목표 생성 API까지 포함한다. - 현재 백엔드는 사용자별 목표 목록 조회와 목표 생성 API까지 포함한다.
- 현재는 `docker-compose.yml``postgres + backend + frontend(nginx)` 초안을 올릴 수 있게 정리했다.
- 현재 환경에서는 Docker 데몬이 꺼져 있어 `docker compose build` 실검증은 아직 완료하지 못했다.
- 프론트에는 로그인/회원가입 모달과 현재 사용자 상태 표시가 추가되었다. - 프론트에는 로그인/회원가입 모달과 현재 사용자 상태 표시가 추가되었다.
- 로그인 상태일 때는 서버 저장을 우선 사용하는 흐름으로 전환 중이다. - 로그인 상태일 때는 서버 저장을 우선 사용하는 흐름으로 전환 중이다.
- 로그인 전에는 플래너 본문을 사용하지 못하도록 막고, 인증 후 사용 흐름으로 정리했다. - 로그인 전에는 플래너 본문을 사용하지 못하도록 막고, 인증 후 사용 흐름으로 정리했다.

View File

@@ -1,3 +1,3 @@
PORT=3001 PORT=3001
DB_FILE=./data/planner.sqlite DATABASE_URL=postgresql://planner:planner1234@localhost:5432/ten_minute_planner
CORS_ORIGIN=http://localhost:5173 CORS_ORIGIN=http://localhost:5173

14
backend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:22-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
COPY .env.example ./.env.example
COPY drizzle.config.js ./drizzle.config.js
EXPOSE 3001
CMD ["node", "src/server.js"]

View File

@@ -10,8 +10,8 @@ config({ path: path.join(__dirname, '.env') })
export default { export default {
schema: './src/db/schema.js', schema: './src/db/schema.js',
out: './drizzle', out: './drizzle',
dialect: 'sqlite', dialect: 'postgresql',
dbCredentials: { dbCredentials: {
url: process.env.DB_FILE ?? './data/planner.sqlite', url: process.env.DATABASE_URL ?? 'postgresql://planner:planner1234@localhost:5432/ten_minute_planner',
}, },
} }

View File

@@ -9,10 +9,10 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fastify/cors": "^10.0.1", "@fastify/cors": "^10.0.1",
"better-sqlite3": "^11.8.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"drizzle-orm": "^0.39.1", "drizzle-orm": "^0.39.1",
"fastify": "^5.2.1", "fastify": "^5.2.1",
"pg": "^8.13.3",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
@@ -1083,7 +1083,9 @@
"url": "https://feross.org/support" "url": "https://feross.org/support"
} }
], ],
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/better-sqlite3": { "node_modules/better-sqlite3": {
"version": "11.10.0", "version": "11.10.0",
@@ -1091,6 +1093,8 @@
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"bindings": "^1.5.0", "bindings": "^1.5.0",
"prebuild-install": "^7.1.1" "prebuild-install": "^7.1.1"
@@ -1101,6 +1105,8 @@
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"file-uri-to-path": "1.0.0" "file-uri-to-path": "1.0.0"
} }
@@ -1110,6 +1116,8 @@
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"buffer": "^5.5.0", "buffer": "^5.5.0",
"inherits": "^2.0.4", "inherits": "^2.0.4",
@@ -1135,6 +1143,8 @@
} }
], ],
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"base64-js": "^1.3.1", "base64-js": "^1.3.1",
"ieee754": "^1.1.13" "ieee754": "^1.1.13"
@@ -1151,7 +1161,9 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC" "license": "ISC",
"optional": true,
"peer": true
}, },
"node_modules/cookie": { "node_modules/cookie": {
"version": "1.1.1", "version": "1.1.1",
@@ -1189,6 +1201,8 @@
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"mimic-response": "^3.1.0" "mimic-response": "^3.1.0"
}, },
@@ -1204,6 +1218,8 @@
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=4.0.0" "node": ">=4.0.0"
} }
@@ -1222,6 +1238,8 @@
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -1377,6 +1395,8 @@
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"once": "^1.4.0" "once": "^1.4.0"
} }
@@ -1451,6 +1471,8 @@
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)", "license": "(MIT OR WTFPL)",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -1578,7 +1600,9 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/find-my-way": { "node_modules/find-my-way": {
"version": "9.5.0", "version": "9.5.0",
@@ -1598,7 +1622,9 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/gel": { "node_modules/gel": {
"version": "2.2.0", "version": "2.2.0",
@@ -1638,7 +1664,9 @@
"version": "0.0.0", "version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
@@ -1658,19 +1686,25 @@
"url": "https://feross.org/support" "url": "https://feross.org/support"
} }
], ],
"license": "BSD-3-Clause" "license": "BSD-3-Clause",
"optional": true,
"peer": true
}, },
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC",
"optional": true,
"peer": true
}, },
"node_modules/ini": { "node_modules/ini": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC" "license": "ISC",
"optional": true,
"peer": true
}, },
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "2.3.0", "version": "2.3.0",
@@ -1758,6 +1792,8 @@
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -1770,6 +1806,8 @@
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
@@ -1778,7 +1816,9 @@
"version": "0.5.3", "version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/mnemonist": { "node_modules/mnemonist": {
"version": "0.40.0", "version": "0.40.0",
@@ -1800,13 +1840,17 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/node-abi": { "node_modules/node-abi": {
"version": "3.89.0", "version": "3.89.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"semver": "^7.3.5" "semver": "^7.3.5"
}, },
@@ -1834,10 +1878,101 @@
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
"pg-protocol": "^1.13.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/pino": { "node_modules/pino": {
"version": "10.3.1", "version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
@@ -1875,12 +2010,53 @@
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prebuild-install": { "node_modules/prebuild-install": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"detect-libc": "^2.0.0", "detect-libc": "^2.0.0",
"expand-template": "^2.0.3", "expand-template": "^2.0.3",
@@ -1923,6 +2099,8 @@
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"end-of-stream": "^1.1.0", "end-of-stream": "^1.1.0",
"once": "^1.3.1" "once": "^1.3.1"
@@ -1939,6 +2117,8 @@
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"deep-extend": "^0.6.0", "deep-extend": "^0.6.0",
"ini": "~1.3.0", "ini": "~1.3.0",
@@ -1954,6 +2134,8 @@
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"inherits": "^2.0.3", "inherits": "^2.0.3",
"string_decoder": "^1.1.1", "string_decoder": "^1.1.1",
@@ -2034,7 +2216,9 @@
"url": "https://feross.org/support" "url": "https://feross.org/support"
} }
], ],
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/safe-regex2": { "node_modules/safe-regex2": {
"version": "5.1.1", "version": "5.1.1",
@@ -2132,7 +2316,9 @@
"url": "https://feross.org/support" "url": "https://feross.org/support"
} }
], ],
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/simple-get": { "node_modules/simple-get": {
"version": "4.0.1", "version": "4.0.1",
@@ -2153,6 +2339,8 @@
} }
], ],
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"decompress-response": "^6.0.0", "decompress-response": "^6.0.0",
"once": "^1.3.1", "once": "^1.3.1",
@@ -2203,6 +2391,8 @@
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
} }
@@ -2212,6 +2402,8 @@
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -2221,6 +2413,8 @@
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"chownr": "^1.1.1", "chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2", "mkdirp-classic": "^0.5.2",
@@ -2233,6 +2427,8 @@
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"bl": "^4.0.3", "bl": "^4.0.3",
"end-of-stream": "^1.4.1", "end-of-stream": "^1.4.1",
@@ -2270,6 +2466,8 @@
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
}, },
@@ -2281,7 +2479,9 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/which": { "node_modules/which": {
"version": "4.0.0", "version": "4.0.0",
@@ -2303,7 +2503,18 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC",
"optional": true,
"peer": true
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",

View File

@@ -11,10 +11,10 @@
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^10.0.1", "@fastify/cors": "^10.0.1",
"better-sqlite3": "^11.8.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"drizzle-orm": "^0.39.1", "drizzle-orm": "^0.39.1",
"fastify": "^5.2.1", "fastify": "^5.2.1",
"pg": "^8.13.3",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -10,7 +10,7 @@ config({ path: path.join(__dirname, '..', '.env') })
const envSchema = z.object({ const envSchema = z.object({
PORT: z.coerce.number().default(3001), PORT: z.coerce.number().default(3001),
DB_FILE: z.string().default('./data/planner.sqlite'), DATABASE_URL: z.string().min(1).default('postgresql://planner:planner1234@localhost:5432/ten_minute_planner'),
CORS_ORIGIN: z.string().default('http://localhost:5173'), CORS_ORIGIN: z.string().default('http://localhost:5173'),
SESSION_TTL_DAYS: z.coerce.number().default(30), SESSION_TTL_DAYS: z.coerce.number().default(30),
}) })

View File

@@ -1,17 +1,12 @@
import fs from 'node:fs' import { drizzle } from 'drizzle-orm/node-postgres'
import path from 'node:path' import pg from 'pg'
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { env } from '../config.js' import { env } from '../config.js'
import * as schema from './schema.js' import * as schema from './schema.js'
function ensureDatabaseDirectory(dbFile) { const { Pool } = pg
const absoluteDbPath = path.resolve(dbFile)
fs.mkdirSync(path.dirname(absoluteDbPath), { recursive: true })
return absoluteDbPath
}
const sqlite = new Database(ensureDatabaseDirectory(env.DB_FILE)) export const pool = new Pool({
connectionString: env.DATABASE_URL,
})
export const db = drizzle(sqlite, { schema }) export const db = drizzle(pool, { schema })
export { sqlite }

View File

@@ -1,63 +1,55 @@
import { sqlite } from './client.js' import { pool } from './client.js'
function ensureColumn(tableName, columnName, definition) { export async function ensureDatabaseSchema() {
const columns = sqlite.prepare(`PRAGMA table_info(${tableName})`).all() await pool.query(`
const hasColumn = columns.some((column) => column.name === columnName)
if (!hasColumn) {
sqlite.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`)
}
}
export function ensureDatabaseSchema() {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE, email VARCHAR(255) NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_hash VARCHAR(255) NOT NULL,
nickname TEXT NOT NULL, nickname VARCHAR(60) NOT NULL,
created_at INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL,
updated_at INTEGER NOT NULL updated_at TIMESTAMPTZ NOT NULL
); );
CREATE TABLE IF NOT EXISTS auth_sessions ( CREATE TABLE IF NOT EXISTS auth_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE, token_hash VARCHAR(255) NOT NULL UNIQUE,
expires_at INTEGER NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
created_at INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx
ON auth_sessions (user_id);
CREATE TABLE IF NOT EXISTS planner_entries ( CREATE TABLE IF NOT EXISTS planner_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
entry_date TEXT NOT NULL, entry_date VARCHAR(10) NOT NULL,
payload TEXT NOT NULL, payload JSONB NOT NULL,
created_at INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL,
updated_at INTEGER NOT NULL, updated_at TIMESTAMPTZ NOT NULL
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE UNIQUE INDEX IF NOT EXISTS planner_entries_user_date_unique CREATE UNIQUE INDEX IF NOT EXISTS planner_entries_user_date_unique
ON planner_entries (user_id, entry_date); ON planner_entries (user_id, entry_date);
CREATE TABLE IF NOT EXISTS goals ( CREATE INDEX IF NOT EXISTS planner_entries_user_id_idx
id INTEGER PRIMARY KEY AUTOINCREMENT, ON planner_entries (user_id);
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
target_date TEXT NOT NULL,
active_from TEXT,
active_until TEXT,
status TEXT NOT NULL DEFAULT 'active',
color TEXT NOT NULL DEFAULT '#1c1917',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
completed_at INTEGER,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`)
ensureColumn('goals', 'active_from', 'TEXT') CREATE TABLE IF NOT EXISTS goals (
ensureColumn('goals', 'active_until', 'TEXT') id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(120) NOT NULL,
target_date VARCHAR(10) NOT NULL,
active_from VARCHAR(10),
active_until VARCHAR(10),
color VARCHAR(32) NOT NULL DEFAULT '#1c1917',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS goals_user_id_idx
ON goals (user_id);
`)
} }

View File

@@ -1,47 +1,67 @@
import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core' import {
integer,
index,
jsonb,
pgTable,
serial,
timestamp,
uniqueIndex,
varchar,
} from 'drizzle-orm/pg-core'
export const users = sqliteTable('users', { export const users = pgTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }), id: serial('id').primaryKey(),
email: text('email').notNull().unique(), email: varchar('email', { length: 255 }).notNull().unique(),
passwordHash: text('password_hash').notNull(), passwordHash: varchar('password_hash', { length: 255 }).notNull(),
nickname: text('nickname').notNull(), nickname: varchar('nickname', { length: 60 }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
}) })
export const authSessions = sqliteTable('auth_sessions', { export const authSessions = pgTable(
id: integer('id').primaryKey({ autoIncrement: true }), 'auth_sessions',
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
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 }), id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
entryDate: text('entry_date').notNull(), tokenHash: varchar('token_hash', { length: 255 }).notNull().unique(),
payload: text('payload').notNull(), expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
}, },
(table) => ({ (table) => ({
userDateUnique: uniqueIndex('planner_entries_user_date_unique').on(table.userId, table.entryDate), userIndex: index('auth_sessions_user_id_idx').on(table.userId),
}), }),
) )
export const goals = sqliteTable('goals', { export const plannerEntries = pgTable(
id: integer('id').primaryKey({ autoIncrement: true }), 'planner_entries',
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), {
title: text('title').notNull(), id: serial('id').primaryKey(),
targetDate: text('target_date').notNull(), userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
activeFrom: text('active_from'), entryDate: varchar('entry_date', { length: 10 }).notNull(),
activeUntil: text('active_until'), payload: jsonb('payload').notNull(),
status: text('status').notNull().default('active'), createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
color: text('color').notNull().default('#1c1917'), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), },
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), (table) => ({
completedAt: integer('completed_at', { mode: 'timestamp_ms' }), userDateUnique: uniqueIndex('planner_entries_user_date_unique').on(table.userId, table.entryDate),
}) userIndex: index('planner_entries_user_id_idx').on(table.userId),
}),
)
export const goals = pgTable(
'goals',
{
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
title: varchar('title', { length: 120 }).notNull(),
targetDate: varchar('target_date', { length: 10 }).notNull(),
activeFrom: varchar('active_from', { length: 10 }),
activeUntil: varchar('active_until', { length: 10 }),
color: varchar('color', { length: 32 }).notNull().default('#1c1917'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
},
(table) => ({
userIndex: index('goals_user_id_idx').on(table.userId),
}),
)

View File

@@ -9,7 +9,6 @@ const goalSchema = z.object({
targetDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), targetDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
activeFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), activeFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
activeUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), activeUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
status: z.enum(['active', 'done', 'archived']).optional(),
color: z.string().trim().min(4).max(32).optional(), color: z.string().trim().min(4).max(32).optional(),
}) })
@@ -18,13 +17,11 @@ const goalUpdateSchema = z.object({
targetDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), targetDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
activeFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), activeFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
activeUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), activeUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
status: z.enum(['active', 'done', 'archived']).optional(),
color: z.string().trim().min(4).max(32).optional(), color: z.string().trim().min(4).max(32).optional(),
}) })
const goalQuerySchema = z.object({ const goalQuerySchema = z.object({
query: z.string().trim().optional(), query: z.string().trim().optional(),
status: z.enum(['active', 'done', 'archived', 'all']).optional(),
}) })
async function requireAuthenticatedUser(request, reply) { async function requireAuthenticatedUser(request, reply) {
@@ -48,10 +45,9 @@ async function validateGoalSchedule({
userId, userId,
activeFrom, activeFrom,
activeUntil, activeUntil,
status,
excludeGoalId = null, excludeGoalId = null,
}) { }) {
if (!activeFrom || !activeUntil || status !== 'active') { if (!activeFrom || !activeUntil) {
return null return null
} }
@@ -65,7 +61,7 @@ async function validateGoalSchedule({
return false return false
} }
if (goal.status !== 'active' || !goal.activeFrom || !goal.activeUntil) { if (!goal.activeFrom || !goal.activeUntil) {
return false return false
} }
@@ -92,10 +88,6 @@ export async function registerGoalRoutes(app) {
const filters = [eq(goals.userId, user.id)] 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) { if (query.data.query) {
filters.push(like(goals.title, `%${query.data.query}%`)) filters.push(like(goals.title, `%${query.data.query}%`))
} }
@@ -141,7 +133,6 @@ export async function registerGoalRoutes(app) {
userId: user.id, userId: user.id,
activeFrom: payload.data.activeFrom ?? null, activeFrom: payload.data.activeFrom ?? null,
activeUntil: payload.data.activeUntil ?? null, activeUntil: payload.data.activeUntil ?? null,
status: payload.data.status ?? 'active',
}) })
if (overlappedGoal) { if (overlappedGoal) {
@@ -161,10 +152,8 @@ export async function registerGoalRoutes(app) {
activeFrom: payload.data.activeFrom ?? null, activeFrom: payload.data.activeFrom ?? null,
activeUntil: payload.data.activeUntil ?? null, activeUntil: payload.data.activeUntil ?? null,
color: payload.data.color ?? '#1c1917', color: payload.data.color ?? '#1c1917',
status: payload.data.status ?? 'active',
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
completedAt: payload.data.status === 'done' ? now : null,
}) })
.returning() .returning()
@@ -214,8 +203,6 @@ export async function registerGoalRoutes(app) {
const nextActiveFrom = payload.data.activeFrom !== undefined ? payload.data.activeFrom : existingGoal.activeFrom const nextActiveFrom = payload.data.activeFrom !== undefined ? payload.data.activeFrom : existingGoal.activeFrom
const nextActiveUntil = payload.data.activeUntil !== undefined ? payload.data.activeUntil : existingGoal.activeUntil const nextActiveUntil = payload.data.activeUntil !== undefined ? payload.data.activeUntil : existingGoal.activeUntil
const nextStatus = payload.data.status ?? existingGoal.status
if ((nextActiveFrom && !nextActiveUntil) || (!nextActiveFrom && nextActiveUntil)) { if ((nextActiveFrom && !nextActiveUntil) || (!nextActiveFrom && nextActiveUntil)) {
return reply.code(400).send({ return reply.code(400).send({
message: '표시 시작일과 종료일은 함께 입력해 주세요.', message: '표시 시작일과 종료일은 함께 입력해 주세요.',
@@ -232,7 +219,6 @@ export async function registerGoalRoutes(app) {
userId: user.id, userId: user.id,
activeFrom: nextActiveFrom, activeFrom: nextActiveFrom,
activeUntil: nextActiveUntil, activeUntil: nextActiveUntil,
status: nextStatus,
excludeGoalId: existingGoal.id, excludeGoalId: existingGoal.id,
}) })
@@ -262,22 +248,10 @@ export async function registerGoalRoutes(app) {
nextValues.activeUntil = payload.data.activeUntil nextValues.activeUntil = payload.data.activeUntil
} }
if (payload.data.status !== undefined) {
nextValues.status = payload.data.status
}
if (payload.data.color !== undefined) { if (payload.data.color !== undefined) {
nextValues.color = payload.data.color nextValues.color = payload.data.color
} }
if (payload.data.status === 'done' && !existingGoal.completedAt) {
nextValues.completedAt = new Date()
}
if (payload.data.status && payload.data.status !== 'done') {
nextValues.completedAt = null
}
const [goal] = await db const [goal] = await db
.update(goals) .update(goals)
.set(nextValues) .set(nextValues)
@@ -289,4 +263,42 @@ export async function registerGoalRoutes(app) {
goal, goal,
} }
}) })
app.delete('/api/goals/:goalId', async (request, reply) => {
const user = await requireAuthenticatedUser(request, reply)
if (!user) {
return
}
const params = z.object({
goalId: z.coerce.number().int().positive(),
}).safeParse(request.params)
if (!params.success) {
return reply.code(400).send({
message: '목표 식별자가 올바르지 않습니다.',
})
}
const [existingGoal] = await db
.select()
.from(goals)
.where(and(eq(goals.id, params.data.goalId), eq(goals.userId, user.id)))
.limit(1)
if (!existingGoal) {
return reply.code(404).send({
message: '목표를 찾을 수 없습니다.',
})
}
await db
.delete(goals)
.where(and(eq(goals.id, params.data.goalId), eq(goals.userId, user.id)))
return {
message: '목표가 삭제되었습니다.',
}
})
} }

View File

@@ -67,10 +67,7 @@ export async function registerPlannerRoutes(app) {
.orderBy(asc(plannerEntries.entryDate)) .orderBy(asc(plannerEntries.entryDate))
return { return {
entries: entries.map((entry) => ({ entries,
...entry,
payload: JSON.parse(entry.payload),
})),
} }
}) })
@@ -101,12 +98,7 @@ export async function registerPlannerRoutes(app) {
.limit(1) .limit(1)
return { return {
entry: entry entry: entry ?? null,
? {
...entry,
payload: JSON.parse(entry.payload),
}
: null,
} }
}) })
@@ -141,14 +133,14 @@ export async function registerPlannerRoutes(app) {
.values({ .values({
userId: user.id, userId: user.id,
entryDate: dateResult.data, entryDate: dateResult.data,
payload: JSON.stringify(payloadResult.data.payload), payload: payloadResult.data.payload,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [plannerEntries.userId, plannerEntries.entryDate], target: [plannerEntries.userId, plannerEntries.entryDate],
set: { set: {
payload: JSON.stringify(payloadResult.data.payload), payload: payloadResult.data.payload,
updatedAt: now, updatedAt: now,
}, },
}) })
@@ -156,10 +148,7 @@ export async function registerPlannerRoutes(app) {
return { return {
message: '플래너가 저장되었습니다.', message: '플래너가 저장되었습니다.',
entry: { entry,
...entry,
payload: JSON.parse(entry.payload),
},
} }
}) })

View File

@@ -1,7 +1,7 @@
import Fastify from 'fastify' import Fastify from 'fastify'
import cors from '@fastify/cors' import cors from '@fastify/cors'
import { env } from './config.js' import { env } from './config.js'
import { sqlite } from './db/client.js' import { pool } from './db/client.js'
import { ensureDatabaseSchema } from './db/init.js' import { ensureDatabaseSchema } from './db/init.js'
import { registerAuthRoutes } from './routes/auth.js' import { registerAuthRoutes } from './routes/auth.js'
import { registerGoalRoutes } from './routes/goals.js' import { registerGoalRoutes } from './routes/goals.js'
@@ -11,7 +11,7 @@ const app = Fastify({
logger: true, logger: true,
}) })
ensureDatabaseSchema() await ensureDatabaseSchema()
await app.register(cors, { await app.register(cors, {
origin: env.CORS_ORIGIN, origin: env.CORS_ORIGIN,
@@ -23,13 +23,14 @@ await registerGoalRoutes(app)
await registerPlannerRoutes(app) await registerPlannerRoutes(app)
app.get('/health', async () => { app.get('/health', async () => {
const version = sqlite.prepare('select sqlite_version() as version').get() const versionResult = await pool.query('select version() as version')
const version = versionResult.rows[0]
return { return {
status: 'ok', status: 'ok',
service: 'ten-minute-planner-backend', service: 'ten-minute-planner-backend',
database: { database: {
client: 'sqlite', client: 'postgresql',
version: version?.version ?? 'unknown', version: version?.version ?? 'unknown',
}, },
} }
@@ -37,11 +38,11 @@ app.get('/health', async () => {
app.get('/api/meta', async () => ({ app.get('/api/meta', async () => ({
auth: 'active', auth: 'active',
storage: 'sqlite', storage: 'postgresql',
orm: 'drizzle', orm: 'drizzle',
notes: [ notes: [
'회원가입, 로그인, 현재 사용자 확인 API가 준비되어 있습니다.', '회원가입, 로그인, 현재 사용자 확인 API가 준비되어 있습니다.',
'사용자별 목표 목록과 생성 API가 준비되어 있습니다.', '사용자별 목표 목록, 수정, 삭제 API가 준비되어 있습니다.',
'사용자별 플래너 저장 및 조회 API가 준비되어 있습니다.', '사용자별 플래너 저장 및 조회 API가 준비되어 있습니다.',
], ],
})) }))

26
deploy/nginx/default.conf Normal file
View File

@@ -0,0 +1,26 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:3001/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health {
proxy_pass http://backend:3001/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location / {
try_files $uri $uri/ /index.html;
}
}

61
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,61 @@
services:
postgres:
image: postgres:16-alpine
container_name: ten-minute-postgres-dev
environment:
POSTGRES_DB: ten_minute_planner
POSTGRES_USER: planner
POSTGRES_PASSWORD: planner1234
volumes:
- postgres_dev_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U planner -d ten_minute_planner"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
backend:
image: node:22-alpine
container_name: ten-minute-backend-dev
working_dir: /app
command: sh -c "npm install && npm run dev"
environment:
PORT: 3001
DATABASE_URL: postgresql://planner:planner1234@postgres:5432/ten_minute_planner
CORS_ORIGIN: http://localhost:5173
SESSION_TTL_DAYS: 30
volumes:
- ./backend:/app
- backend_node_modules:/app/node_modules
depends_on:
postgres:
condition: service_healthy
ports:
- "3001:3001"
restart: unless-stopped
frontend:
image: node:22-alpine
container_name: ten-minute-frontend-dev
working_dir: /app
command: sh -c "npm install && npm run dev"
environment:
VITE_API_BASE_URL: http://localhost:3001
CHOKIDAR_USEPOLLING: "true"
volumes:
- .:/app
- /app/backend
- frontend_node_modules:/app/node_modules
depends_on:
- backend
ports:
- "5173:5173"
restart: unless-stopped
volumes:
postgres_dev_data:
backend_node_modules:
frontend_node_modules:

49
docker-compose.yml Normal file
View File

@@ -0,0 +1,49 @@
services:
postgres:
image: postgres:16-alpine
container_name: ten-minute-postgres
environment:
POSTGRES_DB: ten_minute_planner
POSTGRES_USER: planner
POSTGRES_PASSWORD: planner1234
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U planner -d ten_minute_planner"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
backend:
build:
context: ./backend
container_name: ten-minute-backend
environment:
PORT: 3001
DATABASE_URL: postgresql://planner:planner1234@postgres:5432/ten_minute_planner
CORS_ORIGIN: http://localhost:8080
SESSION_TTL_DAYS: 30
depends_on:
postgres:
condition: service_healthy
expose:
- "3001"
restart: unless-stopped
frontend:
build:
context: .
args:
VITE_API_BASE_URL: /api
container_name: ten-minute-frontend
depends_on:
- backend
ports:
- "8080:80"
restart: unless-stopped
volumes:
postgres_data:

4
package-lock.json generated
View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref, watch, nextTick } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import AuthDialog from './components/AuthDialog.vue' import AuthDialog from './components/AuthDialog.vue'
import GoalsDashboard from './components/GoalsDashboard.vue' import GoalsDashboard from './components/GoalsDashboard.vue'
import MiniCalendar from './components/MiniCalendar.vue' import MiniCalendar from './components/MiniCalendar.vue'
@@ -16,7 +16,8 @@ import {
updatePassword, updatePassword,
updateProfile, updateProfile,
} from './lib/authClient' } from './lib/authClient'
import { createGoal, fetchGoals, updateGoal } from './lib/goalsApi' import { toUserFacingApiError } from './lib/apiBase'
import { createGoal, deleteGoal, fetchGoals, updateGoal } from './lib/goalsApi'
import { deletePlannerEntry, fetchPlannerEntries, savePlannerEntry } from './lib/plannerApi' import { deletePlannerEntry, fetchPlannerEntries, savePlannerEntry } from './lib/plannerApi'
import { import {
createInitialPlannerRecords, createInitialPlannerRecords,
@@ -36,7 +37,6 @@ const authToken = ref('')
const currentUser = ref(null) const currentUser = ref(null)
const goals = ref([]) const goals = ref([])
const goalQuery = ref('') const goalQuery = ref('')
const goalStatusFilter = ref('all')
const goalBusy = ref(false) const goalBusy = ref(false)
const goalMessage = ref('') const goalMessage = ref('')
const editingGoalId = ref(null) const editingGoalId = ref(null)
@@ -45,6 +45,8 @@ const syncMessage = ref('')
const syncToastVisible = ref(false) const syncToastVisible = ref(false)
const selectedDate = ref(new Date()) const selectedDate = ref(new Date())
const calendarViewDate = ref(new Date(selectedDate.value)) const calendarViewDate = ref(new Date(selectedDate.value))
const windowWidth = ref(typeof window === 'undefined' ? 1920 : window.innerWidth)
const rightPanelOpen = ref(false)
const statsRangeStart = ref(toKey(new Date(new Date().setDate(new Date().getDate() - 6)))) const statsRangeStart = ref(toKey(new Date(new Date().setDate(new Date().getDate() - 6))))
const statsRangeEnd = ref(toKey(new Date())) const statsRangeEnd = ref(toKey(new Date()))
const authForm = reactive({ const authForm = reactive({
@@ -57,7 +59,6 @@ const goalForm = reactive({
targetDate: '', targetDate: '',
activeFrom: '', activeFrom: '',
activeUntil: '', activeUntil: '',
status: 'active',
}) })
const profileForm = reactive({ const profileForm = reactive({
nickname: '', nickname: '',
@@ -315,7 +316,7 @@ const calendarDays = computed(() => {
const start = new Date(first) const start = new Date(first)
start.setDate(first.getDate() - first.getDay()) start.setDate(first.getDate() - first.getDay())
return Array.from({ length: 35 }, (_, index) => { return Array.from({ length: 42 }, (_, index) => {
const date = new Date(start) const date = new Date(start)
date.setDate(start.getDate() + index) date.setDate(start.getDate() + index)
return { return {
@@ -337,10 +338,6 @@ const isAuthenticated = computed(() => Boolean(authToken.value && currentUser.va
const filteredGoals = computed(() => { const filteredGoals = computed(() => {
const query = goalQuery.value.trim().toLowerCase() const query = goalQuery.value.trim().toLowerCase()
return goals.value.filter((goal) => { return goals.value.filter((goal) => {
if (goalStatusFilter.value !== 'all' && goal.status !== goalStatusFilter.value) {
return false
}
if (!query) { if (!query) {
return true return true
} }
@@ -351,7 +348,7 @@ const filteredGoals = computed(() => {
const activePlannerGoals = computed(() => const activePlannerGoals = computed(() =>
goals.value goals.value
.filter((goal) => { .filter((goal) => {
if (goal.status !== 'active' || !goal.activeFrom || !goal.activeUntil) { if (!goal.activeFrom || !goal.activeUntil) {
return false return false
} }
@@ -642,6 +639,100 @@ 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}`
: '오늘 코멘트는 아직 비어 있습니다.',
]
})
const isWideFocusSidebar = computed(() => windowWidth.value >= 1620)
const isOverlayFocusSidebar = computed(() => windowWidth.value < 1280)
const showInlineFocusSidebar = computed(() => !isOverlayFocusSidebar.value)
const focusSidebarOuterClass = computed(() =>
showInlineFocusSidebar.value
? isWideFocusSidebar.value
? 'xl:w-[640px]'
: 'xl:w-full xl:max-w-[360px]'
: '',
)
const focusSidebarGridClass = computed(() =>
isWideFocusSidebar.value
? '2xl:grid-cols-[280px_minmax(0,1fr)] 2xl:items-start'
: 'grid-cols-1',
)
const spreadScale = computed(() => {
const reservedWidth = windowWidth.value >= 1280 ? 410 : 96
const availableWidth = Math.max(windowWidth.value - reservedWidth, 980)
const baseWidth = 1548
const nextScale = Math.min(1, availableWidth / baseWidth)
return Number(Math.max(nextScale, 0.86).toFixed(3))
})
const spreadCanvasStyle = computed(() => {
const scale = spreadScale.value
return {
width: `${1548 * scale}px`,
minWidth: `${1548 * scale}px`,
}
})
const spreadPageFrameStyle = computed(() => {
const scale = spreadScale.value
return {
width: `${762 * scale}px`,
height: `${1118 * scale}px`,
}
})
const spreadPageStyle = computed(() => ({
width: '762px',
maxWidth: 'none',
transform: `scale(${spreadScale.value})`,
transformOrigin: 'top left',
}))
watch( watch(
[plannerRecords, selectedDate, calendarViewDate, statsRangeStart, statsRangeEnd], [plannerRecords, selectedDate, calendarViewDate, statsRangeStart, statsRangeEnd],
() => { () => {
@@ -662,7 +753,10 @@ watch(
() => { () => {
if (!plannerGoal.value && planner.value.goalEnabled) { if (!plannerGoal.value && planner.value.goalEnabled) {
planner.value.goalEnabled = false planner.value.goalEnabled = false
schedulePlannerSyncForRecord(planner.value)
if (hasPlannerContent(planner.value)) {
schedulePlannerSyncForRecord(planner.value)
}
} }
}, },
) )
@@ -685,6 +779,22 @@ function areTaskLabelsNumbered(record) {
return record.tasks.every((task, index) => task.label === createTaskLabel(index)) return record.tasks.every((task, index) => task.label === createTaskLabel(index))
} }
function updateWindowWidth() {
windowWidth.value = window.innerWidth
if (windowWidth.value >= 1280) {
rightPanelOpen.value = false
}
}
function openRightPanel() {
rightPanelOpen.value = true
}
function closeRightPanel() {
rightPanelOpen.value = false
}
function setSyncFeedback(status, message, options = {}) { function setSyncFeedback(status, message, options = {}) {
const { const {
visible = true, visible = true,
@@ -713,8 +823,8 @@ function getTodayKey() {
return toKey(new Date()) return toKey(new Date())
} }
function findOverlappingGoal({ activeFrom, activeUntil, status, excludeGoalId = null }) { function findOverlappingGoal({ activeFrom, activeUntil, excludeGoalId = null }) {
if (!activeFrom || !activeUntil || status !== 'active') { if (!activeFrom || !activeUntil) {
return null return null
} }
@@ -723,7 +833,7 @@ function findOverlappingGoal({ activeFrom, activeUntil, status, excludeGoalId =
return false return false
} }
if (goal.status !== 'active' || !goal.activeFrom || !goal.activeUntil) { if (!goal.activeFrom || !goal.activeUntil) {
return false return false
} }
@@ -742,7 +852,6 @@ function resetGoalForm() {
goalForm.targetDate = '' goalForm.targetDate = ''
goalForm.activeFrom = getTodayKey() goalForm.activeFrom = getTodayKey()
goalForm.activeUntil = '' goalForm.activeUntil = ''
goalForm.status = 'active'
editingGoalId.value = null editingGoalId.value = null
} }
@@ -806,7 +915,7 @@ async function submitAuthForm() {
await applyAuthSuccess(result) await applyAuthSuccess(result)
} catch (error) { } catch (error) {
authMessage.value = error.message || '인증 처리 중 문제가 발생했습니다.' authMessage.value = toUserFacingApiError(error, '인증 처리 중 문제가 발생했습니다.')
} finally { } finally {
authBusy.value = false authBusy.value = false
} }
@@ -850,7 +959,6 @@ function logout() {
goals.value = [] goals.value = []
goalQuery.value = '' goalQuery.value = ''
goalMessage.value = '' goalMessage.value = ''
goalStatusFilter.value = 'all'
setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', { setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', {
visible: false, visible: false,
}) })
@@ -900,7 +1008,6 @@ async function submitGoal() {
const overlappedGoal = findOverlappingGoal({ const overlappedGoal = findOverlappingGoal({
activeFrom: goalForm.activeFrom || null, activeFrom: goalForm.activeFrom || null,
activeUntil: goalForm.activeUntil || null, activeUntil: goalForm.activeUntil || null,
status: goalForm.status,
}) })
if (overlappedGoal) { if (overlappedGoal) {
@@ -917,7 +1024,6 @@ async function submitGoal() {
targetDate: goalForm.targetDate, targetDate: goalForm.targetDate,
activeFrom: goalForm.activeFrom || null, activeFrom: goalForm.activeFrom || null,
activeUntil: goalForm.activeUntil || null, activeUntil: goalForm.activeUntil || null,
status: goalForm.status,
}) })
await loadGoals() await loadGoals()
@@ -949,7 +1055,6 @@ function startGoalEdit(goal) {
goalForm.targetDate = goal.targetDate ?? '' goalForm.targetDate = goal.targetDate ?? ''
goalForm.activeFrom = goal.activeFrom ?? '' goalForm.activeFrom = goal.activeFrom ?? ''
goalForm.activeUntil = goal.activeUntil ?? '' goalForm.activeUntil = goal.activeUntil ?? ''
goalForm.status = goal.status ?? 'active'
goalMessage.value = '' goalMessage.value = ''
} }
@@ -976,7 +1081,6 @@ async function saveGoalEdit() {
const overlappedGoal = findOverlappingGoal({ const overlappedGoal = findOverlappingGoal({
activeFrom: goalForm.activeFrom || null, activeFrom: goalForm.activeFrom || null,
activeUntil: goalForm.activeUntil || null, activeUntil: goalForm.activeUntil || null,
status: goalForm.status,
excludeGoalId: editingGoalId.value, excludeGoalId: editingGoalId.value,
}) })
@@ -994,7 +1098,6 @@ async function saveGoalEdit() {
targetDate: goalForm.targetDate, targetDate: goalForm.targetDate,
activeFrom: goalForm.activeFrom || null, activeFrom: goalForm.activeFrom || null,
activeUntil: goalForm.activeUntil || null, activeUntil: goalForm.activeUntil || null,
status: goalForm.status,
}) })
await loadGoals() await loadGoals()
@@ -1007,6 +1110,30 @@ async function saveGoalEdit() {
} }
} }
async function removeGoal(goal) {
const confirmed = window.confirm(`"${goal.title}" 목표를 삭제할까요? 삭제하면 과거 날짜에서도 더 이상 표시되지 않습니다.`)
if (!confirmed) {
return
}
goalBusy.value = true
goalMessage.value = ''
try {
const result = await deleteGoal(authToken.value, goal.id)
await loadGoals()
if (editingGoalId.value === goal.id) {
resetGoalForm()
}
goalMessage.value = result.message || '목표가 삭제되었습니다.'
} catch (error) {
goalMessage.value = error.message || '목표를 삭제하지 못했습니다.'
} finally {
goalBusy.value = false
}
}
function updateProfileField({ field, value }) { function updateProfileField({ field, value }) {
profileForm[field] = value profileForm[field] = value
} }
@@ -1229,14 +1356,26 @@ onMounted(() => {
setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', { setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', {
visible: false, visible: false,
}) })
updateWindowWidth()
window.addEventListener('resize', updateWindowWidth)
restoreAuthSession() restoreAuthSession()
}) })
onBeforeUnmount(() => {
window.removeEventListener('resize', updateWindowWidth)
})
</script> </script>
<template> <template>
<main class="min-h-screen px-4 py-6 text-ink sm:px-6 lg:px-10 xl:h-screen xl:overflow-hidden"> <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"> <div
<aside class="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"> class="print-root mx-auto flex max-w-[1760px] flex-col gap-6"
:class="isAuthenticated ? 'xl:h-[calc(100vh-3rem)] xl:grid xl:grid-cols-[300px_minmax(0,1fr)] xl:items-start' : 'min-h-[calc(100vh-3rem)] items-center justify-center'"
>
<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-6">
<div class="space-y-2"> <div class="space-y-2">
<p class="text-[11px] font-bold uppercase tracking-[0.28em] text-stone-500">10 Minute Planner</p> <p class="text-[11px] font-bold uppercase tracking-[0.28em] text-stone-500">10 Minute Planner</p>
@@ -1391,10 +1530,10 @@ onMounted(() => {
</div> </div>
</aside> </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' : 'w-full'">
<section <section
v-if="!isAuthenticated" v-if="!isAuthenticated"
class="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" class="scrollbar-hide print-hidden mx-auto w-full max-w-4xl rounded-[28px] 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="mx-auto flex max-w-3xl flex-col gap-6 text-center">
<div class="space-y-3"> <div class="space-y-3">
@@ -1451,9 +1590,25 @@ onMounted(() => {
<section <section
v-else-if="screenMode === 'planner' && viewMode === 'focus'" v-else-if="screenMode === 'planner' && viewMode === 'focus'"
class="print-hidden grid gap-6 xl:h-full xl:min-h-0 xl:grid-cols-[minmax(0,1fr)_340px]" class="print-hidden relative grid gap-6 xl:h-full xl:min-h-0"
:class="showInlineFocusSidebar ? 'xl:grid-cols-[minmax(0,1fr)_minmax(0,360px)] 2xl:grid-cols-[minmax(0,1fr)_640px]' : ''"
> >
<div class="print-target rounded-[28px] border border-white/60 bg-white/45 p-4 shadow-[0_18px_60px_rgba(28,25,23,0.06)] xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-3"> <div
v-if="isOverlayFocusSidebar && rightPanelOpen"
class="fixed inset-0 z-30 bg-stone-900/18 backdrop-blur-[2px]"
@click="closeRightPanel"
/>
<div class="scrollbar-hide print-target rounded-[28px] border border-white/60 bg-white/45 p-4 shadow-[0_18px_60px_rgba(28,25,23,0.06)] xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-3">
<div v-if="isOverlayFocusSidebar" class="mb-4 flex justify-end">
<button
type="button"
class="rounded-full border border-stone-200 bg-white px-4 py-2 text-[11px] font-bold tracking-[0.16em] text-stone-700 transition hover:border-stone-400 hover:text-ink"
@click="openRightPanel"
>
OPEN SIDE PANEL
</button>
</div>
<PlannerPage <PlannerPage
:date-main="selectedDateDisplay.main" :date-main="selectedDateDisplay.main"
:date-weekday="selectedDateDisplay.weekday" :date-weekday="selectedDateDisplay.weekday"
@@ -1476,150 +1631,181 @@ onMounted(() => {
/> />
</div> </div>
<aside class="print-hidden flex flex-col gap-4 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-2"> <aside
<section class="rounded-[24px] border border-stone-200 bg-white/82 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]"> v-if="showInlineFocusSidebar || rightPanelOpen"
<p class="mb-4 text-[11px] font-bold tracking-[0.22em] text-ink">PREV SNAPSHOT</p> class="scrollbar-hide print-hidden rounded-[28px] border border-white/60 bg-[#f7f2ea]/95 p-3 shadow-[0_18px_60px_rgba(28,25,23,0.12)]"
<div class="space-y-3"> :class="[
<p focusSidebarOuterClass,
v-for="item in planner.prevSummary" showInlineFocusSidebar
:key="item" ? 'xl:h-full xl:min-h-0 xl:overflow-y-auto'
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" : 'fixed inset-y-4 right-4 z-40 w-[min(360px,calc(100vw-2rem))] overflow-y-auto',
> ]"
{{ item || ' ' }} >
</p> <div v-if="!showInlineFocusSidebar" class="mb-3 flex items-center justify-between rounded-[18px] border border-stone-200 bg-white/90 px-4 py-3">
</div> <p class="text-[11px] font-bold tracking-[0.18em] text-ink">SIDE PANEL</p>
</section> <button
type="button"
class="rounded-full border border-stone-200 px-3 py-1 text-[10px] font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
@click="closeRightPanel"
>
CLOSE
</button>
</div>
<section class="rounded-[24px] border border-stone-200 bg-white/82 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]"> <div class="grid gap-4 rounded-[22px] p-2" :class="focusSidebarGridClass">
<div class="flex items-start justify-between gap-3"> <div class="grid gap-4" :class="isWideFocusSidebar ? '' : 'max-w-[360px]'">
<div> <section class="2xl:w-[280px]">
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">TASK LABELS</p> <MiniCalendar
<p class="mt-2 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600"> :month-label="monthLabel"
ON이면 왼쪽 라벨을 01, 02 형태로 채우고 OFF이면 비워 둡니다. :year-label="yearLabel"
</p> :days="calendarDays"
</div> :selected-key="toKey(selectedDate)"
<button :marked-keys="markedDateKeys"
type="button" @shift-month="shiftCalendarMonth"
class="relative h-8 w-16 shrink-0 rounded-full transition-colors duration-300 ease-out" @shift-year="shiftCalendarYear"
:class="areTaskLabelsNumbered(planner) ? 'bg-stone-900' : 'bg-stone-300'" @go-today="selectDate(new Date())"
@click="areTaskLabelsNumbered(planner) ? clearTaskLabels(planner) : fillTaskLabelsWithNumbers(planner)" @select="selectDate"
>
<span
class="absolute left-1 top-1 h-6 w-6 transform-gpu rounded-full bg-white shadow-sm transition-transform duration-300 ease-out will-change-transform"
:class="areTaskLabelsNumbered(planner) ? 'translate-x-8' : 'translate-x-0'"
/> />
</button> </section>
</div>
</section>
<section class="rounded-[24px] border border-stone-200 bg-white/82 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]"> <section class="rounded-[24px] border border-stone-200 bg-white/88 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]">
<p class="mb-4 text-[11px] font-bold tracking-[0.22em] text-ink">READ NEXT</p> <div class="flex items-start justify-between gap-3">
<div class="space-y-3"> <div>
<p <p class="text-[11px] font-bold tracking-[0.22em] text-ink">TASK LABELS</p>
v-for="item in planner.nextFocus" <p class="mt-2 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600">
: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" </p>
> </div>
{{ item || ' ' }} <button
</p> type="button"
</div> class="relative h-8 w-16 shrink-0 rounded-full transition-colors duration-300 ease-out"
</section> :class="areTaskLabelsNumbered(planner) ? 'bg-stone-900' : 'bg-stone-300'"
@click="areTaskLabelsNumbered(planner) ? clearTaskLabels(planner) : fillTaskLabelsWithNumbers(planner)"
<section class="rounded-[24px] border border-stone-200 bg-white/82 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]"> >
<div class="flex items-start justify-between gap-3"> <span
<div> class="absolute left-1 top-1 h-6 w-6 transform-gpu rounded-full bg-white shadow-sm transition-transform duration-300 ease-out will-change-transform"
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">D-DAY 사용</p> :class="areTaskLabelsNumbered(planner) ? 'translate-x-8' : 'translate-x-0'"
<p class="mt-2 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600"> />
목표 검색과 기간 설정은 GOALS 화면에서 관리하고, 여기서는 현재 날짜에 D-DAY를 보여줄지 여부만 제어합니다. </button>
</p>
</div>
<button
type="button"
class="relative h-8 w-16 shrink-0 rounded-full transition-colors duration-300 ease-out disabled:cursor-not-allowed disabled:opacity-50"
:class="planner.goalEnabled ? 'bg-stone-900' : 'bg-stone-300'"
:disabled="!hasActiveGoalForSelectedDate"
@click="updateGoalEnabled(planner, !planner.goalEnabled)"
>
<span
class="absolute left-1 top-1 h-6 w-6 transform-gpu rounded-full bg-white shadow-sm transition-transform duration-300 ease-out will-change-transform"
:class="planner.goalEnabled ? 'translate-x-8' : 'translate-x-0'"
/>
</button>
</div>
<div class="mt-4 space-y-3">
<div v-if="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 }} / 적용 {{ plannerGoal.activeFrom }} ~ {{ plannerGoal.activeUntil }}
</p>
</div>
<div v-if="plannerGoal" class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-4">
<p class="text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600">
날짜에는 이미 적용된 목표가 있으므로 토글만으로 표시 여부를 빠르게 조절할 있습니다.
</p>
</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">
현재 날짜에 적용된 목표가 없습니다. GOALS 화면에서 표시 기간을 지정하면 여기 토글이 자동으로 활성화됩니다.
</p>
</div>
</div>
</section>
<MiniCalendar
:month-label="monthLabel"
:year-label="yearLabel"
:days="calendarDays"
:selected-key="toKey(selectedDate)"
:marked-keys="markedDateKeys"
@shift-month="shiftCalendarMonth"
@shift-year="shiftCalendarYear"
@go-today="selectDate(new Date())"
@select="selectDate"
/>
<section class="grid grid-cols-2 gap-4">
<article class="rounded-[24px] border border-stone-200 bg-white/82 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]">
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">STATS</p>
<div class="mt-5 space-y-4">
<div>
<p class="text-[28px] font-semibold tracking-[-0.05em] text-stone-900">{{ completionRate }}%</p>
<p class="text-[11px] font-semibold tracking-[0.08em] text-stone-500">TASK COMPLETION</p>
</div> </div>
<div> </section>
<p class="text-[22px] font-semibold tracking-[-0.04em] text-stone-900">{{ formatTotalTime(planner) }}</p>
<p class="text-[11px] font-semibold tracking-[0.08em] text-stone-500">FOCUSED TIME</p>
</div>
</div>
</article>
<article class="rounded-[24px] border border-stone-200 bg-white/82 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]"> <section class="rounded-[24px] border border-stone-200 bg-white/88 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]">
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">NEXT DAY</p> <div class="flex items-start justify-between gap-3">
<div class="mt-5 space-y-3"> <div>
<p class="text-lg font-semibold tracking-[-0.04em] text-stone-900"> <p class="text-[11px] font-bold tracking-[0.22em] text-ink">D-DAY 사용</p>
<span>{{ secondaryDateDisplay.main }}</span> <p class="mt-2 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600">
<span class="ml-1" :class="secondaryDateDisplay.weekdayTone">{{ secondaryDateDisplay.weekday }}</span> 현재 날짜에 적용된 목표가 있을 때만 본문 D-DAY 표시를 켜고 있습니다.
</p> </p>
<p class="text-[11px] font-semibold tracking-[0.08em] text-stone-600"> </div>
내일의 작업은 "{{ secondaryPlanner.tasks[0]?.title || '새 작업 추가' }}" 시작합니다. <button
type="button"
class="relative h-8 w-16 shrink-0 rounded-full transition-colors duration-300 ease-out disabled:cursor-not-allowed disabled:opacity-50"
:class="planner.goalEnabled ? 'bg-stone-900' : 'bg-stone-300'"
:disabled="!hasActiveGoalForSelectedDate"
@click="updateGoalEnabled(planner, !planner.goalEnabled)"
>
<span
class="absolute left-1 top-1 h-6 w-6 transform-gpu rounded-full bg-white shadow-sm transition-transform duration-300 ease-out will-change-transform"
:class="planner.goalEnabled ? 'translate-x-8' : 'translate-x-0'"
/>
</button>
</div>
<div class="mt-4 space-y-3">
<div v-if="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 }} / 적용 {{ plannerGoal.activeFrom }} ~ {{ plannerGoal.activeUntil }}
</p>
</div>
<div v-if="plannerGoal" class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-4">
<p class="text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600">
날짜에는 이미 적용된 목표가 있으므로 토글만으로 표시 여부를 빠르게 조절할 있습니다.
</p>
</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">
현재 날짜에 적용된 목표가 없습니다. GOALS 화면에서 표시 기간을 지정하면 여기 토글이 자동으로 활성화됩니다.
</p>
</div>
</div>
</section>
</div>
<div class="grid gap-4" :class="isWideFocusSidebar ? '' : 'max-w-[360px]'">
<section class="rounded-[24px] border border-stone-200 bg-white/82 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]">
<article>
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">STATS</p>
<div class="mt-5 space-y-4">
<div>
<p class="text-[28px] font-semibold tracking-[-0.05em] text-stone-900">{{ completionRate }}%</p>
<p class="text-[11px] font-semibold tracking-[0.08em] text-stone-500">TASK COMPLETION</p>
</div>
<div>
<p class="text-[22px] font-semibold tracking-[-0.04em] text-stone-900">{{ formatTotalTime(planner) }}</p>
<p class="text-[11px] font-semibold tracking-[0.08em] text-stone-500">FOCUSED TIME</p>
</div>
</div>
</article>
</section>
<section class="rounded-[24px] border border-stone-200 bg-white/82 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]">
<article>
<p class="text-[11px] font-bold tracking-[0.22em] text-ink">NEXT DAY</p>
<div class="mt-5 space-y-3">
<p class="text-lg font-semibold tracking-[-0.04em] text-stone-900">
<span>{{ secondaryDateDisplay.main }}</span>
<span class="ml-1" :class="secondaryDateDisplay.weekdayTone">{{ secondaryDateDisplay.weekday }}</span>
</p>
<p class="text-[11px] font-semibold tracking-[0.08em] text-stone-600">
내일의 작업은 "{{ secondaryPlanner.tasks.find((task) => task.title.trim())?.title || '새 작업 추가' }}" 시작합니다.
</p>
</div>
</article>
</section>
</div>
<section class="rounded-[24px] border border-stone-200 bg-white/82 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]" :class="isWideFocusSidebar ? '2xl:col-span-2' : ''">
<p class="mb-4 text-[11px] font-bold tracking-[0.22em] text-ink">READ NEXT</p>
<div class="grid gap-3" :class="isWideFocusSidebar ? 'sm:grid-cols-3 2xl:grid-cols-3' : 'grid-cols-1'">
<p
v-for="item in readNextItems"
:key="item"
class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-4 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-700"
>
{{ item || ' ' }}
</p> </p>
</div> </div>
</article> </section>
</section>
<section class="rounded-[24px] border border-stone-200 bg-white/82 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]" :class="isWideFocusSidebar ? '2xl:col-span-2' : ''">
<p class="mb-4 text-[11px] font-bold tracking-[0.22em] text-ink">PREV SNAPSHOT</p>
<div class="grid gap-3" :class="isWideFocusSidebar ? 'sm:grid-cols-3 2xl:grid-cols-3' : 'grid-cols-1'">
<p
v-for="item in prevSnapshotItems"
:key="item"
class="rounded-2xl border border-stone-200 bg-white px-4 py-4 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-700"
>
{{ item || ' ' }}
</p>
</div>
</section>
</div>
</aside> </aside>
</section> </section>
<section <section
v-else-if="screenMode === 'planner'" v-else-if="screenMode === 'planner'"
class="print-hidden overflow-x-auto rounded-[28px] border border-white/60 bg-white/45 p-4 shadow-[0_18px_60px_rgba(28,25,23,0.06)] sm:p-6 xl:h-full xl:overflow-y-auto" class="scrollbar-hide print-hidden overflow-x-auto rounded-[28px] border border-white/60 bg-white/45 p-4 shadow-[0_18px_60px_rgba(28,25,23,0.06)] sm:p-6 xl:h-full xl:overflow-y-auto"
> >
<div class="flex min-w-[1260px] gap-6"> <div class="mx-auto flex gap-6" :style="spreadCanvasStyle">
<div class="print-target"> <div class="print-target overflow-hidden" :style="spreadPageFrameStyle">
<PlannerPage <PlannerPage
:style="spreadPageStyle"
:date-main="selectedDateDisplay.main" :date-main="selectedDateDisplay.main"
:date-weekday="selectedDateDisplay.weekday" :date-weekday="selectedDateDisplay.weekday"
:date-weekday-tone="selectedDateDisplay.weekdayTone" :date-weekday-tone="selectedDateDisplay.weekdayTone"
@@ -1640,8 +1826,9 @@ onMounted(() => {
@update:timetable="updateTimetable(planner, $event)" @update:timetable="updateTimetable(planner, $event)"
/> />
</div> </div>
<div class="print-hidden"> <div class="print-hidden overflow-hidden" :style="spreadPageFrameStyle">
<PlannerPage <PlannerPage
:style="spreadPageStyle"
:date-main="secondaryDateDisplay.main" :date-main="secondaryDateDisplay.main"
:date-weekday="secondaryDateDisplay.weekday" :date-weekday="secondaryDateDisplay.weekday"
:date-weekday-tone="secondaryDateDisplay.weekdayTone" :date-weekday-tone="secondaryDateDisplay.weekdayTone"
@@ -1667,27 +1854,26 @@ onMounted(() => {
<GoalsDashboard <GoalsDashboard
v-else-if="screenMode === 'goals'" v-else-if="screenMode === 'goals'"
class="print-hidden xl:h-full xl:overflow-y-auto" class="scrollbar-hide print-hidden xl:h-full xl:overflow-y-auto"
:goals="filteredGoals" :goals="filteredGoals"
:query="goalQuery" :query="goalQuery"
:status="goalStatusFilter"
:form="goalForm" :form="goalForm"
:editing-goal-id="editingGoalId" :editing-goal-id="editingGoalId"
:busy="goalBusy" :busy="goalBusy"
:message="goalMessage" :message="goalMessage"
:selected-date-key="toKey(selectedDate)" :selected-date-key="toKey(selectedDate)"
@update:query="goalQuery = $event" @update:query="goalQuery = $event"
@update:status="goalStatusFilter = $event"
@update:form-field="updateGoalFormField" @update:form-field="updateGoalFormField"
@submit:create="submitGoal" @submit:create="submitGoal"
@start-edit="startGoalEdit" @start-edit="startGoalEdit"
@cancel-edit="resetGoalForm(); goalMessage = ''" @cancel-edit="resetGoalForm(); goalMessage = ''"
@submit:update="saveGoalEdit" @submit:update="saveGoalEdit"
@delete-goal="removeGoal"
/> />
<SettingsDashboard <SettingsDashboard
v-else-if="screenMode === 'settings'" v-else-if="screenMode === 'settings'"
class="print-hidden xl:h-full xl:overflow-y-auto" class="scrollbar-hide print-hidden xl:h-full xl:overflow-y-auto"
:user="currentUser" :user="currentUser"
:profile-form="profileForm" :profile-form="profileForm"
:password-form="passwordForm" :password-form="passwordForm"
@@ -1703,7 +1889,7 @@ onMounted(() => {
<StatsDashboard <StatsDashboard
v-else v-else
class="print-hidden xl:h-full xl:overflow-y-auto" class="scrollbar-hide print-hidden xl:h-full xl:overflow-y-auto"
:overview-cards="overviewCards" :overview-cards="overviewCards"
:weekly-records="weeklyRecords" :weekly-records="weeklyRecords"
:recent-records="recentRecords" :recent-records="recentRecords"
@@ -1825,3 +2011,14 @@ onMounted(() => {
</transition> </transition>
</main> </main>
</template> </template>
<style>
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
</style>

View File

@@ -8,10 +8,6 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
status: {
type: String,
default: 'all',
},
form: { form: {
type: Object, type: Object,
required: true, required: true,
@@ -36,12 +32,12 @@ const props = defineProps({
const emit = defineEmits([ const emit = defineEmits([
'update:query', 'update:query',
'update:status',
'update:form-field', 'update:form-field',
'submit:create', 'submit:create',
'start-edit', 'start-edit',
'cancel-edit', 'cancel-edit',
'submit:update', 'submit:update',
'delete-goal',
]) ])
function updateField(field, event) { function updateField(field, event) {
@@ -109,19 +105,6 @@ function isActiveOnSelectedDate(goal) {
</div> </div>
</div> </div>
<div class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">상태</label>
<select
:value="form.status"
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"
@change="updateField('status', $event)"
>
<option value="active">진행 </option>
<option value="done">완료</option>
<option value="archived">보관</option>
</select>
</div>
<p class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600"> <p class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600">
여기서 목표와 표시 기간을 설정해 두면, 플래너 작성 화면에서는 해당 날짜에 보여줄지 여부만 간단히 ON/OFF 있습니다. 여기서 목표와 표시 기간을 설정해 두면, 플래너 작성 화면에서는 해당 날짜에 보여줄지 여부만 간단히 ON/OFF 있습니다.
</p> </p>
@@ -161,7 +144,7 @@ function isActiveOnSelectedDate(goal) {
목표가 많아져도 플래너 작성 화면이 길어지지 않도록, 전체 관리는 화면에서 처리합니다. 목표가 많아져도 플래너 작성 화면이 길어지지 않도록, 전체 관리는 화면에서 처리합니다.
</p> </p>
</div> </div>
<div class="grid gap-2 sm:grid-cols-[220px_140px]"> <div class="grid gap-2 sm:grid-cols-[220px]">
<input <input
:value="query" :value="query"
type="text" type="text"
@@ -169,16 +152,6 @@ function isActiveOnSelectedDate(goal) {
placeholder="목표 검색" placeholder="목표 검색"
@input="emit('update:query', $event.target.value)" @input="emit('update:query', $event.target.value)"
/> />
<select
:value="status"
class="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"
@change="emit('update:status', $event.target.value)"
>
<option value="all">전체</option>
<option value="active">진행 </option>
<option value="done">완료</option>
<option value="archived">보관</option>
</select>
</div> </div>
</div> </div>
@@ -186,17 +159,18 @@ function isActiveOnSelectedDate(goal) {
<article <article
v-for="goal in goals" v-for="goal in goals"
:key="goal.id" :key="goal.id"
class="rounded-[24px] border border-stone-200 bg-white px-5 py-5" class="rounded-[24px] border px-5 py-5 transition"
:class="editingGoalId === goal.id ? 'border-stone-900 bg-[#f7f1e7] shadow-[0_18px_40px_rgba(28,25,23,0.10)]' : 'border-stone-200 bg-white'"
> >
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"> <div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="space-y-2"> <div class="space-y-2">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<p class="text-lg font-semibold tracking-[-0.03em] text-stone-900">{{ goal.title }}</p> <p class="text-lg font-semibold tracking-[-0.03em] text-stone-900">{{ goal.title }}</p>
<span <span
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.16em]" v-if="editingGoalId === goal.id"
:class="goal.status === 'done' ? 'bg-emerald-100 text-emerald-700' : goal.status === 'archived' ? 'bg-stone-200 text-stone-600' : 'bg-stone-900 text-white'" class="rounded-full bg-stone-900 px-3 py-1 text-[10px] font-bold tracking-[0.16em] text-white"
> >
{{ goal.status === 'done' ? '완료' : goal.status === 'archived' ? '보관' : '진행 중' }} 수정
</span> </span>
<span <span
v-if="isActiveOnSelectedDate(goal)" v-if="isActiveOnSelectedDate(goal)"
@@ -211,13 +185,22 @@ function isActiveOnSelectedDate(goal) {
</p> </p>
</div> </div>
<button <div class="flex flex-wrap gap-2">
type="button" <button
class="rounded-full border border-stone-300 px-4 py-2 text-[11px] font-bold tracking-[0.16em] text-stone-600 transition hover:border-stone-500 hover:text-stone-900" type="button"
@click="emit('start-edit', goal)" class="rounded-full border border-stone-300 px-4 py-2 text-[11px] font-bold tracking-[0.16em] text-stone-600 transition hover:border-stone-500 hover:text-stone-900"
> @click="emit('start-edit', goal)"
수정 >
</button> 수정
</button>
<button
type="button"
class="rounded-full border border-red-200 px-4 py-2 text-[11px] font-bold tracking-[0.16em] text-red-500 transition hover:border-red-400 hover:bg-red-50"
@click="emit('delete-goal', goal)"
>
삭제
</button>
</div>
</div> </div>
</article> </article>

View File

@@ -40,9 +40,9 @@ function selectYear(year) {
</script> </script>
<template> <template>
<section class="border border-stone-200 bg-white/80 p-5"> <section class="rounded-[24px] border border-stone-200 bg-white/82 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]">
<div class="relative mb-4 flex items-start justify-between gap-4"> <div class="relative mb-4 flex items-start justify-between gap-4">
<div> <div class="min-w-0">
<h2 class="text-[11px] font-bold tracking-[0.22em] text-ink">CALENDAR</h2> <h2 class="text-[11px] font-bold tracking-[0.22em] text-ink">CALENDAR</h2>
<div class="mt-2 flex items-center gap-3"> <div class="mt-2 flex items-center gap-3">
<button <button
@@ -52,7 +52,7 @@ function selectYear(year) {
> >
</button> </button>
<div class="flex items-center gap-2"> <div class="flex min-w-0 items-center gap-2">
<p class="text-base font-semibold tracking-[-0.04em] text-stone-900">{{ monthLabel }}</p> <p class="text-base font-semibold tracking-[-0.04em] text-stone-900">{{ monthLabel }}</p>
<button <button
type="button" type="button"
@@ -73,7 +73,7 @@ function selectYear(year) {
</div> </div>
<button <button
type="button" type="button"
class="rounded-full border border-stone-200 px-3 py-2 text-[10px] font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink" class="shrink-0 rounded-full border border-stone-200 px-3 py-2 text-[10px] font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink"
@click="emit('go-today')" @click="emit('go-today')"
> >
TODAY TODAY
@@ -117,7 +117,12 @@ function selectYear(year) {
</div> </div>
</div> </div>
<div class="mb-3 grid grid-cols-7 gap-2 text-center text-[10px] font-bold tracking-[0.12em] text-stone-400"> <div class="mb-3 grid grid-cols-7 gap-2 text-center text-[10px] font-bold tracking-[0.12em] text-stone-400">
<span v-for="weekday in ['S', 'M', 'T', 'W', 'T', 'F', 'S']" :key="weekday">{{ weekday }}</span> <span
v-for="weekday in ['일', '월', '화', '수', '목', '금', '토']"
:key="weekday"
>
{{ weekday }}
</span>
</div> </div>
<div class="grid grid-cols-7 gap-2"> <div class="grid grid-cols-7 gap-2">
<button <button

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,25 +1,26 @@
const AUTH_STORAGE_KEY = 'ten-minute-planner-auth' 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 = {}) { function buildHeaders(token, hasBody, extraHeaders = {}) {
return { return {
'Content-Type': 'application/json', ...(hasBody ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
...extraHeaders, ...extraHeaders,
} }
} }
async function request(path, { method = 'GET', token, body } = {}) { async function request(path, { method = 'GET', token, body } = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, { const hasBody = body !== undefined
const response = await fetch(buildApiUrl(path), {
method, method,
headers: buildHeaders(token), headers: buildHeaders(token, hasBody),
body: body ? JSON.stringify(body) : undefined, body: hasBody ? JSON.stringify(body) : undefined,
}) })
const data = await response.json().catch(() => ({})) const data = await response.json().catch(() => ({}))
if (!response.ok) { if (!response.ok) {
throw new Error(data.message || '요청 처리 중 문제가 발생했습니다.') throw new Error(toUserFacingApiError(data, '요청 처리 중 문제가 발생했습니다.'))
} }
return data return data

View File

@@ -1,23 +1,24 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001' import { buildApiUrl, toUserFacingApiError } from './apiBase'
function buildHeaders(token) { function buildHeaders(token, hasBody) {
return { return {
'Content-Type': 'application/json', ...(hasBody ? { 'Content-Type': 'application/json' } : {}),
Authorization: `Bearer ${token}`, ...(token ? { Authorization: `Bearer ${token}` } : {}),
} }
} }
async function request(path, { method = 'GET', token, body } = {}) { async function request(path, { method = 'GET', token, body } = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, { const hasBody = body !== undefined
const response = await fetch(buildApiUrl(path), {
method, method,
headers: buildHeaders(token), headers: buildHeaders(token, hasBody),
body: body ? JSON.stringify(body) : undefined, body: hasBody ? JSON.stringify(body) : undefined,
}) })
const data = await response.json().catch(() => ({})) const data = await response.json().catch(() => ({}))
if (!response.ok) { if (!response.ok) {
throw new Error(data.message || '목표 데이터를 처리하지 못했습니다.') throw new Error(toUserFacingApiError(data, '목표 데이터를 처리하지 못했습니다.'))
} }
return data return data
@@ -55,3 +56,10 @@ export async function updateGoal(token, goalId, payload) {
body: payload, body: payload,
}) })
} }
export async function deleteGoal(token, goalId) {
return request(`/api/goals/${goalId}`, {
method: 'DELETE',
token,
})
}

View File

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

View File

@@ -88,15 +88,15 @@
.print-paper--double { .print-paper--double {
display: grid !important; display: grid !important;
grid-template-columns: repeat(2, 141mm); grid-template-columns: repeat(2, 139mm);
justify-content: center; justify-content: center;
align-content: center; align-content: center;
column-gap: 3mm; column-gap: 4mm;
} }
body[data-print-layout='double'] .print-paper { body[data-print-layout='double'] .print-paper {
width: 287mm; width: 285mm;
height: 198mm; height: 196mm;
} }
body[data-print-layout='single'] .print-paper--single .print-sheet-frame { body[data-print-layout='single'] .print-paper--single .print-sheet-frame {
@@ -113,8 +113,8 @@
} }
body[data-print-layout='double'] .print-paper--double .print-sheet-frame { body[data-print-layout='double'] .print-paper--double .print-sheet-frame {
width: 141mm; width: 139mm;
height: 198mm; height: 196mm;
} }
.planner-sheet { .planner-sheet {
@@ -134,7 +134,7 @@
width: 762px !important; width: 762px !important;
max-width: none !important; max-width: none !important;
transform-origin: top left; transform-origin: top left;
transform: scale(0.694); transform: scale(0.684);
box-shadow: none !important; box-shadow: none !important;
background: #ffffff !important; background: #ffffff !important;
} }

View File

@@ -3,4 +3,12 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
watch: {
usePolling: true,
},
},
}) })