Files
fete/frontend/e2e/home-events.spec.ts
nitrix c450849e4d
All checks were successful
CI / backend-test (push) Successful in 1m0s
CI / frontend-test (push) Successful in 27s
CI / frontend-e2e (push) Successful in 1m30s
CI / build-and-publish (push) Has been skipped
Implement watch-event feature (017) with bookmark in RsvpBar
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>
2026-03-12 22:20:57 +01:00

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()
})
})