6 Commits

Author SHA1 Message Date
d7ed28e036 Merge pull request 'Add event list temporal grouping (010)' (#19) from 010-event-list-grouping into master
All checks were successful
CI / backend-test (push) Successful in 1m2s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m9s
CI / build-and-publish (push) Successful in 1m1s
2026-03-08 17:30:35 +01:00
a52d0cd1d3 Add temporal grouping to event list (Today/This Week/Next Week/Later/Past)
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m9s
CI / build-and-publish (push) Has been skipped
Group events into five temporal sections with section headers, date subheaders,
and context-aware time display (clock time for upcoming, relative for past).
Includes new useEventGrouping composable, SectionHeader and DateSubheader
components, full unit and E2E test coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:26:58 +01:00
373f3671f6 Merge pull request 'Redesign event detail view with full-screen layout' (#18) from redesign-event-detail-view into master
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 22s
CI / frontend-e2e (push) Successful in 1m5s
CI / build-and-publish (push) Has been skipped
2026-03-08 16:51:09 +01:00
8f78c6cd45 Redesign event detail view: full-screen layout with hero image
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m5s
CI / build-and-publish (push) Has been skipped
Replace card-based event detail view with full-screen gradient layout.
Add hero image with gradient overlay, icon-based meta rows, and
"About" section. Content renders directly on the gradient background
with white text for an app-native feel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 16:47:29 +01:00
fe291e36e4 Merge pull request 'Add event list feature (009-list-events)' (#17) from 009-list-events into master
All checks were successful
CI / backend-test (push) Successful in 56s
CI / frontend-test (push) Successful in 22s
CI / frontend-e2e (push) Successful in 1m3s
CI / build-and-publish (push) Successful in 59s
2026-03-08 15:58:04 +01:00
e56998b17c 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>
2026-03-08 15:53:55 +01:00
42 changed files with 3453 additions and 112 deletions

View File

@@ -53,6 +53,8 @@ The following skills are available and should be used for their respective purpo
## Active Technologies
- Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript (007-view-event)
- PostgreSQL (JPA via Spring Data, Liquibase migrations) (007-view-event)
- TypeScript 5.9 (frontend only) + Vue 3, Vue Router 5 (existing — no additions) (010-event-list-grouping)
- localStorage via `useEventStorage.ts` composable (existing — no changes) (010-event-list-grouping)
## Recent Changes
- 007-view-event: Added Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript

View File

@@ -0,0 +1,377 @@
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('Temporal Grouping: Section Headers', () => {
test('events are distributed under correct section headers', async ({ page }) => {
// Use dates relative to "now" to ensure correct section assignment
const now = new Date()
const todayEvent: StoredEvent = {
eventToken: 'today-1',
title: 'Today Standup',
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0, 0).toISOString(),
expiryDate: '',
}
const laterEvent: StoredEvent = {
eventToken: 'later-1',
title: 'Future Conference',
dateTime: new Date(now.getFullYear() + 1, 0, 15, 10, 0, 0).toISOString(),
expiryDate: '',
}
await page.addInitScript(seedEvents([todayEvent, laterEvent, pastEvent]))
await page.goto('/')
// Verify section headers appear
await expect(page.getByRole('heading', { name: 'Today', level: 2 })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Later', level: 2 })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Past', level: 2 })).toBeVisible()
// Events are in the correct sections
const sections = page.locator('.event-section')
const todaySection = sections.filter({ has: page.getByRole('heading', { name: 'Today', level: 2 }) })
await expect(todaySection.getByText('Today Standup')).toBeVisible()
const laterSection = sections.filter({ has: page.getByRole('heading', { name: 'Later', level: 2 }) })
await expect(laterSection.getByText('Future Conference')).toBeVisible()
const pastSection = sections.filter({ has: page.getByRole('heading', { name: 'Past', level: 2 }) })
await expect(pastSection.getByText('New Year Party')).toBeVisible()
})
test('empty sections are not rendered', async ({ page }) => {
// Only a past event — no Today, This Week, or Later sections
await page.addInitScript(seedEvents([pastEvent]))
await page.goto('/')
await expect(page.getByRole('heading', { name: 'Past', level: 2 })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Today', level: 2 })).toHaveCount(0)
await expect(page.getByRole('heading', { name: 'This Week', level: 2 })).toHaveCount(0)
await expect(page.getByRole('heading', { name: 'Next Week', level: 2 })).toHaveCount(0)
await expect(page.getByRole('heading', { name: 'Later', level: 2 })).toHaveCount(0)
})
test('Today section header has emphasis CSS class', async ({ page }) => {
const now = new Date()
const todayEvent: StoredEvent = {
eventToken: 'today-emph',
title: 'Emphasis Test',
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 20, 0, 0).toISOString(),
expiryDate: '',
}
await page.addInitScript(seedEvents([todayEvent]))
await page.goto('/')
const todayHeader = page.getByRole('heading', { name: 'Today', level: 2 })
await expect(todayHeader).toHaveClass(/section-header--emphasized/)
})
})
test.describe('Temporal Grouping: Date Subheaders', () => {
test('no date subheader in Today section', async ({ page }) => {
const now = new Date()
const todayEvent: StoredEvent = {
eventToken: 'today-sub',
title: 'No Subheader Test',
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 19, 0, 0).toISOString(),
expiryDate: '',
}
await page.addInitScript(seedEvents([todayEvent]))
await page.goto('/')
const todaySection = page.locator('.event-section').filter({
has: page.getByRole('heading', { name: 'Today', level: 2 }),
})
await expect(todaySection.locator('.date-subheader')).toHaveCount(0)
})
test('date subheaders appear in Later section', async ({ page }) => {
await page.addInitScript(seedEvents([futureEvent1, futureEvent2]))
await page.goto('/')
const laterSection = page.locator('.event-section').filter({
has: page.getByRole('heading', { name: 'Later', level: 2 }),
})
// Both future events are on different dates, so expect subheaders
const subheaders = laterSection.locator('.date-subheader')
await expect(subheaders).toHaveCount(2)
})
test('date subheaders appear in Past section', async ({ page }) => {
await page.addInitScript(seedEvents([pastEvent]))
await page.goto('/')
const pastSection = page.locator('.event-section').filter({
has: page.getByRole('heading', { name: 'Past', level: 2 }),
})
await expect(pastSection.locator('.date-subheader')).toHaveCount(1)
})
})
test.describe('Temporal Grouping: Time Display', () => {
test('future event cards show clock time', async ({ page }) => {
await page.addInitScript(seedEvents([futureEvent1]))
await page.goto('/')
const timeLabel = page.locator('.event-card__time')
const text = await timeLabel.first().textContent()
// Should show clock time (e.g., "18:00" or "6:00 PM"), not relative time
expect(text).toMatch(/\d{1,2}[:.]\d{2}/)
})
test('past event cards show relative time', async ({ page }) => {
await page.addInitScript(seedEvents([pastEvent]))
await page.goto('/')
const timeLabel = page.locator('.event-card__time')
const text = await timeLabel.first().textContent()
// Should show relative time like "X years ago" or "last year"
expect(text).toMatch(/ago|last|yesterday/)
})
})
test.describe('US1: View My Events', () => {
test('displays all stored events with title and relative time', async ({ page }) => {
await page.addInitScript(seedEvents([futureEvent1, futureEvent2, pastEvent]))
await page.goto('/')
await expect(page.getByText('Summer BBQ')).toBeVisible()
await expect(page.getByText('Team Meeting')).toBeVisible()
await expect(page.getByText('New Year Party')).toBeVisible()
})
test('events are sorted: upcoming ascending, then past', async ({ page }) => {
await page.addInitScript(seedEvents([futureEvent1, futureEvent2, pastEvent]))
await page.goto('/')
const titles = page.locator('.event-card__title')
await expect(titles).toHaveCount(3)
// Team Meeting (Jan 2027) before Summer BBQ (Jun 2027), then past event
await expect(titles.nth(0)).toHaveText('Team Meeting')
await expect(titles.nth(1)).toHaveText('Summer BBQ')
await expect(titles.nth(2)).toHaveText('New Year Party')
})
test('clicking an event navigates to its detail page', async ({ page, network }) => {
await page.addInitScript(seedEvents([futureEvent1]))
// Mock the event detail API so navigation doesn't fail
const { http, HttpResponse } = await import('msw')
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json({
eventToken: futureEvent1.eventToken,
title: futureEvent1.title,
dateTime: futureEvent1.dateTime,
description: '',
location: '',
timezone: 'UTC',
attendeeCount: 0,
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()
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

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,19 @@
<template>
<h3 class="date-subheader">{{ label }}</h3>
</template>
<script setup lang="ts">
defineProps<{
label: string
}>()
</script>
<style scoped>
.date-subheader {
font-size: 0.85rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
margin: 0;
padding: var(--spacing-xs) 0;
}
</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,181 @@
<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">{{ displayTime }}</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'
timeDisplayMode?: 'clock' | 'relative'
dateTime?: string
}>()
const emit = defineEmits<{
delete: [eventToken: string]
}>()
const displayTime = computed(() => {
if (props.timeDisplayMode === 'clock' && props.dateTime) {
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(new Date(props.dateTime))
}
return props.relativeTime
})
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,94 @@
<template>
<div class="event-list">
<section
v-for="section in groupedSections"
:key="section.key"
:aria-label="section.label"
class="event-section"
>
<SectionHeader :label="section.label" :emphasized="section.emphasized" />
<div role="list">
<template v-for="group in section.dateGroups" :key="group.dateKey">
<DateSubheader v-if="group.showSubheader" :label="group.label" />
<div v-for="event in group.events" :key="event.eventToken" role="listitem">
<EventCard
:event-token="event.eventToken"
:title="event.title"
:relative-time="formatRelativeTime(event.dateTime)"
:is-past="section.key === 'past'"
:event-role="getRole(event)"
:time-display-mode="section.key === 'past' ? 'relative' : 'clock'"
:date-time="event.dateTime"
@delete="requestDelete"
/>
</div>
</template>
</div>
</section>
<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 { useEventGrouping } from '../composables/useEventGrouping'
import { formatRelativeTime } from '../composables/useRelativeTime'
import EventCard from './EventCard.vue'
import SectionHeader from './SectionHeader.vue'
import DateSubheader from './DateSubheader.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 getRole(event: StoredEvent): 'organizer' | 'attendee' | undefined {
if (event.organizerToken) return 'organizer'
if (event.rsvpToken) return 'attendee'
return undefined
}
const groupedSections = computed(() => {
const valid = getStoredEvents().filter(isValidStoredEvent)
return useEventGrouping(valid)
})
</script>
<style scoped>
.event-list {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.event-section [role="list"] {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
</style>

View File

@@ -0,0 +1,27 @@
<template>
<h2 class="section-header" :class="{ 'section-header--emphasized': emphasized }">
{{ label }}
</h2>
</template>
<script setup lang="ts">
defineProps<{
label: string
emphasized?: boolean
}>()
</script>
<style scoped>
.section-header {
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
margin: 0;
padding: var(--spacing-sm) 0;
}
.section-header--emphasized {
font-size: 1.1rem;
font-weight: 800;
}
</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,17 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import DateSubheader from '../DateSubheader.vue'
describe('DateSubheader', () => {
it('renders the date label as an h3', () => {
const wrapper = mount(DateSubheader, { props: { label: 'Wed, 12 Mar' } })
const h3 = wrapper.find('h3')
expect(h3.exists()).toBe(true)
expect(h3.text()).toBe('Wed, 12 Mar')
})
it('applies the date-subheader class', () => {
const wrapper = mount(DateSubheader, { props: { label: 'Fri, 14 Mar' } })
expect(wrapper.find('.date-subheader').exists()).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,100 @@
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']])
})
it('displays clock time when timeDisplayMode is clock', () => {
const wrapper = mountCard({
timeDisplayMode: 'clock',
dateTime: '2026-03-11T18:30:00',
})
const timeText = wrapper.find('.event-card__time').text()
// Locale-dependent: could be "18:30" or "06:30 PM"
expect(timeText).toMatch(/(?:18.30|6.30\s*PM)/i)
})
it('displays relative time when timeDisplayMode is relative', () => {
const wrapper = mountCard({
relativeTime: '3 days ago',
timeDisplayMode: 'relative',
dateTime: '2026-03-08T10:00:00',
})
expect(wrapper.find('.event-card__time').text()).toBe('3 days ago')
})
it('falls back to relativeTime when timeDisplayMode is not set', () => {
const wrapper = mountCard({ relativeTime: 'in 3 days' })
expect(wrapper.find('.event-card__time').text()).toBe('in 3 days')
})
})

View File

@@ -0,0 +1,140 @@
import { describe, it, expect, vi, beforeEach, afterEach } 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 />' } },
],
})
// Fixed "now": Wednesday, 2026-03-11 12:00
const NOW = new Date(2026, 2, 11, 12, 0, 0)
const mockEvents = [
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2026-03-01T10:00:00', expiryDate: '' },
{ eventToken: 'later-1', title: 'Later Event', dateTime: '2027-06-15T10:00:00', expiryDate: '' },
{ eventToken: 'today-1', title: 'Today Event', dateTime: '2026-03-11T18:00:00', expiryDate: '' },
{ eventToken: 'week-1', title: 'This Week Event', dateTime: '2026-03-13T10:00:00', expiryDate: '' },
{ eventToken: 'nextweek-1', title: 'Next Week Event', dateTime: '2026-03-16T10:00:00', 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.includes('03-01')) return '10 days ago'
if (dateTime.includes('06-15')) return 'in 1 year'
if (dateTime.includes('03-11')) return 'in 6 hours'
if (dateTime.includes('03-13')) return 'in 2 days'
if (dateTime.includes('03-16')) return 'in 5 days'
return 'sometime'
},
}))
function mountList() {
return mount(EventList, {
global: { plugins: [router] },
})
}
describe('EventList', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
afterEach(() => {
vi.useRealTimers()
})
it('renders section headers for each non-empty section', () => {
const wrapper = mountList()
const headers = wrapper.findAll('.section-header')
expect(headers).toHaveLength(5)
expect(headers[0]!.text()).toBe('Today')
expect(headers[1]!.text()).toBe('This Week')
expect(headers[2]!.text()).toBe('Next Week')
expect(headers[3]!.text()).toBe('Later')
expect(headers[4]!.text()).toBe('Past')
})
it('renders events within their correct sections', () => {
const wrapper = mountList()
const sections = wrapper.findAll('.event-section')
expect(sections).toHaveLength(5)
expect(sections[0]!.text()).toContain('Today Event')
expect(sections[1]!.text()).toContain('This Week Event')
expect(sections[2]!.text()).toContain('Next Week Event')
expect(sections[3]!.text()).toContain('Later Event')
expect(sections[4]!.text()).toContain('Past Event')
})
it('renders all valid events as cards', () => {
const wrapper = mountList()
const cards = wrapper.findAll('.event-card')
expect(cards).toHaveLength(5)
})
it('marks past events with isPast class', () => {
const wrapper = mountList()
const pastSection = wrapper.findAll('.event-section')[4]!
const pastCards = pastSection.findAll('.event-card')
expect(pastCards).toHaveLength(1)
expect(pastCards[0]!.classes()).toContain('event-card--past')
})
it('does not mark non-past events with isPast class', () => {
const wrapper = mountList()
const todaySection = wrapper.findAll('.event-section')[0]!
const cards = todaySection.findAll('.event-card')
expect(cards[0]!.classes()).not.toContain('event-card--past')
})
it('sections have aria-label attributes', () => {
const wrapper = mountList()
const sections = wrapper.findAll('section')
expect(sections[0]!.attributes('aria-label')).toBe('Today')
expect(sections[1]!.attributes('aria-label')).toBe('This Week')
expect(sections[2]!.attributes('aria-label')).toBe('Next Week')
expect(sections[3]!.attributes('aria-label')).toBe('Later')
expect(sections[4]!.attributes('aria-label')).toBe('Past')
})
it('does not render date subheader in "Today" section', () => {
const wrapper = mountList()
const todaySection = wrapper.findAll('.event-section')[0]!
expect(todaySection.find('.date-subheader').exists()).toBe(false)
})
it('renders date subheaders in non-today sections', () => {
const wrapper = mountList()
const thisWeekSection = wrapper.findAll('.event-section')[1]!
expect(thisWeekSection.find('.date-subheader').exists()).toBe(true)
const nextWeekSection = wrapper.findAll('.event-section')[2]!
expect(nextWeekSection.find('.date-subheader').exists()).toBe(true)
const laterSection = wrapper.findAll('.event-section')[3]!
expect(laterSection.find('.date-subheader').exists()).toBe(true)
const pastSection = wrapper.findAll('.event-section')[4]!
expect(pastSection.find('.date-subheader').exists()).toBe(true)
})
})

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import SectionHeader from '../SectionHeader.vue'
describe('SectionHeader', () => {
it('renders the section label as an h2', () => {
const wrapper = mount(SectionHeader, { props: { label: 'Today' } })
const h2 = wrapper.find('h2')
expect(h2.exists()).toBe(true)
expect(h2.text()).toBe('Today')
})
it('does not apply emphasized class by default', () => {
const wrapper = mount(SectionHeader, { props: { label: 'Later' } })
expect(wrapper.find('.section-header--emphasized').exists()).toBe(false)
})
it('applies emphasized class when emphasized prop is true', () => {
const wrapper = mount(SectionHeader, { props: { label: 'Today', emphasized: true } })
expect(wrapper.find('.section-header--emphasized').exists()).toBe(true)
})
it('does not apply emphasized class when emphasized prop is false', () => {
const wrapper = mount(SectionHeader, { props: { label: 'Past', emphasized: false } })
expect(wrapper.find('.section-header--emphasized').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useEventGrouping } from '../../composables/useEventGrouping'
import type { StoredEvent } from '../../composables/useEventStorage'
function makeEvent(overrides: Partial<StoredEvent> & { dateTime: string }): StoredEvent {
return {
eventToken: `evt-${Math.random().toString(36).slice(2, 8)}`,
title: 'Test Event',
expiryDate: '',
...overrides,
}
}
describe('useEventGrouping', () => {
// Fixed "now": Wednesday, 2026-03-11 12:00 local
const NOW = new Date(2026, 2, 11, 12, 0, 0)
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
afterEach(() => {
vi.useRealTimers()
})
it('returns empty array when no events', () => {
const sections = useEventGrouping([], NOW)
expect(sections).toEqual([])
})
it('classifies a today event into "today" section', () => {
const event = makeEvent({ dateTime: '2026-03-11T18:30:00' })
const sections = useEventGrouping([event], NOW)
expect(sections).toHaveLength(1)
expect(sections[0]!.key).toBe('today')
expect(sections[0]!.label).toBe('Today')
expect(sections[0]!.dateGroups[0]!.events).toHaveLength(1)
})
it('classifies events into all five sections', () => {
const events = [
makeEvent({ title: 'Today', dateTime: '2026-03-11T10:00:00' }),
makeEvent({ title: 'This Week', dateTime: '2026-03-13T10:00:00' }), // Friday (same week)
makeEvent({ title: 'Next Week', dateTime: '2026-03-16T10:00:00' }), // Monday next week
makeEvent({ title: 'Later', dateTime: '2026-03-30T10:00:00' }), // far future
makeEvent({ title: 'Past', dateTime: '2026-03-09T10:00:00' }), // Monday (past)
]
const sections = useEventGrouping(events, NOW)
expect(sections).toHaveLength(5)
expect(sections[0]!.key).toBe('today')
expect(sections[1]!.key).toBe('thisWeek')
expect(sections[2]!.key).toBe('nextWeek')
expect(sections[3]!.key).toBe('later')
expect(sections[4]!.key).toBe('past')
})
it('omits empty sections', () => {
const events = [
makeEvent({ title: 'Today', dateTime: '2026-03-11T10:00:00' }),
makeEvent({ title: 'Past', dateTime: '2026-03-01T10:00:00' }),
]
const sections = useEventGrouping(events, NOW)
expect(sections).toHaveLength(2)
expect(sections[0]!.key).toBe('today')
expect(sections[1]!.key).toBe('past')
})
it('sorts upcoming events ascending by time', () => {
const events = [
makeEvent({ title: 'Later', dateTime: '2026-03-11T20:00:00' }),
makeEvent({ title: 'Earlier', dateTime: '2026-03-11T08:00:00' }),
]
const sections = useEventGrouping(events, NOW)
const todayEvents = sections[0]!.dateGroups[0]!.events
expect(todayEvents[0]!.title).toBe('Earlier')
expect(todayEvents[1]!.title).toBe('Later')
})
it('sorts past events descending by time (most recent first)', () => {
const events = [
makeEvent({ title: 'Older', dateTime: '2026-03-01T10:00:00' }),
makeEvent({ title: 'Newer', dateTime: '2026-03-09T10:00:00' }),
]
const sections = useEventGrouping(events, NOW)
const pastEvents = sections[0]!.dateGroups
expect(pastEvents[0]!.events[0]!.title).toBe('Newer')
expect(pastEvents[1]!.events[0]!.title).toBe('Older')
})
it('groups events by date within a section', () => {
const events = [
makeEvent({ title: 'Fri AM', dateTime: '2026-03-13T09:00:00' }),
makeEvent({ title: 'Fri PM', dateTime: '2026-03-13T18:00:00' }),
makeEvent({ title: 'Sat', dateTime: '2026-03-14T12:00:00' }),
]
const sections = useEventGrouping(events, NOW)
expect(sections[0]!.key).toBe('thisWeek')
const dateGroups = sections[0]!.dateGroups
expect(dateGroups).toHaveLength(2) // Friday and Saturday
expect(dateGroups[0]!.events).toHaveLength(2) // Two Friday events
expect(dateGroups[1]!.events).toHaveLength(1) // One Saturday event
})
it('sets showSubheader=false for "today" section', () => {
const event = makeEvent({ dateTime: '2026-03-11T18:00:00' })
const sections = useEventGrouping([event], NOW)
expect(sections[0]!.dateGroups[0]!.showSubheader).toBe(false)
})
it('sets showSubheader=true for non-today sections', () => {
const events = [
makeEvent({ dateTime: '2026-03-13T10:00:00' }), // thisWeek
makeEvent({ dateTime: '2026-03-30T10:00:00' }), // later (beyond next week)
makeEvent({ dateTime: '2026-03-01T10:00:00' }), // past
]
const sections = useEventGrouping(events, NOW)
for (const section of sections) {
for (const group of section.dateGroups) {
expect(group.showSubheader).toBe(true)
}
}
})
it('sets emphasized=true only for "today" section', () => {
const events = [
makeEvent({ dateTime: '2026-03-11T18:00:00' }),
makeEvent({ dateTime: '2026-03-30T10:00:00' }),
]
const sections = useEventGrouping(events, NOW)
expect(sections[0]!.emphasized).toBe(true) // today
expect(sections[1]!.emphasized).toBe(false) // later
})
it('on Sunday, tomorrow (Monday) goes to "nextWeek" not "thisWeek"', () => {
// Sunday 2026-03-15
const sunday = new Date(2026, 2, 15, 12, 0, 0)
const mondayEvent = makeEvent({ title: 'Monday', dateTime: '2026-03-16T10:00:00' })
const sections = useEventGrouping([mondayEvent], sunday)
expect(sections).toHaveLength(1)
expect(sections[0]!.key).toBe('nextWeek')
})
it('on Sunday, today events still appear under "today"', () => {
const sunday = new Date(2026, 2, 15, 12, 0, 0)
const todayEvent = makeEvent({ dateTime: '2026-03-15T18:00:00' })
const sections = useEventGrouping([todayEvent], sunday)
expect(sections[0]!.key).toBe('today')
})
it('dateGroup labels are formatted via Intl', () => {
const event = makeEvent({ dateTime: '2026-03-13T10:00:00' }) // Friday
const sections = useEventGrouping([event], NOW)
const label = sections[0]!.dateGroups[0]!.label
// The exact format depends on locale, but should contain the day number
expect(label).toContain('13')
})
})

View File

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

View File

@@ -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/)
})
})

