From 1571c3946f8fecfcf292c0ab32edb9056bbb8488 Mon Sep 17 00:00:00 2001 From: bboysoul Date: Thu, 17 Sep 2026 09:32:22 +0800 Subject: [PATCH] feat: refine task and countdown interactions --- backend/mvp.py | 13 ++ frontend/e2e/task-detail-save.spec.ts | 212 ++++++++++++++++++ frontend/e2e/ui-reduction-acceptance.spec.ts | 5 +- frontend/src/App.vue | 54 ++++- frontend/src/CountdownPanel.test.ts | 23 +- frontend/src/CountdownPanel.vue | 4 +- frontend/src/MemoPanel.vue | 2 +- frontend/src/MvpPanel.vue | 2 +- .../CalendarPicker.integration.test.ts | 4 +- frontend/src/lib/task-utils.test.ts | 21 +- frontend/src/lib/task-utils.ts | 16 ++ frontend/src/style.css | 5 +- frontend/src/style.test.ts | 52 ++++- tests/test_countdowns.py | 6 + 14 files changed, 388 insertions(+), 31 deletions(-) create mode 100644 frontend/e2e/task-detail-save.spec.ts diff --git a/backend/mvp.py b/backend/mvp.py index 01d908f..9004b23 100644 --- a/backend/mvp.py +++ b/backend/mvp.py @@ -827,6 +827,19 @@ async def pin_countdown(countdown_id: UUID, user: User = Depends(current_user), return countdown_dict(row) +@router.delete("/countdowns/{countdown_id}/pin") +async def unpin_countdown(countdown_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): + row = await owned_countdown(db, user.id, countdown_id) + if row.archived_at is not None: + raise HTTPException(409, "已归档倒数日不能取消置顶") + row.pinned = False + row.updated_at = utcnow() + audit(db, user.id, "update", "countdown", row.id) + await db.commit() + await db.refresh(row) + return countdown_dict(row) + + @router.delete("/countdowns/{countdown_id}", status_code=204) async def archive_countdown(countdown_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): row = await owned_countdown(db, user.id, countdown_id) diff --git a/frontend/e2e/task-detail-save.spec.ts b/frontend/e2e/task-detail-save.spec.ts new file mode 100644 index 0000000..f69f0ec --- /dev/null +++ b/frontend/e2e/task-detail-save.spec.ts @@ -0,0 +1,212 @@ +import type { APIRequestContext, Locator, Page, Request } from '@playwright/test' +import { allowExpectedError, expect, test } from './fixtures' + +type TaskFixture = { id: string; title: string; due_at: string | null; due_has_time: boolean; version: number } +type RecurrenceFixture = { id: string; task_id: string; rrule: string | null } +type Mutation = { method: string; pathname: string; body: Record | null } + +async function csrf(request: APIRequestContext) { + const state = await request.storageState() + return state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value ?? '' +} + +async function mutate( + request: APIRequestContext, + baseURL: string, + path: string, + options: Parameters[1], +) { + return request.fetch(path, { + ...options, + headers: { ...options?.headers, 'x-csrf-token': await csrf(request), origin: baseURL }, + }) +} + +async function inboxId(request: APIRequestContext) { + const response = await request.get('/api/v1/bootstrap') + expect(response.ok(), await response.text()).toBeTruthy() + const inbox = (await response.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox) + expect(inbox).toBeTruthy() + return inbox.id as string +} + +async function createTask( + request: APIRequestContext, + baseURL: string, + title: string, + options: { dueAt?: string; recurrence?: 'daily' } = {}, +) { + const response = await mutate(request, baseURL, '/api/v1/tasks', { + method: 'POST', + data: { + title, + list_id: await inboxId(request), + ...(options.dueAt ? { due_at: options.dueAt, due_has_time: false } : {}), + ...(options.recurrence === 'daily' ? { trigger_mode: 'scheduled', rrule: 'FREQ=DAILY;INTERVAL=1' } : {}), + }, + }) + expect(response.ok(), await response.text()).toBeTruthy() + return response.json() as Promise +} + +async function openTaskDetail(page: Page, title: string) { + await page.goto('/') + const inbox = page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true }) + const menu = page.locator('.topbar').getByRole('button', { name: /菜单$/ }) + const inboxBox = await inbox.boundingBox() + if (!inboxBox || inboxBox.x < 0 || inboxBox.x >= page.viewportSize()!.width) await menu.click() + await expect(inbox).toBeInViewport() + await inbox.click() + const row = page.locator('.task-row').filter({ has: page.locator('strong', { hasText: title }) }) + await expect(row).toHaveCount(1) + await row.locator('.task-main').click() + const detail = page.getByRole('dialog', { name: '任务详情' }) + await expect(detail).toBeVisible() + await expect(detail.getByRole('button', { name: '保存更改' })).toBeEnabled() + return detail +} + +async function reopenTaskDetail(page: Page, title: string) { + await page.getByRole('button', { name: '关闭详情' }).click() + await expect(page.getByRole('dialog', { name: '任务详情' })).toBeHidden() + await page.reload() + return openTaskDetail(page, title) +} + +function repeatSelect(detail: Locator) { + return detail.locator('label').filter({ hasText: /^重复/ }).locator('select') +} + +function recordSaveMutations(page: Page, taskId: string) { + const mutations: Mutation[] = [] + const listener = (request: Request) => { + const url = new URL(request.url()) + if (!['PATCH', 'POST', 'DELETE'].includes(request.method())) return + if (url.pathname !== `/api/v1/tasks/${taskId}` && !url.pathname.startsWith('/api/v1/recurrences')) return + mutations.push({ method: request.method(), pathname: url.pathname, body: request.postDataJSON() ?? null }) + } + page.on('request', listener) + return { mutations, stop: () => page.off('request', listener) } +} + +async function clickSaveAndWait(page: Page, taskId: string, recurrenceWrite?: { method: 'POST' | 'PATCH' | 'DELETE'; path: string }) { + const responses = [ + page.waitForResponse(response => response.url().includes(`/api/v1/tasks/${taskId}`) && response.request().method() === 'PATCH' && response.ok()), + ] + if (recurrenceWrite) { + responses.push(page.waitForResponse(response => { + const url = new URL(response.url()) + return url.pathname === recurrenceWrite.path && response.request().method() === recurrenceWrite.method && response.ok() + })) + } + await Promise.all([...responses, page.getByRole('button', { name: '保存更改' }).click()]) + await expect(page.locator('.toast')).toContainText('已保存') +} + +async function getRecurrence(request: APIRequestContext, taskId: string) { + const response = await request.get(`/api/v1/tasks/${taskId}/recurrence`) + expect(response.ok(), await response.text()).toBeTruthy() + return response.json() as Promise +} + +test('clearing recurring task deadline persists after reload and detail reopen', async ({ page, request, baseURL }, testInfo) => { + const task = await createTask(request, baseURL!, `E2E 清除日期 ${testInfo.project.name}`, { + dueAt: '2030-06-15T23:59:00Z', recurrence: 'daily', + }) + let detail = await openTaskDetail(page, task.title) + await expect(detail.locator('input[aria-label="截止日期"]')).not.toHaveValue('') + await expect(repeatSelect(detail)).toHaveValue('daily') + + const capture = recordSaveMutations(page, task.id) + await detail.getByRole('button', { name: '清除截止日期' }).click() + await clickSaveAndWait(page, task.id) + capture.stop() + + expect(capture.mutations).toHaveLength(1) + expect(capture.mutations[0]).toMatchObject({ method: 'PATCH', pathname: `/api/v1/tasks/${task.id}` }) + expect(capture.mutations[0].body).toMatchObject({ due_at: null, due_has_time: false }) + expect(capture.mutations.some(item => item.method === 'DELETE' && item.pathname.startsWith('/api/v1/recurrences/'))).toBe(false) + expect(await getRecurrence(request, task.id)).toBeNull() + + detail = await reopenTaskDetail(page, task.title) + await expect(detail.locator('input[aria-label="截止日期"]')).toHaveValue('') + await expect(repeatSelect(detail)).toHaveValue('none') +}) + +test('choosing no repeat keeps the deadline and persists after reload and detail reopen', async ({ page, request, baseURL }, testInfo) => { + const task = await createTask(request, baseURL!, `E2E 取消重复 ${testInfo.project.name}`, { + dueAt: '2030-06-16T23:59:00Z', recurrence: 'daily', + }) + let detail = await openTaskDetail(page, task.title) + const originalDate = await detail.locator('input[aria-label="截止日期"]').inputValue() + const recurrence = await getRecurrence(request, task.id) + expect(recurrence).not.toBeNull() + + const capture = recordSaveMutations(page, task.id) + await repeatSelect(detail).selectOption('none') + allowExpectedError(page, `requestfailed: DELETE http://127.0.0.1:5173/api/v1/recurrences/${recurrence!.id} net::ERR_ABORTED`) + await clickSaveAndWait(page, task.id, { method: 'DELETE', path: `/api/v1/recurrences/${recurrence!.id}` }) + capture.stop() + + expect(capture.mutations.map(item => `${item.method} ${item.pathname}`)).toEqual([ + `PATCH /api/v1/tasks/${task.id}`, + `DELETE /api/v1/recurrences/${recurrence!.id}`, + ]) + expect(capture.mutations[0].body).toMatchObject({ due_has_time: false }) + expect(capture.mutations[0].body?.due_at).not.toBeNull() + expect(await getRecurrence(request, task.id)).toBeNull() + + detail = await reopenTaskDetail(page, task.title) + await expect(repeatSelect(detail)).toHaveValue('none') + await expect(detail.locator('input[aria-label="截止日期"]')).toHaveValue(originalDate) +}) + +test('editing an existing rule to weekly persists after reload and detail reopen', async ({ page, request, baseURL }, testInfo) => { + const task = await createTask(request, baseURL!, `E2E 修改重复 ${testInfo.project.name}`, { + dueAt: '2030-06-17T23:59:00Z', recurrence: 'daily', + }) + let detail = await openTaskDetail(page, task.title) + const recurrence = await getRecurrence(request, task.id) + expect(recurrence).not.toBeNull() + + const capture = recordSaveMutations(page, task.id) + await repeatSelect(detail).selectOption('weekly') + await clickSaveAndWait(page, task.id, { method: 'PATCH', path: `/api/v1/recurrences/${recurrence!.id}` }) + capture.stop() + + expect(capture.mutations.map(item => `${item.method} ${item.pathname}`)).toEqual([ + `PATCH /api/v1/tasks/${task.id}`, + `PATCH /api/v1/recurrences/${recurrence!.id}`, + ]) + expect(capture.mutations[1].body).toMatchObject({ trigger_mode: 'scheduled' }) + expect(capture.mutations[1].body?.rrule).toContain('FREQ=WEEKLY') + expect((await getRecurrence(request, task.id))?.rrule).toContain('FREQ=WEEKLY') + + detail = await reopenTaskDetail(page, task.title) + await expect(repeatSelect(detail)).toHaveValue('weekly') + await expect(detail.locator('.repeat-custom-fields')).toHaveCount(0) +}) + +test('adding a daily rule persists after reload and detail reopen', async ({ page, request, baseURL }, testInfo) => { + const task = await createTask(request, baseURL!, `E2E 新增重复 ${testInfo.project.name}`, { + dueAt: '2030-06-18T23:59:00Z', + }) + let detail = await openTaskDetail(page, task.title) + expect(await getRecurrence(request, task.id)).toBeNull() + + const capture = recordSaveMutations(page, task.id) + await repeatSelect(detail).selectOption('daily') + await clickSaveAndWait(page, task.id, { method: 'POST', path: '/api/v1/recurrences' }) + capture.stop() + + expect(capture.mutations.map(item => `${item.method} ${item.pathname}`)).toEqual([ + `PATCH /api/v1/tasks/${task.id}`, + 'POST /api/v1/recurrences', + ]) + expect(capture.mutations[1].body).toMatchObject({ task_id: task.id, trigger_mode: 'scheduled' }) + expect(capture.mutations[1].body?.rrule).toContain('FREQ=DAILY') + expect((await getRecurrence(request, task.id))?.rrule).toContain('FREQ=DAILY') + + detail = await reopenTaskDetail(page, task.title) + await expect(repeatSelect(detail)).toHaveValue('daily') +}) diff --git a/frontend/e2e/ui-reduction-acceptance.spec.ts b/frontend/e2e/ui-reduction-acceptance.spec.ts index 0635c85..ce9d814 100644 --- a/frontend/e2e/ui-reduction-acceptance.spec.ts +++ b/frontend/e2e/ui-reduction-acceptance.spec.ts @@ -193,8 +193,9 @@ test('Memo mobile search is one-row, focus-safe, persistent when collapsed, and await expect(toggle).toHaveAttribute('aria-expanded', 'false') await toggle.click() + const closeToggle = page.getByRole('button', { name: '收起搜索备忘录' }) const input = page.getByRole('textbox', { name: '搜索备忘录' }) - await expect(toggle).toHaveAttribute('aria-expanded', 'true') + await expect(closeToggle).toHaveAttribute('aria-expanded', 'true') await expect(input).toBeFocused() await input.fill(`保留查询-${testInfo.project.name}`) const openPanel = page.locator('#memo-search-panel') @@ -207,7 +208,7 @@ test('Memo mobile search is one-row, focus-safe, persistent when collapsed, and expect(openMetrics.right).toBeLessThanOrEqual(page.viewportSize()!.width + 1) expect(openMetrics.scrollWidth).toBeLessThanOrEqual(openMetrics.clientWidth) - await toggle.click() + await closeToggle.click() await expect(openPanel).toBeHidden() await expect(toggle).toBeFocused() await expect(toggle).toHaveAttribute('aria-expanded', 'false') diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c83f76f..82d2ed7 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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(null) const composeTimePicker = ref(null) +const selectedDueDate = ref('') +const selectedDueHasTime = ref(false) +const selectedDueTime = ref('12:00') +const selectedDueTimePicker = ref(null) const composePriority = ref(0) const composeDescription = ref('') const composeRepeat = ref('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, 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, 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(() => {