From c160e188e31c02408d97480ac26206c254cf0f7e Mon Sep 17 00:00:00 2001 From: bboysoul Date: Sun, 20 Sep 2026 12:41:23 +0800 Subject: [PATCH] feat: add calendar frontend --- frontend/e2e/backup-roundtrip.spec.ts | 7 +- frontend/e2e/mobile-ui.spec.ts | 27 ++--- frontend/e2e/ui-reduction-acceptance.spec.ts | 2 +- frontend/src/App.vue | 10 +- frontend/src/CalendarIntegration.test.ts | 8 ++ frontend/src/CalendarPanel.test.ts | 104 +++++++++++++++++++ frontend/src/CalendarPanel.vue | 65 ++++++++++++ frontend/src/MemoIntegration.test.ts | 6 +- frontend/src/calendar.css | 1 + frontend/src/lib/mvp-utils.test.ts | 2 + frontend/src/lib/mvp-utils.ts | 4 +- frontend/src/main.ts | 1 + frontend/src/style.css | 2 +- frontend/src/style.test.ts | 10 +- 14 files changed, 221 insertions(+), 28 deletions(-) create mode 100644 frontend/src/CalendarIntegration.test.ts create mode 100644 frontend/src/CalendarPanel.test.ts create mode 100644 frontend/src/CalendarPanel.vue create mode 100644 frontend/src/calendar.css diff --git a/frontend/e2e/backup-roundtrip.spec.ts b/frontend/e2e/backup-roundtrip.spec.ts index 517ff42..b6c2509 100644 --- a/frontend/e2e/backup-roundtrip.spec.ts +++ b/frontend/e2e/backup-roundtrip.spec.ts @@ -8,6 +8,11 @@ function bottomTab(page: Page, name: string) { return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true }) } +async function openSidebarView(page: Page, name: string) { + await page.getByRole('button', { name: /展开菜单|收起菜单/ }).click() + await page.locator('.sidebar').getByRole('button', { name, exact: true }).click() +} + async function csrf(request: APIRequestContext, baseURL: string) { const state = await request.storageState() return state.cookies.find(cookie => cookie.name === 'dodo_csrf' && baseURL.includes(cookie.domain))?.value @@ -50,7 +55,7 @@ test('complete ZIP backup preflights and replace-restores task, habit history, c expect(countdownResponse.ok()).toBeTruthy() await page.goto('/') - await bottomTab(page, '设置').click() + await openSidebarView(page, '设置') const downloadPromise = page.waitForEvent('download') await page.getByRole('button', { name: '导出 ZIP' }).click() const download = await downloadPromise diff --git a/frontend/e2e/mobile-ui.spec.ts b/frontend/e2e/mobile-ui.spec.ts index 9501eab..8f97495 100644 --- a/frontend/e2e/mobile-ui.spec.ts +++ b/frontend/e2e/mobile-ui.spec.ts @@ -6,11 +6,6 @@ function bottomTab(page: Page, name: string) { } async function openSettings(page: Page) { - const mobileTab = bottomTab(page, '设置') - if (await mobileTab.isVisible()) { - await mobileTab.click() - return - } const desktopSettings = page.getByRole('navigation', { name: '管理' }).getByRole('button', { name: '设置', exact: true }) const box = await desktopSettings.boundingBox() if (box && box.x + box.width > 0 && box.y + box.height > 0 && box.x < (await page.viewportSize())!.width) await desktopSettings.click() @@ -159,15 +154,21 @@ test('bottom navigation keeps its safe-area gap after dragging', async ({ page } }) test('all bottom destinations expose one active page and desktop layout stays unchanged', async ({ page }) => { + await page.route('**/api/v1/calendar-subscriptions', route => route.fulfill({ json: [] })) + await page.route('**/api/v1/calendar-events?*', route => route.fulfill({ json: { events: [], sources: [] } })) await page.goto('/') const navigation = page.getByRole('navigation', { name: '主要导航' }) - for (const label of ['今天', '习惯', '倒数日', '设置']) { - await bottomTab(page, label).click() - await expect(navigation.locator('[aria-current="page"]')).toHaveCount(1) - await expect(bottomTab(page, label)).toHaveAttribute('aria-current', 'page') + const mobile = (await page.viewportSize())!.width <= 930 + if (mobile) { + for (const label of ['今天', '习惯', '倒数日', '备忘录', '日历']) { + await bottomTab(page, label).click() + await expect(navigation.locator('[aria-current="page"]')).toHaveCount(1) + await expect(bottomTab(page, label)).toHaveAttribute('aria-current', 'page') + } + await bottomTab(page, '今天').click() + } else { + await expect(navigation).toBeHidden() } - - await bottomTab(page, '今天').click() await page.setViewportSize({ width: 1440, height: 900 }) const desktop = await page.locator('.shell').evaluate(element => { const shell = getComputedStyle(element) @@ -202,7 +203,9 @@ test('all bottom destinations expose one active page and desktop layout stays un test('settings match the approved paper-ledger geometry and action hierarchy', async ({ page }) => { await page.goto('/') await openSettings(page) - if ((await page.viewportSize())!.width <= 720) await expect(bottomTab(page, '设置')).toHaveAttribute('aria-current', 'page') + if ((await page.viewportSize())!.width <= 720) { + await expect(page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name: '设置', exact: true })).toHaveCount(0) + } const groups = page.locator('.settings-group') await expect(groups).toHaveCount(4) const layout = await page.locator('.settings-sections').evaluate(element => { diff --git a/frontend/e2e/ui-reduction-acceptance.spec.ts b/frontend/e2e/ui-reduction-acceptance.spec.ts index 6a928fd..8ab715a 100644 --- a/frontend/e2e/ui-reduction-acceptance.spec.ts +++ b/frontend/e2e/ui-reduction-acceptance.spec.ts @@ -130,7 +130,7 @@ test('task rows use the body for detail and Trash keeps distinct actions', async test('Settings removes intro/empty danger and places mode-specific restore risk copy correctly', async ({ page }, testInfo) => { await page.goto('/') - await bottomTab(page, '设置').click() + await openSidebarView(page, '设置') expect(await page.locator('.view-intro').count()).toBe(0) await expect(page.locator('.settings-group')).toHaveCount(4) expect(await page.locator('.settings-danger').count()).toBe(0) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4090fb6..2c3288b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -16,6 +16,7 @@ import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/arch import MvpPanel from './MvpPanel.vue' import CountdownPanel from './CountdownPanel.vue' import MemoPanel from './MemoPanel.vue' +import CalendarPanel from './CalendarPanel.vue' import FloatingAddButton from './components/FloatingAddButton.vue' import CompletedFilterPill from './components/CompletedFilterPill.vue' import CalendarPicker from './components/CalendarPicker.vue' @@ -32,7 +33,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 } -type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'settings' +type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'calendar' | 'settings' const initialized = ref(null) const authReady = ref(false) @@ -389,6 +390,7 @@ const activeName = computed(() => { if (activeView.value === 'habits') return '习惯' if (activeView.value === 'countdowns') return '倒数日' if (activeView.value === 'memos') return '备忘录' + if (activeView.value === 'calendar') return '日历' if (activeView.value === 'settings') return '设置与数据' return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱' }) @@ -409,7 +411,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'].includes(activeView.value)) return [] + if (['habits','settings','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() @@ -1613,6 +1615,7 @@ onUnmounted(() => { +
我的清单
@@ -1684,6 +1687,7 @@ onUnmounted(() => { +