feat: refine task and countdown interactions
ci / gitleaks (push) Successful in 9s
ci / docker (push) Successful in 3m31s

This commit is contained in:
2026-09-17 09:32:22 +08:00
parent bdf2a53fa9
commit 1571c3946f
14 changed files with 388 additions and 31 deletions
+42 -12
View File
@@ -5,7 +5,7 @@ import {
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote, Search,
Settings, Trash2, X, Repeat2, RefreshCw, StickyNote,
} from 'lucide-vue-next'
import { applyMarkdownFormat, buildTaskRecurrencePayload, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
import { beginLatestRequest, createMutationReconciler, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, normalizeRequiredName, performTrashMutation, readStoredBoolean, readStoredNavigation, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
@@ -143,6 +143,10 @@ const composeTime = ref('12:00')
const composeCalendarOpen = ref(false)
const composeDateButton = ref<HTMLButtonElement | null>(null)
const composeTimePicker = ref<HTMLInputElement | null>(null)
const selectedDueDate = ref('')
const selectedDueHasTime = ref(false)
const selectedDueTime = ref('12:00')
const selectedDueTimePicker = ref<HTMLInputElement | null>(null)
const composePriority = ref(0)
const composeDescription = ref('')
const composeRepeat = ref<RepeatOption>('none')
@@ -917,11 +921,14 @@ async function saveTask(options?: { showSuccess?: boolean, expectedTaskId?: stri
return false
}
task.title = normalized.value
const due = buildTaskDueDraft({ date: selectedDueDate.value, hasTime: selectedDueHasTime.value, time: selectedDueTime.value })
try {
const dueAt = fromDateTimeLocal(toDateTimeLocal(task.due_at))
const updated = await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), due_at: dueAt, list_id: task.list_id } as Partial<Task>, false)
const updated = await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), ...due, list_id: task.list_id } as Partial<Task>, false)
const selectionMatches = options?.expectedSelectionToken === undefined || recurrenceLoadToken === options.expectedSelectionToken
if (selectedTask.value?.id === task.id && selectionMatches) selectedTask.value = { ...selectedTask.value, ...updated }
if (selectedTask.value?.id === task.id && selectionMatches) {
selectedTask.value = { ...selectedTask.value, ...updated }
selectedDueHasTime.value = Boolean(updated.due_has_time)
}
await refreshTodayAfterTaskSave()
if (options?.showSuccess !== false) toast('已保存')
return updated
@@ -936,18 +943,22 @@ async function saveSelectedTaskChanges() {
if (!taskId) return
savingSelectedTask.value = true
const selectionToken = recurrenceLoadToken
const repeatValue = selectedTask.value?.due_at ? selectedTaskRepeat.value : 'none'
const repeatConfig = structuredClone(selectedRepeatConfig.value)
const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none'
const repeatConfig = JSON.parse(JSON.stringify(selectedRepeatConfig.value)) as TaskRepeatConfig
const afterCompletionDays = selectedAfterCompletionDays.value
const recurrence = selectedTaskRecurrence.value
selectedRepeatError.value = ''
try {
const taskSaved = await saveTask({ showSuccess: false, expectedTaskId: taskId, expectedSelectionToken: selectionToken })
if (!taskSaved || recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
const repeatRecurrence = taskSaved.due_at ? recurrence : null
const updatedRecurrence = await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, repeatRecurrence)
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
selectedTaskRecurrence.value = updatedRecurrence
if (!taskSaved.due_at) {
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
} else {
const updatedRecurrence = await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
selectedTaskRecurrence.value = updatedRecurrence
}
toast('已保存')
} catch (reason) {
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
@@ -1002,8 +1013,24 @@ function closeTaskDetail() {
mobileDetail.value = false
selectedTask.value = null
}
function addSelectedDueTime() {
selectedDueHasTime.value = true
nextTick(() => {
const picker = selectedDueTimePicker.value as (HTMLInputElement & { showPicker?: () => void }) | null
try { picker?.showPicker?.() } catch { picker?.focus() }
})
}
function clearSelectedDueDate() {
selectedDueDate.value = ''
selectedDueHasTime.value = false
selectedTaskRepeat.value = 'none'
}
function selectTask(task: Task) {
selectedTask.value = { ...task, subtasks: task.subtasks ? [...task.subtasks] : [] }
const due = parseTaskDueDraft(task.due_at, task.due_has_time)
selectedDueDate.value = due.date
selectedDueHasTime.value = due.hasTime
selectedDueTime.value = due.time
markdownPreview.value = false; moreSettingsOpen.value = false; mobileDetail.value = true
void loadTaskRecurrence(task)
}
@@ -1543,8 +1570,11 @@ onUnmounted(() => {
<div class="detail-form">
<div class="detail-title"><button class="task-check detail-task-check" type="button" :aria-label="selectedTask.completed ? `重新打开${selectedTask.title}` : `完成${selectedTask.title}`" :aria-pressed="selectedTask.completed" @click="toggle(selectedTask)"><span class="task-check-mark" :class="`p${selectedTask.priority}`"><Check v-if="selectedTask.completed" /></span></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题"/></div>
<label>清单<select v-model="selectedTask.list_id" class="task-detail-field-input"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
<label class="task-detail-due-row">截止时间<input class="task-detail-due-input task-detail-field-input" :value="toDateTimeLocal(selectedTask.due_at)" type="datetime-local" @change="selectedTask!.due_at=($event.target as HTMLInputElement).value"></label>
<label>重复<select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedTask.due_at"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!selectedTask.due_at" class="field-hint">请先设置截止时间,才能开启重复</small></label>
<div class="task-detail-due-row">
<span>截止时间</span>
<div class="task-detail-due-controls"><input v-model="selectedDueDate" class="task-detail-due-input task-detail-field-input" type="date" aria-label="截止日期"><button v-if="selectedDueDate" class="task-compose-date-clear" type="button" aria-label="清除截止日期" @click="clearSelectedDueDate"><X/></button><button v-if="selectedDueDate && !selectedDueHasTime" class="task-compose-time-add" type="button" @click="addSelectedDueTime">添加时间</button><label v-else-if="selectedDueDate" class="task-compose-time-chip"><span>时间</span><input ref="selectedDueTimePicker" v-model="selectedDueTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="selectedDueHasTime=false"><X/></button></label></div>
</div>
<label>重复<select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedDueDate"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!selectedDueDate" class="field-hint">请先设置截止时间,才能开启重复</small></label>
<section v-if="selectedTaskRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="selectedAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数"> 天重复</div><small>每次完成后,将截止时间顺延对应天数;首版永不结束</small></section>
<section v-if="selectedTaskRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="selectedRepeatConfig.interval" type="number" min="1"><select v-model="selectedRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="selectedRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="selectedRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="selectedRepeatConfig.frequency==='monthly'">每月日期<input v-model="selectedRepeatConfig.monthDays" placeholder="例如 1,15,31" @change="selectedRepeatConfig.monthDays=String(($event.target as HTMLInputElement).value).split(',').map(Number).filter(Boolean)"></label><label>结束方式<select v-model="selectedRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="selectedRepeatConfig.endMode==='date'">结束日期<input v-model="selectedRepeatConfig.until" type="date"></label><label v-if="selectedRepeatConfig.endMode==='count'">重复次数<input v-model.number="selectedRepeatConfig.count" type="number" min="1"></label></section>
<small v-if="selectedRepeatError" role="alert" class="field-error">{{selectedRepeatError}}</small>
+22 -1
View File
@@ -195,7 +195,28 @@ describe('countdown modal accessibility', () => {
newPin.resolve(json({})); await flush()
})
it('keeps the normal current-detail pin flow working', async () => {
it('pins and unpins from detail without duplicate requests', async () => {
const mutation = deferred<Response>()
const pinned = { ...countdown, pinned: true }
const fetchMock = vi.fn((url: string, options?: RequestInit) => {
if (url.endsWith('/countdowns/c1/pin') && options?.method === 'DELETE') return mutation.promise
if (url.endsWith('/countdowns')) return Promise.resolve(json([pinned]))
if (url.includes('archived=true')) return Promise.resolve(json([]))
throw new Error(`unexpected ${url}`)
})
const { host, notices } = await mountWithFetch(fetchMock)
clickCountdown(host, '发布日'); await nextTick()
const button = detailButton('取消置顶')
button.click(); button.click()
await nextTick()
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/countdowns/c1/pin'))).toHaveLength(1)
expect(button.disabled).toBe(true)
mutation.resolve(json({ ...pinned, pinned: false })); await flush()
expect(notices).toEqual(['已取消置顶'])
expect(document.querySelector('.countdown-detail-sheet')).toBeNull()
})
it('keeps normal current-detail pin flow working', async () => {
const pin = deferred<Response>()
const fetchMock = vi.fn((url: string) => {
if (url.endsWith('/countdowns/c1/pin')) return pin.promise
+2 -2
View File
@@ -161,7 +161,7 @@ async function save() {
emit('notice',editingId.value?'倒数日已更新':'倒数日已添加')
})
}
async function pin(item:Countdown){await safe(item.id,async(context)=>{await request(`/countdowns/${item.id}/pin`,{method:'POST'});invalidateCountdownCache();if(!currentContext(context)){void load(true,false);return}emit('notice','已置顶');closeDetail();await load(true,false)})}
async function setPinned(item:Countdown){const nextPinned=!item.pinned;await safe(item.id,async(context)=>{await request(`/countdowns/${item.id}/pin`,{method:nextPinned?'POST':'DELETE'});invalidateCountdownCache();if(!currentContext(context)){void load(true,false);return}emit('notice',nextPinned?'已置顶':'已取消置顶');closeDetail();await load(true,false)})}
async function archiveItem(item:Countdown){await safe(item.id,async(context)=>{await request(`/countdowns/${item.id}`,{method:'DELETE'});invalidateCountdownCache();if(!currentContext(context)){void load(true,false);return}emit('notice','已归档');closeDetail();await load(true,false)})}
async function restore(item:Countdown){await safe(null,async(context)=>{await request(`/countdowns/${item.id}/restore`,{method:'POST'});invalidateCountdownCache();await load(true,false);if(!currentContext(context))return;emit('notice','已恢复')})}
async function purge(item:Countdown){if(busy.value)return;if(await appDialog.value?.show({title:`永久删除“${item.title}”?`,description:'这个操作不能撤销。',danger:true,confirmText:'永久删除'})!==true)return;if(busy.value)return;await safe(null,async(context)=>{await request(`/countdowns/${item.id}/purge`,{method:'DELETE'});invalidateCountdownCache();await load(true,false);if(!currentContext(context))return;emit('notice','已永久删除')})}
@@ -210,7 +210,7 @@ onBeforeUnmount(() => {
<header class="app-sheet__header"><div><small>重要日子详情</small><h3 id="countdown-detail-title">{{detailItem.title}}</h3></div><button type="button" aria-label="关闭详情" @click="closeDetail"><X/></button></header>
<div class="app-sheet__body"><div class="countdown-detail-days"><strong>{{detailItem.days===0?'今天':Math.abs(detailItem.days)}}</strong><span v-if="detailItem.days!==0"></span><b>{{countdownDayText(detailItem.days)}}</b></div>
<dl><div><dt>日期</dt><dd>{{primaryDate(detailItem)}}</dd></div><div v-if="secondaryDate(detailItem)"><dt>换算</dt><dd>{{secondaryDate(detailItem)}}</dd></div><div><dt>类型</dt><dd>{{countdownKindLabel(detailItem.kind)}} · {{detailItem.calendar_mode==='lunar'?'农历':'公历'}} · {{repeatBadge(detailItem) || '不重复'}}</dd></div></dl></div>
<footer class="app-sheet__footer"><button v-if="!detailItem.pinned" type="button" :disabled="busy" @click="pin(detailItem)"><Pin/>置顶</button><button type="button" :disabled="busy" @click="edit(detailItem)"><Pencil/>编辑</button><button type="button" class="danger-text" :disabled="busy" @click="archiveItem(detailItem)"><Archive/>归档</button></footer>
<footer class="app-sheet__footer"><button type="button" :disabled="busy" @click="setPinned(detailItem)"><Pin/>{{detailItem.pinned?'取消置顶':'置顶'}}</button><button type="button" :disabled="busy" @click="edit(detailItem)"><Pencil/>编辑</button><button type="button" class="danger-text" :disabled="busy" @click="archiveItem(detailItem)"><Archive/>归档</button></footer>
</template>
</AppSheet>
<AppSheet :open="open" variant="create" panel-class="countdown-modal" title-id="countdown-dialog-title" initial-focus="input[aria-label='倒数日名称']" :busy="busy" :style="composerStyle" @close="closeDialog" @submit.prevent="save">
+1 -1
View File
@@ -211,7 +211,7 @@ defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dir
<div class="memo-panel__main" :inert="selected && mobileDetail ? true : undefined">
<div class="memo-toolbar">
<div class="memo-scope" role="tablist" aria-label="备忘录范围"><button role="tab" data-scope="active" :aria-selected="scope==='active'" @click="setScope('active')">活动</button><button role="tab" data-scope="trash" :aria-selected="scope==='trash'" @click="setScope('trash')"><Archive/>回收站</button></div>
<button ref="searchToggle" class="memo-search-toggle" type="button" aria-label="展开搜索备忘录" :aria-expanded="mobileLayout ? mobileSearchOpen : true" aria-controls="memo-search-panel" @click="mobileSearchOpen ? closeMobileSearch() : openMobileSearch()"><Search/></button>
<button ref="searchToggle" class="memo-search-toggle" type="button" :aria-label="mobileSearchOpen ? '收起搜索备忘录' : '展开搜索备忘录'" :aria-expanded="mobileLayout ? mobileSearchOpen : true" aria-controls="memo-search-panel" @click="mobileSearchOpen ? closeMobileSearch() : openMobileSearch()"><Search/></button>
<div id="memo-search-panel" class="memo-search-panel" :class="{'is-open':mobileSearchOpen}" :hidden="mobileLayout && !mobileSearchOpen"><label class="memo-search"><Search/><input id="memo-search-input" ref="searchInput" v-model="query" aria-label="搜索备忘录" placeholder="搜索标题或正文…" @keydown="handleSearchKeydown"></label><button v-if="query" type="button" class="memo-search-clear" aria-label="清空搜索" @click="clearSearch"><X/></button></div>
</div>
<p v-if="error" class="memo-error" role="alert">{{error}} <button class="link" @click="load()">重试</button></p>
+1 -1
View File
@@ -748,7 +748,7 @@ onBeforeUnmount(() => {
<div v-if="view === 'today-habits' && (habits.length || !busy)" class="habit-list today-habit-list">
<article v-for="h in visibleTodayHabits" :key="h.id" :data-habit-id="h.id" class="habit-row today-habit-row swipeable" :class="{ done: isDone(h, todayKey), 'just-completed': justCompletedHabitIds.has(h.id), 'completion-exiting': completionExitingHabitIds.has(h.id), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
<button class="task-check habit-check" :disabled="!habitAction(h).writable" :aria-disabled="!habitAction(h).writable" :title="habitAction(h).reason" :aria-label="habitAction(h).reason || (isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`)" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)"><span class="task-check-mark"><Check v-if="isDone(h, todayKey)" /></span></button>
<div class="habit-main">
<div class="habit-main" role="button" tabindex="0" :aria-label="`查看习惯详情:${h.name}`" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.space.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)">
<span class="habit-name">{{ h.name }}</span>
<small v-if="habitAction(h).reason" class="habit-state-note">{{ habitAction(h).reason }}</small>
<small v-if="habitProgressText(h)" class="habit-progress">{{ habitProgressText(h) }}</small>
@@ -14,8 +14,10 @@ describe('add-task CalendarPicker integration', () => {
expect(app).toContain("if (!value) { composeHasTime.value = false; composeRepeat.value = 'none' }")
expect(app).not.toContain('ref="composeDatePicker"')
expect(app).not.toContain('function openComposeDuePicker()')
expect(app).toContain('type="datetime-local"')
expect(app).not.toContain('type="datetime-local"')
expect(app).toContain('v-model="composeTime" type="time"')
expect(app).toContain('v-model="selectedDueDate"')
expect(app).toContain('v-model="selectedDueTime" type="time"')
})
it('uses a fixed desktop popover and opaque mobile bottom sheet above task compose', () => {
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { applyMarkdownFormat, buildTaskRecurrencePayload, buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, filterTasks, groupTaskTree, isSameTaskSortTier, mergeReorderedSubset, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, filterTasks, groupTaskTree, isSameTaskSortTier, mergeReorderedSubset, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
type SearchTask = {
id: string
@@ -136,4 +136,23 @@ describe('task utilities', () => {
process.env.TZ = original
expect(toDateTimeLocal(null)).toBe('')
})
it('parses date-only and timed task deadlines without inventing a time', () => {
const original = process.env.TZ
process.env.TZ = 'Asia/Shanghai'
expect(parseTaskDueDraft('2026-09-05T15:59:00Z', false)).toEqual({ date: '2026-09-05', hasTime: false, time: '12:00' })
expect(parseTaskDueDraft('2026-09-05T12:30:00Z', true)).toEqual({ date: '2026-09-05', hasTime: true, time: '20:30' })
expect(parseTaskDueDraft(null, true)).toEqual({ date: '', hasTime: false, time: '12:00' })
process.env.TZ = original
})
it('builds date-only, timed, toggled and cleared task deadlines in local time', () => {
const original = process.env.TZ
process.env.TZ = 'Asia/Shanghai'
expect(buildTaskDueDraft({ date: '2026-09-05', hasTime: false, time: '08:15' })).toEqual({ due_at: '2026-09-05T15:59:00.000Z', due_has_time: false })
expect(buildTaskDueDraft({ date: '2026-09-05', hasTime: true, time: '08:15' })).toEqual({ due_at: '2026-09-05T00:15:00.000Z', due_has_time: true })
expect(buildTaskDueDraft({ date: '2026-09-05', hasTime: true, time: '' })).toEqual({ due_at: '2026-09-05T15:59:00.000Z', due_has_time: false })
expect(buildTaskDueDraft({ date: '', hasTime: true, time: '08:15' })).toEqual({ due_at: null, due_has_time: false })
process.env.TZ = original
})
})
+16
View File
@@ -217,6 +217,22 @@ export function defaultTaskDueAt(now = new Date()) {
return `${year}-${month}-${day}`
}
export type TaskDueDraft = { date: string; hasTime: boolean; time: string }
export function parseTaskDueDraft(value: string | null | undefined, dueHasTime: boolean): TaskDueDraft {
const local = toDateTimeLocal(value)
if (!local) return { date: '', hasTime: false, time: '12:00' }
const [date, time = '12:00'] = local.split('T')
return { date, hasTime: dueHasTime, time: dueHasTime ? time : '12:00' }
}
export function buildTaskDueDraft(draft: TaskDueDraft) {
if (!draft.date) return { due_at: null, due_has_time: false }
const hasTime = draft.hasTime && Boolean(draft.time)
const local = `${draft.date}T${hasTime ? draft.time : '23:59'}`
return { due_at: fromDateTimeLocal(local), due_has_time: hasTime }
}
export function toDateTimeLocal(value: string | null | undefined) {
if (!value) return ''
const date = new Date(value)
File diff suppressed because one or more lines are too long
+44 -8
View File
@@ -246,6 +246,33 @@ describe('approved UI detail direction', () => {
expect(css).toContain('.task-detail-field-input{width:190px!important;max-width:100%;justify-self:end}')
})
it('opens Today habit bodies with keyboard parity while check clicks stay isolated', () => {
const todayRows = mvpPanel.slice(mvpPanel.indexOf('class="habit-list today-habit-list"'), mvpPanel.indexOf('<!-- 完整习惯列表 -->'))
expect(todayRows).toContain('role="button" tabindex="0"')
expect(todayRows).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(todayRows).toContain('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(todayRows).toContain('@keydown.space.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(todayRows).toContain('@click.stop="toggleHabitFromButton(h)"')
})
it('keeps custom recurrence controls at 44px without overflowing narrow screens', () => {
expect(css).toContain('.repeat-custom-fields input,.repeat-custom-fields select{min-height:44px;')
expect(css).toContain('.weekday-picker label{width:44px;height:44px;')
expect(css).toContain('@media(max-width:930px){.repeat-custom-fields{padding:10px;gap:8px}')
expect(css).toContain('.repeat-custom-fields>div,.repeat-custom-fields>label{min-width:0;flex-wrap:wrap;gap:6px}')
})
it('compresses countdown focus without hiding its primary information', () => {
expect(css).toMatch(/\.countdown-focus\{[^}]*min-height:140px/)
expect(css).toMatch(/\.countdown-number strong\{[^}]*font-size:64px/)
expect(countdownPanel).toContain('<h3>{{focusItem.title}}</h3>')
expect(countdownPanel).toContain('<p>{{primaryDate(focusItem)}}</p>')
})
it('uses the real Memo search state in its toggle name', () => {
expect(readFileSync('src/MemoPanel.vue', 'utf8')).toContain(':aria-label="mobileSearchOpen ? \'收起搜索备忘录\' : \'展开搜索备忘录\'"')
})
it('keeps refresh neutral and at least 44px', () => {
expect(css).toContain('.topbar-refresh{width:44px;height:44px;min-width:44px;border-radius:50%;color:var(--text-secondary)}')
expect(css).not.toContain('.topbar-refresh:hover:not(:disabled){background:#fff7eb;color:var(--accent)}')
@@ -320,8 +347,13 @@ describe('completion feedback motion', () => {
const saveTaskBlock = app.slice(app.indexOf('async function saveTask('), app.indexOf('async function saveSelectedTaskChanges('))
expect(saveTaskBlock).not.toContain('/recurrences/')
expect(saveTaskBlock).toContain('as Partial<Task>, false')
expect(saveTaskBlock).toContain('selectedDueHasTime.value = Boolean(updated.due_has_time)')
const unifiedSaveBlock = app.slice(app.indexOf('async function saveSelectedTaskChanges('), app.indexOf('async function removeTask('))
expect(unifiedSaveBlock).toContain('const repeatRecurrence = taskSaved.due_at ? recurrence : null')
expect(unifiedSaveBlock).toContain('if (!taskSaved.due_at) {')
expect(unifiedSaveBlock).toContain('selectedTaskRecurrence.value = null')
expect(unifiedSaveBlock).toContain("selectedTaskRepeat.value = 'none'")
const dueRemovalBlock = unifiedSaveBlock.slice(unifiedSaveBlock.indexOf('if (!taskSaved.due_at) {'), unifiedSaveBlock.indexOf('} else {'))
expect(dueRemovalBlock).not.toContain('saveRepeat(')
})
it('keeps all task composer touch controls at least 44px on mobile without horizontal overflow', () => {
@@ -1062,8 +1094,8 @@ describe('task detail layout', () => {
})
it('keeps the list, due datetime and repeat controls equally compact', () => {
expect(app).toContain('<label class="task-detail-due-row">截止时间')
expect(app).toContain('class="task-detail-due-input task-detail-field-input"')
expect(app).toContain('<div class="task-detail-due-row">')
expect(app).toContain('v-model="selectedDueDate" class="task-detail-due-input task-detail-field-input"')
expect(app.match(/class="task-detail-field-input"/g)).toHaveLength(2)
expect(css).toContain('.task-detail-field-input{width:190px!important;max-width:100%;justify-self:end}')
expect(css).toContain('.task-detail-due-input{padding-inline:9px!important}')
@@ -1116,19 +1148,23 @@ describe('unified floating add interaction', () => {
const saveBlock = app.slice(app.indexOf('async function saveSelectedTaskChanges()'), app.indexOf('async function removeTask'))
expect(saveBlock).toContain("const taskId = selectedTask.value?.id")
expect(saveBlock).toContain('const selectionToken = recurrenceLoadToken')
expect(saveBlock).toContain("const repeatValue = selectedTask.value?.due_at ? selectedTaskRepeat.value : 'none'")
expect(saveBlock).toContain("const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none'")
expect(saveBlock).toContain('structuredClone(selectedRepeatConfig.value)')
expect(saveBlock).toContain("const taskSaved = await saveTask({ showSuccess: false, expectedTaskId: taskId, expectedSelectionToken: selectionToken })")
expect(saveBlock).toContain("recurrenceLoadToken !== selectionToken")
expect(saveBlock).toContain('const repeatRecurrence = taskSaved.due_at ? recurrence : null')
expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, repeatRecurrence)')
expect(saveBlock).toContain('if (!taskSaved.due_at) {')
expect(saveBlock).toContain('selectedTaskRecurrence.value = null')
expect(saveBlock).toContain("selectedTaskRepeat.value = 'none'")
expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)')
const dueRemovalBlock = saveBlock.slice(saveBlock.indexOf('if (!taskSaved.due_at) {'), saveBlock.indexOf('} else {'))
expect(dueRemovalBlock).not.toContain('saveRepeat(')
expect(saveBlock).toContain("selectedRepeatError.value = ''")
expect(saveBlock).toContain("selectedRepeatError.value = reason instanceof Error ? reason.message : '保存失败'")
expect(saveBlock.indexOf("toast('已保存')")).toBeGreaterThan(saveBlock.indexOf('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, repeatRecurrence)'))
expect(saveBlock.indexOf("toast('已保存')")).toBeGreaterThan(saveBlock.indexOf('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)'))
expect(saveBlock).toContain('if (savingSelectedTask.value || recurrenceLoading.value) return')
expect(saveBlock).toContain('savingSelectedTask.value = true')
expect(saveBlock).toContain('savingSelectedTask.value = false')
expect(saveBlock).toContain("const repeatValue = selectedTask.value?.due_at ? selectedTaskRepeat.value : 'none'")
expect(saveBlock).toContain("const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none'")
expect(app).toContain(':disabled="savingSelectedTask || recurrenceLoading" @click="saveSelectedTaskChanges"')
expect(app).not.toContain('@blur="saveTask()"')
expect(app).not.toContain('@change="saveTask()"')