v0.0.8
This commit is contained in:
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>
|
||||
|
||||
Reference in New Issue
Block a user