Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85d21f5842 |
12
HANDOFF.md
12
HANDOFF.md
@@ -4,7 +4,7 @@
|
||||
|
||||
- 프로젝트명: 10 Minute Planner 웹 UI
|
||||
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
|
||||
- 현재 기준 버전: `v0.0.7`
|
||||
- 현재 기준 버전: `v0.0.8`
|
||||
|
||||
## 기준 디자인
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
- 기본 UX 방향은 `1페이지 + 추가 정보 패널`이다.
|
||||
- `2페이지 펼침 보기`는 비교용 보조 모드로 함께 유지한다.
|
||||
- 화면 인상은 종이 다이어리 같아야 하지만, 상호작용은 웹앱처럼 빠르고 자연스러워야 한다.
|
||||
- 현재는 개인용 단일 브라우저 흐름으로 개발 중이지만, 이후 회원 기반 개인 문서 관리 서비스로 확장해야 한다.
|
||||
|
||||
## 현재 구현 상태
|
||||
|
||||
@@ -37,6 +38,8 @@
|
||||
- 입력 내용이 있는 날짜는 달력 하단에 빨간 점으로 표시된다.
|
||||
- 상단 날짜 표시에서는 토요일의 `(토)`만 파란색, 일요일의 `(일)`만 빨간색으로 표시한다.
|
||||
- 플래너 상태는 `localStorage`에 저장되며, 날짜별 기록과 선택 날짜, 달력 보고 있던 월까지 복원된다.
|
||||
- 상단 전환 버튼으로 `PLANNER / STATS` 화면을 오갈 수 있다.
|
||||
- 통계 화면에서는 전체 집중 시간, 평균 완료율, 기록 일수, 최근 7일 흐름, 최근 기록, 베스트 데이를 보여준다.
|
||||
|
||||
## 확정된 결정사항
|
||||
|
||||
@@ -44,6 +47,7 @@
|
||||
날짜 전환, 모드 토글, 사이드 패널 요약, 이후 저장 기능 등 상태 기반 상호작용이 많기 때문이다.
|
||||
- TailwindCSS는 Vue를 사용하더라도 반드시 유지해야 하는 스타일링 방식이다.
|
||||
- 현재 데이터는 레이아웃과 상호작용 검증을 위한 목업 데이터다.
|
||||
- 현재 저장은 `localStorage`를 사용하지만, 적절한 시점에 DB를 붙여 사용자별 저장 구조로 전환해야 한다.
|
||||
- 상단 날짜는 시스템 날짜 또는 현재 선택된 플래너 날짜 기준으로 자동 표시되어야 한다.
|
||||
- `D-DAY`는 지금은 보류이며, 이후 별도의 목표 관리 패널과 연결해서 계산한다.
|
||||
- `COMMENT`, `TASKS`, `MEMO`는 모두 입력 가능한 필드가 되어야 한다.
|
||||
@@ -55,12 +59,16 @@
|
||||
- 달력에는 연/월 이동 기능이 필요하다.
|
||||
- 내용이 저장된 날짜에는 달력에 빨간 점 표시가 필요하다.
|
||||
- 현재 단계의 저장 방식은 `localStorage`이며, 이후 외부 저장소 도입 전까지 기본 저장 경로로 사용한다.
|
||||
- 장기적으로는 회원 가입 후 사용자별로 각자 문서를 작성/관리할 수 있어야 한다.
|
||||
- 공유 문서 서비스가 아니라, 사용자 개인 보관과 회고 중심의 서비스 구조를 목표로 한다.
|
||||
- 사용자는 스스로 통계를 확인할 수 있어야 하고, 특정 날짜에 작성한 문서는 출력 가능해야 한다.
|
||||
|
||||
## 다음 권장 작업
|
||||
|
||||
- `TODO.md` 기준으로 작은 단위씩 구현을 진행한다.
|
||||
- 목표나 통계 기능보다 먼저, 플래너 본문의 입력과 상호작용을 우선 구현한다.
|
||||
- 달력 표시와 통계 기능을 본격화하기 전에 저장 구조를 먼저 정리하는 것이 좋다.
|
||||
- 통계 화면 구현은 현재 `localStorage` 기반으로 먼저 진행해도 된다.
|
||||
- DB는 기능 탐색 속도를 해치지 않는 선에서, 저장 레이어를 분리할 수 있는 적절한 시점에 붙이는 것이 좋다.
|
||||
|
||||
## 갱신 규칙
|
||||
|
||||
|
||||
20
TODO.md
20
TODO.md
@@ -47,6 +47,10 @@
|
||||
- [x] 입력 데이터의 저장 위치를 결정한다.
|
||||
- [x] 로컬 저장 또는 외부 저장 방식 중 우선 구현 방식을 정한다.
|
||||
- [x] 입력 상태가 새로고침 후에도 유지되도록 만든다.
|
||||
- [ ] DB 전환 시점을 잡을 수 있도록 저장 레이어를 분리한다.
|
||||
- [ ] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다.
|
||||
- [ ] 사용자별 문서 저장/조회 흐름을 정리한다.
|
||||
- [ ] 출력용 문서 포맷과 프린트 흐름을 고려한 데이터 구조를 정리한다.
|
||||
|
||||
## 추가 반영 메모
|
||||
|
||||
@@ -55,12 +59,22 @@
|
||||
|
||||
## 5단계: 확장 화면
|
||||
|
||||
- [ ] 통계 페이지 요구사항을 정리한다.
|
||||
- [ ] 통계 페이지 라우팅 또는 화면 전환 구조를 설계한다.
|
||||
- [ ] 집중 시간, 완료율, 연속 기록 같은 핵심 지표를 정의한다.
|
||||
- [x] 통계 페이지 요구사항을 정리한다.
|
||||
- [x] 통계 페이지 라우팅 또는 화면 전환 구조를 설계한다.
|
||||
- [x] 집중 시간, 완료율, 연속 기록 같은 핵심 지표를 정의한다.
|
||||
- [ ] 사용자 개인 통계 화면 기준을 정리한다.
|
||||
|
||||
## 6단계: 계정 및 서비스 확장
|
||||
|
||||
- [ ] 회원 가입 / 로그인 방식 후보를 정리한다.
|
||||
- [ ] 사용자별 문서 분리 저장 구조를 설계한다.
|
||||
- [ ] 공유가 아닌 개인 보관용 서비스 흐름으로 요구사항을 정리한다.
|
||||
- [ ] 향후 출력 기능을 위한 인쇄 레이아웃 요구사항을 정리한다.
|
||||
|
||||
## 메모
|
||||
|
||||
- D-DAY는 현재 보류 상태다. 목표 패널 설계 후 연결한다.
|
||||
- `TIME TABLE` 드래그는 단순 사각형 선택이 아니라 시간 셀 단위의 연속 선택으로 해석한다.
|
||||
- 현재는 `localStorage`로 개발을 진행하지만, 적절한 시점에 DB를 붙여 사용자별 저장 구조로 확장해야 한다.
|
||||
- 최종적으로는 회원 가입 후 각자 자신의 문서를 작성/관리하고, 개인 통계를 확인하며, 특정 날짜 문서를 출력할 수 있어야 한다.
|
||||
- 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ten-minute-planner",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ten-minute-planner",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.8",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ten-minute-planner",
|
||||
"private": true,
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.8",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
175
src/App.vue
175
src/App.vue
@@ -2,8 +2,10 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import MiniCalendar from './components/MiniCalendar.vue'
|
||||
import PlannerPage from './components/PlannerPage.vue'
|
||||
import StatsDashboard from './components/StatsDashboard.vue'
|
||||
|
||||
const STORAGE_KEY = 'ten-minute-planner-state'
|
||||
const screenMode = ref('planner')
|
||||
const viewMode = ref('focus')
|
||||
const selectedDate = ref(new Date())
|
||||
const calendarViewDate = ref(new Date(selectedDate.value))
|
||||
@@ -320,6 +322,21 @@ function formatTotalTime(record) {
|
||||
return `${hoursPart}H ${minutesPart}M`
|
||||
}
|
||||
|
||||
function getFocusedMinutes(record) {
|
||||
return record.timetable.filter(Boolean).length * 10
|
||||
}
|
||||
|
||||
function getCompletionRate(record) {
|
||||
const activeTasks = record.tasks.filter((task) => task.title.trim())
|
||||
|
||||
if (activeTasks.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const doneTasks = activeTasks.filter((task) => task.checked).length
|
||||
return Math.round((doneTasks / activeTasks.length) * 100)
|
||||
}
|
||||
|
||||
function shiftDate(amount) {
|
||||
const next = new Date(selectedDate.value)
|
||||
next.setDate(next.getDate() + amount)
|
||||
@@ -381,6 +398,130 @@ function hasPlannerContent(record) {
|
||||
)
|
||||
}
|
||||
|
||||
const plannerEntries = computed(() =>
|
||||
Object.entries(plannerRecords)
|
||||
.filter(([, record]) => hasPlannerContent(record))
|
||||
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)),
|
||||
)
|
||||
|
||||
const totalFocusedMinutes = computed(() =>
|
||||
plannerEntries.value.reduce((total, [, record]) => total + getFocusedMinutes(record), 0),
|
||||
)
|
||||
|
||||
const totalRecordedTasks = computed(() =>
|
||||
plannerEntries.value.reduce(
|
||||
(total, [, record]) => total + record.tasks.filter((task) => task.title.trim()).length,
|
||||
0,
|
||||
),
|
||||
)
|
||||
|
||||
const completedRecordedTasks = computed(() =>
|
||||
plannerEntries.value.reduce(
|
||||
(total, [, record]) => total + record.tasks.filter((task) => task.title.trim() && task.checked).length,
|
||||
0,
|
||||
),
|
||||
)
|
||||
|
||||
const aggregateCompletionRate = computed(() => {
|
||||
if (totalRecordedTasks.value === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.round((completedRecordedTasks.value / totalRecordedTasks.value) * 100)
|
||||
})
|
||||
|
||||
const overviewCards = computed(() => [
|
||||
{
|
||||
label: 'TOTAL FOCUSED',
|
||||
value: formatMinutes(totalFocusedMinutes.value),
|
||||
caption: '지금까지 기록된 전체 집중 시간',
|
||||
},
|
||||
{
|
||||
label: 'AVERAGE COMPLETION',
|
||||
value: `${aggregateCompletionRate.value}%`,
|
||||
caption: '입력된 할 일 기준 전체 평균 완료율',
|
||||
},
|
||||
{
|
||||
label: 'RECORDED DAYS',
|
||||
value: `${plannerEntries.value.length}일`,
|
||||
caption: '기록이 남아 있는 날짜 수',
|
||||
},
|
||||
{
|
||||
label: 'COMPLETED TASKS',
|
||||
value: `${completedRecordedTasks.value}/${totalRecordedTasks.value || 0}`,
|
||||
caption: '완료된 할 일과 전체 입력된 할 일 수',
|
||||
},
|
||||
])
|
||||
|
||||
function formatMinutes(totalMinutes) {
|
||||
const hoursPart = Math.floor(totalMinutes / 60)
|
||||
const minutesPart = totalMinutes % 60
|
||||
return `${hoursPart}H ${`${minutesPart}`.padStart(2, '0')}M`
|
||||
}
|
||||
|
||||
function createDateLabel(dateKey) {
|
||||
const date = toDateValue(dateKey)
|
||||
const display = getDateDisplay(date)
|
||||
return `${display.main} ${display.weekday}`
|
||||
}
|
||||
|
||||
const weeklyRecords = computed(() => {
|
||||
const entries = Array.from({ length: 7 }, (_, index) => {
|
||||
const date = new Date(selectedDate.value)
|
||||
date.setDate(selectedDate.value.getDate() - (6 - index))
|
||||
const record = getPlannerRecord(date)
|
||||
const focusedMinutes = getFocusedMinutes(record)
|
||||
const weekdayShort = ['일', '월', '화', '수', '목', '금', '토'][date.getDay()]
|
||||
|
||||
return {
|
||||
key: toKey(date),
|
||||
weekday: weekdayShort,
|
||||
focusedMinutes,
|
||||
focusedTime: formatMinutes(focusedMinutes),
|
||||
}
|
||||
})
|
||||
|
||||
const maxMinutes = Math.max(...entries.map((entry) => entry.focusedMinutes), 60)
|
||||
|
||||
return entries.map((entry) => ({
|
||||
...entry,
|
||||
barHeight: Math.max(12, Math.round((entry.focusedMinutes / maxMinutes) * 100)),
|
||||
}))
|
||||
})
|
||||
|
||||
const recentRecords = computed(() =>
|
||||
[...plannerEntries.value]
|
||||
.sort(([leftKey], [rightKey]) => rightKey.localeCompare(leftKey))
|
||||
.slice(0, 5)
|
||||
.map(([key, record]) => ({
|
||||
key,
|
||||
dateLabel: createDateLabel(key),
|
||||
comment: record.comment.trim(),
|
||||
focusedTime: formatTotalTime(record),
|
||||
completionRate: getCompletionRate(record),
|
||||
})),
|
||||
)
|
||||
|
||||
const bestDay = computed(() => {
|
||||
if (plannerEntries.value.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [bestKey, bestRecord] = [...plannerEntries.value].reduce((bestEntry, currentEntry) => {
|
||||
const [, bestRecordValue] = bestEntry
|
||||
const [, currentRecordValue] = currentEntry
|
||||
|
||||
return getFocusedMinutes(currentRecordValue) > getFocusedMinutes(bestRecordValue) ? currentEntry : bestEntry
|
||||
})
|
||||
|
||||
return {
|
||||
dateLabel: createDateLabel(bestKey),
|
||||
summary: `${formatTotalTime(bestRecord)} 집중, 완료율 ${getCompletionRate(bestRecord)}%, 코멘트 "${
|
||||
bestRecord.comment.trim() || '없음'
|
||||
}"`,
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
[plannerRecords, selectedDate, calendarViewDate],
|
||||
() => {
|
||||
@@ -440,6 +581,24 @@ function clearTaskLabels(record) {
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="inline-flex rounded-full border border-stone-200 bg-stone-100 p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-4 py-2 text-xs font-bold tracking-[0.14em] transition"
|
||||
:class="screenMode === 'planner' ? 'bg-white text-ink shadow-sm' : 'text-stone-500'"
|
||||
@click="screenMode = 'planner'"
|
||||
>
|
||||
PLANNER
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-4 py-2 text-xs font-bold tracking-[0.14em] transition"
|
||||
:class="screenMode === 'stats' ? 'bg-white text-ink shadow-sm' : 'text-stone-500'"
|
||||
@click="screenMode = 'stats'"
|
||||
>
|
||||
STATS
|
||||
</button>
|
||||
</div>
|
||||
<div class="inline-flex rounded-full border border-stone-200 bg-stone-100 p-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -479,7 +638,7 @@ function clearTaskLabels(record) {
|
||||
</header>
|
||||
|
||||
<section
|
||||
v-if="viewMode === 'focus'"
|
||||
v-if="screenMode === 'planner' && viewMode === 'focus'"
|
||||
class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]"
|
||||
>
|
||||
<PlannerPage
|
||||
@@ -594,7 +753,10 @@ function clearTaskLabels(record) {
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section v-else class="overflow-x-auto rounded-[32px] border border-white/60 bg-white/40 p-4 sm:p-6">
|
||||
<section
|
||||
v-else-if="screenMode === 'planner'"
|
||||
class="overflow-x-auto rounded-[32px] border border-white/60 bg-white/40 p-4 sm:p-6"
|
||||
>
|
||||
<div class="flex min-w-[1260px] gap-6">
|
||||
<PlannerPage
|
||||
:date-main="selectedDateDisplay.main"
|
||||
@@ -636,6 +798,15 @@ function clearTaskLabels(record) {
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<StatsDashboard
|
||||
v-else
|
||||
:overview-cards="overviewCards"
|
||||
:weekly-records="weeklyRecords"
|
||||
:recent-records="recentRecords"
|
||||
:best-day="bestDay"
|
||||
:selected-date-label="`${selectedDateDisplay.main} ${selectedDateDisplay.weekday}`"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
116
src/components/StatsDashboard.vue
Normal file
116
src/components/StatsDashboard.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
overviewCards: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
weeklyRecords: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
recentRecords: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
bestDay: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
selectedDateLabel: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="grid gap-6">
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<article
|
||||
v-for="card in overviewCards"
|
||||
:key="card.label"
|
||||
class="rounded-[28px] border border-white/60 bg-white/80 p-5 shadow-paper backdrop-blur"
|
||||
>
|
||||
<p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">{{ card.label }}</p>
|
||||
<p class="mt-4 text-[34px] font-semibold tracking-[-0.06em] text-stone-900">{{ card.value }}</p>
|
||||
<p class="mt-2 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600">{{ card.caption }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.7fr)]">
|
||||
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6 shadow-paper backdrop-blur">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">WEEKLY FLOW</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold tracking-[-0.05em] text-stone-900">
|
||||
최근 7일 기록 흐름
|
||||
</h2>
|
||||
</div>
|
||||
<p class="text-[11px] font-semibold tracking-[0.06em] text-stone-500">
|
||||
기준 날짜: {{ selectedDateLabel }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid grid-cols-7 gap-3">
|
||||
<div
|
||||
v-for="record in weeklyRecords"
|
||||
:key="record.key"
|
||||
class="flex flex-col items-center gap-3"
|
||||
>
|
||||
<div class="flex h-40 items-end">
|
||||
<div
|
||||
class="w-10 rounded-full bg-stone-900/90 transition-all"
|
||||
:style="{ height: `${record.barHeight}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-[11px] font-bold tracking-[0.12em] text-stone-500">{{ record.weekday }}</p>
|
||||
<p class="mt-1 text-[11px] font-semibold text-stone-800">{{ record.focusedTime }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="grid gap-6">
|
||||
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6 shadow-paper backdrop-blur">
|
||||
<p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">BEST DAY</p>
|
||||
<div v-if="bestDay" class="mt-4">
|
||||
<h2 class="text-2xl font-semibold tracking-[-0.05em] text-stone-900">
|
||||
{{ bestDay.dateLabel }}
|
||||
</h2>
|
||||
<p class="mt-3 text-[13px] font-semibold leading-6 text-stone-700">
|
||||
{{ bestDay.summary }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-else class="mt-4 text-[13px] font-semibold leading-6 text-stone-600">
|
||||
아직 통계를 보여줄 기록이 충분하지 않습니다.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6 shadow-paper backdrop-blur">
|
||||
<p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">RECENT RECORDS</p>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div
|
||||
v-for="record in recentRecords"
|
||||
:key="record.key"
|
||||
class="rounded-2xl border border-stone-200 bg-stone-50/80 p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-[12px] font-bold tracking-[0.08em] text-stone-900">{{ record.dateLabel }}</p>
|
||||
<p class="mt-2 text-[11px] font-semibold leading-5 text-stone-600">
|
||||
{{ record.comment || '코멘트 없음' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-semibold tracking-[-0.03em] text-stone-900">{{ record.focusedTime }}</p>
|
||||
<p class="mt-1 text-[11px] font-semibold text-stone-500">{{ record.completionRate }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user