feat: refine today progress overview
This commit is contained in:
+55
-11
@@ -50,6 +50,8 @@ const showCompleted = ref(readStoredBoolean(window.localStorage, SHOW_COMPLETED_
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 50
|
const pageSize = 50
|
||||||
const totalTasks = ref(0)
|
const totalTasks = ref(0)
|
||||||
|
const todayTaskTotal = ref(0)
|
||||||
|
const todayTaskCompleted = ref(0)
|
||||||
const todayHabitTotal = ref(0)
|
const todayHabitTotal = ref(0)
|
||||||
const todayHabitCompleted = ref(0)
|
const todayHabitCompleted = ref(0)
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize)))
|
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize)))
|
||||||
@@ -77,6 +79,7 @@ const composeRepeatConfig = ref<TaskRepeatConfig>(defaultRepeatConfig())
|
|||||||
const selectedRepeatConfig = ref<TaskRepeatConfig>(defaultRepeatConfig())
|
const selectedRepeatConfig = ref<TaskRepeatConfig>(defaultRepeatConfig())
|
||||||
const weekdayOptions = [{ value: 'MO', label: '一' }, { value: 'TU', label: '二' }, { value: 'WE', label: '三' }, { value: 'TH', label: '四' }, { value: 'FR', label: '五' }, { value: 'SA', label: '六' }, { value: 'SU', label: '日' }]
|
const weekdayOptions = [{ value: 'MO', label: '一' }, { value: 'TU', label: '二' }, { value: 'WE', label: '三' }, { value: 'TH', label: '四' }, { value: 'FR', label: '五' }, { value: 'SA', label: '六' }, { value: 'SU', label: '日' }]
|
||||||
let recurrenceLoadToken = 0
|
let recurrenceLoadToken = 0
|
||||||
|
let todaySummaryLoadToken = 0
|
||||||
const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null)
|
const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null)
|
||||||
const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null)
|
const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null)
|
||||||
const composeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
|
const composeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
|
||||||
@@ -171,6 +174,7 @@ async function submitTaskCompose() {
|
|||||||
tasks.value.push(task)
|
tasks.value.push(task)
|
||||||
totalTasks.value = nextTotalAfterLocalTaskAdd(totalTasks.value)
|
totalTasks.value = nextTotalAfterLocalTaskAdd(totalTasks.value)
|
||||||
}
|
}
|
||||||
|
if (activeView.value === 'today') await loadTodayTaskSummary()
|
||||||
taskComposeOpen.value = false
|
taskComposeOpen.value = false
|
||||||
toast('任务已添加')
|
toast('任务已添加')
|
||||||
} catch (reason) { fail(reason) }
|
} catch (reason) { fail(reason) }
|
||||||
@@ -219,15 +223,23 @@ const activeName = computed(() => {
|
|||||||
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
|
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
|
||||||
})
|
})
|
||||||
const todayCompletedTasks = computed(() => activeView.value === 'today' ? tasks.value.filter((task) => task.completed).length : 0)
|
const todayCompletedTasks = computed(() => activeView.value === 'today' ? tasks.value.filter((task) => task.completed).length : 0)
|
||||||
const todayOpenTasks = computed(() => activeView.value === 'today' ? Math.max(totalTasks.value - todayCompletedTasks.value, 0) : 0)
|
const todayOpenTasks = computed(() => activeView.value === 'today' ? Math.max(todayTaskTotal.value - todayTaskCompleted.value, 0) : 0)
|
||||||
const todayHabitProgress = computed(() => `${todayHabitCompleted.value} / ${todayHabitTotal.value}`)
|
const todayHabitProgress = computed(() => `${todayHabitCompleted.value} / ${todayHabitTotal.value}`)
|
||||||
|
const progressWidth = (completed: number, total: number) => `${total > 0 ? Math.min(100, Math.round(completed / total * 100)) : 0}%`
|
||||||
|
const todayTaskProgressPercent = computed(() => progressWidth(todayTaskCompleted.value, todayTaskTotal.value))
|
||||||
|
const todayHabitProgressPercent = computed(() => progressWidth(todayHabitCompleted.value, todayHabitTotal.value))
|
||||||
|
const todayTaskProgressHint = computed(() => todayTaskTotal.value ? `还剩 ${todayOpenTasks.value} 件` : '今天暂无任务')
|
||||||
|
const todayHabitProgressHint = computed(() => todayHabitTotal.value ? `还差 ${Math.max(todayHabitTotal.value - todayHabitCompleted.value, 0)} 次` : '今天暂无习惯')
|
||||||
|
function scrollTodaySection(id: 'today-tasks' | 'today-habits') {
|
||||||
|
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
function updateTodayHabitSummary(value: { total: number; completed: number }) {
|
function updateTodayHabitSummary(value: { total: number; completed: number }) {
|
||||||
todayHabitTotal.value = value.total
|
todayHabitTotal.value = value.total
|
||||||
todayHabitCompleted.value = value.completed
|
todayHabitCompleted.value = value.completed
|
||||||
}
|
}
|
||||||
const todayProgressText = computed(() => {
|
const todayProgressText = computed(() => {
|
||||||
if (activeView.value !== 'today') return ''
|
if (activeView.value !== 'today') return ''
|
||||||
if (!totalTasks.value) return '今天还没有任务'
|
if (!todayTaskTotal.value) return '今天还没有任务'
|
||||||
if (!todayOpenTasks.value) return '今天任务都清掉了'
|
if (!todayOpenTasks.value) return '今天任务都清掉了'
|
||||||
return `还有 ${todayOpenTasks.value} 件事要处理`
|
return `还有 ${todayOpenTasks.value} 件事要处理`
|
||||||
})
|
})
|
||||||
@@ -354,6 +366,21 @@ function isoAtLocalDayOffset(offset: number) {
|
|||||||
day.setHours(0, 0, 0, 0)
|
day.setHours(0, 0, 0, 0)
|
||||||
return day.toISOString()
|
return day.toISOString()
|
||||||
}
|
}
|
||||||
|
async function loadTodayTaskSummary() {
|
||||||
|
const token = ++todaySummaryLoadToken
|
||||||
|
const summaryTotal = async (completed: boolean) => {
|
||||||
|
const params = new URLSearchParams({ page: '1', page_size: '1' })
|
||||||
|
params.set('due_from', isoAtLocalDayOffset(0))
|
||||||
|
params.set('due_to', isoAtLocalDayOffset(1))
|
||||||
|
params.set('completed', String(completed))
|
||||||
|
const data = await api(`/tasks?${params}`)
|
||||||
|
return Number(data.total ?? data.items?.length ?? 0)
|
||||||
|
}
|
||||||
|
const [open, completed] = await Promise.all([summaryTotal(false), summaryTotal(true)])
|
||||||
|
if (token !== todaySummaryLoadToken || activeView.value !== 'today') return
|
||||||
|
todayTaskCompleted.value = completed
|
||||||
|
todayTaskTotal.value = open + completed
|
||||||
|
}
|
||||||
async function loadOverdueTasks() {
|
async function loadOverdueTasks() {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('due_to', isoAtLocalDayOffset(0))
|
params.set('due_to', isoAtLocalDayOffset(0))
|
||||||
@@ -395,7 +422,7 @@ async function loadAll() {
|
|||||||
try {
|
try {
|
||||||
if (!navigationLoaded.value) await loadNavigation()
|
if (!navigationLoaded.value) await loadNavigation()
|
||||||
await loadTasksPage()
|
await loadTasksPage()
|
||||||
if (activeView.value === 'today') await loadOverdueTasks()
|
if (activeView.value === 'today') await Promise.all([loadOverdueTasks(), loadTodayTaskSummary()])
|
||||||
else overdueTasks.value = []
|
else overdueTasks.value = []
|
||||||
if (page.value > totalPages.value) { page.value = totalPages.value; await loadTasksPage() }
|
if (page.value > totalPages.value) { page.value = totalPages.value; await loadTasksPage() }
|
||||||
} catch (reason) { fail(reason) } finally { loading.value = false }
|
} catch (reason) { fail(reason) } finally { loading.value = false }
|
||||||
@@ -440,7 +467,11 @@ async function patchTask(task: Task, patch: Partial<Task>) {
|
|||||||
return updated as Task
|
return updated as Task
|
||||||
}
|
}
|
||||||
async function toggle(task: Task) {
|
async function toggle(task: Task) {
|
||||||
try { await patchTask(task, { completed: !task.completed }); toast(task.completed ? '已重新打开' : '完成啦') } catch (reason) { fail(reason) }
|
try {
|
||||||
|
await patchTask(task, { completed: !task.completed })
|
||||||
|
if (activeView.value === 'today') await loadTodayTaskSummary()
|
||||||
|
toast(task.completed ? '已重新打开' : '完成啦')
|
||||||
|
} catch (reason) { fail(reason) }
|
||||||
}
|
}
|
||||||
function isInteractiveTarget(target: EventTarget | null) {
|
function isInteractiveTarget(target: EventTarget | null) {
|
||||||
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
|
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
|
||||||
@@ -611,12 +642,20 @@ async function saveTask() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (selectedTask.value) selectedTask.value = { ...selectedTask.value, ...updated }
|
if (selectedTask.value) selectedTask.value = { ...selectedTask.value, ...updated }
|
||||||
|
if (activeView.value === 'today') await loadTodayTaskSummary()
|
||||||
toast('已保存')
|
toast('已保存')
|
||||||
} catch (reason) { fail(reason) }
|
} catch (reason) { fail(reason) }
|
||||||
}
|
}
|
||||||
async function removeTask(task: Task) {
|
async function removeTask(task: Task) {
|
||||||
if (!window.confirm(`把“${task.title}”移到回收站?`)) return
|
if (!window.confirm(`把“${task.title}”移到回收站?`)) return
|
||||||
try { await api(`/tasks/${task.id}`, { method: 'DELETE' }); tasks.value = tasks.value.filter((item) => item.id !== task.id && item.parent_id !== task.id); selectedTask.value = null; mobileDetail.value = false; toast('已移到回收站') } catch (reason) { fail(reason) }
|
try {
|
||||||
|
await api(`/tasks/${task.id}`, { method: 'DELETE' })
|
||||||
|
tasks.value = tasks.value.filter((item) => item.id !== task.id && item.parent_id !== task.id)
|
||||||
|
selectedTask.value = null
|
||||||
|
mobileDetail.value = false
|
||||||
|
if (activeView.value === 'today') await loadTodayTaskSummary()
|
||||||
|
toast('已移到回收站')
|
||||||
|
} catch (reason) { fail(reason) }
|
||||||
}
|
}
|
||||||
async function restoreTask(task: Task) {
|
async function restoreTask(task: Task) {
|
||||||
try { await api(`/tasks/${task.id}/restore`, { method: 'POST' }); trash.value = trash.value.filter((item) => item.id !== task.id); toast('任务已恢复') } catch (reason) { fail(reason) }
|
try { await api(`/tasks/${task.id}/restore`, { method: 'POST' }); trash.value = trash.value.filter((item) => item.id !== task.id); toast('任务已恢复') } catch (reason) { fail(reason) }
|
||||||
@@ -736,10 +775,15 @@ onMounted(bootstrap)
|
|||||||
</template>
|
</template>
|
||||||
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
|
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section v-if="activeView==='today'" class="today-board" aria-label="今日概览">
|
<section v-if="activeView==='today'" class="today-board" aria-label="今日进度">
|
||||||
<div class="today-stat"><strong>{{ todayOpenTasks }}</strong><span>待处理</span></div>
|
<button class="today-track today-task-track" type="button" @click="scrollTodaySection('today-tasks')">
|
||||||
<div class="today-stat"><strong>{{ todayCompletedTasks }}</strong><span>已完成</span></div>
|
<span class="today-track-head"><strong>任务 {{ todayTaskCompleted }} / {{ todayTaskTotal }}</strong><small>{{ todayTaskProgressHint }}</small></span>
|
||||||
<div class="today-stat habit-stat"><strong>{{ todayHabitProgress }}</strong><span>习惯打卡</span></div>
|
<span class="today-track-rail" aria-hidden="true"><i class="today-track-fill" :style="{ width: todayTaskProgressPercent }" /></span>
|
||||||
|
</button>
|
||||||
|
<button class="today-track today-habit-track" type="button" @click="scrollTodaySection('today-habits')">
|
||||||
|
<span class="today-track-head"><strong>习惯 {{ todayHabitProgress }}</strong><small>{{ todayHabitProgressHint }}</small></span>
|
||||||
|
<span class="today-track-rail" aria-hidden="true"><i class="today-track-fill" :style="{ width: todayHabitProgressPercent }" /></span>
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
<template v-if="activeView==='today'">
|
<template v-if="activeView==='today'">
|
||||||
<section v-if="overdueTaskTree.length" class="overdue-section">
|
<section v-if="overdueTaskTree.length" class="overdue-section">
|
||||||
@@ -750,7 +794,7 @@ onMounted(bootstrap)
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<h3 class="section-heading"><ListTodo/>任务</h3>
|
<h3 id="today-tasks" class="section-heading today-section-anchor"><ListTodo/>任务</h3>
|
||||||
</template>
|
</template>
|
||||||
<div class="list-toolbar"><label v-if="activeView!=='trash'"><input v-model="showCompleted" type="checkbox"> 显示已完成</label><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项</span><button v-if="query" class="link" @click="query=''">清除搜索</button></div>
|
<div class="list-toolbar"><label v-if="activeView!=='trash'"><input v-model="showCompleted" type="checkbox"> 显示已完成</label><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项</span><button v-if="query" class="link" @click="query=''">清除搜索</button></div>
|
||||||
<div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
|
<div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
|
||||||
@@ -769,7 +813,7 @@ onMounted(bootstrap)
|
|||||||
</template>
|
</template>
|
||||||
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span><button v-if="activeView==='today' && !query" class="soft-button empty-action" @click="openTaskCompose"><Plus/>添加今天任务</button></div>
|
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span><button v-if="activeView==='today' && !query" class="soft-button empty-action" @click="openTaskCompose"><Plus/>添加今天任务</button></div>
|
||||||
</section>
|
</section>
|
||||||
<div v-if="activeView==='today'">
|
<div v-if="activeView==='today'" id="today-habits" class="today-section-anchor">
|
||||||
<h3 class="section-heading"><Repeat2/>习惯</h3>
|
<h3 class="section-heading"><Repeat2/>习惯</h3>
|
||||||
<MvpPanel ref="habitComposer" view="today-habits" @changed="refreshAll" @notice="toast" @summary="updateTodayHabitSummary" />
|
<MvpPanel ref="habitComposer" view="today-habits" @changed="refreshAll" @notice="toast" @summary="updateTodayHabitSummary" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -157,10 +157,20 @@ describe('task and habit row decoration', () => {
|
|||||||
|
|
||||||
it('keeps Today as a low-noise dashboard with direct empty-state actions', () => {
|
it('keeps Today as a low-noise dashboard with direct empty-state actions', () => {
|
||||||
expect(app).toContain('class="today-board"')
|
expect(app).toContain('class="today-board"')
|
||||||
expect(app).toContain('todayOpenTasks')
|
expect(app).toContain('class="today-track today-task-track"')
|
||||||
expect(app).toContain('todayCompletedTasks')
|
expect(app).toContain('class="today-track today-habit-track"')
|
||||||
expect(app).toContain('todayHabitProgress')
|
expect(app).toContain(':style="{ width: todayTaskProgressPercent }"')
|
||||||
expect(app).toContain('习惯打卡')
|
expect(app).toContain(':style="{ width: todayHabitProgressPercent }"')
|
||||||
|
expect(app).toContain('async function loadTodayTaskSummary()')
|
||||||
|
expect(app).toContain('const token = ++todaySummaryLoadToken')
|
||||||
|
expect(app).toContain("params.set('completed', String(completed))")
|
||||||
|
expect(app).toContain('await Promise.all([summaryTotal(false), summaryTotal(true)])')
|
||||||
|
expect(app).toContain("if (activeView.value === 'today') await loadTodayTaskSummary()")
|
||||||
|
expect(app).toContain('任务 {{ todayTaskCompleted }} / {{ todayTaskTotal }}')
|
||||||
|
expect(app).toContain('习惯 {{ todayHabitProgress }}')
|
||||||
|
expect(app).toContain("scrollTodaySection('today-tasks')")
|
||||||
|
expect(app).toContain("scrollTodaySection('today-habits')")
|
||||||
|
expect(app).not.toContain('<div class="today-stat"')
|
||||||
expect(app).toContain('@summary="updateTodayHabitSummary"')
|
expect(app).toContain('@summary="updateTodayHabitSummary"')
|
||||||
expect(mvpPanel).toContain("summary: [value: { total: number; completed: number }]")
|
expect(mvpPanel).toContain("summary: [value: { total: number; completed: number }]")
|
||||||
expect(mvpPanel).toContain('const todayHabitSummary = computed')
|
expect(mvpPanel).toContain('const todayHabitSummary = computed')
|
||||||
@@ -171,6 +181,10 @@ describe('task and habit row decoration', () => {
|
|||||||
expect(mvpPanel).toContain('class="empty-panel today-empty-panel"')
|
expect(mvpPanel).toContain('class="empty-panel today-empty-panel"')
|
||||||
expect(mvpPanel).toContain('添加习惯')
|
expect(mvpPanel).toContain('添加习惯')
|
||||||
expect(css).toContain('.today-board{')
|
expect(css).toContain('.today-board{')
|
||||||
|
expect(css).toContain('.today-track{')
|
||||||
|
expect(css).toContain('.today-track-fill{')
|
||||||
|
expect(css).toContain('.today-habit-track .today-track-fill{')
|
||||||
|
expect(css).not.toContain('.today-stat{')
|
||||||
expect(css).toContain('.today-summary-grid{')
|
expect(css).toContain('.today-summary-grid{')
|
||||||
expect(css).toContain('.empty-action{')
|
expect(css).toContain('.empty-action{')
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user