View File

@@ -0,0 +1,149 @@
import type { StoredEvent } from './useEventStorage'
export type SectionKey = 'today' | 'thisWeek' | 'nextWeek' | 'later' | 'past'
export interface DateGroup {
dateKey: string
label: string
events: StoredEvent[]
showSubheader: boolean
}
export interface EventSection {
key: SectionKey
label: string
dateGroups: DateGroup[]
emphasized: boolean
}
const SECTION_ORDER: SectionKey[] = ['today', 'thisWeek', 'nextWeek', 'later', 'past']
const SECTION_LABELS: Record<SectionKey, string> = {
today: 'Today',
thisWeek: 'This Week',
nextWeek: 'Next Week',
later: 'Later',
past: 'Past',
}
function startOfDay(date: Date): Date {
const d = new Date(date)
d.setHours(0, 0, 0, 0)
return d
}
function endOfDay(date: Date): Date {
const d = new Date(date)
d.setHours(23, 59, 59, 999)
return d
}
function endOfWeek(date: Date): Date {
const d = new Date(date)
const dayOfWeek = d.getDay() // 0=Sun, 1=Mon, ..., 6=Sat
// ISO week: Monday is first day. End of week = Sunday.
// If today is Sunday (0), end of week is today.
// Otherwise, days until Sunday = 7 - dayOfWeek
const daysUntilSunday = dayOfWeek === 0 ? 0 : 7 - dayOfWeek
d.setDate(d.getDate() + daysUntilSunday)
return endOfDay(d)
}
function endOfNextWeek(date: Date): Date {
const thisWeekEnd = endOfWeek(date)
const d = new Date(thisWeekEnd)
d.setDate(d.getDate() + 7)
return endOfDay(d)
}
function toDateKey(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function formatDateLabel(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
weekday: 'short',
day: 'numeric',
month: 'short',
}).format(date)
}
function classifyEvent(eventDate: Date, todayStart: Date, todayEnd: Date, weekEnd: Date, nextWeekEnd: Date): SectionKey {
if (eventDate < todayStart) return 'past'
if (eventDate <= todayEnd) return 'today'
if (eventDate <= weekEnd) return 'thisWeek'
if (eventDate <= nextWeekEnd) return 'nextWeek'
return 'later'
}
export function useEventGrouping(events: StoredEvent[], now: Date = new Date()): EventSection[] {
const todayStart = startOfDay(now)
const todayEnd = endOfDay(now)
const weekEnd = endOfWeek(now)
const nextWeekEnd = endOfNextWeek(now)
// Classify events into sections
const buckets: Record<SectionKey, StoredEvent[]> = {
today: [],
thisWeek: [],
nextWeek: [],
later: [],
past: [],
}
for (const event of events) {
const eventDate = new Date(event.dateTime)
const section = classifyEvent(eventDate, todayStart, todayEnd, weekEnd, nextWeekEnd)
buckets[section].push(event)
}
// Build sections
const sections: EventSection[] = []
for (const key of SECTION_ORDER) {
const sectionEvents = buckets[key]
if (sectionEvents.length === 0) continue
// Sort events
const ascending = key !== 'past'
sectionEvents.sort((a, b) => {
const diff = new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime()
return ascending ? diff : -diff
})
// Group by date
const dateGroupMap = new Map<string, StoredEvent[]>()
for (const event of sectionEvents) {
const dateKey = toDateKey(new Date(event.dateTime))
const group = dateGroupMap.get(dateKey)
if (group) {
group.push(event)
} else {
dateGroupMap.set(dateKey, [event])
}
}
// Convert to DateGroup array (order preserved from sorted events)
const dateGroups: DateGroup[] = []
for (const [dateKey, groupEvents] of dateGroupMap) {
dateGroups.push({
dateKey,
label: formatDateLabel(new Date(groupEvents[0]!.dateTime)),
events: groupEvents,
showSubheader: key !== 'today',
})
}
sections.push({
key,
label: SECTION_LABELS[key],
dateGroups,
emphasized: key === 'today',
})
}
return sections
}

