Enable users to see all their saved events on the home screen, sorted by date with upcoming events first. Key capabilities: - EventCard with title, relative time display, and organizer/attendee role badge - Sortable EventList with past-event visual distinction (faded style) - Empty state when no events are stored - Swipe-to-delete gesture with confirmation dialog - Floating action button for quick event creation - Rename router param :token → :eventToken across all views - useRelativeTime composable (Intl.RelativeTimeFormat) - useEventStorage: add validation, removeEvent(), reactive versioning - Full E2E and unit test coverage for all new components Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
251 lines
8.5 KiB
TypeScript
251 lines
8.5 KiB
TypeScript
import { test, expect } from './msw-setup'
|
|
import type { StoredEvent } from '../src/composables/useEventStorage'
|
|
|
|
const STORAGE_KEY = 'fete:events'
|
|
|
|
const futureEvent1: StoredEvent = {
|
|
eventToken: 'future-aaa',
|
|
title: 'Summer BBQ',
|
|
dateTime: '2027-06-15T18:00:00Z',
|
|
expiryDate: '2027-06-16T00:00:00Z',
|
|
organizerToken: 'org-token-1',
|
|
}
|
|
|
|
const futureEvent2: StoredEvent = {
|
|
eventToken: 'future-bbb',
|
|
title: 'Team Meeting',
|
|
dateTime: '2027-01-10T09:00:00Z',
|
|
expiryDate: '2027-01-11T00:00:00Z',
|
|
rsvpToken: 'rsvp-token-1',
|
|
rsvpName: 'Alice',
|
|
}
|
|
|
|
const pastEvent: StoredEvent = {
|
|
eventToken: 'past-ccc',
|
|
title: 'New Year Party',
|
|
dateTime: '2025-01-01T00:00:00Z',
|
|
expiryDate: '2025-01-02T00:00:00Z',
|
|
}
|
|
|
|
function seedEvents(events: StoredEvent[]): string {
|
|
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
|
}
|
|
|
|
test.describe('US2: Empty State', () => {
|
|
test('shows empty state when no events are stored', async ({ page }) => {
|
|
await page.goto('/')
|
|
|
|
await expect(page.getByText('No events yet')).toBeVisible()
|
|
await expect(page.getByRole('link', { name: /Create Event/ })).toBeVisible()
|
|
})
|
|
|
|
test('empty state links to create page', async ({ page }) => {
|
|
await page.goto('/')
|
|
|
|
const link = page.getByRole('link', { name: /Create Event/ })
|
|
await expect(link).toHaveAttribute('href', '/create')
|
|
})
|
|
|
|
test('empty state is hidden when events exist', async ({ page }) => {
|
|
await page.addInitScript(seedEvents([futureEvent1]))
|
|
await page.goto('/')
|
|
|
|
await expect(page.getByText('No events yet')).not.toBeVisible()
|
|
})
|
|
})
|
|
|
|
test.describe('US4: Past Events Appear Faded', () => {
|
|
test('past events have the faded modifier class', async ({ page }) => {
|
|
await page.addInitScript(seedEvents([futureEvent1, pastEvent]))
|
|
await page.goto('/')
|
|
|
|
const cards = page.locator('.event-card')
|
|
await expect(cards).toHaveCount(2)
|
|
|
|
// Future event should NOT have past class
|
|
const futureCard = cards.filter({ hasText: 'Summer BBQ' })
|
|
await expect(futureCard).not.toHaveClass(/event-card--past/)
|
|
|
|
// Past event should have past class
|
|
const pastCard = cards.filter({ hasText: 'New Year Party' })
|
|
await expect(pastCard).toHaveClass(/event-card--past/)
|
|
})
|
|
|
|
test('past events remain clickable', async ({ page, network }) => {
|
|
await page.addInitScript(seedEvents([pastEvent]))
|
|
|
|
const { http, HttpResponse } = await import('msw')
|
|
network.use(
|
|
http.get('*/api/events/:token', () => {
|
|
return HttpResponse.json({
|
|
eventToken: pastEvent.eventToken,
|
|
title: pastEvent.title,
|
|
dateTime: pastEvent.dateTime,
|
|
description: '',
|
|
location: '',
|
|
timezone: 'UTC',
|
|
attendeeCount: 0,
|
|
expired: true,
|
|
})
|
|
}),
|
|
)
|
|
|
|
await page.goto('/')
|
|
await page.getByText('New Year Party').click()
|
|
await expect(page).toHaveURL(`/events/${pastEvent.eventToken}`)
|
|
})
|
|
})
|
|
|
|
test.describe('US3: Remove Event from List', () => {
|
|
test('delete icon triggers confirmation dialog, confirm removes event', async ({ page }) => {
|
|
await page.addInitScript(seedEvents([futureEvent1, futureEvent2]))
|
|
await page.goto('/')
|
|
|
|
// Both events visible
|
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
|
await expect(page.getByText('Team Meeting')).toBeVisible()
|
|
|
|
// Click delete on Summer BBQ
|
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
|
|
|
// Confirmation dialog appears
|
|
await expect(page.getByText('Remove event?')).toBeVisible()
|
|
|
|
// Confirm removal
|
|
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
|
|
|
// Event is gone, other remains
|
|
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
|
await expect(page.getByText('Team Meeting')).toBeVisible()
|
|
})
|
|
|
|
test('cancel keeps the event in the list', async ({ page }) => {
|
|
await page.addInitScript(seedEvents([futureEvent1]))
|
|
await page.goto('/')
|
|
|
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
|
await expect(page.getByText('Remove event?')).toBeVisible()
|
|
|
|
// Cancel
|
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
|
|
|
// Dialog gone, event still there
|
|
await expect(page.getByText('Remove event?')).not.toBeVisible()
|
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
|
})
|
|
})
|
|
|
|
test.describe('US5: Visual Distinction for Event Roles', () => {
|
|
test('shows organizer badge for events with organizerToken', async ({ page }) => {
|
|
await page.addInitScript(seedEvents([futureEvent1]))
|
|
await page.goto('/')
|
|
|
|
const card = page.locator('.event-card').filter({ hasText: 'Summer BBQ' })
|
|
const badge = card.locator('.event-card__badge')
|
|
await expect(badge).toBeVisible()
|
|
await expect(badge).toHaveText('Organizer')
|
|
await expect(badge).toHaveClass(/event-card__badge--organizer/)
|
|
})
|
|
|
|
test('shows attendee badge for events with rsvpToken only', async ({ page }) => {
|
|
await page.addInitScript(seedEvents([futureEvent2]))
|
|
await page.goto('/')
|
|
|
|
const card = page.locator('.event-card').filter({ hasText: 'Team Meeting' })
|
|
const badge = card.locator('.event-card__badge')
|
|
await expect(badge).toBeVisible()
|
|
await expect(badge).toHaveText('Attendee')
|
|
await expect(badge).toHaveClass(/event-card__badge--attendee/)
|
|
})
|
|
|
|
test('shows no badge for events without organizerToken or rsvpToken', async ({ page }) => {
|
|
await page.addInitScript(seedEvents([pastEvent]))
|
|
await page.goto('/')
|
|
|
|
const card = page.locator('.event-card').filter({ hasText: 'New Year Party' })
|
|
await expect(card.locator('.event-card__badge')).toHaveCount(0)
|
|
})
|
|
})
|
|
|
|
test.describe('FAB: Create Event Button', () => {
|
|
test('FAB is visible when events exist', async ({ page }) => {
|
|
await page.addInitScript(seedEvents([futureEvent1]))
|
|
await page.goto('/')
|
|
|
|
const fab = page.getByRole('link', { name: 'Create event' })
|
|
await expect(fab).toBeVisible()
|
|
})
|
|
|
|
test('FAB navigates to create page', async ({ page }) => {
|
|
await page.addInitScript(seedEvents([futureEvent1]))
|
|
await page.goto('/')
|
|
|
|
const fab = page.getByRole('link', { name: 'Create event' })
|
|
await expect(fab).toHaveAttribute('href', '/create')
|
|
})
|
|
|
|
test('FAB is not visible on empty state (empty state has its own CTA)', async ({ page }) => {
|
|
await page.goto('/')
|
|
|
|
await expect(page.locator('.fab')).toHaveCount(0)
|
|
})
|
|
})
|
|
|
|
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]))
|
|
await page.goto('/')
|
|
|
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
|
await expect(page.getByText('Team Meeting')).toBeVisible()
|
|
await expect(page.getByText('New Year Party')).toBeVisible()
|
|
})
|
|
|
|
test('events are sorted: upcoming ascending, then past', async ({ page }) => {
|
|
await page.addInitScript(seedEvents([futureEvent1, futureEvent2, pastEvent]))
|
|
await page.goto('/')
|
|
|
|
const titles = page.locator('.event-card__title')
|
|
await expect(titles).toHaveCount(3)
|
|
// Team Meeting (Jan 2027) before Summer BBQ (Jun 2027), then past event
|
|
await expect(titles.nth(0)).toHaveText('Team Meeting')
|
|
await expect(titles.nth(1)).toHaveText('Summer BBQ')
|
|
await expect(titles.nth(2)).toHaveText('New Year Party')
|
|
})
|
|
|
|
test('clicking an event navigates to its detail page', async ({ page, network }) => {
|
|
await page.addInitScript(seedEvents([futureEvent1]))
|
|
|
|
// Mock the event detail API so navigation doesn't fail
|
|
const { http, HttpResponse } = await import('msw')
|
|
network.use(
|
|
http.get('*/api/events/:token', () => {
|
|
return HttpResponse.json({
|
|
eventToken: futureEvent1.eventToken,
|
|
title: futureEvent1.title,
|
|
dateTime: futureEvent1.dateTime,
|
|
description: '',
|
|
location: '',
|
|
timezone: 'UTC',
|
|
attendeeCount: 0,
|
|
expired: false,
|
|
})
|
|
}),
|
|
)
|
|
|
|
await page.goto('/')
|
|
await page.getByText('Summer BBQ').click()
|
|
await expect(page).toHaveURL(`/events/${futureEvent1.eventToken}`)
|
|
})
|
|
|
|
test('each event shows a relative time label', async ({ page }) => {
|
|
await page.addInitScript(seedEvents([futureEvent1]))
|
|
await page.goto('/')
|
|
|
|
// The relative time element should exist and contain text (exact value depends on current time)
|
|
const timeLabel = page.locator('.event-card__time')
|
|
await expect(timeLabel).toHaveCount(1)
|
|
await expect(timeLabel.first()).not.toBeEmpty()
|
|
})
|
|
})
|