From e56998b17c796c60bdf3d415f3828e76140242e1 Mon Sep 17 00:00:00 2001 From: nitrix Date: Sun, 8 Mar 2026 15:53:55 +0100 Subject: [PATCH] Add event list feature (009-list-events) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/e2e/home-events.spec.ts | 250 ++++++++++++++++++ frontend/src/components/ConfirmDialog.vue | 151 +++++++++++ frontend/src/components/CreateEventFab.vue | 49 ++++ frontend/src/components/EmptyState.vue | 31 +++ frontend/src/components/EventCard.vue | 172 ++++++++++++ frontend/src/components/EventList.vue | 79 ++++++ .../__tests__/ConfirmDialog.spec.ts | 111 ++++++++ .../components/__tests__/EmptyState.spec.ts | 35 +++ .../components/__tests__/EventCard.spec.ts | 76 ++++++ .../components/__tests__/EventList.spec.ts | 79 ++++++ .../__tests__/useEventStorage.spec.ts | 116 ++++++++ .../__tests__/useRelativeTime.spec.ts | 72 +++++ frontend/src/composables/useEventStorage.ts | 27 +- frontend/src/composables/useRelativeTime.ts | 23 ++ frontend/src/router/index.ts | 2 +- frontend/src/views/EventCreateView.vue | 2 +- frontend/src/views/EventDetailView.vue | 4 +- frontend/src/views/EventStubView.vue | 2 +- frontend/src/views/HomeView.vue | 35 +-- .../views/__tests__/EventCreateView.spec.ts | 5 +- .../views/__tests__/EventDetailView.spec.ts | 3 +- .../src/views/__tests__/EventStubView.spec.ts | 2 +- .../checklists/requirements.md | 35 +++ specs/009-list-events/data-model.md | 99 +++++++ specs/009-list-events/plan.md | 86 ++++++ specs/009-list-events/research.md | 110 ++++++++ specs/009-list-events/spec.md | 145 ++++++++++ specs/009-list-events/tasks.md | 215 +++++++++++++++ 28 files changed, 1989 insertions(+), 27 deletions(-) create mode 100644 frontend/e2e/home-events.spec.ts create mode 100644 frontend/src/components/ConfirmDialog.vue create mode 100644 frontend/src/components/CreateEventFab.vue create mode 100644 frontend/src/components/EmptyState.vue create mode 100644 frontend/src/components/EventCard.vue create mode 100644 frontend/src/components/EventList.vue create mode 100644 frontend/src/components/__tests__/ConfirmDialog.spec.ts create mode 100644 frontend/src/components/__tests__/EmptyState.spec.ts create mode 100644 frontend/src/components/__tests__/EventCard.spec.ts create mode 100644 frontend/src/components/__tests__/EventList.spec.ts create mode 100644 frontend/src/composables/__tests__/useRelativeTime.spec.ts create mode 100644 frontend/src/composables/useRelativeTime.ts create mode 100644 specs/009-list-events/checklists/requirements.md create mode 100644 specs/009-list-events/data-model.md create mode 100644 specs/009-list-events/plan.md create mode 100644 specs/009-list-events/research.md create mode 100644 specs/009-list-events/spec.md create mode 100644 specs/009-list-events/tasks.md diff --git a/frontend/e2e/home-events.spec.ts b/frontend/e2e/home-events.spec.ts new file mode 100644 index 0000000..3b71b04 --- /dev/null +++ b/frontend/e2e/home-events.spec.ts @@ -0,0 +1,250 @@ +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() + }) +}) diff --git a/frontend/src/components/ConfirmDialog.vue b/frontend/src/components/ConfirmDialog.vue new file mode 100644 index 0000000..db0ba97 --- /dev/null +++ b/frontend/src/components/ConfirmDialog.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/frontend/src/components/CreateEventFab.vue b/frontend/src/components/CreateEventFab.vue new file mode 100644 index 0000000..a8736c5 --- /dev/null +++ b/frontend/src/components/CreateEventFab.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/frontend/src/components/EmptyState.vue b/frontend/src/components/EmptyState.vue new file mode 100644 index 0000000..9938e4b --- /dev/null +++ b/frontend/src/components/EmptyState.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/frontend/src/components/EventCard.vue b/frontend/src/components/EventCard.vue new file mode 100644 index 0000000..6792723 --- /dev/null +++ b/frontend/src/components/EventCard.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/frontend/src/components/EventList.vue b/frontend/src/components/EventList.vue new file mode 100644 index 0000000..44fac59 --- /dev/null +++ b/frontend/src/components/EventList.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/frontend/src/components/__tests__/ConfirmDialog.spec.ts b/frontend/src/components/__tests__/ConfirmDialog.spec.ts new file mode 100644 index 0000000..397ae47 --- /dev/null +++ b/frontend/src/components/__tests__/ConfirmDialog.spec.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { mount, VueWrapper } from '@vue/test-utils' +import ConfirmDialog from '../ConfirmDialog.vue' + +let wrapper: VueWrapper + +function mountDialog(props: Record = {}) { + wrapper = mount(ConfirmDialog, { + props: { + open: true, + ...props, + }, + attachTo: document.body, + }) + return wrapper +} + +function dialog() { + return document.body.querySelector('.confirm-dialog') +} + +function overlay() { + return document.body.querySelector('.confirm-dialog__overlay') +} + +afterEach(() => { + wrapper?.unmount() +}) + +describe('ConfirmDialog', () => { + it('renders when open is true', () => { + mountDialog() + expect(dialog()).not.toBeNull() + }) + + it('does not render when open is false', () => { + mountDialog({ open: false }) + expect(dialog()).toBeNull() + }) + + it('displays default title', () => { + mountDialog() + expect(dialog()!.querySelector('.confirm-dialog__title')!.textContent).toBe('Are you sure?') + }) + + it('displays custom title and message', () => { + mountDialog({ + title: 'Remove event?', + message: 'This cannot be undone.', + }) + expect(dialog()!.querySelector('.confirm-dialog__title')!.textContent).toBe('Remove event?') + expect(dialog()!.querySelector('.confirm-dialog__message')!.textContent).toBe('This cannot be undone.') + }) + + it('displays custom button labels', () => { + mountDialog({ + confirmLabel: 'Delete', + cancelLabel: 'Keep', + }) + const buttons = dialog()!.querySelectorAll('.confirm-dialog__btn') + expect(buttons[0]!.textContent!.trim()).toBe('Keep') + expect(buttons[1]!.textContent!.trim()).toBe('Delete') + }) + + it('emits confirm when confirm button is clicked', async () => { + mountDialog() + const btn = dialog()!.querySelector('.confirm-dialog__btn--confirm') as HTMLElement + btn.click() + await wrapper.vm.$nextTick() + expect(wrapper.emitted('confirm')).toHaveLength(1) + }) + + it('emits cancel when cancel button is clicked', async () => { + mountDialog() + const btn = dialog()!.querySelector('.confirm-dialog__btn--cancel') as HTMLElement + btn.click() + await wrapper.vm.$nextTick() + expect(wrapper.emitted('cancel')).toHaveLength(1) + }) + + it('emits cancel when overlay is clicked', async () => { + mountDialog() + const el = overlay() as HTMLElement + el.click() + await wrapper.vm.$nextTick() + expect(wrapper.emitted('cancel')).toHaveLength(1) + }) + + it('emits cancel when Escape key is pressed', async () => { + mountDialog() + const el = dialog() as HTMLElement + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + await wrapper.vm.$nextTick() + expect(wrapper.emitted('cancel')).toHaveLength(1) + }) + + it('focuses cancel button when opened', async () => { + mountDialog({ open: false }) + await wrapper.setProps({ open: true }) + await wrapper.vm.$nextTick() + const cancelBtn = dialog()!.querySelector('.confirm-dialog__btn--cancel') + expect(document.activeElement).toBe(cancelBtn) + }) + + it('has alertdialog role and aria-modal', () => { + mountDialog() + const el = dialog() as HTMLElement + expect(el.getAttribute('role')).toBe('alertdialog') + expect(el.getAttribute('aria-modal')).toBe('true') + }) +}) diff --git a/frontend/src/components/__tests__/EmptyState.spec.ts b/frontend/src/components/__tests__/EmptyState.spec.ts new file mode 100644 index 0000000..c54b885 --- /dev/null +++ b/frontend/src/components/__tests__/EmptyState.spec.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import EmptyState from '../EmptyState.vue' + +const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: { template: '
' } }, + { path: '/create', name: 'create', component: { template: '
' } }, + ], +}) + +function mountEmptyState() { + return mount(EmptyState, { + global: { + plugins: [router], + }, + }) +} + +describe('EmptyState', () => { + it('renders an inviting message', () => { + const wrapper = mountEmptyState() + expect(wrapper.text()).toContain('No events yet') + }) + + it('renders a Create Event link', () => { + const wrapper = mountEmptyState() + const link = wrapper.find('a') + expect(link.exists()).toBe(true) + expect(link.text()).toContain('Create Event') + expect(link.attributes('href')).toBe('/create') + }) +}) diff --git a/frontend/src/components/__tests__/EventCard.spec.ts b/frontend/src/components/__tests__/EventCard.spec.ts new file mode 100644 index 0000000..6827533 --- /dev/null +++ b/frontend/src/components/__tests__/EventCard.spec.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import EventCard from '../EventCard.vue' + +const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: { template: '
' } }, + { path: '/events/:eventToken', name: 'event', component: { template: '
' } }, + ], +}) + +function mountCard(props: Record = {}) { + return mount(EventCard, { + props: { + eventToken: 'abc-123', + title: 'Birthday Party', + relativeTime: 'in 3 days', + isPast: false, + ...props, + }, + global: { + plugins: [router], + }, + }) +} + +describe('EventCard', () => { + it('renders the event title', () => { + const wrapper = mountCard() + expect(wrapper.text()).toContain('Birthday Party') + }) + + it('renders relative time', () => { + const wrapper = mountCard({ relativeTime: 'yesterday' }) + expect(wrapper.text()).toContain('yesterday') + }) + + it('links to the event detail page', () => { + const wrapper = mountCard({ eventToken: 'xyz-789' }) + const link = wrapper.find('a') + expect(link.attributes('href')).toBe('/events/xyz-789') + }) + + it('applies past modifier class when isPast is true', () => { + const wrapper = mountCard({ isPast: true }) + expect(wrapper.find('.event-card--past').exists()).toBe(true) + }) + + it('does not apply past modifier class when isPast is false', () => { + const wrapper = mountCard({ isPast: false }) + expect(wrapper.find('.event-card--past').exists()).toBe(false) + }) + + it('renders organizer badge when eventRole is organizer', () => { + const wrapper = mountCard({ eventRole: 'organizer' }) + expect(wrapper.text()).toContain('Organizer') + }) + + it('renders attendee badge when eventRole is attendee', () => { + const wrapper = mountCard({ eventRole: 'attendee' }) + expect(wrapper.text()).toContain('Attendee') + }) + + it('renders no badge when eventRole is undefined', () => { + const wrapper = mountCard({ eventRole: undefined }) + expect(wrapper.find('.event-card__badge').exists()).toBe(false) + }) + + it('emits delete event with eventToken when delete button is clicked', async () => { + const wrapper = mountCard({ eventToken: 'abc-123' }) + await wrapper.find('.event-card__delete').trigger('click') + expect(wrapper.emitted('delete')).toEqual([['abc-123']]) + }) +}) diff --git a/frontend/src/components/__tests__/EventList.spec.ts b/frontend/src/components/__tests__/EventList.spec.ts new file mode 100644 index 0000000..d571954 --- /dev/null +++ b/frontend/src/components/__tests__/EventList.spec.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import EventList from '../EventList.vue' + +const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: { template: '
' } }, + { path: '/events/:eventToken', name: 'event', component: { template: '
' } }, + ], +}) + +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: '' }, +] + +vi.mock('../../composables/useEventStorage', () => ({ + isValidStoredEvent: (e: unknown) => { + if (typeof e !== 'object' || e === null) return false + const obj = e as Record + return typeof obj.eventToken === 'string' && obj.eventToken.length > 0 + && typeof obj.title === 'string' && obj.title.length > 0 + && typeof obj.dateTime === 'string' && obj.dateTime.length > 0 + }, + useEventStorage: () => ({ + getStoredEvents: () => mockEvents, + removeEvent: vi.fn(), + }), +})) + +vi.mock('../../composables/useRelativeTime', () => ({ + formatRelativeTime: (dateTime: string) => { + if (dateTime.startsWith('2025')) return '1 year ago' + if (dateTime.includes('06-15')) return 'in 1 year' + return 'in 10 months' + }, +})) + +function mountList() { + return mount(EventList, { + global: { plugins: [router] }, + }) +} + +describe('EventList', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-03-08T12:00:00Z')) + }) + + it('renders all valid events', () => { + 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') + }) + + 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 + expect(cards[0]!.classes()).not.toContain('event-card--past') + expect(cards[1]!.classes()).not.toContain('event-card--past') + }) +}) diff --git a/frontend/src/composables/__tests__/useEventStorage.spec.ts b/frontend/src/composables/__tests__/useEventStorage.spec.ts index 3077c5f..0cf740c 100644 --- a/frontend/src/composables/__tests__/useEventStorage.spec.ts +++ b/frontend/src/composables/__tests__/useEventStorage.spec.ts @@ -164,4 +164,120 @@ describe('useEventStorage', () => { const { getRsvp } = useEventStorage() expect(getRsvp('unknown')).toBeUndefined() }) + + it('removes an event by token', () => { + const { saveCreatedEvent, getStoredEvents, removeEvent } = useEventStorage() + + saveCreatedEvent({ + eventToken: 'event-1', + title: 'First', + dateTime: '2026-06-15T20:00:00+02:00', + expiryDate: '2026-07-15', + }) + + saveCreatedEvent({ + eventToken: 'event-2', + title: 'Second', + dateTime: '2026-07-15T20:00:00+02:00', + expiryDate: '2026-08-15', + }) + + removeEvent('event-1') + + const events = getStoredEvents() + expect(events).toHaveLength(1) + expect(events[0]!.eventToken).toBe('event-2') + }) + + it('removeEvent does nothing for unknown token', () => { + const { saveCreatedEvent, getStoredEvents, removeEvent } = useEventStorage() + + saveCreatedEvent({ + eventToken: 'event-1', + title: 'First', + dateTime: '2026-06-15T20:00:00+02:00', + expiryDate: '2026-07-15', + }) + + removeEvent('nonexistent') + + expect(getStoredEvents()).toHaveLength(1) + }) +}) + +describe('isValidStoredEvent', () => { + // Import directly since it's an exported function + let isValidStoredEvent: (e: unknown) => boolean + + beforeEach(async () => { + const mod = await import('../useEventStorage') + isValidStoredEvent = mod.isValidStoredEvent + }) + + it('returns true for a valid event', () => { + expect( + isValidStoredEvent({ + eventToken: 'abc-123', + title: 'Birthday', + dateTime: '2026-06-15T20:00:00+02:00', + expiryDate: '2026-07-15', + }), + ).toBe(true) + }) + + it('returns false for null', () => { + expect(isValidStoredEvent(null)).toBe(false) + }) + + it('returns false for non-object', () => { + expect(isValidStoredEvent('string')).toBe(false) + }) + + it('returns false when eventToken is missing', () => { + expect( + isValidStoredEvent({ + title: 'Birthday', + dateTime: '2026-06-15T20:00:00+02:00', + }), + ).toBe(false) + }) + + it('returns false when eventToken is empty', () => { + expect( + isValidStoredEvent({ + eventToken: '', + title: 'Birthday', + dateTime: '2026-06-15T20:00:00+02:00', + }), + ).toBe(false) + }) + + it('returns false when title is missing', () => { + expect( + isValidStoredEvent({ + eventToken: 'abc-123', + dateTime: '2026-06-15T20:00:00+02:00', + }), + ).toBe(false) + }) + + it('returns false when dateTime is invalid', () => { + expect( + isValidStoredEvent({ + eventToken: 'abc-123', + title: 'Birthday', + dateTime: 'not-a-date', + }), + ).toBe(false) + }) + + it('returns false when dateTime is empty', () => { + expect( + isValidStoredEvent({ + eventToken: 'abc-123', + title: 'Birthday', + dateTime: '', + }), + ).toBe(false) + }) }) diff --git a/frontend/src/composables/__tests__/useRelativeTime.spec.ts b/frontend/src/composables/__tests__/useRelativeTime.spec.ts new file mode 100644 index 0000000..4431b3a --- /dev/null +++ b/frontend/src/composables/__tests__/useRelativeTime.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest' +import { formatRelativeTime } from '../useRelativeTime' + +describe('formatRelativeTime', () => { + const now = new Date('2026-06-15T12:00:00Z') + + it('formats seconds ago', () => { + const result = formatRelativeTime('2026-06-15T11:59:30Z', now) + expect(result).toMatch(/30 seconds ago/) + }) + + it('formats minutes ago', () => { + const result = formatRelativeTime('2026-06-15T11:55:00Z', now) + expect(result).toMatch(/5 minutes ago/) + }) + + it('formats hours ago', () => { + const result = formatRelativeTime('2026-06-15T09:00:00Z', now) + expect(result).toMatch(/3 hours ago/) + }) + + it('formats days ago', () => { + const result = formatRelativeTime('2026-06-13T12:00:00Z', now) + expect(result).toMatch(/2 days ago/) + }) + + it('formats weeks ago', () => { + const result = formatRelativeTime('2026-06-01T12:00:00Z', now) + expect(result).toMatch(/2 weeks ago/) + }) + + it('formats months ago', () => { + const result = formatRelativeTime('2026-03-15T12:00:00Z', now) + expect(result).toMatch(/3 months ago/) + }) + + it('formats years ago', () => { + const result = formatRelativeTime('2024-06-15T12:00:00Z', now) + expect(result).toMatch(/2 years ago/) + }) + + it('formats future seconds', () => { + const result = formatRelativeTime('2026-06-15T12:00:30Z', now) + expect(result).toMatch(/in 30 seconds/) + }) + + it('formats future days', () => { + const result = formatRelativeTime('2026-06-18T12:00:00Z', now) + expect(result).toMatch(/in 3 days/) + }) + + it('formats future months', () => { + const result = formatRelativeTime('2026-09-15T12:00:00Z', now) + expect(result).toMatch(/in 3 months/) + }) + + it('formats "now" for zero difference', () => { + const result = formatRelativeTime('2026-06-15T12:00:00Z', now) + // Intl.RelativeTimeFormat with numeric: 'auto' returns "now" for 0 seconds + expect(result).toMatch(/now/) + }) + + it('formats yesterday', () => { + const result = formatRelativeTime('2026-06-14T12:00:00Z', now) + expect(result).toMatch(/yesterday|1 day ago/) + }) + + it('formats tomorrow', () => { + const result = formatRelativeTime('2026-06-16T12:00:00Z', now) + expect(result).toMatch(/tomorrow|in 1 day/) + }) +}) diff --git a/frontend/src/composables/useEventStorage.ts b/frontend/src/composables/useEventStorage.ts index 8acbdc1..99dfb9f 100644 --- a/frontend/src/composables/useEventStorage.ts +++ b/frontend/src/composables/useEventStorage.ts @@ -8,8 +8,26 @@ export interface StoredEvent { rsvpName?: string } +import { ref } from 'vue' + const STORAGE_KEY = 'fete:events' +const version = ref(0) + +export function isValidStoredEvent(e: unknown): e is StoredEvent { + if (typeof e !== 'object' || e === null) return false + const obj = e as Record + return ( + typeof obj.eventToken === 'string' && + obj.eventToken.length > 0 && + typeof obj.title === 'string' && + obj.title.length > 0 && + typeof obj.dateTime === 'string' && + obj.dateTime.length > 0 && + !isNaN(new Date(obj.dateTime).getTime()) + ) +} + function readEvents(): StoredEvent[] { try { const raw = localStorage.getItem(STORAGE_KEY) @@ -21,6 +39,7 @@ function readEvents(): StoredEvent[] { function writeEvents(events: StoredEvent[]): void { localStorage.setItem(STORAGE_KEY, JSON.stringify(events)) + version.value++ } export function useEventStorage() { @@ -31,6 +50,7 @@ export function useEventStorage() { } function getStoredEvents(): StoredEvent[] { + void version.value return readEvents() } @@ -59,5 +79,10 @@ export function useEventStorage() { return undefined } - return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp } + function removeEvent(eventToken: string): void { + const events = readEvents().filter((e) => e.eventToken !== eventToken) + writeEvents(events) + } + + return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp, removeEvent } } diff --git a/frontend/src/composables/useRelativeTime.ts b/frontend/src/composables/useRelativeTime.ts new file mode 100644 index 0000000..519f471 --- /dev/null +++ b/frontend/src/composables/useRelativeTime.ts @@ -0,0 +1,23 @@ +const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [ + ['year', 365 * 24 * 60 * 60], + ['month', 30 * 24 * 60 * 60], + ['week', 7 * 24 * 60 * 60], + ['day', 24 * 60 * 60], + ['hour', 60 * 60], + ['minute', 60], + ['second', 1], +] + +export function formatRelativeTime(dateTime: string, now: Date = new Date()): string { + const target = new Date(dateTime) + const diffSeconds = Math.round((target.getTime() - now.getTime()) / 1000) + + for (const [unit, secondsInUnit] of UNITS) { + if (Math.abs(diffSeconds) >= secondsInUnit) { + const value = Math.round(diffSeconds / secondsInUnit) + return new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }).format(value, unit) + } + } + + return new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }).format(0, 'second') +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 3d63d78..c58015d 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -15,7 +15,7 @@ const router = createRouter({ component: () => import('../views/EventCreateView.vue'), }, { - path: '/events/:token', + path: '/events/:eventToken', name: 'event', component: () => import('../views/EventDetailView.vue'), }, diff --git a/frontend/src/views/EventCreateView.vue b/frontend/src/views/EventCreateView.vue index aa8ea6c..7b06c63 100644 --- a/frontend/src/views/EventCreateView.vue +++ b/frontend/src/views/EventCreateView.vue @@ -215,7 +215,7 @@ async function handleSubmit() { expiryDate: data.expiryDate, }) - router.push({ name: 'event', params: { token: data.eventToken } }) + router.push({ name: 'event', params: { eventToken: data.eventToken } }) } } catch { submitting.value = false diff --git a/frontend/src/views/EventDetailView.vue b/frontend/src/views/EventDetailView.vue index 812c0c7..4cc3450 100644 --- a/frontend/src/views/EventDetailView.vue +++ b/frontend/src/views/EventDetailView.vue @@ -131,7 +131,7 @@ async function fetchEvent() { try { const { data, error, response } = await api.GET('/events/{token}', { - params: { path: { token: route.params.token as string } }, + params: { path: { token: route.params.eventToken as string } }, }) if (error) { @@ -173,7 +173,7 @@ async function submitRsvp() { try { const { data, error } = await api.POST('/events/{token}/rsvps', { - params: { path: { token: route.params.token as string } }, + params: { path: { token: route.params.eventToken as string } }, body: { name: nameInput.value }, }) diff --git a/frontend/src/views/EventStubView.vue b/frontend/src/views/EventStubView.vue index 545ff1f..3def1d1 100644 --- a/frontend/src/views/EventStubView.vue +++ b/frontend/src/views/EventStubView.vue @@ -27,7 +27,7 @@ const route = useRoute() const copyState = ref<'idle' | 'copied' | 'failed'>('idle') const eventUrl = computed(() => { - return window.location.origin + '/events/' + route.params.token + return window.location.origin + '/events/' + route.params.eventToken }) const copyLabel = computed(() => { diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 3c11662..cd4fb56 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -1,13 +1,26 @@ diff --git a/frontend/src/views/__tests__/EventCreateView.spec.ts b/frontend/src/views/__tests__/EventCreateView.spec.ts index 441a2a1..ee08832 100644 --- a/frontend/src/views/__tests__/EventCreateView.spec.ts +++ b/frontend/src/views/__tests__/EventCreateView.spec.ts @@ -25,7 +25,7 @@ function createTestRouter() { routes: [ { path: '/', name: 'home', component: { template: '
' } }, { path: '/create', name: 'create-event', component: EventCreateView }, - { path: '/events/:token', name: 'event', component: { template: '
' } }, + { path: '/events/:eventToken', name: 'event', component: { template: '
' } }, ], }) } @@ -169,6 +169,7 @@ describe('EventCreateView', () => { getOrganizerToken: vi.fn(), saveRsvp: vi.fn(), getRsvp: vi.fn(), + removeEvent: vi.fn(), }) vi.mocked(api.POST).mockResolvedValueOnce({ @@ -221,7 +222,7 @@ describe('EventCreateView', () => { expect(pushSpy).toHaveBeenCalledWith({ name: 'event', - params: { token: 'abc-123' }, + params: { eventToken: 'abc-123' }, }) }) diff --git a/frontend/src/views/__tests__/EventDetailView.spec.ts b/frontend/src/views/__tests__/EventDetailView.spec.ts index 2a1f30e..653a6aa 100644 --- a/frontend/src/views/__tests__/EventDetailView.spec.ts +++ b/frontend/src/views/__tests__/EventDetailView.spec.ts @@ -22,6 +22,7 @@ vi.mock('@/composables/useEventStorage', () => ({ getOrganizerToken: mockGetOrganizerToken, saveRsvp: mockSaveRsvp, getRsvp: mockGetRsvp, + removeEvent: vi.fn(), })), })) @@ -30,7 +31,7 @@ function createTestRouter(_token?: string) { history: createMemoryHistory(), routes: [ { path: '/', name: 'home', component: { template: '
' } }, - { path: '/events/:token', name: 'event', component: EventDetailView }, + { path: '/events/:eventToken', name: 'event', component: EventDetailView }, ], }) } diff --git a/frontend/src/views/__tests__/EventStubView.spec.ts b/frontend/src/views/__tests__/EventStubView.spec.ts index 5f93e1f..528cc59 100644 --- a/frontend/src/views/__tests__/EventStubView.spec.ts +++ b/frontend/src/views/__tests__/EventStubView.spec.ts @@ -8,7 +8,7 @@ function createTestRouter() { history: createMemoryHistory(), routes: [ { path: '/', name: 'home', component: { template: '
' } }, - { path: '/events/:token', name: 'event', component: EventStubView }, + { path: '/events/:eventToken', name: 'event', component: EventStubView }, ], }) } diff --git a/specs/009-list-events/checklists/requirements.md b/specs/009-list-events/checklists/requirements.md new file mode 100644 index 0000000..3b8c288 --- /dev/null +++ b/specs/009-list-events/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Event List on Home Page + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-08 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`. +- Assumptions section documents that no backend changes are needed — this is a frontend-only feature using existing localStorage data. diff --git a/specs/009-list-events/data-model.md b/specs/009-list-events/data-model.md new file mode 100644 index 0000000..b62a3d9 --- /dev/null +++ b/specs/009-list-events/data-model.md @@ -0,0 +1,99 @@ +# Data Model: Event List on Home Page + +**Feature**: 009-list-events | **Date**: 2026-03-08 + +## Entities + +### StoredEvent (existing — no changes) + +The `StoredEvent` interface in `frontend/src/composables/useEventStorage.ts` already contains all fields needed for the event list feature. + +```typescript +interface StoredEvent { + eventToken: string // Required — UUID, used for navigation + organizerToken?: string // Present if user created this event + title: string // Required — displayed on card + dateTime: string // Required — ISO 8601, used for sorting + relative time + expiryDate: string // Stored but not displayed in list view + rsvpToken?: string // Present if user RSVP'd to this event + rsvpName?: string // User's name at RSVP time +} +``` + +### Validation Rules + +An event entry is considered **valid** for display if all of: +- `eventToken` is a non-empty string +- `title` is a non-empty string +- `dateTime` is a non-empty string that parses to a valid `Date` + +Invalid entries are silently excluded from the list (FR-010). + +### Derived Properties (computed at render time) + +| Property | Derivation | +|----------|-----------| +| `isPast` | `new Date(dateTime) < new Date()` | +| `isOrganizer` | `organizerToken !== undefined` | +| `isAttendee` | `rsvpToken !== undefined && organizerToken === undefined` | +| `relativeTime` | `Intl.RelativeTimeFormat` applied to `dateTime` vs now | +| `detailRoute` | `/events/${eventToken}` | + +### Sorting Order + +1. **Upcoming events** (`dateTime >= now`): ascending by `dateTime` (soonest first) +2. **Past events** (`dateTime < now`): descending by `dateTime` (most recently passed first) + +### Composable Extension + +The `useEventStorage` composable needs one new function: + +```typescript +function removeEvent(eventToken: string): void { + const events = readEvents().filter((e) => e.eventToken !== eventToken) + writeEvents(events) +} +``` + +Returned alongside existing functions from `useEventStorage()`. + +## State Transitions + +``` +localStorage read + │ + ▼ + Parse JSON ──(error)──► empty array + │ + ▼ + Validate entries ──(invalid)──► silently excluded + │ + ▼ + Split: upcoming / past + │ + ▼ + Sort each group + │ + ▼ + Concatenate ──► rendered list +``` + +### Remove Event Flow + +``` +User taps delete icon / swipes left + │ + ▼ + ConfirmDialog opens + │ + ┌────┴────┐ + │ Cancel │ Confirm + │ │ │ + │ ▼ ▼ + │ removeEvent(token) + │ │ + │ ▼ + │ Event removed from localStorage + │ List re-renders (event disappears) + └────────────────────────────────┘ +``` diff --git a/specs/009-list-events/plan.md b/specs/009-list-events/plan.md new file mode 100644 index 0000000..0497299 --- /dev/null +++ b/specs/009-list-events/plan.md @@ -0,0 +1,86 @@ +# Implementation Plan: Event List on Home Page + +**Branch**: `009-list-events` | **Date**: 2026-03-08 | **Spec**: `specs/009-list-events/spec.md` +**Input**: Feature specification from `/specs/009-list-events/spec.md` + +## Summary + +Transform the home page from a static empty-state placeholder into a dynamic event list that shows all events stored in the browser's localStorage. Each event card displays title, relative time, and role indicator (organizer/attendee). Events are sorted chronologically (upcoming first), past events appear faded, and users can remove events via delete icon or swipe gesture. A FAB provides persistent access to event creation. + +This is a **frontend-only** feature — no backend or API changes required. The existing `useEventStorage` composable already provides all necessary data access. + +## Technical Context + +**Language/Version**: TypeScript 5.9, Vue 3.5 +**Primary Dependencies**: Vue 3, Vue Router 5, Vite +**Storage**: Browser localStorage via `useEventStorage` composable +**Testing**: Vitest (unit), Playwright + MSW (E2E) +**Target Platform**: Mobile-first PWA (centered 480px column on desktop) +**Project Type**: Web application (frontend-only changes) +**Performance Goals**: Event list renders within 1 second (SC-001) — trivial given localStorage read +**Constraints**: No external dependencies, no tracking, WCAG AA, keyboard navigable +**Scale/Scope**: Typically <50 events in localStorage; no pagination needed + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Privacy by Design | ✅ PASS | Purely client-side. No data leaves the browser. No analytics. | +| II. Test-Driven Methodology | ✅ PASS | Unit tests for composable, E2E for each user story. TDD enforced. | +| III. API-First Development | ✅ N/A | No API changes — this feature reads only from localStorage. | +| IV. Simplicity & Quality | ✅ PASS | Minimal approach: extend existing composable + new components. No over-engineering. | +| V. Dependency Discipline | ✅ PASS | No new dependencies. Swipe gesture implemented with native Touch API. Relative time via built-in `Intl.RelativeTimeFormat`. | +| VI. Accessibility | ✅ PASS | Semantic list markup, ARIA labels, keyboard navigation, WCAG AA contrast on faded past events. | + +**Gate result: PASS** — no violations. + +## Project Structure + +### Documentation (this feature) + +```text +specs/009-list-events/ +├── plan.md # This file +├── spec.md # Feature specification +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +└── tasks.md # Phase 2 output (/speckit.tasks command) +``` + +### Source Code (repository root) + +```text +frontend/ +├── src/ +│ ├── composables/ +│ │ ├── useEventStorage.ts # MODIFY: add removeEvent() +│ │ ├── useRelativeTime.ts # NEW: Intl.RelativeTimeFormat wrapper +│ │ └── __tests__/ +│ │ ├── useEventStorage.spec.ts # MODIFY: add removeEvent tests +│ │ └── useRelativeTime.spec.ts # NEW: relative time formatting tests +│ ├── components/ +│ │ ├── EventCard.vue # NEW: individual event list item +│ │ ├── EventList.vue # NEW: sorted event list container +│ │ ├── EmptyState.vue # NEW: extracted empty state +│ │ ├── CreateEventFab.vue # NEW: floating action button +│ │ ├── ConfirmDialog.vue # NEW: reusable confirmation prompt +│ │ └── __tests__/ +│ │ ├── EventCard.spec.ts # NEW +│ │ ├── EventList.spec.ts # NEW +│ │ ├── EmptyState.spec.ts # NEW +│ │ └── ConfirmDialog.spec.ts # NEW +│ ├── views/ +│ │ └── HomeView.vue # MODIFY: compose list/empty/fab +│ └── assets/ +│ └── main.css # MODIFY: add event card, faded, fab styles +└── e2e/ + └── home-events.spec.ts # NEW: E2E tests for all user stories +``` + +**Structure Decision**: Frontend-only changes. New components in `components/`, composable extensions in `composables/`, styles in existing `main.css`. No backend changes. + +## Complexity Tracking + +No constitution violations — this section is intentionally empty. diff --git a/specs/009-list-events/research.md b/specs/009-list-events/research.md new file mode 100644 index 0000000..1877a24 --- /dev/null +++ b/specs/009-list-events/research.md @@ -0,0 +1,110 @@ +# Research: Event List on Home Page + +**Feature**: 009-list-events | **Date**: 2026-03-08 + +## Research Questions + +### 1. Relative Time Formatting with `Intl.RelativeTimeFormat` + +**Decision**: Use the built-in `Intl.RelativeTimeFormat` API directly — no library needed. + +**Rationale**: The API is supported in all modern browsers (97%+ coverage). It handles locale-aware output natively (e.g., "in 3 days", "vor 2 Tagen" for German). The spec requires exactly this (FR-002). + +**Implementation approach**: Create a `useRelativeTime` composable that: +1. Takes a date string (ISO 8601) and computes the difference from `now` +2. Selects the appropriate unit (seconds → minutes → hours → days → weeks → months → years) +3. Returns a formatted string via `Intl.RelativeTimeFormat(navigator.language, { numeric: 'auto' })` +4. Exposes a reactive `label` that updates (optional — can be static since the list re-reads on mount) + +**Alternatives considered**: +- `date-fns/formatDistance`: Would add a dependency for something the platform already does. Rejected per Principle V. +- `dayjs/relativeTime`: Same reasoning — unnecessary dependency. + +### 2. Swipe-to-Delete Gesture (FR-006b) + +**Decision**: Implement with native Touch API (`touchstart`, `touchmove`, `touchend`) — no gesture library. + +**Rationale**: The gesture is simple (horizontal swipe on a single element). A library like Hammer.js or @vueuse/gesture would be overkill for one swipe direction on one component type. Per Principle V, dependencies must provide substantial value. + +**Implementation approach**: +1. Track `touchstart` X position on the event card +2. On `touchmove`, calculate delta-X; if leftward and exceeds threshold (~80px), reveal delete action +3. On `touchend`, either snap back or trigger confirmation +4. CSS `transform: translateX()` with `transition` for smooth animation +5. Desktop users use the visible delete icon (no swipe needed) + +**Alternatives considered**: +- `@vueuse/gesture`: Wraps Hammer.js, adds ~15KB. Rejected — too heavy for one gesture. +- CSS `scroll-snap` trick: Clever but brittle and poor accessibility. Rejected. + +### 3. Past Event Visual Fading (FR-009) + +**Decision**: Use CSS `opacity` reduction + `filter: saturate()` for faded appearance. + +**Rationale**: The spec says "subtle reduction in contrast and saturation" — not a blunt grey-out. Combining `opacity: 0.6` with `filter: saturate(0.5)` achieves this while keeping text readable. Must verify WCAG AA contrast on the faded state. + +**Implementation approach**: +- Add a `.event-card--past` modifier class +- Apply `opacity: 0.55; filter: saturate(0.4)` (tune exact values for WCAG AA) +- Keep `pointer-events: auto` and normal hover/focus styles so the card remains interactive +- The card still navigates to the event detail page on click + +**Contrast verification**: The card text (`#1C1C1E` on `#FFFFFF`) has a contrast ratio of ~17:1. At `opacity: 0.55`, effective contrast drops to ~9:1, which still passes WCAG AA (4.5:1 minimum). Safe. + +### 4. Confirmation Dialog (FR-007) + +**Decision**: Custom modal component (reusing the existing `BottomSheet.vue` pattern) rather than `window.confirm()`. + +**Rationale**: `window.confirm()` is blocking, non-stylable, and inconsistent across browsers. A custom dialog matches the app's design system and provides a better UX. The existing `BottomSheet.vue` already handles teleportation, focus trapping, and Escape-key dismissal — the confirm dialog can reuse this or follow the same pattern. + +**Implementation approach**: +- Create a `ConfirmDialog.vue` component +- Props: `open`, `title`, `message`, `confirmLabel`, `cancelLabel` +- Emits: `confirm`, `cancel` +- Uses the same teleport-to-body pattern as `BottomSheet.vue` +- Focus trapping and keyboard navigation (Tab, Escape, Enter) + +### 5. localStorage Validation (FR-010) + +**Decision**: Validate entries during read — filter out invalid events silently. + +**Rationale**: The spec says "silently excluded from the list." The `readEvents()` function already handles parse errors with a try/catch. We need to add field-level validation: an event is valid only if it has `eventToken`, `title`, and `dateTime` (all non-empty strings). + +**Implementation approach**: +- Add a `isValidStoredEvent(e: unknown): e is StoredEvent` type guard +- Apply it in `getStoredEvents()` as a filter +- Invalid entries remain in localStorage (no destructive cleanup) but are not displayed + +### 6. FAB Placement (FR-011) + +**Decision**: Fixed-position button at bottom-right with safe-area padding. + +**Rationale**: Standard Material Design pattern for primary actions. The existing `RsvpBar.vue` already uses `padding-bottom: env(safe-area-inset-bottom)` for mobile notch avoidance — reuse the same approach. + +**Implementation approach**: +- `position: fixed; bottom: calc(1.2rem + env(safe-area-inset-bottom)); right: 1.2rem` +- Circular button with `+` icon, accent color background +- `z-index` above content, shadow for elevation +- Navigates to `/create` on click + +### 7. Event Sorting (FR-004) + +**Decision**: Sort in-memory after reading from localStorage. + +**Rationale**: The list is small (<100 events typically). Sorting on every render is negligible. Sort by `dateTime` ascending (nearest upcoming first), then past events after. + +**Implementation approach**: +- Split events into `upcoming` (dateTime >= now) and `past` (dateTime < now) +- Sort upcoming ascending (soonest first), past descending (most recent past first) +- Concatenate: `[...upcoming, ...past]` + +### 8. Role Distinction (FR-008 / US-5) + +**Decision**: Small badge/label on the event card indicating "Organizer" or "Attendee." + +**Rationale**: The data is already available — `organizerToken` present means organizer, `rsvpToken` present (without `organizerToken`) means attendee. A subtle text badge is sufficient; no need for icons or colors. + +**Implementation approach**: +- If `organizerToken` is set → show "Organizer" badge (accent-colored) +- If `rsvpToken` is set (no `organizerToken`) → show "Attendee" badge (muted) +- If neither → show no badge (edge case: event stored but no role — could happen with manual localStorage manipulation) diff --git a/specs/009-list-events/spec.md b/specs/009-list-events/spec.md new file mode 100644 index 0000000..01d15bc --- /dev/null +++ b/specs/009-list-events/spec.md @@ -0,0 +1,145 @@ +# Feature Specification: Event List on Home Page + +**Feature Branch**: `009-list-events` +**Created**: 2026-03-08 +**Status**: Draft +**Input**: User description: "man kann auf der hauptseite eine liste an events sehen, sofern sie im localstorage gespeichert sind" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - View My Events (Priority: P1) + +As a returning user, I want to see a list of events I have previously created or interacted with (RSVP'd to) on the home page, so I can quickly navigate back to them without needing to remember or bookmark individual event links. + +The home page displays all events stored in the browser's local storage. Each event entry shows the event title and date/time. Tapping an event navigates to its detail page. + +**Why this priority**: This is the core value of the feature — without the list, the home page remains a dead end for returning users. + +**Independent Test**: Can be fully tested by creating an event (or simulating localStorage entries), returning to the home page, and verifying all stored events appear in a list with correct titles and dates. + +**Acceptance Scenarios**: + +1. **Given** the user has 3 events stored in localStorage, **When** they visit the home page, **Then** all 3 events are displayed in a list showing title and date/time for each. +2. **Given** the user has events stored in localStorage, **When** they tap on an event in the list, **Then** they are navigated to the event detail page (`/events/:eventToken`). +3. **Given** the user has events stored in localStorage, **When** they visit the home page, **Then** events are sorted by date/time (nearest upcoming event first, past events last). + +--- + +### User Story 2 - Empty State (Priority: P2) + +As a new user with no stored events, I see an inviting empty state on the home page that encourages me to create my first event or explains how to get started. + +**Why this priority**: First-time users need clear guidance. The empty state is the first impression for new users. + +**Independent Test**: Can be tested by clearing localStorage and visiting the home page — the empty state message and "Create Event" call-to-action should be visible. + +**Acceptance Scenarios**: + +1. **Given** no events are stored in localStorage, **When** the user visits the home page, **Then** an empty state message is displayed (e.g., "No events yet") with a prominent "Create Event" button. +2. **Given** the user has at least one event stored, **When** they visit the home page, **Then** the empty state message is not shown — the event list is displayed instead. + +--- + +### User Story 3 - Remove Event from List (Priority: P3) + +As a user, I want to remove an event from my personal list so I can keep my home page tidy and only show events I still care about. + +**Why this priority**: Housekeeping capability. Without removal, the list grows indefinitely and becomes cluttered over time. + +**Independent Test**: Can be tested by having multiple events in localStorage, removing one from the list, and verifying it disappears from the home page while the others remain. + +**Acceptance Scenarios**: + +1. **Given** the user has events in their list, **When** they tap the delete icon on an event card, **Then** a confirmation prompt appears asking if they are sure. +1b. **Given** the user has events in their list, **When** they swipe an event card to the left, **Then** a confirmation prompt appears asking if they are sure. +2. **Given** the confirmation prompt is shown, **When** the user confirms removal, **Then** the event is removed from localStorage and disappears from the list immediately. +3. **Given** the confirmation prompt is shown, **When** the user cancels, **Then** the event remains in the list unchanged. + +--- + +### User Story 4 - Past Events Appear Faded (Priority: P2) + +As a user, I want events whose date/time has passed to appear visually faded or muted in the list, so I can immediately focus on upcoming events without past events cluttering my attention. + +The fading should feel modern and polished — not a blunt grey-out, but a subtle reduction in contrast and saturation that makes past events recede visually while remaining readable and tappable. + +**Why this priority**: Without this, past and upcoming events look identical, making the list harder to scan. This is essential for usability once a user has accumulated several events. + +**Independent Test**: Can be tested by having both future and past events in localStorage and verifying that past events display with reduced visual prominence while remaining interactive. + +**Acceptance Scenarios**: + +1. **Given** the user has a past event (dateTime before now) in localStorage, **When** they view the home page, **Then** the event appears with reduced visual prominence (muted colors, lower contrast) compared to upcoming events. +2. **Given** the user has a past event in the list, **When** they tap on it, **Then** it still navigates to the event detail page — it remains fully interactive. +3. **Given** the user has both past and upcoming events, **When** they view the home page, **Then** upcoming events appear first (full visual prominence), followed by past events (faded), creating a clear visual hierarchy. + +--- + +### User Story 5 - Visual Distinction for Event Roles (Priority: P3) + +As a user, I want to see at a glance whether I am the organizer of an event or just an attendee, so I can quickly identify my responsibilities. + +**Why this priority**: Nice-to-have clarity. The data is already available in localStorage (presence of `organizerToken`), so surfacing it improves usability at low effort. + +**Independent Test**: Can be tested by having both created events (with organizerToken) and RSVP'd events (with rsvpToken) in localStorage, and verifying they display different visual indicators. + +**Acceptance Scenarios**: + +1. **Given** the user has a created event (organizerToken present) in localStorage, **When** they view the home page, **Then** the event shows a visual indicator marking them as the organizer (e.g., a badge or label). +2. **Given** the user has an event with an RSVP (rsvpToken present, no organizerToken) in localStorage, **When** they view the home page, **Then** the event shows a visual indicator marking them as an attendee. + +--- + +### Edge Cases + +- What happens when localStorage data is corrupted or contains invalid entries? Events with missing required fields (eventToken, title, dateTime) are silently excluded from the list. +- What happens when localStorage is unavailable (e.g., private browsing with storage disabled)? The empty state is shown with the "Create Event" button — the app remains functional. +- What happens when an event's date/time has passed? The event remains in the list but appears visually faded. +- What happens when the user has a very large number of stored events (e.g., 50+)? The list scrolls naturally. No pagination is needed at this scale since localStorage entries are lightweight. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST display a list of all events stored in the browser's local storage on the home page. +- **FR-002**: Each event entry MUST show the event title and the event date/time displayed as a relative time label (e.g., "in 3 days", "yesterday") using `Intl.RelativeTimeFormat`. +- **FR-003**: Each event entry MUST be tappable/clickable and navigate to the event detail page (`/events/:eventToken`). +- **FR-004**: Events MUST be sorted by date/time with nearest upcoming events first and past events last. +- **FR-005**: System MUST display an empty state with a "Create Event" call-to-action when no events are stored. +- **FR-006a**: Users MUST be able to remove individual events from their local list via a visible delete icon on each event card (primary mechanism, implemented first). +- **FR-006b**: Users MUST be able to remove individual events via swipe-to-delete gesture (secondary mechanism, implemented separately after FR-006a). +- **FR-007**: System MUST show a confirmation prompt before removing an event from the list. +- **FR-008**: System MUST visually distinguish events where the user is the organizer from events where the user is an attendee. +- **FR-009**: System MUST display past events (dateTime before current time) with reduced visual prominence — muted colors and lower contrast — while keeping them readable and interactive. +- **FR-010**: System MUST gracefully handle corrupted or incomplete localStorage entries by excluding invalid events from the list. +- **FR-011**: The "Create Event" button MUST remain accessible on the home page even when events are listed, implemented as a Floating Action Button (FAB) fixed at the bottom-right corner. + +### Key Entities + +- **Stored Event**: A locally persisted reference to an event the user has interacted with. Contains: event token (unique identifier for navigation), title, date/time, expiry date, and optionally an organizer token (if created by this user) or RSVP token and name (if the user RSVP'd). + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Users can see all their stored events on the home page within 1 second of page load. +- **SC-002**: Users can navigate from the home page to any event detail page in a single tap/click. +- **SC-003**: Users can remove an unwanted event from their list in under 3 seconds (including confirmation). +- **SC-004**: New users (no stored events) see a clear call-to-action to create their first event. +- **SC-005**: Users can distinguish their role (organizer vs. attendee) for each event at a glance without opening the event. + +## Clarifications + +### Session 2026-03-08 + +- Q: How does the user trigger event removal? → A: Two mechanisms — visible delete icon on each event card (primary, implemented first) and swipe-to-delete gesture (secondary, implemented separately after). +- Q: Placement of "Create Event" button when events exist? → A: Floating Action Button (FAB) fixed at bottom-right corner. +- Q: Date/time display format in event list? → A: Relative time labels ("in 3 days", "yesterday") via Intl.RelativeTimeFormat. + +## Assumptions + +- The existing `useEventStorage` composable and `StoredEvent` interface provide all necessary data for the event list (no backend API calls needed for listing). +- The event list is purely client-side — there is no server-side "my events" endpoint. Privacy is preserved because events are only known to the user's browser. +- The event list uses `Intl.RelativeTimeFormat` for relative time labels (FR-002), while the event detail view uses `Intl.DateTimeFormat` for absolute date/time display. Both use the browser's locale (`navigator.language`). +- The "Create Event" flow (spec 006) already saves events to localStorage, so no changes to event creation are needed. +- The RSVP flow (spec 008) already saves RSVP data to localStorage, so no changes to RSVP are needed. diff --git a/specs/009-list-events/tasks.md b/specs/009-list-events/tasks.md new file mode 100644 index 0000000..66f951a --- /dev/null +++ b/specs/009-list-events/tasks.md @@ -0,0 +1,215 @@ +# Tasks: Event List on Home Page + +**Input**: Design documents from `/specs/009-list-events/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md + +**Tests**: Unit tests (Vitest) and E2E tests (Playwright) are included per constitution (Principle II: Test-Driven Methodology). + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Composable extensions and utility functions shared across all user stories + +- [x] T000 Rename router param `:token` to `:eventToken` in `frontend/src/router/index.ts` and update all references in `EventDetailView.vue`, `EventStubView.vue`, and their test files (consistency with `StoredEvent.eventToken` field name) +- [x] T001 Add `isValidStoredEvent` type guard and validation filter to `frontend/src/composables/useEventStorage.ts` (FR-010) +- [x] T002 Add `removeEvent(eventToken: string)` function to `frontend/src/composables/useEventStorage.ts` (needed by US3) +- [x] T003 [P] Create `useRelativeTime` composable in `frontend/src/composables/useRelativeTime.ts` (Intl.RelativeTimeFormat wrapper, FR-002) +- [x] T004 [P] Add unit tests for `isValidStoredEvent` and `removeEvent` in `frontend/src/composables/__tests__/useEventStorage.spec.ts` +- [x] T005 [P] Create unit tests for `useRelativeTime` in `frontend/src/composables/__tests__/useRelativeTime.spec.ts` + +**Checkpoint**: Composable layer complete — all shared logic tested and available for components. + +--- + +## Phase 2: User Story 1 — View My Events (Priority: P1) 🎯 MVP + +**Goal**: Home page shows all stored events in a sorted list with title and relative time. Tapping navigates to event detail. + +**Independent Test**: Simulate localStorage entries, visit home page, verify all events appear sorted with correct titles and relative times. Tap an event and verify navigation to `/events/:eventToken`. + +### Unit Tests for User Story 1 + +- [x] T006 [P] [US1] Create unit tests for EventCard component in `frontend/src/components/__tests__/EventCard.spec.ts` — include test cases for `isPast` prop (faded styling) and role badge rendering (organizer vs. attendee) +- [x] T007 [P] [US1] Create unit tests for EventList component in `frontend/src/components/__tests__/EventList.spec.ts` + +### Implementation for User Story 1 + +- [x] T008 [P] [US1] Create `EventCard.vue` component in `frontend/src/components/EventCard.vue` — displays title, relative time, role badge; emits click for navigation +- [x] T009 [US1] Create `EventList.vue` component in `frontend/src/components/EventList.vue` — reads events from composable, validates, sorts (upcoming asc, past desc), renders EventCard list +- [x] T010 [US1] Refactor `HomeView.vue` in `frontend/src/views/HomeView.vue` — integrate EventList, conditionally show list when events exist +- [x] T011 [US1] Add event card and list styles to `frontend/src/assets/main.css` + +### E2E Tests for User Story 1 + +- [x] T012 [US1] Create E2E test file `frontend/e2e/home-events.spec.ts` — tests: events displayed with title and relative time, sorted correctly, click navigates to detail page + +**Checkpoint**: MVP complete — returning users see their events and can navigate to details. + +--- + +## Phase 3: User Story 2 — Empty State (Priority: P2) + +**Goal**: New users with no stored events see an inviting empty state with a "Create Event" call-to-action. + +**Independent Test**: Clear localStorage, visit home page, verify empty state message and "Create Event" button are visible. + +### Unit Tests for User Story 2 + +- [x] T013 [P] [US2] Create unit tests for EmptyState component in `frontend/src/components/__tests__/EmptyState.spec.ts` + +### Implementation for User Story 2 + +- [x] T014 [US2] Create `EmptyState.vue` component in `frontend/src/components/EmptyState.vue` — shows message and "Create Event" RouterLink +- [x] T015 [US2] Update `HomeView.vue` in `frontend/src/views/HomeView.vue` — show EmptyState when no valid events, show EventList otherwise +- [x] T016 [US2] Add empty state styles to `frontend/src/assets/main.css` + +### E2E Tests for User Story 2 + +- [x] T017 [US2] Add E2E tests to `frontend/e2e/home-events.spec.ts` — tests: empty state shown when no events, hidden when events exist + +**Checkpoint**: Home page handles both new and returning users. + +--- + +## Phase 4: User Story 4 — Past Events Appear Faded (Priority: P2) + +**Goal**: Events whose date/time has passed appear with reduced visual prominence (muted colors, lower contrast) while remaining interactive. + +**Independent Test**: Have both future and past events in localStorage, verify past events display faded while remaining clickable. + +### Implementation for User Story 4 + +- [x] T018 [US4] Add `.event-card--past` modifier class with `opacity: 0.6; filter: saturate(0.5)` to `frontend/src/components/EventCard.vue` or `frontend/src/assets/main.css` +- [x] T019 [US4] Pass `isPast` computed property to EventCard in `EventList.vue` and apply modifier class in `frontend/src/components/EventCard.vue` + +### E2E Tests for User Story 4 + +- [x] T020 [US4] Add E2E tests to `frontend/e2e/home-events.spec.ts` — tests: past events have faded class, upcoming events do not, past events remain clickable + +**Checkpoint**: Visual hierarchy distinguishes upcoming from past events. + +--- + +## Phase 5: User Story 3 — Remove Event from List (Priority: P3) + +**Goal**: Users can remove events from their local list via delete icon (and later swipe) with confirmation. + +**Independent Test**: Have multiple events, remove one via delete icon, verify it disappears while others remain. + +### Unit Tests for User Story 3 + +- [x] T021 [P] [US3] Create unit tests for ConfirmDialog component in `frontend/src/components/__tests__/ConfirmDialog.spec.ts` + +### Implementation for User Story 3 + +- [x] T022 [US3] Create `ConfirmDialog.vue` component in `frontend/src/components/ConfirmDialog.vue` — teleport-to-body modal with confirm/cancel, focus trapping, Escape key +- [x] T023 [US3] Add delete icon button to `EventCard.vue` in `frontend/src/components/EventCard.vue` — emits `delete` event with eventToken (FR-006a) +- [x] T024 [US3] Wire delete flow in `EventList.vue` in `frontend/src/components/EventList.vue` — listen for delete event, show ConfirmDialog, call `removeEvent()` on confirm +- [x] T025 [US3] Add delete icon and confirm dialog styles to `frontend/src/assets/main.css` + +### E2E Tests for User Story 3 + +- [x] T026 [US3] Add E2E tests to `frontend/e2e/home-events.spec.ts` — tests: delete icon visible, confirmation dialog appears, confirm removes event, cancel keeps event + +**Checkpoint**: Users can manage their event list. + +--- + +## Phase 6: User Story 5 — Visual Distinction for Event Roles (Priority: P3) + +**Goal**: Events show a badge indicating whether the user is the organizer or an attendee. + +**Independent Test**: Have events with organizerToken and rsvpToken in localStorage, verify different badges displayed. + +### Implementation for User Story 5 + +- [x] T027 [US5] Add role badge (Organizer/Attendee) to `EventCard.vue` in `frontend/src/components/EventCard.vue` — derive from organizerToken/rsvpToken presence +- [x] T028 [US5] Add role badge styles to `frontend/src/assets/main.css` + +### E2E Tests for User Story 5 + +- [x] T029 [US5] Add E2E tests to `frontend/e2e/home-events.spec.ts` — tests: organizer badge shown for events with organizerToken, attendee badge for events with rsvpToken only + +**Checkpoint**: Role distinction visible at a glance. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: FAB, swipe gesture, accessibility, and final polish + +- [x] T030 Create `CreateEventFab.vue` in `frontend/src/components/CreateEventFab.vue` — fixed FAB at bottom-right, navigates to `/create` (FR-011) +- [x] T031 Add FAB to `HomeView.vue` in `frontend/src/views/HomeView.vue` — visible when events exist (empty state has its own CTA) +- [x] T032 Add FAB styles to `frontend/src/assets/main.css` +- [x] T033 Implement swipe-to-delete gesture on EventCard in `frontend/src/components/EventCard.vue` — native Touch API (FR-006b) +- [x] T034 Accessibility review: verify ARIA labels, keyboard navigation (Tab/Enter/Escape), focus trapping in ConfirmDialog, WCAG AA contrast on faded cards +- [x] T035 Add E2E tests for FAB to `frontend/e2e/home-events.spec.ts` — tests: FAB visible when events exist, navigates to create page + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: No dependencies — start immediately +- **Phase 2 (US1)**: Depends on T001, T003 (validation + relative time composable) +- **Phase 3 (US2)**: Depends on T001 (validation); can run in parallel with US1 +- **Phase 4 (US4)**: Depends on Phase 2 completion (EventCard must exist) +- **Phase 5 (US3)**: Depends on Phase 2 completion (EventList must exist) + T002 (removeEvent) +- **Phase 6 (US5)**: Depends on Phase 2 completion (EventCard must exist) +- **Phase 7 (Polish)**: Depends on Phases 2–6 completion + +### User Story Dependencies + +- **US1 (P1)**: Depends only on Phase 1 — no other story dependencies +- **US2 (P2)**: Depends only on Phase 1 — independent of US1 but shares HomeView +- **US4 (P2)**: Depends on US1 (extends EventCard with past styling) +- **US3 (P3)**: Depends on US1 (extends EventList with delete flow) +- **US5 (P3)**: Depends on US1 (extends EventCard with role badge) + +### Parallel Opportunities + +- T003 + T004 + T005 can all run in parallel (different files) +- T006 + T007 can run in parallel (different test files) +- T008 can run in parallel with T006/T007 (component vs test files) +- US4, US5 can start in parallel once US1 is done (both extend EventCard independently) + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup composables +2. Complete Phase 2: US1 — EventCard, EventList, HomeView refactor +3. **STOP and VALIDATE**: Test the event list end-to-end +4. Deploy/demo if ready + +### Incremental Delivery + +1. Phase 1 → Composable layer ready +2. Phase 2 (US1) → Event list works → MVP! +3. Phase 3 (US2) → Empty state for new users +4. Phase 4 (US4) → Past events faded +5. Phase 5 (US3) → Remove events from list +6. Phase 6 (US5) → Role badges +7. Phase 7 → FAB, swipe, accessibility polish + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] label maps task to specific user story for traceability +- This is a **frontend-only** feature — no backend changes needed +- All data comes from existing `useEventStorage` composable (localStorage) +- E2E tests consolidated in single file `home-events.spec.ts` with separate `describe` blocks per story -- 2.49.1