View File

@@ -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<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 &&
!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 }
}

View File

@@ -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')
}

View File

@@ -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'),
},

View File

@@ -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

View File

@@ -1,58 +1,75 @@
<template>
<main class="detail">
<header class="detail__header">
<RouterLink to="/" class="detail__back" aria-label="Back to home">&larr;</RouterLink>
<span class="detail__brand">fete</span>
</header>
<!-- Loading state -->
<div v-if="state === 'loading'" class="detail__card" aria-busy="true" aria-label="Loading event details">
<div class="skeleton skeleton--title" />
<div class="skeleton skeleton--line" />
<div class="skeleton skeleton--line skeleton--short" />
<div class="skeleton skeleton--line" />
<!-- Hero image with overlaid header -->
<div class="detail__hero">
<img
class="detail__hero-img"
src="@/assets/images/event-hero-placeholder.jpg"
alt=""
/>
<div class="detail__hero-overlay" />
<header class="detail__header">
<RouterLink to="/" class="detail__back" aria-label="Back to home">&larr;</RouterLink>
<span class="detail__brand">fete</span>
</header>
</div>
<!-- Loaded state -->
<div v-else-if="state === 'loaded' && event" class="detail__card">
<h1 class="detail__title">{{ event.title }}</h1>
<dl class="detail__fields">
<div class="detail__field">
<dt class="detail__label">Date &amp; Time</dt>
<dd class="detail__value">{{ formattedDateTime }}</dd>
</div>
<div v-if="event.description" class="detail__field">
<dt class="detail__label">Description</dt>
<dd class="detail__value">{{ event.description }}</dd>
</div>
<div v-if="event.location" class="detail__field">
<dt class="detail__label">Location</dt>
<dd class="detail__value">{{ event.location }}</dd>
</div>
<div class="detail__field">
<dt class="detail__label">Attendees</dt>
<dd class="detail__value">{{ event.attendeeCount }}</dd>
</div>
</dl>
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
This event has ended.
<div class="detail__body">
<!-- Loading state -->
<div v-if="state === 'loading'" class="detail__content" aria-busy="true" aria-label="Loading event details">
<div class="skeleton skeleton--title" />
<div class="skeleton skeleton--line" />
<div class="skeleton skeleton--line skeleton--short" />
<div class="skeleton skeleton--line" />
</div>
</div>
<!-- Not found state -->
<div v-else-if="state === 'not-found'" class="detail__card detail__card--center" role="status">
<p class="detail__message">Event not found.</p>
</div>
<!-- Loaded state -->
<div v-else-if="state === 'loaded' && event" class="detail__content">
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
This event has ended.
</div>
<!-- Error state -->
<div v-else-if="state === 'error'" class="detail__card detail__card--center" role="alert">
<p class="detail__message">Something went wrong.</p>
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
<h1 class="detail__title">{{ event.title }}</h1>
<dl class="detail__meta">
<div class="detail__meta-item">
<dt class="detail__meta-icon" aria-label="Date and time">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</dt>
<dd class="detail__meta-text">{{ formattedDateTime }}</dd>
</div>
<div v-if="event.location" class="detail__meta-item">
<dt class="detail__meta-icon" aria-label="Location">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
</dt>
<dd class="detail__meta-text">{{ event.location }}</dd>
</div>
<div class="detail__meta-item">
<dt class="detail__meta-icon" aria-label="Attendees">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</dt>
<dd class="detail__meta-text">{{ event.attendeeCount }} going</dd>
</div>
</dl>
<div v-if="event.description" class="detail__section">
<h2 class="detail__section-title">About</h2>
<p class="detail__description">{{ event.description }}</p>
</div>
</div>
<!-- Not found state -->
<div v-else-if="state === 'not-found'" class="detail__content detail__content--center" role="status">
<p class="detail__message">Event not found.</p>
</div>
<!-- Error state -->
<div v-else-if="state === 'error'" class="detail__content detail__content--center" role="alert">
<p class="detail__message">Something went wrong.</p>
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
</div>
</div>
<!-- RSVP bar (only for loaded, non-expired events) -->
@@ -131,7 +148,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 +190,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 },
})
@@ -210,14 +227,53 @@ onMounted(fetchEvent)
.detail {
display: flex;
flex-direction: column;
gap: var(--spacing-2xl);
padding-top: var(--spacing-lg);
/* Break out of .app-container constraints */
width: 100dvw;
flex: 1;
position: relative;
left: 50%;
transform: translateX(-50%);
margin: calc(-1 * var(--content-padding)) 0;
overflow-x: hidden;
}
/* Hero image section */
.detail__hero {
position: relative;
width: 100%;
height: 260px;
overflow: hidden;
flex-shrink: 0;
}
.detail__hero-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.detail__hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.4) 0%,
transparent 50%,
var(--color-gradient-start) 100%
);
}
.detail__header {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-lg) var(--content-padding);
padding-top: env(safe-area-inset-top, var(--spacing-lg));
z-index: 1;
}
.detail__back {
@@ -233,85 +289,130 @@ onMounted(fetchEvent)
color: var(--color-text-on-gradient);
}
.detail__card {
background: var(--color-card);
border-radius: var(--radius-card);
padding: var(--spacing-xl);
box-shadow: var(--shadow-card);
.detail__body {
flex: 1;
padding: var(--spacing-lg) var(--content-padding);
padding-bottom: 6rem;
}
.detail__content {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
gap: var(--spacing-2xl);
max-width: var(--content-max-width);
margin: 0 auto;
}
.detail__card--center {
.detail__content--center {
align-items: center;
text-align: center;
padding-top: 4rem;
}
/* Title */
.detail__title {
font-size: 1.4rem;
font-weight: 700;
color: var(--color-text);
font-size: 2rem;
font-weight: 800;
color: var(--color-text-on-gradient);
word-break: break-word;
line-height: 1.2;
letter-spacing: -0.02em;
}
.detail__fields {
/* Meta rows: icon + text */
.detail__meta {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.detail__field {
.detail__meta-item {
display: flex;
flex-direction: column;
gap: 0.15rem;
align-items: center;
gap: var(--spacing-sm);
}
.detail__label {
font-size: 0.8rem;
font-weight: 700;
color: #888;
text-transform: uppercase;
letter-spacing: 0.04em;
.detail__meta-icon {
flex-shrink: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.15);
border-radius: 10px;
color: var(--color-text-on-gradient);
}
.detail__value {
font-size: 0.95rem;
color: var(--color-text);
.detail__meta-text {
font-size: 0.9rem;
color: var(--color-text-on-gradient);
word-break: break-word;
}
/* About section */
.detail__section {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.detail__section-title {
font-size: 0.75rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.detail__description {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.85);
line-height: 1.6;
word-break: break-word;
}
/* Expired banner */
.detail__banner {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-card);
border-radius: 10px;
font-weight: 600;
font-size: 0.9rem;
font-size: 0.85rem;
text-align: center;
}
.detail__banner--expired {
background: #fff3e0;
color: #e65100;
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(4px);
}
/* Error / not-found message */
.detail__message {
font-size: 1rem;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-text);
color: var(--color-text-on-gradient);
}
/* Skeleton shimmer on gradient */
.skeleton {
background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.25) 50%, rgba(255, 255, 255, 0.1) 75%);
background-size: 200% 100%;
}
/* Skeleton sizes */
.skeleton--title {
height: 1.6rem;
width: 60%;
height: 2rem;
width: 70%;
border-radius: 8px;
}
.skeleton--line {
height: 1rem;
width: 80%;
width: 85%;
border-radius: 6px;
}
.skeleton--short {
width: 40%;
width: 45%;
}
</style>

View File

@@ -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(() => {

View File

@@ -1,13 +1,26 @@
<template>
<main class="home">
<h1 class="home__title">fete</h1>
<p class="home__subtitle">No events yet.<br />Create your first one!</p>
<RouterLink to="/create" class="btn-primary home__cta">+ Create Event</RouterLink>
<template v-if="events.length > 0">
<EventList />
<CreateEventFab />
</template>
<template v-else>
<EmptyState />
</template>
</main>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { computed } from 'vue'
import { useEventStorage, isValidStoredEvent } from '../composables/useEventStorage'
import EventList from '../components/EventList.vue'
import EmptyState from '../components/EmptyState.vue'
import CreateEventFab from '../components/CreateEventFab.vue'
const { getStoredEvents } = useEventStorage()
const events = computed(() => getStoredEvents().filter(isValidStoredEvent))
</script>
<style scoped>
@@ -15,27 +28,15 @@ import { RouterLink } from 'vue-router'
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-lg);
text-align: center;
padding-top: var(--spacing-lg);
}
.home__title {
font-size: 2rem;
font-weight: 800;
color: var(--color-text-on-gradient);
text-align: center;
}
.home__subtitle {
font-size: 1rem;
font-weight: 400;
color: var(--color-text-on-gradient);
opacity: 0.9;
}
.home__cta {
margin-top: var(--spacing-md);
max-width: 280px;
}
</style>

View File

@@ -25,7 +25,7 @@ function createTestRouter() {
routes: [
{ path: '/', name: 'home', component: { template: '<div />' } },
{ path: '/create', name: 'create-event', component: EventCreateView },
{ path: '/events/:token', name: 'event', component: { template: '<div />' } },
{ path: '/events/:eventToken', name: 'event', component: { template: '<div />' } },
],
})
}
@@ -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' },
})
})

