This commit is contained in:
2026-04-21 14:15:09 +09:00
parent d6dda9639b
commit 85d21f5842
6 changed files with 319 additions and 10 deletions

View File

@@ -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>

View 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>