diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..69d2536 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +POSTGRES_DB=ten_minute_planner +POSTGRES_USER=replace-with-private-db-user +POSTGRES_PASSWORD=replace-with-private-db-password +DATABASE_URL=postgresql://replace-with-private-db-user:replace-with-private-db-password@postgres:5432/ten_minute_planner +ADMIN_ACCOUNT_ID=replace-with-private-admin-id +ADMIN_ACCOUNT_PASSWORD=replace-with-private-admin-password +ADMIN_ACCOUNT_EMAIL=admin@example.com +ADMIN_ACCOUNT_NICKNAME=Planner Admin diff --git a/.gitignore b/.gitignore index 41e6156..5f94ccf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,7 @@ backend/node_modules/ dist/ .DS_Store *.log +.env +.env.dev backend/.env backend/data/ diff --git a/HANDOFF.md b/HANDOFF.md index ca001d6..be3602b 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -203,15 +203,27 @@ - 관리자 전용 `ADMIN` 메뉴와 기본 대시보드가 추가되었다. 현재는 사용자 수, 최근 접속, 문서 수, 목표 수, 계정별 최종 접속일을 읽기 전용으로 확인할 수 있다. - `users` 테이블에 `login_id`, `role`, `last_login_at` 컬럼이 추가되었다. - 관리자 계정은 이제 이메일이 아니라 별도 자동 생성 계정으로 관리한다. -- 기본 관리자 계정은 `planner-admin / wps!vmffosj180204` 이고, 서버 시작 시 자동 생성된다. -- 관리자 판별용 환경변수는 `ADMIN_EMAILS`가 아니라 `ADMIN_ACCOUNT_ID`, `ADMIN_ACCOUNT_PASSWORD`, `ADMIN_ACCOUNT_EMAIL`, `ADMIN_ACCOUNT_NICKNAME` 조합으로 바뀌었다. +- 관리자 계정은 서버 시작 시 `ADMIN_ACCOUNT_ID`, `ADMIN_ACCOUNT_PASSWORD`, `ADMIN_ACCOUNT_EMAIL`, `ADMIN_ACCOUNT_NICKNAME` 환경변수 조합으로 자동 생성된다. +- 관리자 아이디와 비밀번호는 저장소 문서에 실제 값을 남기지 않고, Docker 배포 시 루트 `.env` 같은 비공개 환경변수 파일에서만 관리한다. - 관리자 대시보드는 현재 읽기 전용이며, 계정 정지/삭제/강제 로그아웃 같은 실제 운영 액션은 아직 없다. -- 배포용 `docker-compose.yml`은 현재 PostgreSQL 외부 포트 `45432`, 프론트 외부 포트 `48081`, DB 계정 `zenn` 기준으로 맞춰져 있다. +- 배포용 `docker-compose.yml`은 현재 PostgreSQL 외부 포트 `45432`, 프론트 외부 포트 `48081` 기준이며, DB 계정/비밀번호는 루트 `.env`에서 주입한다. - 설정 화면의 보조 메모 카드는 주석 처리되어 현재는 보이지 않는다. - 비로그인 랜딩 카드는 상단 고정이 아니라 화면 중앙에 오도록 정렬을 수정했다. +- 로그인/회원가입 문구와 플레이스홀더는 일반 사용자 기준으로 정리했고, 사이드바 계정 카드의 `ADMIN/USER` 역할 텍스트는 숨겼다. +- 오른쪽 정보 패널에 `미완료 항목 이월` 버튼을 추가했다. 현재 날짜의 체크 안 된 할 일을 다음 날짜의 비어 있는 할 일 칸에 순서대로 복사한다. +- TASK 체크 버튼은 키보드 탭 이동 시 focus ring이 보이도록 개선했다. TASK 행은 클릭만으로 선택되지 않고, 포인터를 누른 채 다른 행까지 드래그했을 때부터 전역 pointer move 기준으로 여러 행을 선택한다. input에서 드래그를 시작해도 두 번째 행으로 넘어가면 기존 input 포커스를 blur 처리해 `Delete` / `Backspace`는 선택한 할 일 제목과 체크 상태를 한 번에 비우고, `Escape`는 선택만 해제하도록 했다. +- 인쇄 전용 CSS에서 COMMENT와 총 시간 영역의 폭을 고정하고 textarea overflow를 숨겨, PRINT 시 COMMENT가 우측 시간 영역과 겹치지 않도록 보정했다. +- 오른쪽 패널의 `TASK LABELS`와 `D-DAY 사용`은 한 카드 안의 두 줄 토글로 압축했다. 설명 문구는 공통 `GuideTooltip` 컴포넌트로 옮겼고, 물음표 버튼 클릭으로 열고 다시 클릭하거나 외부 클릭으로 닫는다. 각 툴팁은 `더 이상 보지 않기`로 숨길 수 있으며 SETTINGS의 `가이드 다시 보기`에서 전체 복원할 수 있다. +- 오른쪽 패널의 `READ NEXT`와 `PREV SNAPSHOT`은 별도 가로 카드가 아니라 `NEXT DAY` 카드 아래쪽에 세로로 배치했다. +- READ NEXT는 내일 첫 작업과 오늘 미처리 할 일 개수만 보여주도록 줄였고, 오늘 코멘트 반복 노출은 제거했다. +- 플래너 본문 시간 라벨은 `총 시간`에서 `FOCUSED TIME`으로 바꿨다. 인쇄 CSS에서 COMMENT/FOCUSED TIME 라벨이 잘리지 않도록 부모 overflow를 열고, COMMENT는 남는 폭을 채우며 FOCUSED TIME은 오른쪽 210px 칸에 붙도록 조정했다. +- Docker 배포용 루트 `.env`와 개발용 `.env.dev`를 분리했다. `docker-compose.yml`은 운영 `.env`, `docker-compose.dev.yml`은 기존 dev Postgres 볼륨과 맞는 `.env.dev`를 읽는다. +- `5173` 포트가 직접 `npm run dev` 없이 열려 있으면 대부분 `ten-minute-frontend-dev` 컨테이너가 떠 있는 상태다. 개발용 Docker를 끄려면 `docker compose -f docker-compose.dev.yml down`을 사용한다. - 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다. - 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. - Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다. +- 플래너 본문 라벨은 더 이상 `bg-paper` 배경으로 선을 덮지 않는다. `라벨 + 오른쪽 선` 구조로 바꿔 화면과 인쇄에서 노란 배경이 튀지 않도록 정리했다. +- 날짜에 적용되는 목표가 새로 생기면 D-DAY는 기본 표시된다. 사용자가 해당 날짜에서 직접 `D-DAY 사용`을 끈 경우에만 로컬 숨김 목록에 저장해 다시 숨긴다. ## 갱신 규칙 diff --git a/README.md b/README.md index bbc1660..2f35f55 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,14 @@ NAS나 서버에서 처음 올리는 경우 흐름은 아래처럼 생각하면 ```bash cd /volume1/docker -git clone https://git.sori.studio/zenn/planner.sori.studio.git +git clone https://git.sori.studio/zenn/planner.sori.studio.git . cd planner.sori.studio +cp .env.example .env docker compose up -d --build ``` 처음 한 번은 이미지 빌드 때문에 시간이 걸릴 수 있다. +실행 전에 `.env`의 DB와 관리자 계정 환경변수는 운영자만 아는 값으로 반드시 바꾼다. ## 초보자용 빠른 실행 @@ -41,6 +43,8 @@ cd planner.sori.studio 실제 동작 확인이나 NAS 상시 실행은 아래 명령으로 시작한다. ```bash +cp .env.example .env +# .env에서 POSTGRES_*, DATABASE_URL, ADMIN_ACCOUNT_* 값을 비공개 운영 값으로 수정한다. docker compose up -d --build ``` @@ -55,14 +59,9 @@ docker compose up -d --build - 프론트엔드: `http://NAS주소:48081` - PostgreSQL: `NAS주소:45432` -기본 관리자 계정: - -- 아이디: `planner-admin` -- 비밀번호: `wps!vmffosj180204` - -관리자 계정은 백엔드 시작 시 자동 생성된다. -일반 사용자는 기존처럼 회원가입 후 이메일로 로그인하고, -관리자는 `planner-admin` 아이디로 로그인하면 된다. +관리자 계정은 백엔드 시작 시 `.env`의 `ADMIN_ACCOUNT_*` 값으로 자동 생성된다. +관리자 아이디와 비밀번호는 저장소 문서에 적지 말고, 운영자만 접근 가능한 비공개 환경변수 파일에서 관리한다. +일반 사용자는 기존처럼 회원가입 후 이메일로 로그인하면 된다. 현재 `docker-compose.yml` 기준 내부 구성: diff --git a/TODO.md b/TODO.md index b6f70f2..f2951e5 100644 --- a/TODO.md +++ b/TODO.md @@ -89,6 +89,8 @@ - [x] UGREEN NAS 기준 `docker-compose.yml` 초안을 작성한다. - [x] 백엔드 기본 스캐폴딩을 추가한다. - [x] PostgreSQL 전환 초안을 적용한다. +- [x] 로그인 화면 문구와 관리자 정보 노출 지점을 일반 사용자 기준으로 정리한다. +- [x] 미완료 항목을 다음 날짜 빈칸으로 이월하는 버튼을 추가한다. - [ ] 이메일 인증 플로우를 설계하고 구현한다. - [ ] 비밀번호 찾기 / 재설정 토큰 흐름을 추가한다. - [ ] 로그인 및 인증 관련 rate limit / 잠금 정책을 추가한다. @@ -129,3 +131,4 @@ - 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다. - Resend 무료 플랜은 도메인 수 제약이 있으므로, 실제 인증 메일은 AWS SES 또는 별도 SMTP 서비스 전환을 전제로 설계하는 편이 안전하다. - 관리자 기능은 읽기 전용 대시보드부터 시작하고, 실제 정리 액션은 권한/감사 로그 정책을 정한 뒤 추가하는 편이 안전하다. +- 관리자 아이디/비밀번호는 README나 HANDOFF에 실제 값으로 남기지 않고, Docker 배포용 비공개 `.env`에서만 관리한다. diff --git a/backend/.env.example b/backend/.env.example index 50f31c3..12906b3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,3 +1,8 @@ PORT=3001 DATABASE_URL=postgresql://planner:planner1234@localhost:5432/ten_minute_planner CORS_ORIGIN=http://localhost:5173 +APP_BASE_URL=http://localhost:5173 +ADMIN_ACCOUNT_ID=replace-with-private-admin-id +ADMIN_ACCOUNT_PASSWORD=replace-with-private-admin-password +ADMIN_ACCOUNT_EMAIL=admin@example.com +ADMIN_ACCOUNT_NICKNAME=Planner Admin diff --git a/backend/src/config.js b/backend/src/config.js index 51e71a2..1c51c87 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -14,10 +14,10 @@ const envSchema = z.object({ CORS_ORIGIN: z.string().default('http://localhost:5173'), SESSION_TTL_DAYS: z.coerce.number().default(30), APP_BASE_URL: z.string().default('http://localhost:5173'), - ADMIN_ACCOUNT_ID: z.string().default('planner-admin'), - ADMIN_ACCOUNT_PASSWORD: z.string().default('wps!vmffosj180204'), - ADMIN_ACCOUNT_EMAIL: z.string().default('planner-admin@planner.local'), - ADMIN_ACCOUNT_NICKNAME: z.string().default('Planner Admin'), + ADMIN_ACCOUNT_ID: z.string().min(1), + ADMIN_ACCOUNT_PASSWORD: z.string().min(12), + ADMIN_ACCOUNT_EMAIL: z.string().email(), + ADMIN_ACCOUNT_NICKNAME: z.string().min(1), }) export const env = envSchema.parse(process.env) diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index aedfe89..ba89bb1 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -33,11 +33,11 @@ export async function registerAdminRoutes(app) { const [summary] = await db .select({ - totalUsers: sql`count(*)::int`, - totalAdmins: sql`count(*) filter (where ${users.role} = 'admin')::int`, - verifiedUsers: sql`count(*) filter (where ${users.emailVerifiedAt} is not null)::int`, - activeUsers30d: sql`count(*) filter (where ${users.lastLoginAt} >= now() - interval '30 days')::int`, - newUsers7d: sql`count(*) filter (where ${users.createdAt} >= now() - interval '7 days')::int`, + totalUsers: sql`count(*)::int`, + totalAdmins: sql`count(*) filter (where ${users.role} = 'admin')::int`, + verifiedUsers: sql`count(*) filter (where ${users.emailVerifiedAt} is not null)::int`, + activeUsers30d: sql`count(*) filter (where ${users.lastLoginAt} >= now() - interval '30 days')::int`, + newUsers7d: sql`count(*) filter (where ${users.createdAt} >= now() - interval '7 days')::int`, }) .from(users) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 0762dbc..24879ac 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -2,16 +2,14 @@ services: postgres: image: postgres:16-alpine container_name: ten-minute-postgres-dev - environment: - POSTGRES_DB: ten_minute_planner - POSTGRES_USER: planner - POSTGRES_PASSWORD: planner1234 + env_file: + - ./.env.dev volumes: - postgres_dev_data:/var/lib/postgresql/data ports: - "5432:5432" healthcheck: - test: ["CMD-SHELL", "pg_isready -U planner -d ten_minute_planner"] + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] interval: 10s timeout: 5s retries: 10 @@ -22,9 +20,10 @@ services: container_name: ten-minute-backend-dev working_dir: /app command: sh -c "npm install && npm run dev" + env_file: + - ./.env.dev environment: PORT: 3001 - DATABASE_URL: postgresql://planner:planner1234@postgres:5432/ten_minute_planner CORS_ORIGIN: http://localhost:5173 SESSION_TTL_DAYS: 30 volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 1e624e9..6bb8c6f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,16 +2,14 @@ services: postgres: image: postgres:16-alpine container_name: ten-minute-postgres - environment: - POSTGRES_DB: ten_minute_planner - POSTGRES_USER: zenn - POSTGRES_PASSWORD: wps!vmffosj180204 + env_file: + - ./.env volumes: - postgres_data:/var/lib/postgresql/data ports: - "45432:5432" healthcheck: - test: ["CMD-SHELL", "pg_isready -U zenn -d ten_minute_planner"] + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] interval: 10s timeout: 5s retries: 10 @@ -21,9 +19,10 @@ services: build: context: ./backend container_name: ten-minute-backend + env_file: + - ./.env environment: PORT: 3001 - DATABASE_URL: postgresql://zenn:wps%21vmffosj180204@postgres:5432/ten_minute_planner CORS_ORIGIN: http://localhost:48081 SESSION_TTL_DAYS: 30 depends_on: @@ -46,4 +45,4 @@ services: restart: unless-stopped volumes: - postgres_data: \ No newline at end of file + postgres_data: diff --git a/src/App.vue b/src/App.vue index 013f260..dfe571a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,6 +2,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import AdminDashboard from './components/AdminDashboard.vue' import AuthDialog from './components/AuthDialog.vue' +import GuideTooltip from './components/GuideTooltip.vue' import GoalsDashboard from './components/GoalsDashboard.vue' import MiniCalendar from './components/MiniCalendar.vue' import PlannerPage from './components/PlannerPage.vue' @@ -28,6 +29,8 @@ import { restorePlannerUiState, } from './lib/plannerStorage' +const GUIDE_TOOLTIP_STORAGE_KEY = 'ten-minute-guide-tooltips-hidden' +const DDAY_DISABLED_STORAGE_KEY = 'ten-minute-dday-disabled-dates' const screenMode = ref('planner') const viewMode = ref('focus') const printLayout = ref('single') @@ -76,6 +79,10 @@ const profileBusy = ref(false) const passwordBusy = ref(false) const profileMessage = ref('') const passwordMessage = ref('') +const carryoverMessage = ref('') +const guideTooltipResetMessage = ref('') +const hiddenGuideTooltips = ref(readHiddenGuideTooltips()) +const ddayDisabledDateKeys = ref(readDdayDisabledDateKeys()) const adminBusy = ref(false) const adminMessage = ref('') const adminOverview = ref({ @@ -103,6 +110,84 @@ let isHydratingRemoteRecords = false const syncTimers = new Map() let syncToastTimer = null +function readHiddenGuideTooltips() { + if (typeof window === 'undefined') { + return [] + } + + try { + const value = JSON.parse(window.localStorage.getItem(GUIDE_TOOLTIP_STORAGE_KEY) ?? '[]') + return Array.isArray(value) ? value : [] + } catch (error) { + return [] + } +} + +function persistHiddenGuideTooltips() { + if (typeof window === 'undefined') { + return + } + + window.localStorage.setItem(GUIDE_TOOLTIP_STORAGE_KEY, JSON.stringify(hiddenGuideTooltips.value)) +} + +function isGuideTooltipVisible(id) { + return !hiddenGuideTooltips.value.includes(id) +} + +function dismissGuideTooltip(id) { + if (hiddenGuideTooltips.value.includes(id)) { + return + } + + hiddenGuideTooltips.value = [...hiddenGuideTooltips.value, id] + guideTooltipResetMessage.value = '' + persistHiddenGuideTooltips() +} + +function resetGuideTooltips() { + hiddenGuideTooltips.value = [] + guideTooltipResetMessage.value = '가이드 툴팁을 다시 볼 수 있게 했습니다.' + persistHiddenGuideTooltips() +} + +function readDdayDisabledDateKeys() { + if (typeof window === 'undefined') { + return [] + } + + try { + const value = JSON.parse(window.localStorage.getItem(DDAY_DISABLED_STORAGE_KEY) ?? '[]') + return Array.isArray(value) ? value : [] + } catch (error) { + return [] + } +} + +function persistDdayDisabledDateKeys() { + if (typeof window === 'undefined') { + return + } + + window.localStorage.setItem(DDAY_DISABLED_STORAGE_KEY, JSON.stringify(ddayDisabledDateKeys.value)) +} + +function isDdayDisabledForDate(dateKey) { + return ddayDisabledDateKeys.value.includes(dateKey) +} + +function setDdayDisabledForDate(dateKey, disabled) { + if (disabled) { + if (!ddayDisabledDateKeys.value.includes(dateKey)) { + ddayDisabledDateKeys.value = [...ddayDisabledDateKeys.value, dateKey] + } + } else { + ddayDisabledDateKeys.value = ddayDisabledDateKeys.value.filter((key) => key !== dateKey) + } + + persistDdayDisabledDateKeys() +} + function createEmptyTimetable() { return Array.from({ length: timetableCellCount }, () => false) } @@ -379,9 +464,12 @@ const activePlannerGoals = computed(() => }), ) const plannerGoal = computed(() => activePlannerGoals.value[0] ?? null) -const plannerGoalToggleOn = computed(() => Boolean(plannerGoal.value) && planner.value.goalEnabled) +const selectedDateKey = computed(() => toKey(selectedDate.value)) +const plannerGoalToggleOn = computed(() => + Boolean(plannerGoal.value) && !isDdayDisabledForDate(selectedDateKey.value), +) const plannerDday = computed(() => { - if (!planner.value.goalEnabled || !plannerGoal.value) { + if (!plannerGoalToggleOn.value || !plannerGoal.value) { return '' } @@ -394,7 +482,7 @@ const plannerDday = computed(() => { return `${badge} ${plannerGoal.value.title}` }) const showPlannerDday = computed(() => - planner.value.goalEnabled && Boolean(plannerGoal.value), + plannerGoalToggleOn.value && Boolean(plannerGoal.value), ) const hasActiveGoalForSelectedDate = computed(() => Boolean(plannerGoal.value)) @@ -404,6 +492,9 @@ const filledTasks = computed(() => const completedTasks = computed(() => filledTasks.value.filter((task) => task.checked).length, ) +const incompleteTasks = computed(() => + planner.value.tasks.filter((task) => task.title.trim() && !task.checked), +) const completionRate = computed(() => { if (filledTasks.value.length === 0) { return 0 @@ -451,6 +542,7 @@ function shiftDate(amount) { next.setDate(next.getDate() + amount) selectedDate.value = next calendarViewDate.value = new Date(next) + carryoverMessage.value = '' } function shiftCalendarMonth(amount) { @@ -468,6 +560,7 @@ function shiftCalendarYear(amount) { function selectDate(date) { selectedDate.value = new Date(date) calendarViewDate.value = new Date(date) + carryoverMessage.value = '' } function updateComment(record, value) { @@ -480,8 +573,12 @@ function updateGoalEnabled(record, value) { return } + setDdayDisabledForDate(selectedDateKey.value, !value) record.goalEnabled = value - schedulePlannerSyncForRecord(record) + + if (hasPlannerContent(record)) { + schedulePlannerSyncForRecord(record) + } } function updateTaskLabel(record, { index, value }) { @@ -499,6 +596,59 @@ function toggleTask(record, index) { schedulePlannerSyncForRecord(record) } +function clearTasks(record, indexes) { + indexes.forEach((index) => { + if (!record.tasks[index]) { + return + } + + record.tasks[index].title = '' + record.tasks[index].checked = false + }) + schedulePlannerSyncForRecord(record) +} + +function carryIncompleteTasksToNextDay() { + const tasksToCarry = incompleteTasks.value.map((task) => ({ + label: task.label, + title: task.title, + checked: false, + })) + + if (tasksToCarry.length === 0) { + carryoverMessage.value = '이월할 미완료 항목이 없습니다.' + return + } + + const targetRecord = secondaryPlanner.value + const emptyIndexes = targetRecord.tasks + .map((task, index) => ({ task, index })) + .filter(({ task }) => !task.title.trim()) + .map(({ index }) => index) + const copyCount = Math.min(tasksToCarry.length, emptyIndexes.length) + + if (copyCount === 0) { + carryoverMessage.value = '다음 날짜에 비어 있는 할 일 칸이 없습니다.' + return + } + + for (let index = 0; index < copyCount; index += 1) { + const task = tasksToCarry[index] + targetRecord.tasks[emptyIndexes[index]] = { + ...task, + checked: false, + } + } + + schedulePlannerSyncForRecord(targetRecord) + + const nextDateLabel = createDateLabel(toKey(secondaryDate.value)) + carryoverMessage.value = + copyCount === tasksToCarry.length + ? `${nextDateLabel} 빈칸에 미완료 ${copyCount}개를 이월했습니다.` + : `${nextDateLabel} 빈칸 ${copyCount}개까지만 이월했습니다.` +} + function updateMemo(record, { index, value }) { record.memo[index].text = value schedulePlannerSyncForRecord(record) @@ -698,9 +848,7 @@ const prevSnapshotItems = computed(() => { 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() + const carryTask = incompleteTasks.value[0]?.title return [ nextDayFirstTask @@ -708,12 +856,9 @@ const readNextItems = computed(() => { : carryTask ? `내일 이어갈 첫 작업: ${carryTask}` : '내일 첫 작업은 아직 비어 있습니다.', - incompleteTasks.length > 0 - ? `미완료 ${incompleteTasks.length}개를 이어서 볼 수 있습니다.` - : '오늘 미완료 작업은 없습니다.', - todayComment - ? `오늘 코멘트 이어보기: ${todayComment}` - : '오늘 코멘트는 아직 비어 있습니다.', + incompleteTasks.value.length > 0 + ? `아직 처리되지 않은 할 일이 ${incompleteTasks.value.length}개 있습니다.` + : '오늘 할 일은 모두 처리되었습니다.', ] }) @@ -795,8 +940,8 @@ watch( watch( [selectedDate, plannerGoal], () => { - if (!plannerGoal.value && planner.value.goalEnabled) { - planner.value.goalEnabled = false + if (plannerGoal.value && planner.value.goalEnabled === false && !isDdayDisabledForDate(selectedDateKey.value)) { + planner.value.goalEnabled = true if (hasPlannerContent(planner.value)) { schedulePlannerSyncForRecord(planner.value) @@ -1546,7 +1691,6 @@ onBeforeUnmount(() => {