View File

@@ -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: '<div />' } },
{ path: '/events/:token', name: 'event', component: EventDetailView },
{ path: '/events/:eventToken', name: 'event', component: EventDetailView },
],
})
}
@@ -104,7 +105,7 @@ describe('EventDetailView', () => {
const wrapper = await mountWithToken()
await flushPromises()
const dateField = wrapper.findAll('.detail__value')[0]!
const dateField = wrapper.findAll('.detail__meta-text')[0]!
expect(dateField.text()).toContain('(Europe/Berlin)')
expect(dateField.text()).toContain('2026')
wrapper.unmount()

View File

@@ -8,7 +8,7 @@ function createTestRouter() {
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div />' } },
{ path: '/events/:token', name: 'event', component: EventStubView },
{ path: '/events/:eventToken', name: 'event', component: EventStubView },
],
})
}

View File

@@ -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.

View File

@@ -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)
└────────────────────────────────┘
```

View File

@@ -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.

View File

@@ -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)

View File

@@ -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.

View File

@@ -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 26 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

View File

@@ -0,0 +1,35 @@
# Specification Quality Checklist: Event List Temporal Grouping
**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. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
- One minor note: the Assumptions section mentions `Intl` API and `localStorage` — these are context references to existing behavior, not prescriptive implementation details.

View File

@@ -0,0 +1,91 @@
# Data Model: Event List Temporal Grouping
**Feature**: 010-event-list-grouping | **Date**: 2026-03-08
## Existing Entities (no changes)
### StoredEvent
**Location**: `frontend/src/composables/useEventStorage.ts`
| Field | Type | Description |
|-------|------|-------------|
| `eventToken` | `string` | UUID v4, unique identifier |
| `organizerToken` | `string?` | UUID v4, present if user is organizer |
| `title` | `string` | Event title |
| `dateTime` | `string` | ISO 8601 with UTC offset (e.g., `"2026-03-15T20:00:00+01:00"`) |
| `expiryDate` | `string` | ISO 8601 expiry date |
| `rsvpToken` | `string?` | Present if user has RSVP'd |
| `rsvpName` | `string?` | Name used for RSVP |
**Note**: No changes to `StoredEvent`. The `dateTime` field is the sole input for all grouping and sorting logic.
## New Types (frontend only)
### SectionKey
```typescript
type SectionKey = 'today' | 'thisWeek' | 'later' | 'past'
```
Enum-like union type for the four temporal sections. Ordering is fixed: today → thisWeek → later → past.
### EventSection
```typescript
interface EventSection {
key: SectionKey
label: string // Display label: "Today", "This Week", "Later", "Past"
dateGroups: DateGroup[]
emphasized: boolean // true only for 'today' section
}
```
Represents one temporal section in the grouped list. Sections with no events are omitted entirely (never constructed).
### DateGroup
```typescript
interface DateGroup {
dateKey: string // YYYY-MM-DD (for keying/dedup)
label: string // Formatted via Intl.DateTimeFormat, e.g., "Wed, 12 Mar"
events: StoredEvent[] // Events on this date, sorted by time
showSubheader: boolean // false for "Today" section (FR-005)
}
```
Groups events within a section by their specific calendar date. The `showSubheader` flag controls whether the date subheader is rendered (always false in "Today" section per FR-005).
## Grouping Algorithm
```
Input: StoredEvent[], now: Date
Output: EventSection[]
1. Compute boundaries:
- startOfToday = today at 00:00:00 local
- endOfToday = today at 23:59:59.999 local
- endOfWeek = next Sunday at 23:59:59.999 local (or today if today is Sunday)
2. Classify each event by dateTime:
- dateTime < startOfToday → "past"
- startOfToday ≤ dateTime ≤ endOfToday → "today"
- endOfToday < dateTime ≤ endOfWeek → "thisWeek"
- dateTime > endOfWeek → "later"
3. Within each section, group by calendar date (YYYY-MM-DD)
4. Sort:
- today/thisWeek/later: date groups ascending, events within group ascending by time
- past: date groups descending, events within group descending by time
5. Emit only non-empty sections in fixed order: today, thisWeek, later, past
```
## State Transitions
None. Events are static data in localStorage. Temporal classification is computed on each render based on current time. No event mutation occurs.
## Validation Rules
No new validation. Existing `isValidStoredEvent()` in `useEventStorage.ts` already validates the `dateTime` field as a parseable ISO 8601 string.

View File

@@ -0,0 +1,72 @@
# Implementation Plan: Event List Temporal Grouping
**Branch**: `010-event-list-grouping` | **Date**: 2026-03-08 | **Spec**: `specs/010-event-list-grouping/spec.md`
**Input**: Feature specification from `/specs/010-event-list-grouping/spec.md`
## Summary
Extend the existing flat event list with temporal section grouping (Today, This Week, Later, Past). The feature is purely client-side: the existing `EventList.vue` computed property that separates events into upcoming/past is refactored into a four-section grouping with section headers, date subheaders, and context-aware time formatting. No backend changes, no new dependencies.
## Technical Context
**Language/Version**: TypeScript 5.9 (frontend only)
**Primary Dependencies**: Vue 3, Vue Router 5 (existing — no additions)
**Storage**: localStorage via `useEventStorage.ts` composable (existing — no changes)
**Testing**: Vitest (unit), Playwright + MSW (E2E)
**Target Platform**: PWA, mobile-first, all modern browsers
**Project Type**: Web application (frontend enhancement)
**Performance Goals**: Grouping computation < 1ms for 100 events (trivial — single array pass)
**Constraints**: Client-side only, no additional network requests, offline-capable
**Scale/Scope**: Typically < 50 events per user in localStorage
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| I. Privacy by Design | PASS | No new data collection. Grouping uses existing `dateTime` field. No external services. |
| II. Test-Driven Methodology | PASS | Unit tests for grouping logic + E2E tests for all user stories planned. TDD enforced. |
| III. API-First Development | N/A | No API changes — purely frontend enhancement. |
| IV. Simplicity & Quality | PASS | Minimal new code: one composable for grouping, template changes in EventList. No over-engineering. |
| V. Dependency Discipline | PASS | No new dependencies. Uses browser `Intl` API and existing `Date` methods. |
| VI. Accessibility | PASS | Section headers use semantic HTML (`<h2>`/`<h3>`), ARIA landmarks, keyboard navigable. WCAG AA contrast enforced. |
**Gate result: PASS** — no violations.
## Project Structure
### Documentation (this feature)
```text
specs/010-event-list-grouping/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── spec.md # Feature specification
└── tasks.md # Phase 2 output (via /speckit.tasks)
```
### Source Code (repository root)
```text
frontend/
├── src/
│ ├── components/
│ │ ├── EventList.vue # MODIFY — add section grouping to template + computed
│ │ ├── EventCard.vue # MODIFY — add time format mode prop
│ │ ├── SectionHeader.vue # NEW — temporal section header component
│ │ └── DateSubheader.vue # NEW — date subheader component
│ ├── composables/
│ │ ├── useEventGrouping.ts # NEW — grouping logic (pure function)
│ │ ├── useRelativeTime.ts # EXISTING — no changes
│ │ └── useEventStorage.ts # EXISTING — no changes
│ └── components/__tests__/
│ ├── EventList.spec.ts # MODIFY — update for grouped structure
│ ├── EventCard.spec.ts # MODIFY — add time format tests
│ └── useEventGrouping.spec.ts # NEW — unit tests for grouping logic
├── e2e/
│ └── home-events.spec.ts # MODIFY — add temporal grouping E2E tests
```
**Structure Decision**: Frontend-only changes. Two new small components (SectionHeader, DateSubheader) and one new composable (useEventGrouping). Existing components modified minimally.

View File

@@ -0,0 +1,118 @@
# Research: Event List Temporal Grouping
**Feature**: 010-event-list-grouping | **Date**: 2026-03-08
## 1. Week Boundary Calculation
**Decision**: Use ISO 8601 week convention (Monday = first day of week). "This Week" spans from tomorrow through Sunday of the current week.
**Rationale**: The spec explicitly states "ISO convention where Monday is the first day of the week" (Assumptions section). The browser's `Date.getDay()` returns 0 for Sunday, 1 for Monday — straightforward to compute end-of-week as next Sunday 23:59:59.
**Implementation**: Compare event date against:
- `startOfToday` and `endOfToday` for "Today"
- `startOfTomorrow` and `endOfSunday` for "This Week"
- `after endOfSunday` for "Later"
- `before startOfToday` for "Past"
Edge case (spec scenario 4): On Sunday, "This Week" is empty (tomorrow is already next week Monday), so events for Monday appear under "Later". This falls out naturally from the algorithm.
**Alternatives considered**:
- Using a date library (date-fns, luxon): Rejected — dependency discipline (Constitution V). Native `Date` + `Intl` is sufficient for this logic.
- Locale-dependent week start: Rejected — spec mandates ISO convention explicitly.
## 2. Date Formatting for Subheaders
**Decision**: Use `Intl.DateTimeFormat` with `{ weekday: 'short', day: 'numeric', month: 'short' }` to produce labels like "Wed, 12 Mar".
**Rationale**: Consistent with existing use of `Intl.RelativeTimeFormat` in `useRelativeTime.ts`. Respects user locale for month/weekday names. No external dependency needed.
**Alternatives considered**:
- Hardcoded English day/month names: Rejected — the project already uses `Intl` APIs for locale awareness.
- Full date format (e.g., "Wednesday, March 12, 2026"): Rejected — too long for mobile cards.
## 3. Time Display on Event Cards
**Decision**: Add a `timeDisplayMode` prop to `EventCard.vue` with two modes:
- `'clock'`: Shows formatted time (e.g., "18:30") using `Intl.DateTimeFormat` with `{ hour: '2-digit', minute: '2-digit' }`
- `'relative'`: Shows relative time (e.g., "3 days ago") using existing `formatRelativeTime()`
**Rationale**: Spec requires different time representations per section: clock time for Today/This Week/Later, relative time for Past. A prop-driven approach keeps EventCard stateless regarding section context.
**Alternatives considered**:
- EventCard determining its own display mode: Rejected — card shouldn't know about sections; parent owns that context.
- Passing a pre-formatted string: Viable but less type-safe. A mode enum is clearer.
## 4. Grouping Data Structure
**Decision**: The `useEventGrouping` composable returns an array of section objects:
```typescript
interface EventSection {
key: 'today' | 'thisWeek' | 'later' | 'past'
label: string // "Today", "This Week", "Later", "Past"
events: GroupedEvent[]
}
interface DateGroup {
date: string // ISO date string (YYYY-MM-DD) for keying
label: string // Formatted date label (e.g., "Wed, 12 Mar")
events: StoredEvent[]
}
interface GroupedEvent extends StoredEvent {
dateGroup: string // ISO date for sub-grouping
}
```
Actually, simpler: the composable returns sections, each containing date groups, each containing events.
```typescript
interface EventSection {
key: 'today' | 'thisWeek' | 'later' | 'past'
label: string
dateGroups: DateGroup[]
}
interface DateGroup {
dateKey: string // YYYY-MM-DD
label: string // Formatted: "Wed, 12 Mar"
events: StoredEvent[]
}
```
**Rationale**: Two-level grouping (section → date → events) matches the spec's hierarchy. Empty sections are simply omitted from the returned array (FR-002). The "Today" section still has one DateGroup but the template skips rendering its subheader (FR-005).
**Alternatives considered**:
- Flat list with section markers: Harder to template, mixes data and presentation.
- Map/Record structure: Arrays preserve ordering guarantees (Today → This Week → Later → Past).
## 5. Visual Emphasis for "Today" Section
**Decision**: Apply a CSS class `.section--today` to the Today section that uses:
- Slightly larger section header (font-weight: 800, font-size: 1.1rem vs 700/1rem for others)
- A subtle left border accent using the primary gradient pink (`#F06292`)
**Rationale**: Consistent with Electric Dusk design system. Subtle enough not to distract but visually distinct. The existing past-event fade (opacity: 0.6, saturate: 0.5) already handles the other end of the spectrum.
**Alternatives considered**:
- Background highlight: Could clash with card backgrounds on mobile.
- Icon/emoji prefix: Spec doesn't mention icons; keep it typography-driven per design system.
## 6. Accessibility Considerations
**Decision**:
- Section headers are `<h2>` elements
- Date subheaders are `<h3>` elements
- The event list container keeps its existing `role="list"`
- Each section is a `<section>` element with `aria-label` matching the section label
**Rationale**: Constitution VI requires semantic HTML and ARIA. The heading hierarchy (h2 > h3) provides screen reader navigation landmarks. The `<section>` element with label allows assistive technology to announce section boundaries.
## 7. Existing Test Updates
**Decision**:
- Existing `EventList.spec.ts` unit tests will be updated to account for the new grouped structure (sections instead of flat list)
- Existing `home-events.spec.ts` E2E tests will be extended with new scenarios for temporal grouping
- New `useEventGrouping.spec.ts` tests the pure grouping function in isolation
**Rationale**: TDD (Constitution II). The grouping logic is a pure function — ideal for thorough unit testing with various date combinations and edge cases.

