diff --git a/backend/main.py b/backend/main.py index 6ac6022..b538bcf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1027,7 +1027,6 @@ async def create_task( @app.get("/api/v1/tasks", response_model=TaskPage) async def list_tasks( - q: str | None = None, list_id: UUID | None = None, completed: bool | None = None, due_from: datetime | None = None, @@ -1072,18 +1071,6 @@ async def list_tasks( query = query.where(Task.completed_at >= completed_from) if completed_to is not None: query = query.where(Task.completed_at < completed_to) - if q: - pattern = f"%{q}%" - list_match = exists( - select(TaskList.id).where( - TaskList.id == Task.list_id, - TaskList.user_id == user.id, - TaskList.name.ilike(pattern), - ) - ) - query = query.where( - or_(Task.title.ilike(pattern), Task.description.ilike(pattern), list_match) - ) total = await db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0 ordering = _task_ordering() if page is not None: diff --git a/backend/mvp.py b/backend/mvp.py index 9004b23..db8ccf4 100644 --- a/backend/mvp.py +++ b/backend/mvp.py @@ -144,7 +144,6 @@ def _memo_excerpt(content: str) -> str: @router.get("/memos") async def list_memos( scope: str = Query("active", pattern="^(active|trash)$"), - q: str = "", page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), user: User = Depends(current_user), @@ -152,10 +151,6 @@ async def list_memos( ): filters = [Memo.user_id == user.id] filters.append(Memo.deleted_at.is_(None) if scope == "active" else Memo.deleted_at.is_not(None)) - query = q.strip() - if query: - pattern = f"%{query}%" - filters.append((Memo.title.ilike(pattern)) | (Memo.content.ilike(pattern))) total = await db.scalar(select(func.count()).select_from(Memo).where(*filters)) rows = (await db.scalars( select(Memo).where(*filters).order_by(Memo.updated_at.desc(), Memo.id.desc()) diff --git a/frontend/e2e/backup-roundtrip.spec.ts b/frontend/e2e/backup-roundtrip.spec.ts index 3a13c42..517ff42 100644 --- a/frontend/e2e/backup-roundtrip.spec.ts +++ b/frontend/e2e/backup-roundtrip.spec.ts @@ -92,7 +92,7 @@ test('complete ZIP backup preflights and replace-restores task, habit history, c await confirm.getByRole('button', { name: '确认', exact: true }).click() await expect(page.getByRole('status')).toContainText('数据已恢复') - const restoredTasksResponse = await request.get(`/api/v1/tasks?q=${encodeURIComponent(taskTitle)}&limit=100`) + const restoredTasksResponse = await request.get('/api/v1/tasks?limit=100') expect(restoredTasksResponse.ok()).toBeTruthy() const restoredTasks = (await restoredTasksResponse.json()).items as Array<{ id: string; title: string }> expect(restoredTasks.filter(item => item.title === taskTitle)).toHaveLength(1) diff --git a/frontend/e2e/mobile-ui.spec.ts b/frontend/e2e/mobile-ui.spec.ts index e2ac142..11a6f7a 100644 --- a/frontend/e2e/mobile-ui.spec.ts +++ b/frontend/e2e/mobile-ui.spec.ts @@ -161,7 +161,6 @@ test('settings match the approved paper-ledger geometry and action hierarchy', a const sectionHeaders = [...page.querySelectorAll('.settings-group > header')] const rows = [...page.querySelectorAll('.settings-row')] const menu = main.querySelector('.topbar > .icon')!.getBoundingClientRect() - const refresh = main.querySelector('.settings-refresh')!.getBoundingClientRect() const rect = page.getBoundingClientRect() const mainRect = main.getBoundingClientRect() return { @@ -171,7 +170,7 @@ test('settings match the approved paper-ledger geometry and action hierarchy', a headingSize: getComputedStyle(heading).fontSize, sectionHeights: sectionHeaders.map(header => header.getBoundingClientRect().height), rowHeights: rows.map(row => row.getBoundingClientRect().height), - controlsOverlap: !(menu.right <= refresh.left || refresh.right <= menu.left || menu.bottom <= refresh.top || refresh.bottom <= menu.top), + menuWidth: menu.width, } }) expect(layout.bodyOverflow).toBe(0) @@ -188,7 +187,8 @@ test('settings match the approved paper-ledger geometry and action hierarchy', a } expect(layout.sectionHeights.every(height => height === 44)).toBeTruthy() expect(layout.rowHeights.every(height => height >= (isMobileContract ? 64 : 62))).toBeTruthy() - expect(layout.controlsOverlap).toBe(false) + expect(layout.menuWidth).toBeGreaterThanOrEqual(44) + await expect(page.getByRole('button', { name: /刷新|搜索/ })).toHaveCount(0) const exportButton = page.getByRole('button', { name: '导出 ZIP' }) const fileInput = page.locator('input[type=file]') diff --git a/frontend/e2e/today-plain-list.spec.ts b/frontend/e2e/today-plain-list.spec.ts index 3c967f9..e200b0f 100644 --- a/frontend/e2e/today-plain-list.spec.ts +++ b/frontend/e2e/today-plain-list.spec.ts @@ -32,6 +32,15 @@ test('Today plain-list layout matches the approved responsive geometry', async ( const habitResponse = await mutate(request, baseURL!, '/api/v1/habits', { name: habitName, kind: 'numeric', target: 8, max_value: 8, schedule_type: 'daily' }) expect(habitResponse.ok(), await habitResponse.text()).toBeTruthy() const habitId = (await habitResponse.json()).id as string + await page.route('**/api/v1/today/environment', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + date: { solar_date: '2026-09-19', weekday: '星期六', lunar: '八月初九' }, + weather: { status: 'fresh', temperature_c: 26.4, text: '多云', observed_at: '2026-09-19T20:45:00+08:00' }, + gold: { status: 'fresh', contract: 'Au99.99', price_cny_per_gram: 835.62, market_date: '2026-09-18' }, + }), + })) await page.goto('/') const taskRowLocator = page.locator('#today-tasks .task-row').filter({ hasText: taskTitle }) const habitRowLocator = page.locator('.today-habit-row').filter({ hasText: habitName }) @@ -59,7 +68,7 @@ test('Today plain-list layout matches the approved responsive geometry', async ( await expect(taskRowLocator).toHaveCount(1) await expect(habitRowLocator).toHaveCount(1) await expect(page.locator('.today-environment')).toBeVisible() - await expect(page.getByRole('button', { name: '刷新当前页面' })).toBeVisible() + await expect(page.getByRole('button', { name: /刷新|搜索/ })).toHaveCount(0) await expect(page.getByRole('switch', { name: '显示已完成' })).toHaveCount(1) await expect(page.locator('.today-environment__gold')).toContainText('Au99.99') const metrics = await page.evaluate(({ taskId, habitId }) => { @@ -136,7 +145,7 @@ test('Today plain-list layout matches the approved responsive geometry', async ( insideContent, weatherText: weather.innerText, goldText: gold.innerText, - goldLabel: gold.querySelector('.today-environment__gold-date')?.getAttribute('aria-label'), + goldLabel: gold.querySelector('.today-environment__gold-date')?.getAttribute('aria-label') ?? '', summaries, styles: { mainPaddingLeft: getComputedStyle(main).paddingLeft, diff --git a/frontend/e2e/ui-reduction-acceptance.spec.ts b/frontend/e2e/ui-reduction-acceptance.spec.ts index 5cd43a2..6a928fd 100644 --- a/frontend/e2e/ui-reduction-acceptance.spec.ts +++ b/frontend/e2e/ui-reduction-acceptance.spec.ts @@ -200,56 +200,3 @@ test('Habits and Countdowns use reduced headers, compact rows, and continuous ar expect(archiveMetrics.buttons.every(item => item.width >= 44 && item.height >= 44)).toBeTruthy() await expectNoHorizontalOverflow(page, 'countdowns', testInfo) }) - -test('Memo mobile search is one-row, focus-safe, persistent when collapsed, and desktop-wide', async ({ page }, testInfo) => { - await page.goto('/') - await openSidebarView(page, '备忘录') - const toolbar = page.locator('.memo-toolbar') - const scope = page.locator('.memo-scope') - const toggle = page.getByRole('button', { name: '展开搜索备忘录' }) - const collapsedGeometry = { toolbar: await box(toolbar), scope: await box(scope), toggle: await box(toggle) } - console.log(`[qa-metrics][${testInfo.project.name}][memo-mobile-collapsed] ${JSON.stringify(collapsedGeometry)}`) - expect(collapsedGeometry.toggle.width).toBeGreaterThanOrEqual(44) - expect(collapsedGeometry.toggle.height).toBeGreaterThanOrEqual(44) - expect(Math.abs(collapsedGeometry.scope.y - collapsedGeometry.toggle.y)).toBeLessThanOrEqual(4) - expect(collapsedGeometry.toolbar.height).toBeLessThanOrEqual(52) - await expect(toggle).toHaveAttribute('aria-expanded', 'false') - - await toggle.click() - const closeToggle = page.getByRole('button', { name: '收起搜索备忘录' }) - const input = page.getByRole('textbox', { name: '搜索备忘录' }) - await expect(closeToggle).toHaveAttribute('aria-expanded', 'true') - await expect(input).toBeFocused() - await input.fill(`保留查询-${testInfo.project.name}`) - const openPanel = page.locator('#memo-search-panel') - const openMetrics = await openPanel.evaluate(element => { - const rect = element.getBoundingClientRect() - return { x: rect.x, right: rect.right, width: rect.width, scrollWidth: (element as HTMLElement).scrollWidth, clientWidth: (element as HTMLElement).clientWidth } - }) - console.log(`[qa-metrics][${testInfo.project.name}][memo-mobile-open] ${JSON.stringify(openMetrics)}`) - expect(openMetrics.x).toBeGreaterThanOrEqual(0) - expect(openMetrics.right).toBeLessThanOrEqual(page.viewportSize()!.width + 1) - expect(openMetrics.scrollWidth).toBeLessThanOrEqual(openMetrics.clientWidth) - - await closeToggle.click() - await expect(openPanel).toBeHidden() - await expect(toggle).toBeFocused() - await expect(toggle).toHaveAttribute('aria-expanded', 'false') - await toggle.click() - await expect(input).toHaveValue(`保留查询-${testInfo.project.name}`) - await expect(input).toBeFocused() - await expectNoHorizontalOverflow(page, 'memo-mobile', testInfo) - - await page.setViewportSize({ width: 1440, height: 900 }) - await expect(openPanel).toBeVisible() - const desktopMetrics = await openPanel.evaluate(element => { - const rect = element.getBoundingClientRect() - const style = getComputedStyle(element) - return { viewport: { width: innerWidth, height: innerHeight }, x: rect.x, y: rect.y, width: rect.width, display: style.display, hidden: (element as HTMLElement).hidden, documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth } - }) - console.log(`[qa-metrics][${testInfo.project.name}][memo-desktop] ${JSON.stringify(desktopMetrics)}`) - expect(desktopMetrics.hidden).toBeFalsy() - expect(desktopMetrics.width).toBeGreaterThanOrEqual(320) - expect(desktopMetrics.documentOverflow).toBe(0) - await expect(toggle).toBeHidden() -}) diff --git a/frontend/e2e/upcoming-list-parity.spec.ts b/frontend/e2e/upcoming-list-parity.spec.ts index 986c514..ddbc33e 100644 --- a/frontend/e2e/upcoming-list-parity.spec.ts +++ b/frontend/e2e/upcoming-list-parity.spec.ts @@ -36,7 +36,8 @@ test('Upcoming shares the task-list hierarchy while keeping its date scope and n const inbox = (await bootstrapResponse.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox) expect(inbox).toBeTruthy() - for (const [title, days] of [['未来一天任务', 1], ['未来三天任务', 3]] as const) { + const createdTitles = ['未来一天任务', '未来三天任务'] as const + for (const [title, days] of [[createdTitles[0], 1], [createdTitles[1], 3]] as const) { const response = await mutate(request, baseURL!, '/api/v1/tasks', { method: 'POST', data: { title, list_id: inbox.id, due_at: futureDueAt(days), due_has_time: false }, @@ -55,8 +56,9 @@ test('Upcoming shares the task-list hierarchy while keeping its date scope and n await expect(header.getByRole('switch', { name: '显示已完成' })).toBeVisible() await expect(page.locator('.list-section-heading')).toContainText('任务') await expect(page.getByRole('button', { name: '调整顺序' })).toHaveCount(0) - await expect(page.locator('.task-row')).toHaveCount(2) - await expect(page.locator('.task-row')).toContainText(['未来一天任务', '未来三天任务']) + const createdRows = page.locator('.task-row').filter({ hasText: /未来一天任务|未来三天任务/ }) + await expect(createdRows).toHaveCount(2) + await expect(createdRows).toContainText([...createdTitles]) const geometry = await page.evaluate(() => { const rect = (selector: string) => { @@ -67,14 +69,12 @@ test('Upcoming shares the task-list hierarchy while keeping its date scope and n viewportWidth: innerWidth, overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, header: rect('.list-page-context'), - search: rect('.list-search-reveal'), section: rect('.list-section-heading'), list: rect('.task-list'), } }) expect(geometry.overflow).toBe(0) - expect(geometry.header.left).toBeCloseTo(geometry.search.left, 0) - expect(geometry.search.left).toBeCloseTo(geometry.section.left, 0) + expect(geometry.header.left).toBeCloseTo(geometry.section.left, 0) expect(geometry.section.left).toBeCloseTo(geometry.list.left, 0) expect(geometry.header.width).toBeCloseTo(geometry.list.width, 0) if (geometry.viewportWidth <= 720) { diff --git a/frontend/e2e/visual-polish.spec.ts b/frontend/e2e/visual-polish.spec.ts index dd09270..06efacd 100644 --- a/frontend/e2e/visual-polish.spec.ts +++ b/frontend/e2e/visual-polish.spec.ts @@ -114,14 +114,14 @@ test('boolean habits complete in place while paused and unscheduled rows explain await expect(unscheduledRow.getByRole('button', { name: '今天未安排' })).toBeDisabled() }) -test('approved polish keeps search state, dense rows, title-only memos, and unique detail titles', async ({ page, request, baseURL }, testInfo) => { +test('approved polish keeps dense rows, title-only memos, and unique detail titles', async ({ page, request, baseURL }, testInfo) => { const suffix = `${testInfo.project.name}-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` 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 taskTitle = `搜索保留-${suffix}-这是用于检查长标题和右侧控件不会裁切的任务标题` + const taskTitle = `长标题任务-${suffix}-这是用于检查长标题和右侧控件不会裁切的任务标题` const taskResponse = await mutate(request, baseURL!, '/api/v1/tasks', { method: 'POST', data: { title: taskTitle, list_id: inbox.id }, @@ -145,26 +145,7 @@ test('approved polish keeps search state, dense rows, title-only memos, and uniq await page.goto('/') await openSidebarView(page, '收集箱') - const mobileGeometry = await page.evaluate(() => { - const main = document.querySelector('main.list-main')!.getBoundingClientRect() - const header = document.querySelector('.list-page-context')!.getBoundingClientRect() - const filter = document.querySelector('.list-inline-filter')!.getBoundingClientRect() - const search = document.querySelector('.list-search-reveal')!.getBoundingClientRect() - const toggle = document.querySelector('.list-search-reveal .task-search-toggle')!.getBoundingClientRect() - return { main: { left: main.left, right: main.right }, header: { left: header.left, right: header.right, bottom: header.bottom }, filter: { left: filter.left, bottom: filter.bottom }, search: { left: search.left, right: search.right, top: search.top }, toggle: { left: toggle.left, top: toggle.top } } - }) - expect(mobileGeometry.header.left - mobileGeometry.main.left).toBeCloseTo(29, 0) - expect(mobileGeometry.main.right - mobileGeometry.header.right).toBeCloseTo(29, 0) - expect(mobileGeometry.search.left).toBeGreaterThanOrEqual(mobileGeometry.header.left) - expect(mobileGeometry.search.right).toBeLessThanOrEqual(mobileGeometry.header.right) - expect(mobileGeometry.toggle.top).toBeGreaterThanOrEqual(mobileGeometry.header.bottom - 1) - expect(mobileGeometry.toggle.left).toBeGreaterThanOrEqual(mobileGeometry.filter.left) - const searchToggle = page.getByRole('button', { name: '展开搜索任务' }) - await expect(searchToggle).toHaveAttribute('aria-expanded', 'false') - await searchToggle.click() - const searchInput = page.getByRole('textbox', { name: '搜索任务' }) - await expect(searchInput).toBeFocused() - await searchInput.fill(`搜索保留-${suffix}`) + await expect(page.getByRole('button', { name: /刷新|搜索/ })).toHaveCount(0) const taskRow = page.locator('.task-row').filter({ hasText: taskTitle }) await expect(taskRow).toHaveCount(1) await expectHeightInRange(taskRow, 57, 59) @@ -180,12 +161,6 @@ test('approved polish keeps search state, dense rows, title-only memos, and uniq await expect(taskDetail.getByRole('button', { name: '保存更改' })).toBeEnabled() await page.keyboard.press('Escape') await expect(taskDetail).toBeHidden() - await searchInput.press('Escape') - const collapsedToggle = page.getByRole('button', { name: '展开搜索任务' }) - await expect(collapsedToggle).toBeFocused() - await expect(collapsedToggle).toHaveAttribute('aria-expanded', 'false') - await collapsedToggle.click() - await expect(searchInput).toHaveValue(`搜索保留-${suffix}`) await bottomTab(page, '习惯').click() const mobileHabitGeometry = await page.evaluate(() => { @@ -261,22 +236,5 @@ test('approved polish keeps search state, dense rows, title-only memos, and uniq const desktopInbox = page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true }) await expect(desktopInbox).toBeInViewport() await desktopInbox.click() - await expect(page.getByRole('button', { name: /展开搜索任务|收起搜索任务/ })).toBeHidden() - const desktopSearch = page.getByRole('textbox', { name: '搜索任务' }) - await expect(desktopSearch).toBeVisible() - const desktopPanel = page.locator('.list-search-reveal') - const desktopBox = await desktopPanel.boundingBox() - expect(desktopBox).not.toBeNull() - expect(desktopBox!.width).toBeGreaterThanOrEqual(320) - const desktopHeader = page.locator('.list-page-context') - const desktopList = page.locator('.task-list') - const desktopGeometry = await Promise.all([desktopHeader, desktopPanel, desktopList].map(async locator => locator.boundingBox())) - for (const box of desktopGeometry) expect(box).not.toBeNull() - expect(Math.abs(desktopGeometry[0]!.x - desktopGeometry[1]!.x)).toBeLessThanOrEqual(1) - expect(Math.abs(desktopGeometry[1]!.x - desktopGeometry[2]!.x)).toBeLessThanOrEqual(1) - expect(desktopGeometry[0]!.width).toBeLessThanOrEqual(900) - await desktopSearch.fill(`搜索保留-${suffix}`) - const desktopTaskRow = page.locator('.task-row').filter({ hasText: taskTitle }) - await expect(desktopTaskRow).toHaveCount(1) - await expectNotClipped(desktopTaskRow) + await expect(page.getByRole('button', { name: /刷新|搜索/ })).toHaveCount(0) }) diff --git a/frontend/e2e/visual-regression.spec.ts b/frontend/e2e/visual-regression.spec.ts index db1a154..a008421 100644 --- a/frontend/e2e/visual-regression.spec.ts +++ b/frontend/e2e/visual-regression.spec.ts @@ -30,13 +30,13 @@ test('inbox and task composer match approved visuals', async ({ page, request, b const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox) expect(inbox).toBeTruthy() - await createTask(request, baseURL!, '整理本周工作记录', inbox.id) - await createTask(request, baseURL!, '检查 Dodo 备份', inbox.id) - await createTask(request, baseURL!, '周末买水果和牛奶', inbox.id) + const seededTitles = ['整理本周工作记录', '检查 Dodo 备份', '周末买水果和牛奶'] as const + for (const title of seededTitles) await createTask(request, baseURL!, title, inbox.id) await page.goto('/') await openInbox(page) - await expect(page.locator('.task-row')).toHaveCount(3) + const seededRows = page.locator('.task-row').filter({ hasText: /整理本周工作记录|检查 Dodo 备份|周末买水果和牛奶/ }) + await expect(seededRows).toHaveCount(3) await expect(page).toHaveScreenshot('inbox.png', { fullPage: true }) await page.getByRole('button', { name: '添加任务' }).click() diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1228c23..de21e73 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,15 +2,14 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { ArchiveRestore, Bold, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Code, Folder, - Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote, Search, - Settings, Trash2, X, Repeat2, RefreshCw, StickyNote, + Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote, + Settings, Trash2, X, Repeat2, StickyNote, } from 'lucide-vue-next' -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 { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, 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' import { csrfHeader } from './lib/csrf' import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion' import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag' -import { clampSearchPullDistance, isAtSearchPullOrigin, isSearchShortcut, shouldHideSearchAfterSwipe, shouldRevealSearchAfterPull } from './lib/mobile-search' import { deriveMemoShellState } from './lib/app-shell-state' import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu' import MvpPanel from './MvpPanel.vue' @@ -76,21 +75,9 @@ const listReorderPlacement = ref<'before' | 'after'>('after') let listHandleLongPressTimer: number | undefined let listHandlePending: { id: string; pointer: ListDragPointer } | undefined let suppressListClickId = '' -const query = ref('') -const searchInput = ref(null) -const taskSearchToggle = ref(null) -const mobileSearchOpen = ref(false) -const searchPullDistance = ref(0) -let searchTouchStartY: number | null = null -const taskSearchAvailable = computed(() => ['tasks', 'today', 'upcoming', 'trash'].includes(activeView.value)) -const searchRevealStyle = computed(() => ({ - '--search-pull': `${searchPullDistance.value}px`, - '--search-pull-opacity': String(Math.min(1, searchPullDistance.value / 56)), -})) const error = ref('') const notice = ref('') const loading = ref(false) -const refreshing = ref(false) const taskDueNowMs = useTaskDueClock() const mobileSidebar = ref(false) const sidebarCollapsed = ref(false) @@ -116,7 +103,7 @@ const todayEnvironmentLoading = ref(false) const todayEnvironmentError = ref(false) const todayEnvironmentDateKey = ref('') const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize))) -const taskReorderAvailable = computed(() => activeView.value === 'tasks' && !query.value && totalPages.value === 1 && taskTree.value.length > 1) +const taskReorderAvailable = computed(() => activeView.value === 'tasks' && totalPages.value === 1 && taskTree.value.length > 1) const expandedFolders = ref(new Set()) const justCompletedTaskIds = ref(new Set()) const completionExitingTaskIds = ref(new Set()) @@ -377,22 +364,14 @@ const visibleTasks = computed(() => { return Boolean(dueToday || completedToday) }) if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= startOfLocalDay(0) && new Date(task.due_at) < startOfLocalDay(8)) - return query.value.trim() ? filterTasks(result, query.value) : result + return result }) const selectedTaskSubtasks = computed(() => selectedTask.value?.subtasks ?? []) -const filteredTaskTree = computed(() => groupTaskTree(visibleTasks.value)) -const taskTree = computed(() => filteredTaskTree.value) +const taskTree = computed(() => groupTaskTree(visibleTasks.value)) const overdueTaskTree = computed(() => groupTaskTree(overdueTasks.value)) -let searchTimer: number | undefined watch(composeDueAt, (value) => { if (!value) { composeHasTime.value = false; composeRepeat.value = 'none' } }) -watch(query, () => { - if (searchTimer) window.clearTimeout(searchTimer) - if (!isTaskView(activeView.value)) return - page.value = 1 - searchTimer = window.setTimeout(() => loadAll(), 250) -}) watch(taskReorderAvailable, () => { if (!taskReorderAvailable.value) taskReorderMode.value = false }) @@ -571,8 +550,7 @@ async function loadOverdueTasks(request = beginLatestRequest('tasks')) { } async function loadTasksPage(request = beginLatestRequest('tasks')) { const params = new URLSearchParams({ page: String(page.value), page_size: String(pageSize) }) - if (query.value) params.set('q', query.value) - else if (activeView.value === 'tasks' && activeList.value) params.set('list_id', activeList.value) + if (activeView.value === 'tasks' && activeList.value) params.set('list_id', activeList.value) if (activeView.value === 'today') { params.set('due_from', isoAtLocalDayOffset(0)) params.set('due_to', isoAtLocalDayOffset(1)) @@ -587,9 +565,9 @@ async function loadTasksPage(request = beginLatestRequest('tasks')) { if (!isLatestRequest('tasks', request)) return tasks.value = data.items ?? [] totalTasks.value = data.total ?? tasks.value.length - if (activeView.value === 'upcoming' && !showCompleted.value && !query.value) taskOpenTotal.value = totalTasks.value - if (activeView.value === 'tasks' && !showCompleted.value && !query.value) taskOpenTotal.value = totalTasks.value - if ((activeView.value === 'tasks' || activeView.value === 'upcoming') && (showCompleted.value || query.value)) { + if (activeView.value === 'upcoming' && !showCompleted.value) taskOpenTotal.value = totalTasks.value + if (activeView.value === 'tasks' && !showCompleted.value) taskOpenTotal.value = totalTasks.value + if ((activeView.value === 'tasks' || activeView.value === 'upcoming') && showCompleted.value) { const token = ++taskOpenTotalLoadToken const listId = activeList.value const openParams = new URLSearchParams({ page: '1', page_size: '1', completed: 'false' }) @@ -603,7 +581,7 @@ async function loadTasksPage(request = beginLatestRequest('tasks')) { ) } hiddenCompletedTaskCount.value = 0 - if (!showCompleted.value && !query.value && activeView.value !== 'trash' && tasks.value.length === 0) { + if (!showCompleted.value && activeView.value !== 'trash' && tasks.value.length === 0) { const completedParams = new URLSearchParams(params) completedParams.set('page', '1') completedParams.set('page_size', '1') @@ -671,18 +649,6 @@ async function refreshAll() { navigationLoaded.value = false await loadAll() } -async function refreshCurrentView() { - if (refreshing.value || loading.value) return - refreshing.value = true - try { - if (activeView.value === 'habits') await habitComposer.value?.refreshHabits() - else if (activeView.value === 'settings') await habitComposer.value?.refreshSettings() - else if (activeView.value === 'today') await Promise.all([refreshAll(), habitComposer.value?.refreshHabits()]) - else await refreshAll() - } finally { - refreshing.value = false - } -} type TrashPage = { items?: Task[]; total?: number } async function loadTrashPage() { return api(`/trash?page=${page.value}&page_size=${pageSize}`) as Promise @@ -712,8 +678,6 @@ async function switchView(view: View, listId?: string) { activeView.value = view taskOpenTotal.value = null ++taskOpenTotalLoadToken - if (!query.value) mobileSearchOpen.value = false - searchPullDistance.value = 0 if (view !== 'trash') beginLatestRequest('trash') if (!isTaskView(view)) { beginLatestRequest('tasks') @@ -768,9 +732,9 @@ async function patchTask(task: Task, patch: Partial, updateSelected = true return result.value ?? false } const taskMutationNavigation = ref(0) -type TaskViewContext = { navigation: number; view: View; listId: string; page: number; query: string; showCompleted: boolean } -const currentTaskViewContext = (): TaskViewContext => ({ navigation: taskMutationNavigation.value, view: activeView.value, listId: activeList.value, page: page.value, query: query.value, showCompleted: showCompleted.value }) -const sameTaskViewContext = (left: TaskViewContext, right: TaskViewContext) => left.navigation === right.navigation && left.view === right.view && left.listId === right.listId && left.page === right.page && left.query === right.query && left.showCompleted === right.showCompleted +type TaskViewContext = { navigation: number; view: View; listId: string; page: number; showCompleted: boolean } +const currentTaskViewContext = (): TaskViewContext => ({ navigation: taskMutationNavigation.value, view: activeView.value, listId: activeList.value, page: page.value, showCompleted: showCompleted.value }) +const sameTaskViewContext = (left: TaskViewContext, right: TaskViewContext) => left.navigation === right.navigation && left.view === right.view && left.listId === right.listId && left.page === right.page && left.showCompleted === right.showCompleted async function reconcileCurrentViewAfterTaskMutation(options: { affectsTrash?: boolean; affectsTaskView?: boolean } = {}) { await reconcileCurrentTaskView({ capture: currentTaskViewContext, @@ -1509,76 +1473,6 @@ function nextPage() { activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined } -function isMobileSearchLayout() { - return window.matchMedia('(max-width: 930px)').matches -} -function openTaskSearch() { - if (!taskSearchAvailable.value) return - if (isMobileSearchLayout()) mobileSearchOpen.value = true - searchPullDistance.value = 0 - void nextTick(() => searchInput.value?.focus()) -} -function closeTaskSearch() { - if (!isMobileSearchLayout()) return - mobileSearchOpen.value = false - searchPullDistance.value = 0 - void nextTick(() => taskSearchToggle.value?.focus()) -} -function toggleTaskSearch() { - if (mobileSearchOpen.value) closeTaskSearch() - else openTaskSearch() -} -function handleTaskSearchKeydown(event: KeyboardEvent) { - if (event.key !== 'Escape' || !isMobileSearchLayout()) return - event.preventDefault() - closeTaskSearch() -} -function startSearchPull(event: TouchEvent) { - if (!taskSearchAvailable.value || !isMobileSearchLayout()) return - const target = event.target as Element | null - if (target?.closest('input, textarea, select, button, .task-row, .habit-row, .countdown-row')) return - const main = event.currentTarget as HTMLElement - const shell = main.closest('.shell') as HTMLElement | null - if (!isAtSearchPullOrigin(main.scrollTop, shell?.scrollTop ?? 0, window.scrollY) || event.touches.length !== 1) return - searchTouchStartY = event.touches[0].clientY - searchPullDistance.value = 0 -} -function moveSearchPull(event: TouchEvent) { - if (searchTouchStartY === null || event.touches.length !== 1) return - const deltaY = event.touches[0].clientY - searchTouchStartY - if (mobileSearchOpen.value) { - searchPullDistance.value = Math.min(0, deltaY) - if (deltaY < 0 && !query.value.trim()) event.preventDefault() - return - } - searchPullDistance.value = clampSearchPullDistance(deltaY) - if (deltaY > 0) event.preventDefault() -} -function finishSearchPull(event: TouchEvent) { - if (searchTouchStartY === null) return - const endY = event.changedTouches[0]?.clientY ?? searchTouchStartY - const deltaY = endY - searchTouchStartY - searchTouchStartY = null - if (!mobileSearchOpen.value && shouldRevealSearchAfterPull(deltaY)) openTaskSearch() - else if (mobileSearchOpen.value && shouldHideSearchAfterSwipe(deltaY, query.value)) { - mobileSearchOpen.value = false - searchInput.value?.blur() - } - searchPullDistance.value = 0 -} -function cancelSearchPull() { - searchTouchStartY = null - searchPullDistance.value = 0 -} -function handleTaskSearchShortcut(event: KeyboardEvent) { - if (!isSearchShortcut(event) || !taskSearchAvailable.value) return - event.preventDefault() - openTaskSearch() -} -function handleSearchBlur() { - if (!query.value.trim()) mobileSearchOpen.value = false -} - function handleViewportResize() { compactLayout.value = window.innerWidth <= 930 handleArchivedListViewportChange() @@ -1587,7 +1481,6 @@ function handleViewportResize() { onMounted(() => { document.addEventListener('pointerdown', handleArchivedListOutsidePointer) document.addEventListener('keydown', handleArchivedListEscape) - document.addEventListener('keydown', handleTaskSearchShortcut) window.addEventListener('resize', handleViewportResize) document.addEventListener('visibilitychange', handleTodayEnvironmentResume) window.addEventListener('focus', handleTodayEnvironmentResume) @@ -1598,7 +1491,6 @@ onMounted(() => { onUnmounted(() => { document.removeEventListener('pointerdown', handleArchivedListOutsidePointer) document.removeEventListener('keydown', handleArchivedListEscape) - document.removeEventListener('keydown', handleTaskSearchShortcut) window.removeEventListener('resize', handleViewportResize) document.removeEventListener('visibilitychange', handleTodayEnvironmentResume) window.removeEventListener('focus', handleTodayEnvironmentResume) @@ -1690,19 +1582,10 @@ onUnmounted(() => { -
+

{{ activeName }}

-
- -
- -
- - - -