Add client-side watch/bookmark functionality: users can save events to localStorage without RSVPing via a bookmark button next to the "I'm attending" CTA. Watched events appear in the event list with a "Watching" label. Bookmark is only visible for visitors (not attendees or organizers). Includes spec, plan, research, tasks, unit tests, and E2E tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
372 lines
14 KiB
TypeScript
372 lines
14 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',
|
|
organizerToken: 'org-token-1',
|
|
}
|
|
|
|
const futureEvent2: StoredEvent = {
|
|
eventToken: 'future-bbb',
|
|
title: 'Team Meeting',
|
|
dateTime: '2027-01-10T09:00:00Z',
|
|
rsvpToken: 'rsvp-token-1',
|
|
rsvpName: 'Alice',
|
|
}
|
|
|
|
const pastEvent: StoredEvent = {
|
|
eventToken: 'past-ccc',
|
|
title: 'New Year Party',
|
|
dateTime: '2025-01-01T00: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,
|
|
})
|
|
}),
|
|
)
|
|
|
|
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 watcher 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' })
|
|
const badge = card.locator('.event-card__badge')
|
|
await expect(badge).toBeVisible()
|
|
await expect(badge).toHaveText('Watching')
|
|
await expect(badge).toHaveClass(/event-card__badge--watcher/)
|
|
})
|
|
})
|
|
|
|
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('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(),
|
|
}
|
|
const laterEvent: StoredEvent = {
|
|
eventToken: 'later-1',
|
|
title: 'Future Conference',
|
|
dateTime: new Date(now.getFullYear() + 1, 0, 15, 10, 0, 0).toISOString(),
|
|
}
|
|
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(),
|
|
}
|
|
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(),
|
|
}
|
|
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]))
|
|
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,
|
|
})
|
|
}),
|
|
)
|
|
|
|
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()
|
|
})
|
|
})
|