diff --git a/README.md b/README.md index 523ee1e..6f3c333 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - RFC 5545 计划重复与“完成后重复”;乐观锁避免并发覆盖 - 今日页按逾期任务、今日任务、今日习惯分组,并提供进度与环境信息 - 完成型/数值型习惯、日/周/月/间隔计划、暂停、历史与归档 +- 本地专注计时器:25 分钟专注、5 分钟休息、暂停/继续/重置与当日完成次数 - 倒数日、纪念日、生日及公历/农历重复 - Markdown 备忘录及软删除/恢复 - 任务附件、登录设备管理与审计日志 diff --git a/docs/decisions.md b/docs/decisions.md index 2ae2cd8..41fdc8e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -2,7 +2,7 @@ ## 定位与当前范围 -dodo 是纯自托管、面向个人长期使用的任务与生活管理 PWA。当前包含任务/子任务、文件夹与清单、今日视图、重复任务、习惯、倒数日、Markdown 备忘录、附件、会话管理、审计和数据备份;不提供番茄钟、自然语言建任务或外部通知渠道。 +dodo 是纯自托管、面向个人长期使用的任务与生活管理 PWA。当前包含任务/子任务、文件夹与清单、今日视图、重复任务、习惯、本地专注计时器、倒数日、Markdown 备忘录、附件、会话管理、审计和数据备份;不提供自然语言建任务或外部通知渠道。 ## 技术与数据 @@ -19,11 +19,13 @@ dodo 是纯自托管、面向个人长期使用的任务与生活管理 PWA。 - 习惯支持完成型/数值型、日/周/月/间隔计划、暂停、历史和归档 - 倒数日支持公历/农历、生日/纪念日、重复、置顶与归档 - 备忘录使用 Markdown,支持软删除、恢复及归档后永久删除 +- 专注计时器只使用前端 `localStorage`,不关联任务、不保存历史,也不跨设备同步;自然结束才计入当日完成次数 ## UI 决策 - 桌面保留左导航/内容/可选详情三栏;移动端使用底部导航 - 手机底栏固定为“今天、习惯、倒数日、设置”,精确匹配当前页面;不使用“更多”中转 +- 专注入口位于桌面侧栏的“习惯”之后;手机从汉堡菜单进入,不占用固定五项底栏 - 新建入口使用同一个普通圆形 Plus FAB,禁止装饰性光环或吉祥物 - 设置页使用连续分组:数据、账户与安全、登录设备、活动、危险操作 - 任务、习惯、倒数日、备忘录、操作菜单和确认框统一走 `AppSheet` / `AppDialog` 覆盖层栈;共享背景 inert、焦点陷阱、Escape、忙碌态和嵌套焦点恢复 diff --git a/frontend/e2e/mobile-ui.spec.ts b/frontend/e2e/mobile-ui.spec.ts index 65eb465..e3cd6b8 100644 --- a/frontend/e2e/mobile-ui.spec.ts +++ b/frontend/e2e/mobile-ui.spec.ts @@ -223,7 +223,6 @@ test('calendar week focus keeps seven usable day controls without page overflow' mainOverflow: main.scrollWidth - main.clientWidth, calendarOverflow: calendar.scrollWidth - calendar.clientWidth, filters: { clientWidth: filters.clientWidth, scrollWidth: filters.scrollWidth, overflowX: getComputedStyle(filters).overflowX }, - stripOverflow: element.scrollWidth > element.clientWidth, buttons: [...element.querySelectorAll('.calendar-week-day')].map(button => ({ width: button.getBoundingClientRect().width, height: button.getBoundingClientRect().height })), } }) @@ -240,7 +239,6 @@ test('calendar week focus keeps seven usable day controls without page overflow' return { background: style.backgroundColor, color: style.color } }) expect(selectedColors).toEqual({ background: 'rgb(241, 90, 41)', color: 'rgb(255, 255, 255)' }) - if ((await page.viewportSize())!.width <= 390) expect(metrics.stripOverflow).toBeTruthy() }) test('settings match the approved paper-ledger geometry and action hierarchy', async ({ page }) => { diff --git a/frontend/e2e/task-pagination-position.spec.ts b/frontend/e2e/task-pagination-position.spec.ts index 938e711..ef7b66a 100644 --- a/frontend/e2e/task-pagination-position.spec.ts +++ b/frontend/e2e/task-pagination-position.spec.ts @@ -14,26 +14,34 @@ async function createTask(request: APIRequestContext, baseURL: string, listId: s expect(response.ok(), await response.text()).toBeTruthy() } -async function openInbox(page: Page) { - const inbox = page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true }) +async function createList(request: APIRequestContext, baseURL: string, name: string) { + const response = await request.post('/api/v1/lists', { + data: { name }, + headers: { 'x-csrf-token': await csrf(request), origin: baseURL }, + }) + expect(response.ok(), await response.text()).toBeTruthy() + return response.json() as Promise<{ id: string }> +} + +async function openList(page: Page, name: string) { + const list = page.locator('.sidebar').getByRole('button', { name, exact: true }) if (await page.evaluate(() => window.innerWidth <= 930)) { await page.locator('main .topbar > button').first().click() } - await inbox.click() + await list.click() } test('task pagination stays below the list and returns to the list start after navigation', async ({ page, request, baseURL }) => { - const bootstrapResponse = await request.get('/api/v1/bootstrap') - expect(bootstrapResponse.ok()).toBeTruthy() - const inbox = (await bootstrapResponse.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox) - expect(inbox).toBeTruthy() + const nonce = crypto.randomUUID() + const listName = `分页验收清单 ${nonce}` + const list = await createList(request, baseURL!, listName) for (let index = 1; index <= 51; index += 1) { - await createTask(request, baseURL!, inbox.id, `分页验收任务 ${String(index).padStart(2, '0')}`) + await createTask(request, baseURL!, list.id, `分页验收任务 ${nonce} ${String(index).padStart(2, '0')}`) } await page.goto('/') - await openInbox(page) + await openList(page, listName) const taskList = page.locator('.task-list') const pager = page.locator('.pager') diff --git a/frontend/e2e/visual-regression.spec.ts b/frontend/e2e/visual-regression.spec.ts index a008421..76899fb 100644 --- a/frontend/e2e/visual-regression.spec.ts +++ b/frontend/e2e/visual-regression.spec.ts @@ -14,27 +14,34 @@ async function createTask(request: APIRequestContext, baseURL: string, title: st expect(response.ok(), await response.text()).toBeTruthy() } -async function openInbox(page: Page) { +async function createList(request: APIRequestContext, baseURL: string, name: string) { + const response = await request.post('/api/v1/lists', { + data: { name }, + headers: { 'x-csrf-token': await csrf(request), origin: baseURL }, + }) + expect(response.ok(), await response.text()).toBeTruthy() + return response.json() as Promise<{ id: string }> +} + +async function openList(page: Page, name: string) { if ((await page.viewportSize())!.width <= 930) { await page.locator('.topbar').getByRole('button', { name: /展开菜单|收起菜单/ }).click() } - await page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true }).click() + await page.locator('.sidebar').getByRole('button', { name, exact: true }).click() } test('inbox and task composer match approved visuals', async ({ page, request, baseURL }, testInfo) => { test.skip(testInfo.project.name !== 'mobile-390', 'approved visual baselines are maintained for mobile-390 only') await page.clock.install({ time: new Date('2026-09-19T05:00:00.000Z') }) - const bootstrap = await request.get('/api/v1/bootstrap') - expect(bootstrap.ok()).toBeTruthy() - const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox) - expect(inbox).toBeTruthy() + const listName = '视觉回归清单' + const list = await createList(request, baseURL!, listName) const seededTitles = ['整理本周工作记录', '检查 Dodo 备份', '周末买水果和牛奶'] as const - for (const title of seededTitles) await createTask(request, baseURL!, title, inbox.id) + for (const title of seededTitles) await createTask(request, baseURL!, title, list.id) await page.goto('/') - await openInbox(page) + await openList(page, listName) const seededRows = page.locator('.task-row').filter({ hasText: /整理本周工作记录|检查 Dodo 备份|周末买水果和牛奶/ }) await expect(seededRows).toHaveCount(3) await expect(page).toHaveScreenshot('inbox.png', { fullPage: true }) diff --git a/frontend/e2e/visual-regression.spec.ts-snapshots/inbox-mobile-390.png b/frontend/e2e/visual-regression.spec.ts-snapshots/inbox-mobile-390.png index 79b60ef..5fc7310 100644 Binary files a/frontend/e2e/visual-regression.spec.ts-snapshots/inbox-mobile-390.png and b/frontend/e2e/visual-regression.spec.ts-snapshots/inbox-mobile-390.png differ diff --git a/frontend/e2e/visual-regression.spec.ts-snapshots/task-composer-mobile-390.png b/frontend/e2e/visual-regression.spec.ts-snapshots/task-composer-mobile-390.png index c6ae715..a311b43 100644 Binary files a/frontend/e2e/visual-regression.spec.ts-snapshots/task-composer-mobile-390.png and b/frontend/e2e/visual-regression.spec.ts-snapshots/task-composer-mobile-390.png differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ec1a5d6..0b7dbe2 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,7 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { ArchiveRestore, Bold, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronLeft, ChevronRight, Code, Folder, Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote, - Settings, Trash2, X, Repeat2, StickyNote, + Settings, Trash2, X, Repeat2, StickyNote, TimerReset, } from 'lucide-vue-next' import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, groupTrashTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type AfterCompletionUnit, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils' import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils' @@ -17,6 +17,7 @@ import MvpPanel from './MvpPanel.vue' import CountdownPanel from './CountdownPanel.vue' import MemoPanel from './MemoPanel.vue' import CalendarPanel from './CalendarPanel.vue' +import PomodoroPanel from './PomodoroPanel.vue' import FloatingAddButton from './components/FloatingAddButton.vue' import CompletedFilterPill from './components/CompletedFilterPill.vue' import CalendarPicker from './components/CalendarPicker.vue' @@ -33,7 +34,7 @@ type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; completed_at: string | null; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] } type RepeatOption = TaskRepeatOption type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null; after_completion_unit: AfterCompletionUnit | null } -type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'calendar' | 'settings' +type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'focus' | 'countdowns' | 'memos' | 'calendar' | 'settings' const initialized = ref(null) const authReady = ref(false) @@ -403,6 +404,7 @@ const activeName = computed(() => { if (activeView.value === 'today') return '今天' if (activeView.value === 'upcoming') return '最近 7 天' if (activeView.value === 'habits') return '习惯' + if (activeView.value === 'focus') return '专注' if (activeView.value === 'countdowns') return '倒数日' if (activeView.value === 'memos') return '备忘录' if (activeView.value === 'calendar') return '日历订阅' @@ -426,7 +428,7 @@ const sourceTasks = computed(() => activeView.value === 'trash' ? trash.value : const visibleTasks = computed(() => { const now = new Date() let result = sourceTasks.value - if (['habits','settings','countdowns','memos','calendar'].includes(activeView.value)) return [] + if (['habits','settings','focus','countdowns','memos','calendar'].includes(activeView.value)) return [] if (activeView.value === 'today') result = result.filter((task) => { const dueToday = task.due_at && new Date(task.due_at).toDateString() === now.toDateString() const completedToday = task.completed_at && new Date(task.completed_at).toDateString() === now.toDateString() @@ -1735,6 +1737,7 @@ onUnmounted(() => { + @@ -1801,6 +1804,7 @@ onUnmounted(() => { +