Add event list feature (009-list-events)
All checks were successful
CI / backend-test (push) Successful in 57s
CI / frontend-test (push) Successful in 22s
CI / frontend-e2e (push) Successful in 1m4s
CI / build-and-publish (push) Has been skipped

Enable users to see all their saved events on the home screen, sorted
by date with upcoming events first. Key capabilities:

- EventCard with title, relative time display, and organizer/attendee
  role badge
- Sortable EventList with past-event visual distinction (faded style)
- Empty state when no events are stored
- Swipe-to-delete gesture with confirmation dialog
- Floating action button for quick event creation
- Rename router param :token → :eventToken across all views
- useRelativeTime composable (Intl.RelativeTimeFormat)
- useEventStorage: add validation, removeEvent(), reactive versioning
- Full E2E and unit test coverage for all new components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 15:53:55 +01:00
parent 1b3eafa8d1
commit e56998b17c
28 changed files with 1989 additions and 27 deletions

View File

@@ -0,0 +1,151 @@
<template>
<Teleport to="body">
<Transition name="confirm-dialog">
<div v-if="open" class="confirm-dialog__overlay" @click.self="$emit('cancel')">
<div
class="confirm-dialog"
role="alertdialog"
aria-modal="true"
:aria-label="title"
@keydown.escape="$emit('cancel')"
>
<p class="confirm-dialog__title">{{ title }}</p>
<p class="confirm-dialog__message">{{ message }}</p>
<div class="confirm-dialog__actions">
<button
ref="cancelBtn"
class="confirm-dialog__btn confirm-dialog__btn--cancel"
type="button"
@click="$emit('cancel')"
>
{{ cancelLabel }}
</button>
<button
class="confirm-dialog__btn confirm-dialog__btn--confirm"
type="button"
@click="$emit('confirm')"
>
{{ confirmLabel }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
const props = withDefaults(
defineProps<{
open: boolean
title?: string
message?: string
confirmLabel?: string
cancelLabel?: string
}>(),
{
title: 'Are you sure?',
message: '',
confirmLabel: 'Remove',
cancelLabel: 'Cancel',
},
)
defineEmits<{
confirm: []
cancel: []
}>()
const cancelBtn = ref<HTMLButtonElement | null>(null)
watch(
() => props.open,
async (isOpen) => {
if (isOpen) {
await nextTick()
cancelBtn.value?.focus()
}
},
)
</script>
<style scoped>
.confirm-dialog__overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: var(--spacing-lg);
}
.confirm-dialog {
background: var(--color-card);
border-radius: var(--radius-card);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
padding: var(--spacing-xl);
max-width: 320px;
width: 100%;
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.confirm-dialog__title {
font-size: 1.05rem;
font-weight: 700;
color: var(--color-text);
}
.confirm-dialog__message {
font-size: 0.9rem;
font-weight: 400;
color: #666;
}
.confirm-dialog__actions {
display: flex;
gap: var(--spacing-sm);
justify-content: flex-end;
margin-top: var(--spacing-xs);
}
.confirm-dialog__btn {
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius-button);
font-family: inherit;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s ease;
}
.confirm-dialog__btn:hover {
opacity: 0.85;
}
.confirm-dialog__btn--cancel {
background: #e8e8e8;
color: #555;
}
.confirm-dialog__btn--confirm {
background: #d32f2f;
color: #fff;
}
.confirm-dialog-enter-active,
.confirm-dialog-leave-active {
transition: opacity 0.15s ease;
}
.confirm-dialog-enter-from,
.confirm-dialog-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<RouterLink to="/create" class="fab" aria-label="Create event">
<span class="fab__icon" aria-hidden="true">+</span>
</RouterLink>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>
<style scoped>
.fab {
position: fixed;
bottom: calc(1.2rem + env(safe-area-inset-bottom));
right: 1.2rem;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--color-accent);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
text-decoration: none;
z-index: 100;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.fab:hover {
transform: scale(1.08);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
}
.fab:active {
transform: scale(0.95);
}
.fab:focus-visible {
outline: 2px solid #fff;
outline-offset: 3px;
}
.fab__icon {
font-size: 1.8rem;
font-weight: 300;
line-height: 1;
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<div class="empty-state">
<p class="empty-state__message">No events yet.<br />Create your first one!</p>
<RouterLink to="/create" class="btn-primary empty-state__cta">+ Create Event</RouterLink>
</div>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>
<style scoped>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-lg);
}
.empty-state__message {
font-size: 1rem;
font-weight: 400;
color: var(--color-text-on-gradient);
opacity: 0.9;
text-align: center;
}
.empty-state__cta {
max-width: 280px;
}
</style>

View File