SIGNED IN

{{ currentUser.nickname }}

{{ currentUser.email }}

-

{{ currentUser.role === 'admin' ? 'ADMIN' : 'USER' }}

+ + +
+
+

D-DAY 사용

+ +
+
-
-
+
-

D-DAY 사용

+

미완료 항목 이월

- 현재 날짜에 적용된 목표가 있을 때만 본문 D-DAY 표시를 켜고 끌 수 있습니다. + 체크하지 못한 할 일을 다음 날짜의 빈칸에 순서대로 복사합니다.

-
- -
-
-

현재 목표

-

{{ plannerGoal.title }}

-

- 목표일 {{ plannerGoal.targetDate }} / 적용 {{ plannerGoal.activeFrom }} ~ {{ plannerGoal.activeUntil }} -

-
- -
-

- 이 날짜에는 이미 적용된 목표가 있으므로 토글만으로 표시 여부를 빠르게 조절할 수 있습니다. -

-
- -
-

- 현재 날짜에 적용된 목표가 없습니다. GOALS 화면에서 표시 기간을 지정하면 여기 토글이 자동으로 활성화됩니다. -

-
+

+ {{ carryoverMessage }} +

+
@@ -2097,35 +2249,35 @@ onBeforeUnmount(() => { {{ nextDaySummary }}

+ +
+

READ NEXT

+
+

+ {{ item || ' ' }} +

+
+
+ +
+

PREV SNAPSHOT

+
+

+ {{ item || ' ' }} +

+
+
- -
-

READ NEXT

-
-

- {{ item || ' ' }} -

-
-
- -
-

PREV SNAPSHOT

-
-

- {{ item || ' ' }} -

-
-
@@ -2161,72 +2313,82 @@ onBeforeUnmount(() => { /> -
-
-
-

TASK LABELS

-

- 번호가 필요한 날만 빠르게 채우고, 필요 없으면 바로 비울 수 있습니다. -

+
+
+
+
+

TASK LABELS

+ +
+ +
+ +
+
+

D-DAY 사용

+ +
+
-
-
+
-

D-DAY 사용

+

미완료 항목 이월

- 현재 날짜에 적용된 목표가 있을 때만 본문 D-DAY 표시를 켜고 끌 수 있습니다. + 체크하지 못한 할 일을 다음 날짜의 빈칸에 순서대로 복사합니다.

-
- -
-
-

현재 목표

-

{{ plannerGoal.title }}

-

- 목표일 {{ plannerGoal.targetDate }} / 적용 {{ plannerGoal.activeFrom }} ~ {{ plannerGoal.activeUntil }} -

-
- -
-

- 이 날짜에는 이미 적용된 목표가 있으므로 토글만으로 표시 여부를 빠르게 조절할 수 있습니다. -

-
- -
-

- 현재 날짜에 적용된 목표가 없습니다. GOALS 화면에서 표시 기간을 지정하면 여기 토글이 자동으로 활성화됩니다. -

-
+

+ {{ carryoverMessage }} +

+
@@ -2258,35 +2420,35 @@ onBeforeUnmount(() => { {{ nextDaySummary }}

+ +
+

READ NEXT

+
+

+ {{ item || ' ' }} +

+
+
+ +
+

PREV SNAPSHOT

+
+

+ {{ item || ' ' }} +

+
+
- -
-

READ NEXT

-
-

- {{ item || ' ' }} -

-
-
- -
-

PREV SNAPSHOT

-
-

- {{ item || ' ' }} -

-
-
@@ -2315,6 +2477,7 @@ onBeforeUnmount(() => { @update:task-label="updateTaskLabel(planner, $event)" @update:task-title="updateTaskTitle(planner, $event)" @toggle:task="toggleTask(planner, $event)" + @clear:tasks="clearTasks(planner, $event)" @update:memo-label="updateMemoLabel(planner, $event)" @update:memo="updateMemo(planner, $event)" @update:timetable="updateTimetable(planner, $event)" @@ -2338,6 +2501,7 @@ onBeforeUnmount(() => { @update:task-label="updateTaskLabel(secondaryPlanner, $event)" @update:task-title="updateTaskTitle(secondaryPlanner, $event)" @toggle:task="toggleTask(secondaryPlanner, $event)" + @clear:tasks="clearTasks(secondaryPlanner, $event)" @update:memo-label="updateMemoLabel(secondaryPlanner, $event)" @update:memo="updateMemo(secondaryPlanner, $event)" @update:timetable="updateTimetable(secondaryPlanner, $event)" @@ -2375,10 +2539,12 @@ onBeforeUnmount(() => { :password-busy="passwordBusy" :profile-message="profileMessage" :password-message="passwordMessage" + :guide-tooltip-reset-message="guideTooltipResetMessage" @update:profile-field="updateProfileField" @update:password-field="updatePasswordField" @submit:profile="submitProfileForm" @submit:password="submitPasswordForm" + @reset-guide-tooltips="resetGuideTooltips" /> { @update:task-label="updateTaskLabel(planner, $event)" @update:task-title="updateTaskTitle(planner, $event)" @toggle:task="toggleTask(planner, $event)" + @clear:tasks="clearTasks(planner, $event)" @update:memo-label="updateMemoLabel(planner, $event)" @update:memo="updateMemo(planner, $event)" @update:timetable="updateTimetable(planner, $event)" @@ -2449,6 +2616,7 @@ onBeforeUnmount(() => { @update:task-label="updateTaskLabel(planner, $event)" @update:task-title="updateTaskTitle(planner, $event)" @toggle:task="toggleTask(planner, $event)" + @clear:tasks="clearTasks(planner, $event)" @update:memo-label="updateMemoLabel(planner, $event)" @update:memo="updateMemo(planner, $event)" @update:timetable="updateTimetable(planner, $event)" @@ -2471,6 +2639,7 @@ onBeforeUnmount(() => { @update:task-label="updateTaskLabel(secondaryPlanner, $event)" @update:task-title="updateTaskTitle(secondaryPlanner, $event)" @toggle:task="toggleTask(secondaryPlanner, $event)" + @clear:tasks="clearTasks(secondaryPlanner, $event)" @update:memo-label="updateMemoLabel(secondaryPlanner, $event)" @update:memo="updateMemo(secondaryPlanner, $event)" @update:timetable="updateTimetable(secondaryPlanner, $event)" diff --git a/src/components/AuthDialog.vue b/src/components/AuthDialog.vue index d9b79ef..ba85596 100644 --- a/src/components/AuthDialog.vue +++ b/src/components/AuthDialog.vue @@ -45,12 +45,12 @@ function updateField(field, event) {
-

Account

+

계정 시작

{{ mode === 'login' ? '로그인' : '회원가입' }}

- {{ mode === 'login' ? '저장된 플래너를 다시 이어서 볼 수 있습니다.' : '사용자별 기록과 통계를 연결하기 위한 계정을 만듭니다.' }} + {{ mode === 'login' ? '작성하던 플래너를 이어서 기록하세요.' : '나만의 플래너와 통계를 안전하게 보관할 계정을 만듭니다.' }}

@@ -110,7 +110,7 @@ function updateField(field, event) { class="w-full rounded-full bg-stone-900 px-5 py-3 text-xs font-bold tracking-[0.18em] text-white transition hover:bg-stone-700 disabled:cursor-not-allowed disabled:bg-stone-400" :disabled="busy" > - {{ busy ? '처리 중...' : mode === 'login' ? 'LOGIN' : 'SIGN UP' }} + {{ busy ? '처리 중...' : mode === 'login' ? '로그인하기' : '가입하기' }} diff --git a/src/components/GuideTooltip.vue b/src/components/GuideTooltip.vue new file mode 100644 index 0000000..a74807f --- /dev/null +++ b/src/components/GuideTooltip.vue @@ -0,0 +1,90 @@ + + + diff --git a/src/components/PlannerPage.vue b/src/components/PlannerPage.vue index fc04242..b8470dc 100644 --- a/src/components/PlannerPage.vue +++ b/src/components/PlannerPage.vue @@ -1,5 +1,5 @@ @@ -123,17 +231,23 @@ onBeforeUnmount(() => { >
-
- YEAR / MONTH / DAY -

+

+
+ YEAR / MONTH / DAY + +
+

{{ dateMain }} {{ dateWeekday }}

-
- D-DAY +
+
+ D-DAY + +

-

- COMMENT +
+
+ COMMENT + +