Add temporal grouping to event list (Today/This Week/Next Week/Later/Past)
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m9s
CI / build-and-publish (push) Has been skipped

Group events into five temporal sections with section headers, date subheaders,
and context-aware time display (clock time for upcoming, relative for past).
Includes new useEventGrouping composable, SectionHeader and DateSubheader
components, full unit and E2E test coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 17:26:58 +01:00
parent 373f3671f6
commit a52d0cd1d3
18 changed files with 1325 additions and 47 deletions

View File

@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import DateSubheader from '../DateSubheader.vue'
describe('DateSubheader', () => {
it('renders the date label as an h3', () => {
const wrapper = mount(DateSubheader, { props: { label: 'Wed, 12 Mar' } })
const h3 = wrapper.find('h3')
expect(h3.exists()).toBe(true)
expect(h3.text()).toBe('Wed, 12 Mar')
})
it('applies the date-subheader class', () => {
const wrapper = mount(DateSubheader, { props: { label: 'Fri, 14 Mar' } })
expect(wrapper.find('.date-subheader').exists()).toBe(true)
})
})

View File

@@ -73,4 +73,28 @@ describe('EventCard', () => {
await wrapper.find('.event-card__delete').trigger('click')
expect(wrapper.emitted('delete')).toEqual([['abc-123']])
})
it('displays clock time when timeDisplayMode is clock', () => {
const wrapper = mountCard({
timeDisplayMode: 'clock',
dateTime: '2026-03-11T18:30:00',
})
const timeText = wrapper.find('.event-card__time').text()
// Locale-dependent: could be "18:30" or "06:30 PM"
expect(timeText).toMatch(/(?:18.30|6.30\s*PM)/i)
})
it('displays relative time when timeDisplayMode is relative', () => {
const wrapper = mountCard({
relativeTime: '3 days ago',
timeDisplayMode: 'relative',
dateTime: '2026-03-08T10:00:00',
})
expect(wrapper.find('.event-card__time').text()).toBe('3 days ago')
})
it('falls back to relativeTime when timeDisplayMode is not set', () => {
const wrapper = mountCard({ relativeTime: 'in 3 days' })
expect(wrapper.find('.event-card__time').text()).toBe('in 3 days')
})
})

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import EventList from '../EventList.vue'
@@ -11,10 +11,15 @@ const router = createRouter({
],
})
// Fixed "now": Wednesday, 2026-03-11 12:00
const NOW = new Date(2026, 2, 11, 12, 0, 0)
const mockEvents = [
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2025-01-01T10:00:00Z', expiryDate: '' },
{ eventToken: 'future-1', title: 'Future Event', dateTime: '2027-06-15T10:00:00Z', expiryDate: '' },
{ eventToken: 'future-2', title: 'Soon Event', dateTime: '2027-01-01T10:00:00Z', expiryDate: '' },
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2026-03-01T10:00:00', expiryDate: '' },
{ eventToken: 'later-1', title: 'Later Event', dateTime: '2027-06-15T10:00:00', expiryDate: '' },
{ eventToken: 'today-1', title: 'Today Event', dateTime: '2026-03-11T18:00:00', expiryDate: '' },
{ eventToken: 'week-1', title: 'This Week Event', dateTime: '2026-03-13T10:00:00', expiryDate: '' },
{ eventToken: 'nextweek-1', title: 'Next Week Event', dateTime: '2026-03-16T10:00:00', expiryDate: '' },
]
vi.mock('../../composables/useEventStorage', () => ({
@@ -33,9 +38,12 @@ vi.mock('../../composables/useEventStorage', () => ({
vi.mock('../../composables/useRelativeTime', () => ({
formatRelativeTime: (dateTime: string) => {
if (dateTime.startsWith('2025')) return '1 year ago'
if (dateTime.includes('03-01')) return '10 days ago'
if (dateTime.includes('06-15')) return 'in 1 year'
return 'in 10 months'
if (dateTime.includes('03-11')) return 'in 6 hours'
if (dateTime.includes('03-13')) return 'in 2 days'
if (dateTime.includes('03-16')) return 'in 5 days'
return 'sometime'
},
}))
@@ -48,32 +56,85 @@ function mountList() {
describe('EventList', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-03-08T12:00:00Z'))
vi.setSystemTime(NOW)
})
it('renders all valid events', () => {
afterEach(() => {
vi.useRealTimers()
})
it('renders section headers for each non-empty section', () => {
const wrapper = mountList()
const headers = wrapper.findAll('.section-header')
expect(headers).toHaveLength(5)
expect(headers[0]!.text()).toBe('Today')
expect(headers[1]!.text()).toBe('This Week')
expect(headers[2]!.text()).toBe('Next Week')
expect(headers[3]!.text()).toBe('Later')
expect(headers[4]!.text()).toBe('Past')
})
it('renders events within their correct sections', () => {
const wrapper = mountList()
const sections = wrapper.findAll('.event-section')
expect(sections).toHaveLength(5)
expect(sections[0]!.text()).toContain('Today Event')
expect(sections[1]!.text()).toContain('This Week Event')
expect(sections[2]!.text()).toContain('Next Week Event')
expect(sections[3]!.text()).toContain('Later Event')
expect(sections[4]!.text()).toContain('Past Event')
})
it('renders all valid events as cards', () => {
const wrapper = mountList()
const cards = wrapper.findAll('.event-card')
expect(cards).toHaveLength(3)
})
it('sorts upcoming events before past events', () => {
const wrapper = mountList()
const titles = wrapper.findAll('.event-card__title').map((el) => el.text())
// Upcoming events first (sorted ascending), then past events
expect(titles[0]).toBe('Soon Event')
expect(titles[1]).toBe('Future Event')
expect(titles[2]).toBe('Past Event')
expect(cards).toHaveLength(5)
})
it('marks past events with isPast class', () => {
const wrapper = mountList()
const cards = wrapper.findAll('.event-card')
expect(cards).toHaveLength(3)
// Last card should be past
expect(cards[2]!.classes()).toContain('event-card--past')
// First two should not be past
const pastSection = wrapper.findAll('.event-section')[4]!
const pastCards = pastSection.findAll('.event-card')
expect(pastCards).toHaveLength(1)
expect(pastCards[0]!.classes()).toContain('event-card--past')
})
it('does not mark non-past events with isPast class', () => {
const wrapper = mountList()
const todaySection = wrapper.findAll('.event-section')[0]!
const cards = todaySection.findAll('.event-card')
expect(cards[0]!.classes()).not.toContain('event-card--past')
expect(cards[1]!.classes()).not.toContain('event-card--past')
})
it('sections have aria-label attributes', () => {
const wrapper = mountList()
const sections = wrapper.findAll('section')
expect(sections[0]!.attributes('aria-label')).toBe('Today')
expect(sections[1]!.attributes('aria-label')).toBe('This Week')
expect(sections[2]!.attributes('aria-label')).toBe('Next Week')
expect(sections[3]!.attributes('aria-label')).toBe('Later')
expect(sections[4]!.attributes('aria-label')).toBe('Past')
})
it('does not render date subheader in "Today" section', () => {
const wrapper = mountList()
const todaySection = wrapper.findAll('.event-section')[0]!
expect(todaySection.find('.date-subheader').exists()).toBe(false)
})
it('renders date subheaders in non-today sections', () => {
const wrapper = mountList()
const thisWeekSection = wrapper.findAll('.event-section')[1]!
expect(thisWeekSection.find('.date-subheader').exists()).toBe(true)
const nextWeekSection = wrapper.findAll('.event-section')[2]!
expect(nextWeekSection.find('.date-subheader').exists()).toBe(true)
const laterSection = wrapper.findAll('.event-section')[3]!
expect(laterSection.find('.date-subheader').exists()).toBe(true)
const pastSection = wrapper.findAll('.event-section')[4]!
expect(pastSection.find('.date-subheader').exists()).toBe(true)
})
})

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import SectionHeader from '../SectionHeader.vue'
describe('SectionHeader', () => {
it('renders the section label as an h2', () => {
const wrapper = mount(SectionHeader, { props: { label: 'Today' } })
const h2 = wrapper.find('h2')
expect(h2.exists()).toBe(true)
expect(h2.text()).toBe('Today')
})
it('does not apply emphasized class by default', () => {
const wrapper = mount(SectionHeader, { props: { label: 'Later' } })
expect(wrapper.find('.section-header--emphasized').exists()).toBe(false)
})
it('applies emphasized class when emphasized prop is true', () => {
const wrapper = mount(SectionHeader, { props: { label: 'Today', emphasized: true } })
expect(wrapper.find('.section-header--emphasized').exists()).toBe(true)
})
it('does not apply emphasized class when emphasized prop is false', () => {
const wrapper = mount(SectionHeader, { props: { label: 'Past', emphasized: false } })
expect(wrapper.find('.section-header--emphasized').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useEventGrouping } from '../../composables/useEventGrouping'
import type { StoredEvent } from '../../composables/useEventStorage'
function makeEvent(overrides: Partial<StoredEvent> & { dateTime: string }): StoredEvent {
return {
eventToken: `evt-${Math.random().toString(36).slice(2, 8)}`,
title: 'Test Event',
expiryDate: '',
...overrides,
}
}
describe('useEventGrouping', () => {
// Fixed "now": Wednesday, 2026-03-11 12:00 local
const NOW = new Date(2026, 2, 11, 12, 0, 0)
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
afterEach(() => {
vi.useRealTimers()
})
it('returns empty array when no events', () => {
const sections = useEventGrouping([], NOW)
expect(sections).toEqual([])
})
it('classifies a today event into "today" section', () => {
const event = makeEvent({ dateTime: '2026-03-11T18:30:00' })
const sections = useEventGrouping([event], NOW)
expect(sections).toHaveLength(1)
expect(sections[0]!.key).toBe('today')
expect(sections[0]!.label).toBe('Today')
expect(sections[0]!.dateGroups[0]!.events).toHaveLength(1)
})
it('classifies events into all five sections', () => {
const events = [
makeEvent({ title: 'Today', dateTime: '2026-03-11T10:00:00' }),
makeEvent({ title: 'This Week', dateTime: '2026-03-13T10:00:00' }), // Friday (same week)
makeEvent({ title: 'Next Week', dateTime: '2026-03-16T10:00:00' }), // Monday next week
makeEvent({ title: 'Later', dateTime: '2026-03-30T10:00:00' }), // far future
makeEvent({ title: 'Past', dateTime: '2026-03-09T10:00:00' }), // Monday (past)
]
const sections = useEventGrouping(events, NOW)
expect(sections).toHaveLength(5)
expect(sections[0]!.key).toBe('today')
expect(sections[1]!.key).toBe('thisWeek')
expect(sections[2]!.key).toBe('nextWeek')
expect(sections[3]!.key).toBe('later')
expect(sections[4]!.key).toBe('past')
})
it('omits empty sections', () => {
const events = [
makeEvent({ title: 'Today', dateTime: '2026-03-11T10:00:00' }),
makeEvent({ title: 'Past', dateTime: '2026-03-01T10:00:00' }),
]
const sections = useEventGrouping(events, NOW)
expect(sections).toHaveLength(2)
expect(sections[0]!.key).toBe('today')
expect(sections[1]!.key).toBe('past')
})
it('sorts upcoming events ascending by time', () => {
const events = [
makeEvent({ title: 'Later', dateTime: '2026-03-11T20:00:00' }),
makeEvent({ title: 'Earlier', dateTime: '2026-03-11T08:00:00' }),
]
const sections = useEventGrouping(events, NOW)
const todayEvents = sections[0]!.dateGroups[0]!.events
expect(todayEvents[0]!.title).toBe('Earlier')
expect(todayEvents[1]!.title).toBe('Later')
})
it('sorts past events descending by time (most recent first)', () => {
const events = [
makeEvent({ title: 'Older', dateTime: '2026-03-01T10:00:00' }),
makeEvent({ title: 'Newer', dateTime: '2026-03-09T10:00:00' }),
]
const sections = useEventGrouping(events, NOW)
const pastEvents = sections[0]!.dateGroups
expect(pastEvents[0]!.events[0]!.title).toBe('Newer')
expect(pastEvents[1]!.events[0]!.title).toBe('Older')
})
it('groups events by date within a section', () => {
const events = [
makeEvent({ title: 'Fri AM', dateTime: '2026-03-13T09:00:00' }),
makeEvent({ title: 'Fri PM', dateTime: '2026-03-13T18:00:00' }),
makeEvent({ title: 'Sat', dateTime: '2026-03-14T12:00:00' }),
]
const sections = useEventGrouping(events, NOW)
expect(sections[0]!.key).toBe('thisWeek')
const dateGroups = sections[0]!.dateGroups
expect(dateGroups).toHaveLength(2) // Friday and Saturday
expect(dateGroups[0]!.events).toHaveLength(2) // Two Friday events
expect(dateGroups[1]!.events).toHaveLength(1) // One Saturday event
})
it('sets showSubheader=false for "today" section', () => {
const event = makeEvent({ dateTime: '2026-03-11T18:00:00' })
const sections = useEventGrouping([event], NOW)
expect(sections[0]!.dateGroups[0]!.showSubheader).toBe(false)
})
it('sets showSubheader=true for non-today sections', () => {
const events = [
makeEvent({ dateTime: '2026-03-13T10:00:00' }), // thisWeek
makeEvent({ dateTime: '2026-03-30T10:00:00' }), // later (beyond next week)
makeEvent({ dateTime: '2026-03-01T10:00:00' }), // past
]
const sections = useEventGrouping(events, NOW)
for (const section of sections) {
for (const group of section.dateGroups) {
expect(group.showSubheader).toBe(true)
}
}
})
it('sets emphasized=true only for "today" section', () => {
const events = [
makeEvent({ dateTime: '2026-03-11T18:00:00' }),
makeEvent({ dateTime: '2026-03-30T10:00:00' }),
]
const sections = useEventGrouping(events, NOW)
expect(sections[0]!.emphasized).toBe(true) // today
expect(sections[1]!.emphasized).toBe(false) // later
})
it('on Sunday, tomorrow (Monday) goes to "nextWeek" not "thisWeek"', () => {
// Sunday 2026-03-15
const sunday = new Date(2026, 2, 15, 12, 0, 0)
const mondayEvent = makeEvent({ title: 'Monday', dateTime: '2026-03-16T10:00:00' })
const sections = useEventGrouping([mondayEvent], sunday)
expect(sections).toHaveLength(1)
expect(sections[0]!.key).toBe('nextWeek')
})
it('on Sunday, today events still appear under "today"', () => {
const sunday = new Date(2026, 2, 15, 12, 0, 0)
const todayEvent = makeEvent({ dateTime: '2026-03-15T18:00:00' })
const sections = useEventGrouping([todayEvent], sunday)
expect(sections[0]!.key).toBe('today')
})
it('dateGroup labels are formatted via Intl', () => {
const event = makeEvent({ dateTime: '2026-03-13T10:00:00' }) // Friday
const sections = useEventGrouping([event], NOW)
const label = sections[0]!.dateGroups[0]!.label
// The exact format depends on locale, but should contain the day number
expect(label).toContain('13')
})
})