@@ -0,0 +1,172 @@
<template>
<div
class="event-card"
:class="{ 'event-card--past': isPast, 'event-card--swiping': isSwiping }"
:style="swipeStyle"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<RouterLink :to="`/events/${eventToken}`" class="event-card__link">
<span class="event-card__title">{{ title }}</span>
<span class="event-card__time">{{ relativeTime }}</span>
</RouterLink>
<span v-if="eventRole" class="event-card__badge" :class="`event-card__badge--${eventRole}`">
{{ eventRole === 'organizer' ? 'Organizer' : 'Attendee' }}
</span>
<button
class="event-card__delete"
type="button"
:aria-label="`Remove ${title}`"
@click.stop="$emit('delete', eventToken)"
>
×
</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { RouterLink } from 'vue-router'
const props = defineProps<{
eventToken: string
title: string
relativeTime: string
isPast: boolean
eventRole?: 'organizer' | 'attendee'
}>()
const emit = defineEmits<{
delete: [eventToken: string]
}>()
const SWIPE_THRESHOLD = 80
const startX = ref(0)
const deltaX = ref(0)
const isSwiping = ref(false)
const swipeStyle = computed(() => {
if (deltaX.value === 0) return {}
return { transform: `translateX(${deltaX.value}px)` }
})
function onTouchStart(e: TouchEvent) {
const touch = e.touches[0]
if (!touch) return
startX.value = touch.clientX
deltaX.value = 0
isSwiping.value = false
}
function onTouchMove(e: TouchEvent) {
const touch = e.touches[0]
if (!touch) return
const diff = touch.clientX - startX.value
// Only allow leftward swipe
if (diff < 0) {
deltaX.value = diff
isSwiping.value = true
}
}
function onTouchEnd() {
if (deltaX.value < -SWIPE_THRESHOLD) {
emit('delete', props.eventToken)
}
deltaX.value = 0
isSwiping.value = false
}
</script>
<style scoped>
.event-card {
display: flex;
align-items: center;
background: var(--color-card);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
padding: var(--spacing-md) var(--spacing-lg);
gap: var(--spacing-sm);
}
.event-card--past {
opacity: 0.6;
filter: saturate(0.5);
}
.event-card:not(.event-card--swiping) {
transition: opacity 0.2s ease, filter 0.2s ease, transform 0.2s ease;
}
.event-card__link {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.15rem;
text-decoration: none;
color: inherit;
min-width: 0;
}
.event-card__title {
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-card__time {
font-size: 0.8rem;
font-weight: 400;
color: #888;
}
.event-card__badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.15rem 0.5rem;
border-radius: 999px;
white-space: nowrap;
flex-shrink: 0;
}
.event-card__badge--organizer {
background: var(--color-accent);
color: #fff;
}
.event-card__badge--attendee {
background: #e0e0e0;
color: #555;
}
.event-card__delete {
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
font-size: 1.2rem;
color: #bbb;
cursor: pointer;
border-radius: 50%;
transition: color 0.15s ease, background 0.15s ease;
}
.event-card__delete:hover {
color: #d32f2f;
background: rgba(211, 47, 47, 0.08);
}
.event-card__delete:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="event-list" role="list" aria-label="Your events">
<div v-for="event in sortedEvents" :key="event.eventToken" role="listitem">
<EventCard
:event-token="event.eventToken"
:title="event.title"
:relative-time="formatRelativeTime(event.dateTime)"
:is-past="isPast(event.dateTime)"
:event-role="getRole(event)"
@delete="requestDelete"
/>
</div>
<ConfirmDialog
:open="!!pendingDeleteToken"
title="Remove event?"
message="This event will be removed from your list."
confirm-label="Remove"
cancel-label="Cancel"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useEventStorage, isValidStoredEvent } from '../composables/useEventStorage'
import { formatRelativeTime } from '../composables/useRelativeTime'
import EventCard from './EventCard.vue'
import ConfirmDialog from './ConfirmDialog.vue'
import type { StoredEvent } from '../composables/useEventStorage'
const { getStoredEvents, removeEvent } = useEventStorage()
const pendingDeleteToken = ref<string | null>(null)
function requestDelete(eventToken: string) {
pendingDeleteToken.value = eventToken
}
function confirmDelete() {
if (pendingDeleteToken.value) {
removeEvent(pendingDeleteToken.value)
}
pendingDeleteToken.value = null
}
function cancelDelete() {
pendingDeleteToken.value = null
}
function isPast(dateTime: string): boolean {
return new Date(dateTime) < new Date()
}
function getRole(event: StoredEvent): 'organizer' | 'attendee' | undefined {
if (event.organizerToken) return 'organizer'
if (event.rsvpToken) return 'attendee'
return undefined
}
const sortedEvents = computed(() => {
const valid = getStoredEvents().filter(isValidStoredEvent)
const now = new Date()
const upcoming = valid.filter((e) => new Date(e.dateTime) >= now)
const past = valid.filter((e) => new Date(e.dateTime) < now)
upcoming.sort((a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime())
past.sort((a, b) => new Date(b.dateTime).getTime() - new Date(a.dateTime).getTime())
return [...upcoming, ...past]
})
</script>
<style scoped>
.event-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
</style>

View File

@@ -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<string, unknown> = {}) {
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')
})
})

View File

@@ -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: '<div />' } },
{ path: '/create', name: 'create', component: { template: '<div />' } },
],
})
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')
})
})

View File

@@ -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: '<div />' } },
{ path: '/events/:eventToken', name: 'event', component: { template: '<div />' } },
],
})
function mountCard(props: Record<string, unknown> = {}) {
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']])
})
})

View File

@@ -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: '<div />' } },
{ path: '/events/:eventToken', name: 'event', component: { template: '<div />' } },
],
})
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<string, unknown>
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')
})
})