diff --git a/CLAUDE.md b/CLAUDE.md index ffc6b4a..0dce372 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,8 @@ The following skills are available and should be used for their respective purpo ## Active Technologies - Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript (007-view-event) - PostgreSQL (JPA via Spring Data, Liquibase migrations) (007-view-event) +- TypeScript 5.9 (frontend only) + Vue 3, Vue Router 5 (existing — no additions) (010-event-list-grouping) +- localStorage via `useEventStorage.ts` composable (existing — no changes) (010-event-list-grouping) ## Recent Changes - 007-view-event: Added Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript diff --git a/frontend/e2e/home-events.spec.ts b/frontend/e2e/home-events.spec.ts index 3b71b04..d8a4cfc 100644 --- a/frontend/e2e/home-events.spec.ts +++ b/frontend/e2e/home-events.spec.ts @@ -191,6 +191,133 @@ test.describe('FAB: Create Event Button', () => { }) }) +test.describe('Temporal Grouping: Section Headers', () => { + test('events are distributed under correct section headers', async ({ page }) => { + // Use dates relative to "now" to ensure correct section assignment + const now = new Date() + const todayEvent: StoredEvent = { + eventToken: 'today-1', + title: 'Today Standup', + dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0, 0).toISOString(), + expiryDate: '', + } + const laterEvent: StoredEvent = { + eventToken: 'later-1', + title: 'Future Conference', + dateTime: new Date(now.getFullYear() + 1, 0, 15, 10, 0, 0).toISOString(), + expiryDate: '', + } + await page.addInitScript(seedEvents([todayEvent, laterEvent, pastEvent])) + await page.goto('/') + + // Verify section headers appear + await expect(page.getByRole('heading', { name: 'Today', level: 2 })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Later', level: 2 })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Past', level: 2 })).toBeVisible() + + // Events are in the correct sections + const sections = page.locator('.event-section') + const todaySection = sections.filter({ has: page.getByRole('heading', { name: 'Today', level: 2 }) }) + await expect(todaySection.getByText('Today Standup')).toBeVisible() + + const laterSection = sections.filter({ has: page.getByRole('heading', { name: 'Later', level: 2 }) }) + await expect(laterSection.getByText('Future Conference')).toBeVisible() + + const pastSection = sections.filter({ has: page.getByRole('heading', { name: 'Past', level: 2 }) }) + await expect(pastSection.getByText('New Year Party')).toBeVisible() + }) + + test('empty sections are not rendered', async ({ page }) => { + // Only a past event — no Today, This Week, or Later sections + await page.addInitScript(seedEvents([pastEvent])) + await page.goto('/') + + await expect(page.getByRole('heading', { name: 'Past', level: 2 })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Today', level: 2 })).toHaveCount(0) + await expect(page.getByRole('heading', { name: 'This Week', level: 2 })).toHaveCount(0) + await expect(page.getByRole('heading', { name: 'Next Week', level: 2 })).toHaveCount(0) + await expect(page.getByRole('heading', { name: 'Later', level: 2 })).toHaveCount(0) + }) + + test('Today section header has emphasis CSS class', async ({ page }) => { + const now = new Date() + const todayEvent: StoredEvent = { + eventToken: 'today-emph', + title: 'Emphasis Test', + dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 20, 0, 0).toISOString(), + expiryDate: '', + } + await page.addInitScript(seedEvents([todayEvent])) + await page.goto('/') + + const todayHeader = page.getByRole('heading', { name: 'Today', level: 2 }) + await expect(todayHeader).toHaveClass(/section-header--emphasized/) + }) +}) + +test.describe('Temporal Grouping: Date Subheaders', () => { + test('no date subheader in Today section', async ({ page }) => { + const now = new Date() + const todayEvent: StoredEvent = { + eventToken: 'today-sub', + title: 'No Subheader Test', + dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 19, 0, 0).toISOString(), + expiryDate: '', + } + await page.addInitScript(seedEvents([todayEvent])) + await page.goto('/') + + const todaySection = page.locator('.event-section').filter({ + has: page.getByRole('heading', { name: 'Today', level: 2 }), + }) + await expect(todaySection.locator('.date-subheader')).toHaveCount(0) + }) + + test('date subheaders appear in Later section', async ({ page }) => { + await page.addInitScript(seedEvents([futureEvent1, futureEvent2])) + await page.goto('/') + + const laterSection = page.locator('.event-section').filter({ + has: page.getByRole('heading', { name: 'Later', level: 2 }), + }) + // Both future events are on different dates, so expect subheaders + const subheaders = laterSection.locator('.date-subheader') + await expect(subheaders).toHaveCount(2) + }) + + test('date subheaders appear in Past section', async ({ page }) => { + await page.addInitScript(seedEvents([pastEvent])) + await page.goto('/') + + const pastSection = page.locator('.event-section').filter({ + has: page.getByRole('heading', { name: 'Past', level: 2 }), + }) + await expect(pastSection.locator('.date-subheader')).toHaveCount(1) + }) +}) + +test.describe('Temporal Grouping: Time Display', () => { + test('future event cards show clock time', async ({ page }) => { + await page.addInitScript(seedEvents([futureEvent1])) + await page.goto('/') + + const timeLabel = page.locator('.event-card__time') + const text = await timeLabel.first().textContent() + // Should show clock time (e.g., "18:00" or "6:00 PM"), not relative time + expect(text).toMatch(/\d{1,2}[:.]\d{2}/) + }) + + test('past event cards show relative time', async ({ page }) => { + await page.addInitScript(seedEvents([pastEvent])) + await page.goto('/') + + const timeLabel = page.locator('.event-card__time') + const text = await timeLabel.first().textContent() + // Should show relative time like "X years ago" or "last year" + expect(text).toMatch(/ago|last|yesterday/) + }) +}) + test.describe('US1: View My Events', () => { test('displays all stored events with title and relative time', async ({ page }) => { await page.addInitScript(seedEvents([futureEvent1, futureEvent2, pastEvent])) diff --git a/frontend/src/components/DateSubheader.vue b/frontend/src/components/DateSubheader.vue new file mode 100644 index 0000000..b7b2a23 --- /dev/null +++ b/frontend/src/components/DateSubheader.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/frontend/src/components/EventCard.vue b/frontend/src/components/EventCard.vue index 6792723..e788e32 100644 --- a/frontend/src/components/EventCard.vue +++ b/frontend/src/components/EventCard.vue @@ -9,7 +9,7 @@ > {{ title }} - {{ relativeTime }} + {{ displayTime }} {{ eventRole === 'organizer' ? 'Organizer' : 'Attendee' }} @@ -35,12 +35,21 @@ const props = defineProps<{ relativeTime: string isPast: boolean eventRole?: 'organizer' | 'attendee' + timeDisplayMode?: 'clock' | 'relative' + dateTime?: string }>() const emit = defineEmits<{ delete: [eventToken: string] }>() +const displayTime = computed(() => { + if (props.timeDisplayMode === 'clock' && props.dateTime) { + return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(new Date(props.dateTime)) + } + return props.relativeTime +}) + const SWIPE_THRESHOLD = 80 const startX = ref(0) diff --git a/frontend/src/components/EventList.vue b/frontend/src/components/EventList.vue index 44fac59..a4f19b0 100644 --- a/frontend/src/components/EventList.vue +++ b/frontend/src/components/EventList.vue @@ -1,15 +1,30 @@