View File

@@ -0,0 +1,138 @@
# Feature Specification: Event List Temporal Grouping
**Feature Branch**: `010-event-list-grouping`
**Created**: 2026-03-08
**Status**: Draft
**Input**: User description: "Extend the event list with temporal grouping so users know if an event is today, this week, or further in the future."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Temporal Section Headers (Priority: P1)
As a user viewing my event list, I want events grouped under clear date-based section headers so I can instantly see what's happening today, this week, and later without reading individual dates.
The list displays events under these temporal sections (in order):
1. **Today** — events happening today
2. **This Week** — events from tomorrow through end of current week (Sunday)
3. **Later** — upcoming events beyond this week
4. **Past** — events that have already occurred
Each section only appears if it contains at least one event. Empty sections are hidden entirely.
**Why this priority**: The core value of this feature — temporal orientation at a glance. Without section headers, the rest of the feature has no foundation.
**Independent Test**: Can be fully tested by adding events with various dates to localStorage and verifying they appear under the correct section headers.
**Acceptance Scenarios**:
1. **Given** a user has events today, tomorrow, next week, and last week, **When** they view the event list, **Then** they see four sections: "Today", "This Week", "Later", and "Past" with events correctly distributed.
2. **Given** a user has only events for today, **When** they view the event list, **Then** only the "Today" section is visible — no empty sections appear.
3. **Given** a user has no events at all, **When** they view the event list, **Then** the empty state is shown (as currently implemented).
4. **Given** it is Sunday and an event is scheduled for Monday, **When** the user views the list, **Then** the Monday event appears under "Later" (not "This Week"), because the current week ends on Sunday.
---
### User Story 2 - Date Subheaders Within Sections (Priority: P2)
Within each section (except "Today"), events are further grouped by their specific date with a subheader showing the formatted date (e.g., "Sat, 17 Sep"). This mirrors the inspiration layout where individual dates appear as smaller headings under the main temporal section.
Within the "Today" section, no date subheader is needed since all events share the same date.
**Why this priority**: Adds finer-grained orientation within sections — especially important when "This Week" or "Later" contain multiple events across different days.
**Independent Test**: Can be tested by adding multiple events on different days within the same temporal section and verifying date subheaders appear.
**Acceptance Scenarios**:
1. **Given** a user has events on Wednesday and Friday of this week, **When** they view the "This Week" section, **Then** events are grouped under date subheaders like "Wed, 12 Mar" and "Fri, 14 Mar".
2. **Given** a user has three events today, **When** they view the "Today" section, **Then** no date subheader appears — events are listed directly under the "Today" header.
3. **Given** two events on the same future date, **When** the user views the list, **Then** both appear under a single date subheader for that day, sorted by time.
---
### User Story 3 - Enhanced Event Card Information (Priority: P2)
Each event card within the grouped list shows time information relevant to its context:
- **Today's events**: Show the time (e.g., "18:30") prominently, since the date is implied by the section.
- **Future events**: Show the time (e.g., "18:30") — the date is provided by the subheader.
- **Past events**: Continue showing relative time (e.g., "3 days ago") as currently implemented, since exact time matters less.
The existing role badges (Organizer/Attendee) and event title remain as-is.
**Why this priority**: Completes the information design — users need different time representations depending on temporal context.
**Independent Test**: Can be tested by checking that event cards display the correct time format based on which section they appear in.
**Acceptance Scenarios**:
1. **Given** an event today at 18:30, **When** the user views the "Today" section, **Then** the card shows "18:30" (not "in 3 hours").
2. **Given** an event on Friday at 10:00, **When** the user views it under "This Week", **Then** the card shows "10:00".
3. **Given** a past event from 3 days ago, **When** the user views the "Past" section, **Then** the card shows "3 days ago" as it does currently.
---
### User Story 4 - Today Section Visual Emphasis (Priority: P3)
The "Today" section header and its event cards receive subtle visual emphasis to draw the user's attention to what's happening now. This could be a slightly larger section header, bolder typography, or a subtle highlight — consistent with the Electric Dusk design system.
Past events continue to appear visually faded (reduced opacity/saturation) as currently implemented.
**Why this priority**: Nice visual polish that reinforces the temporal hierarchy, but the feature works without it.
**Independent Test**: Can be verified visually by checking that the "Today" section stands out compared to other sections.
**Acceptance Scenarios**:
1. **Given** events exist for today and later, **When** the user views the list, **Then** the "Today" section is visually more prominent than other sections.
2. **Given** only past events exist, **When** the user views the list, **Then** the "Past" section uses the existing faded treatment without any special emphasis.
---
### Edge Cases
- What happens when the user's device clock is set incorrectly? Events may appear in the wrong section — this is acceptable, no special handling needed.
- What happens at midnight when "today" changes? The grouping updates on next page load or navigation; real-time re-sorting is not required.
- What happens with an event at exactly midnight (00:00)? It belongs to the day it falls on — same as any other time.
- What happens when a section has many events (10+)? All events are shown; no pagination or truncation within sections.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST group events into temporal sections: "Today", "This Week", "Later", and "Past".
- **FR-002**: System MUST hide sections that contain no events.
- **FR-003**: System MUST display section headers with the temporal label (e.g., "Today", "This Week").
- **FR-004**: System MUST display date subheaders within "This Week", "Later", and "Past" sections when events span multiple days.
- **FR-005**: System MUST NOT display a date subheader within the "Today" section.
- **FR-006**: System MUST sort events within each section by time ascending (earliest first) for upcoming events and by time descending (most recent first) for past events.
- **FR-007**: System MUST display clock time (e.g., "18:30") on event cards in "Today", "This Week", and "Later" sections.
- **FR-008**: System MUST display relative time (e.g., "3 days ago") on event cards in the "Past" section.
- **FR-009**: System MUST visually emphasize the "Today" section compared to other sections.
- **FR-010**: System MUST continue to fade past events visually (as currently implemented).
- **FR-011**: System MUST preserve existing functionality: role badges, swipe-to-delete, delete confirmation, empty state.
- **FR-012**: "This Week" MUST include events from tomorrow through the end of the current calendar week (Sunday).
### Key Entities
- **Temporal Section**: A grouping label ("Today", "This Week", "Later", "Past") that organizes events by their relationship to the current date.
- **Date Subheader**: A formatted date label (e.g., "Sat, 17 Sep") that groups events within a temporal section by their specific date.
- **StoredEvent**: Existing entity — no changes to its structure are required. The `dateTime` field is used for all grouping and sorting logic.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Users can identify how many events they have today within 2 seconds of viewing the list.
- **SC-002**: Every event in the list is assigned to exactly one temporal section — no event appears in multiple sections or is missing.
- **SC-003**: Section ordering is always consistent: Today > This Week > Later > Past.
- **SC-004**: The feature works entirely client-side with no additional network requests beyond what currently exists.
- **SC-005**: All existing event list functionality (delete, navigation, role badges) continues to work unchanged.
## Assumptions
- The user's locale and timezone are used for determining "today" and formatting dates/times (via the browser's `Intl` API, consistent with existing approach).
- "Week" follows ISO convention where Monday is the first day of the week. "This Week" runs from tomorrow through Sunday.
- The design system (Electric Dusk + Sora) applies to all new visual elements. The inspiration screenshot's color theme is explicitly NOT adopted.
- No backend changes are needed — this is a purely frontend enhancement to the existing client-side event list.

