From 7140102aeb1d7665f20d4023a17be3ccd0a9a367 Mon Sep 17 00:00:00 2001 From: bboysoul Date: Sun, 20 Sep 2026 17:23:02 +0800 Subject: [PATCH] feat: redesign calendar around weekly focus --- frontend/e2e/mobile-ui.spec.ts | 20 ++++++++ frontend/src/CalendarPanel.test.ts | 75 +++++++++++++++++++++++++----- frontend/src/CalendarPanel.vue | 32 ++++++++----- frontend/src/calendar.css | 2 +- 4 files changed, 105 insertions(+), 24 deletions(-) diff --git a/frontend/e2e/mobile-ui.spec.ts b/frontend/e2e/mobile-ui.spec.ts index 0dff928..d113cf7 100644 --- a/frontend/e2e/mobile-ui.spec.ts +++ b/frontend/e2e/mobile-ui.spec.ts @@ -200,6 +200,26 @@ test('all bottom destinations expose one active page and desktop layout stays un expect(desktopBottomGap).toBe(84) }) +test('calendar week focus keeps seven usable day controls without page overflow', async ({ page }) => { + await page.route('**/api/v1/calendar-subscriptions', route => route.fulfill({ json: [{ id:'calendar-source', name:'工作', url:'https://example.com/work.ics', color:'#f15a29', enabled:true, refreshed_at:null, last_error:null, stale:false }] })) + await page.route('**/api/v1/calendar-events?*', route => route.fulfill({ json: { events: [], sources: [] } })) + await page.goto('/') + const mobile = (await page.viewportSize())!.width <= 930 + if (mobile) await bottomTab(page, '日历订阅').click() + else await page.locator('.primary-nav').getByRole('button', { name: '日历订阅', exact: true }).click() + const strip = page.getByRole('group', { name: '选择日期' }) + await expect(strip).toBeVisible() + await expect(strip.locator('.calendar-week-day')).toHaveCount(7) + const metrics = await strip.evaluate(element => ({ + documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + stripOverflow: element.scrollWidth > element.clientWidth, + buttons: [...element.querySelectorAll('.calendar-week-day')].map(button => ({ width: button.getBoundingClientRect().width, height: button.getBoundingClientRect().height })), + })) + expect(metrics.documentOverflow).toBe(0) + expect(metrics.buttons.every(button => button.width >= 44 && button.height >= 44)).toBeTruthy() + if ((await page.viewportSize())!.width <= 390) expect(metrics.stripOverflow).toBeTruthy() +}) + test('settings match the approved paper-ledger geometry and action hierarchy', async ({ page }) => { await page.goto('/') await openSettings(page) diff --git a/frontend/src/CalendarPanel.test.ts b/frontend/src/CalendarPanel.test.ts index 2c56199..b334d9c 100644 --- a/frontend/src/CalendarPanel.test.ts +++ b/frontend/src/CalendarPanel.test.ts @@ -6,14 +6,55 @@ const cleanups: Array<() => void> = [] const subscriptions = [{ id:'s1', name:'工作', url:'https://example.com/work.ics', color:'#f15a29', enabled:true, refreshed_at:'2026-09-20T08:00:00Z', last_error:null, stale:false }] const events = [{ id:'e1', title:'发布会', starts_at:'2026-09-22T02:00:00Z', ends_at:'2026-09-22T03:00:00Z', all_day:false, description:'产品发布', location:'会议室', source_id:'s1', source_name:'工作', color:'#f15a29' }] const json = (value:unknown, status=200) => new Response(JSON.stringify(value), { status, headers:{'content-type':'application/json'} }) -async function flush(){ await Promise.resolve(); await new Promise(r=>setTimeout(r,0)); await nextTick() } +async function flush(){ await Promise.resolve(); await new Promise(r=>vi.isFakeTimers()?vi.advanceTimersByTimeAsync(0).then(()=>r(undefined)):setTimeout(r,0)); await nextTick() } async function mount(fetchMock:ReturnType){ vi.stubGlobal('fetch',fetchMock); const host=document.createElement('div');document.body.append(host);const notices:string[]=[];const app=createApp(()=>h(CalendarPanel,{onNotice:(v:string)=>notices.push(v)}));app.mount(host);cleanups.push(()=>{app.unmount();host.remove()});await flush();return {host,notices} } -afterEach(()=>{cleanups.splice(0).forEach(fn=>fn());vi.unstubAllGlobals();vi.restoreAllMocks()}) +afterEach(()=>{cleanups.splice(0).forEach(fn=>fn());vi.useRealTimers();vi.unstubAllGlobals();vi.restoreAllMocks()}) describe('CalendarPanel',()=>{ - it('loads subscriptions and the visible month then filters and opens event detail',async()=>{ + it('focuses the current week and shows only the selected day events',async()=>{ + vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10)) + const weekEvents=[ + {...events[0],id:'today',title:'今天日程',starts_at:new Date(2026,8,20,9).toISOString(),ends_at:new Date(2026,8,20,10).toISOString()}, + {...events[0],id:'monday',title:'周一日程',starts_at:new Date(2026,8,14,9).toISOString(),ends_at:new Date(2026,8,14,10).toISOString()}, + ] + const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:weekEvents,sources:[]}:subscriptions))) + const {host}=await mount(fetchMock) + expect(host.querySelectorAll('.calendar-week-day')).toHaveLength(7) + expect(host.querySelector('.calendar-week-day[aria-pressed="true"]')?.textContent).toContain('20') + expect(host.textContent).toContain('今天日程');expect(host.textContent).not.toContain('周一日程') + host.querySelectorAll('.calendar-week-day')[0].click();await nextTick() + expect(host.textContent).toContain('周一日程');expect(host.textContent).not.toContain('今天日程') + vi.useRealTimers() + }) + it('moves one week at a time and can return to today',async()=>{ + vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10)) + const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:[],sources:[]}:subscriptions))) + const {host}=await mount(fetchMock) + host.querySelector('[aria-label="下一周"]')!.click();await flush() + expect(host.querySelector('.calendar-week-day[aria-pressed="true"]')?.textContent).toContain('21') + host.querySelector('[aria-label="回到今天"]')!.click();await flush() + expect(host.querySelector('.calendar-week-day[aria-pressed="true"]')?.textContent).toContain('20') + vi.useRealTimers() + }) + it('navigates to the exact next Monday across month and year boundaries',async()=>{ + vi.useFakeTimers();vi.setSystemTime(new Date(2026,11,31,10)) + const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:[],sources:[]}:subscriptions))) + const {host}=await mount(fetchMock) + host.querySelector('[aria-label="下一周"]')!.click();await flush() + expect(host.querySelector('.calendar-week-day[aria-pressed="true"]')?.getAttribute('data-day')).toBe('2027-01-04') + expect(host.textContent).toContain('2027年1月') + const eventCalls=fetchMock.mock.calls.filter(([url])=>String(url).includes('calendar-events')) + const latest=new URL(String(eventCalls.at(-1)?.[0]),'http://localhost').searchParams + expect(latest.get('start')).toBe(new Date(2027,0,4).toISOString()) + expect(latest.get('end')).toBe(new Date(2027,0,11).toISOString()) + vi.useRealTimers() + }) + it('loads subscriptions and the visible week then filters and opens event detail',async()=>{ + vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10)) const fetchMock=vi.fn((url:string)=>url.includes('calendar-events')?Promise.resolve(json({events,sources:[{id:'s1',name:'工作',stale:false}]})):Promise.resolve(json(subscriptions))) const {host}=await mount(fetchMock) + host.querySelector('[aria-label="下一周"]')!.click();await flush() + host.querySelector('[data-day="2026-09-22"]')!.click();await nextTick() const eventsUrl=String(fetchMock.mock.calls.find(([url])=>String(url).includes('calendar-events'))?.[0]) const params=new URL(eventsUrl,'http://localhost').searchParams expect(params.get('start')).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) @@ -23,39 +64,49 @@ describe('CalendarPanel',()=>{ expect(document.querySelector('.calendar-event-detail')?.textContent).toContain('产品发布') host.querySelector('input[aria-label="筛选工作"]')!.click();await nextTick() expect(host.querySelector('[data-event-id="e1"]')).toBeNull() + vi.useRealTimers() }) it('filters duplicate source names by source id',async()=>{ + vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10)) const duplicateSubscriptions=[subscriptions[0],{...subscriptions[0],id:'s2',url:'https://example.com/personal.ics',color:'#334455'}] const duplicateEvents=[events[0],{...events[0],id:'e2',title:'私人日程',source_id:'s2',color:'#334455'}] const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:duplicateEvents,sources:[]}:duplicateSubscriptions))) const {host}=await mount(fetchMock) + host.querySelector('[aria-label="下一周"]')!.click();await flush() + host.querySelector('[data-day="2026-09-22"]')!.click();await nextTick() host.querySelectorAll('input[aria-label="筛选工作"]')[0].click();await nextTick() expect(host.querySelector('[data-event-id="e1"]')).toBeNull() expect(host.querySelector('[data-event-id="e2"]')).not.toBeNull() + vi.useRealTimers() }) it('groups UTC events by the browser-local calendar day',async()=>{ + vi.useFakeTimers();vi.setSystemTime(new Date(2026,8,20,10)) const boundary=[{...events[0],id:'boundary',starts_at:'2026-09-21T23:30:00Z',ends_at:'2026-09-22T00:30:00Z'}] const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:boundary,sources:[]}:subscriptions))) const {host}=await mount(fetchMock) + const boundaryDay=new Date(boundary[0].starts_at) + if(!host.querySelector(`[data-day="${boundaryDay.getFullYear()}-${String(boundaryDay.getMonth()+1).padStart(2,'0')}-${String(boundaryDay.getDate()).padStart(2,'0')}"]`)){host.querySelector('[aria-label="下一周"]')!.click();await flush()} + host.querySelector(`[data-day="${boundaryDay.getFullYear()}-${String(boundaryDay.getMonth()+1).padStart(2,'0')}-${String(boundaryDay.getDate()).padStart(2,'0')}"]`)!.click();await nextTick() const expected=new Intl.DateTimeFormat('zh-CN',{month:'long',day:'numeric',weekday:'short'}).format(new Date(boundary[0].starts_at)) - expect(host.querySelector('.calendar-agenda h2')?.textContent).toBe(expected) + expect(host.querySelector('.calendar-agenda h2')?.textContent).toContain(expected) + vi.useRealTimers() }) - it('keeps the newest month response when requests finish out of order',async()=>{ + it('keeps the newest week response when requests finish out of order',async()=>{ const pending:Array<{url:string;resolve:(response:Response)=>void}>=[] const fetchMock=vi.fn((url:string)=>String(url).includes('calendar-events')?new Promise(resolve=>pending.push({url:String(url),resolve})):Promise.resolve(json(subscriptions))) const {host}=await mount(fetchMock) expect(pending).toHaveLength(1) - host.querySelector('[aria-label="下个月"]')!.click();await nextTick() + host.querySelector('[aria-label="下一周"]')!.click();await nextTick() expect(pending).toHaveLength(2) - pending[1].resolve(json({events:[{...events[0],id:'new',title:'新月份'}],sources:[]}));await flush() - pending[0].resolve(json({events:[{...events[0],id:'old',title:'旧月份'}],sources:[]}));await flush() - expect(host.textContent).toContain('新月份') - expect(host.textContent).not.toContain('旧月份') + pending[1].resolve(json({events:[{...events[0],id:'new',title:'新一周',starts_at:'2026-09-21T02:00:00Z'}],sources:[]}));await flush() + pending[0].resolve(json({events:[{...events[0],id:'old',title:'旧一周'}],sources:[]}));await flush() + expect(host.textContent).toContain('新一周') + expect(host.textContent).not.toContain('旧一周') }) - it('supports month navigation and today',async()=>{ + it('supports week navigation and today',async()=>{ const fetchMock=vi.fn((url:string)=>Promise.resolve(json(url.includes('calendar-events')?{events:[],sources:[]}:subscriptions))) const {host}=await mount(fetchMock);const before=fetchMock.mock.calls.length - host.querySelector('[aria-label="下个月"]')!.click();await flush() + host.querySelector('[aria-label="下一周"]')!.click();await flush() host.querySelector('[aria-label="回到今天"]')!.click();await flush() expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(before+2) }) diff --git a/frontend/src/CalendarPanel.vue b/frontend/src/CalendarPanel.vue index 9899b6e..0eb986d 100644 --- a/frontend/src/CalendarPanel.vue +++ b/frontend/src/CalendarPanel.vue @@ -13,20 +13,28 @@ type Form = { name:string; url:string; color:string; enabled:boolean } const emit=defineEmits<{notice:[message:string]}>() const subscriptions=ref([]),events=ref([]),loading=ref(false),error=ref('') let eventsRequestGeneration=0 -const month=ref(new Date(new Date().getFullYear(),new Date().getMonth(),1)),hiddenSources=ref(new Set()) +const selectedDay=ref(new Date(new Date().getFullYear(),new Date().getMonth(),new Date().getDate())),hiddenSources=ref(new Set()) const selected=ref(null),manageOpen=ref(false),formOpen=ref(false),editing=ref(null),busyId=ref('') const form=ref
({name:'',url:'',color:'#f15a29',enabled:true}) const appDialog=ref<{show:(options:AppDialogOptions)=>Promise}|null>(null) const request=async(path:string,options:RequestInit={})=>{const headers:Record={...(options.headers as Record||{})};if(options.body)headers['Content-Type']='application/json';Object.assign(headers,csrfHeader(options.method));const response=await fetch('/api/v1'+path,{credentials:'include',...options,headers});if(!response.ok){const body=await response.json().catch(()=>({}));throw new Error(formatApiErrorDetail((body as {detail?:unknown}).detail??body))}return response.status===204?null:response.json()} const key=(date:Date)=>`${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}` -const range=computed(()=>{const start=new Date(month.value.getFullYear(),month.value.getMonth(),1);const end=new Date(month.value.getFullYear(),month.value.getMonth()+1,1);return{start:start.toISOString(),end:end.toISOString()}}) -const monthLabel=computed(()=>new Intl.DateTimeFormat('zh-CN',{year:'numeric',month:'long'}).format(month.value)) +const startOfWeek=(value:Date)=>{const date=new Date(value.getFullYear(),value.getMonth(),value.getDate());date.setDate(date.getDate()-((date.getDay()+6)%7));return date} +const weekStart=computed(()=>startOfWeek(selectedDay.value)) +const weekDays=computed(()=>Array.from({length:7},(_,index)=>{const date=new Date(weekStart.value);date.setDate(date.getDate()+index);return date})) +const range=computed(()=>{const start=weekStart.value;const end=new Date(start);end.setDate(end.getDate()+7);return{start:start.toISOString(),end:end.toISOString()}}) +const weekLabel=computed(()=>{const start=weekDays.value[0],end=weekDays.value[6];if(start.getFullYear()!==end.getFullYear())return `${start.getFullYear()}年${start.getMonth()+1}月${start.getDate()}日 - ${end.getFullYear()}年${end.getMonth()+1}月${end.getDate()}日`;return start.getMonth()===end.getMonth()?`${start.getFullYear()}年${start.getMonth()+1}月`:`${start.getFullYear()}年${start.getMonth()+1}月${start.getDate()}日 - ${end.getMonth()+1}月${end.getDate()}日`}) const eventStart=(event:CalendarEvent)=>event.starts_at const eventEnd=(event:CalendarEvent)=>event.ends_at const eventKey=(event:CalendarEvent)=>event.id -const visibleEvents=computed(()=>events.value.filter(event=>!hiddenSources.value.has(event.source_id)).sort((a,b)=>eventStart(a).localeCompare(eventStart(b)))) +const filteredEvents=computed(()=>events.value.filter(event=>!hiddenSources.value.has(event.source_id)).sort((a,b)=>eventStart(a).localeCompare(eventStart(b)))) const localDayKey=(value:string)=>{const date=new Date(value);return Number.isNaN(date.getTime())?value.slice(0,10):key(date)} -const groupedEvents=computed(()=>{const groups=new Map();for(const event of visibleEvents.value){const day=localDayKey(eventStart(event));groups.set(day,[...(groups.get(day)??[]),event])}return [...groups].map(([day,items])=>({day,items}))}) +const selectedDayKey=computed(()=>key(selectedDay.value)) +const visibleEvents=computed(()=>filteredEvents.value.filter(event=>localDayKey(eventStart(event))===selectedDayKey.value)) +const selectedDayLabel=computed(()=>displayDay(selectedDayKey.value)) +const dayEventCount=(date:Date)=>filteredEvents.value.filter(event=>localDayKey(eventStart(event))===key(date)).length +const isToday=(date:Date)=>key(date)===key(new Date()) +const weekDayLabel=(date:Date)=>new Intl.DateTimeFormat('zh-CN',{weekday:'short'}).format(date).replace('周','') const eventTitle=(event:CalendarEvent)=>event.title||'未命名事件' const eventSource=(event:CalendarEvent)=>event.source_name||'日历' const eventColor=(event:CalendarEvent)=>event.color||'#f15a29' @@ -35,8 +43,9 @@ function displayTime(event:CalendarEvent){if(event.all_day)return'全天';const async function loadSubscriptions(){subscriptions.value=await request('/calendar-subscriptions') as Subscription[]} async function loadEvents(){const generation=++eventsRequestGeneration;const requestedRange=range.value;const data=await request(`/calendar-events?start=${encodeURIComponent(requestedRange.start)}&end=${encodeURIComponent(requestedRange.end)}`) as EventResponse;if(generation===eventsRequestGeneration)events.value=data.events} async function load(){loading.value=true;error.value='';try{await Promise.all([loadSubscriptions(),loadEvents()])}catch(reason){error.value=reason instanceof Error?reason.message:'日历载入失败'}finally{loading.value=false}} -async function moveMonth(offset:number){month.value=new Date(month.value.getFullYear(),month.value.getMonth()+offset,1);await loadEvents().catch(reason=>error.value=reason instanceof Error?reason.message:'事件载入失败')} -async function today(){const now=new Date();month.value=new Date(now.getFullYear(),now.getMonth(),1);await loadEvents().catch(reason=>error.value=reason instanceof Error?reason.message:'事件载入失败')} +async function moveWeek(offset:number){const next=new Date(weekStart.value);next.setDate(next.getDate()+offset*7);selectedDay.value=next;await loadEvents().catch(reason=>error.value=reason instanceof Error?reason.message:'事件载入失败')} +async function today(){const now=new Date();selectedDay.value=new Date(now.getFullYear(),now.getMonth(),now.getDate());await loadEvents().catch(reason=>error.value=reason instanceof Error?reason.message:'事件载入失败')} +function selectDay(date:Date){selectedDay.value=new Date(date.getFullYear(),date.getMonth(),date.getDate())} function toggleFilter(id:string){const next=new Set(hiddenSources.value);next.has(id)?next.delete(id):next.add(id);hiddenSources.value=next} function openCreate(){editing.value=null;form.value={name:'',url:'',color:'#f15a29',enabled:true};formOpen.value=true} function openEdit(item:Subscription){editing.value=item;form.value={name:item.name,url:item.url,color:item.color||'#f15a29',enabled:item.enabled};formOpen.value=true} @@ -50,12 +59,13 @@ onMounted(()=>void load())