View File

@@ -0,0 +1,189 @@
# Tasks: Event List Temporal Grouping
**Input**: Design documents from `/specs/010-event-list-grouping/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md
**Tests**: Included — spec.md references TDD (Constitution II), and research.md explicitly plans unit + E2E test updates.
**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
**Purpose**: No new project setup needed — this is a frontend-only enhancement to an existing codebase. Phase 1 is empty.
*(No tasks — existing project structure is sufficient.)*
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Create the core grouping composable and its types — all user stories depend on this logic.
**CRITICAL**: No user story work can begin until this phase is complete.
- [ ] T001 Define `SectionKey`, `EventSection`, and `DateGroup` types in `frontend/src/composables/useEventGrouping.ts`
- [ ] T002 Implement `useEventGrouping` composable with section classification, date grouping, and sorting logic in `frontend/src/composables/useEventGrouping.ts`
- [ ] T003 Write unit tests for `useEventGrouping` covering all four sections, empty-section omission, sort order, and Sunday edge case in `frontend/src/components/__tests__/useEventGrouping.spec.ts`
**Checkpoint**: Grouping logic is fully tested and ready for consumption by UI components.
---
## Phase 3: User Story 1 — Temporal Section Headers (Priority: P1) MVP
**Goal**: Events appear grouped under "Today", "This Week", "Later", and "Past" section headers. Empty sections are hidden.
**Independent Test**: Add events with various dates to localStorage, verify they appear under correct section headers.
### Tests for User Story 1
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T004 [P] [US1] Write unit tests for `SectionHeader.vue` rendering section label and emphasis flag in `frontend/src/components/__tests__/SectionHeader.spec.ts`
- [ ] T005 [P] [US1] Update `EventList.spec.ts` tests to expect grouped section structure instead of flat list in `frontend/src/components/__tests__/EventList.spec.ts`
### Implementation for User Story 1
- [ ] T006 [P] [US1] Create `SectionHeader.vue` component with section label (`<h2>`) and `aria-label` in `frontend/src/components/SectionHeader.vue`
- [ ] T007 [US1] Refactor `EventList.vue` template to use `useEventGrouping`, render `<section>` per temporal group with `SectionHeader`, and hide empty sections in `frontend/src/components/EventList.vue`
- [ ] T008 [US1] Update E2E tests in `home-events.spec.ts` to verify section headers appear with correct events distributed across "Today", "This Week", "Later", "Past" in `frontend/e2e/home-events.spec.ts`
**Checkpoint**: Event list shows temporal section headers. All four acceptance scenarios pass.
---
## Phase 4: User Story 2 — Date Subheaders Within Sections (Priority: P2)
**Goal**: Within each section (except "Today"), events are further grouped by date with formatted subheaders like "Wed, 12 Mar".
**Independent Test**: Add multiple events on different days within one section, verify date subheaders appear.
### Tests for User Story 2
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T009 [P] [US2] Write unit tests for `DateSubheader.vue` rendering formatted date label in `frontend/src/components/__tests__/DateSubheader.spec.ts`
- [ ] T010 [P] [US2] Add unit tests to `EventList.spec.ts` verifying date subheaders appear within sections and are absent in "Today" in `frontend/src/components/__tests__/EventList.spec.ts`
### Implementation for User Story 2
- [ ] T011 [P] [US2] Create `DateSubheader.vue` component with formatted date (`<h3>`) using `Intl.DateTimeFormat` in `frontend/src/components/DateSubheader.vue`
- [ ] T012 [US2] Update `EventList.vue` template to render `DateSubheader` within each section's date groups, skipping subheader for "Today" section (`showSubheader` flag) in `frontend/src/components/EventList.vue`
- [ ] T013 [US2] Add E2E test scenarios for date subheaders: multiple days within a section, no subheader in "Today" in `frontend/e2e/home-events.spec.ts`
**Checkpoint**: Date subheaders render correctly within sections. "Today" section has no subheader.
---
## Phase 5: User Story 3 — Enhanced Event Card Time Display (Priority: P2)
**Goal**: Event cards show clock time ("18:30") in Today/This Week/Later sections and relative time ("3 days ago") in Past section.
**Independent Test**: Check event cards display the correct time format based on which section they appear in.
### Tests for User Story 3
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T014 [P] [US3] Add unit tests to `EventCard.spec.ts` for `timeDisplayMode` prop: `'clock'` renders formatted time, `'relative'` renders relative time in `frontend/src/components/__tests__/EventCard.spec.ts`
### Implementation for User Story 3
- [ ] T015 [US3] Add `timeDisplayMode` prop (`'clock' | 'relative'`) to `EventCard.vue`, render clock time via `Intl.DateTimeFormat({ hour: '2-digit', minute: '2-digit' })` or existing `formatRelativeTime()` in `frontend/src/components/EventCard.vue`
- [ ] T016 [US3] Update `EventList.vue` to pass `timeDisplayMode="clock"` for today/thisWeek/later sections and `timeDisplayMode="relative"` for past section in `frontend/src/components/EventList.vue`
- [ ] T017 [US3] Add E2E test scenarios verifying clock time in future sections and relative time in past section in `frontend/e2e/home-events.spec.ts`
**Checkpoint**: Time display adapts to section context. All three acceptance scenarios pass.
---
## Phase 6: User Story 4 — Today Section Visual Emphasis (Priority: P3)
**Goal**: The "Today" section header is visually more prominent (bolder, slightly larger, accent border) than other sections.
**Independent Test**: Visual verification that "Today" stands out compared to other section headers.
- [ ] T018 [US4] Add `.section--today` CSS class to `SectionHeader.vue` with `font-weight: 800`, `font-size: 1.1rem`, and left border accent (`#F06292`) — triggered by `emphasized` prop in `frontend/src/components/SectionHeader.vue`
- [ ] T019 [US4] Verify `EventList.vue` passes `emphasized: true` for the "Today" section (already set via `EventSection.emphasized` from data model) in `frontend/src/components/EventList.vue`
- [ ] T020 [US4] Add visual E2E assertion checking that the "Today" section header has the emphasis CSS class applied in `frontend/e2e/home-events.spec.ts`
**Checkpoint**: "Today" section is visually distinct. Past events remain faded.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Final validation and regression checks.
- [ ] T021 Run full unit test suite (`cd frontend && npm run test:unit`) and fix any regressions
- [ ] T022 Run full E2E test suite and verify all existing functionality (swipe-to-delete, role badges, empty state, navigation) still works in `frontend/e2e/home-events.spec.ts`
- [ ] T023 Verify accessibility: section headers are `<h2>`, date subheaders are `<h3>`, sections have `aria-label`, keyboard navigation works
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Empty — no work needed
- **Foundational (Phase 2)**: No dependencies — can start immediately
- **US1 (Phase 3)**: Depends on Phase 2 (grouping composable)
- **US2 (Phase 4)**: Depends on Phase 3 (needs section structure in EventList)
- **US3 (Phase 5)**: Depends on Phase 3 (needs section context for time mode)
- **US4 (Phase 6)**: Depends on Phase 3 (needs SectionHeader component)
- **Polish (Phase 7)**: Depends on all user stories being complete
### User Story Dependencies
- **US1 (P1)**: Can start after Foundational — no dependencies on other stories
- **US2 (P2)**: Depends on US1 (needs section structure in template to add subheaders)
- **US3 (P2)**: Depends on US1 (needs section context to determine time mode), independent of US2
- **US4 (P3)**: Depends on US1 (needs SectionHeader component), independent of US2/US3
### Parallel Opportunities
- **Phase 2**: T001 must precede T002; T003 can run after T002
- **Phase 3**: T004 and T005 in parallel; T006 in parallel with tests; T007 after T006
- **Phase 4**: T009 and T010 in parallel; T011 in parallel with tests; T012 after T011
- **Phase 5**: T014 can start as soon as US1 is done; T015 after T014; T016 after T015
- **Phase 6**: T018 can run in parallel with Phase 5 (different files)
- **US3 and US4** can run in parallel after US1 completes
---
## Parallel Example: After US1 Completes
```bash
# These can run in parallel (different files, no dependencies):
Task: T009 [US2] Write DateSubheader unit tests
Task: T014 [US3] Write EventCard time mode unit tests
Task: T018 [US4] Add .section--today CSS to SectionHeader.vue
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 2: Foundational (grouping composable + tests)
2. Complete Phase 3: User Story 1 (section headers in EventList)
3. **STOP and VALIDATE**: Test US1 independently
4. Deploy/demo if ready — list is already grouped with headers
### Incremental Delivery
1. Phase 2 → Grouping logic ready
2. Add US1 → Section headers visible → Deploy/Demo (MVP!)
3. Add US2 → Date subheaders within sections → Deploy/Demo
4. Add US3 → Context-aware time display → Deploy/Demo
5. Add US4 → Visual polish for "Today" → Deploy/Demo
6. Each story adds value without breaking previous stories