Compare commits
6 Commits
1b3eafa8d1
...
0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| d7ed28e036 | |||
| a52d0cd1d3 | |||
| 373f3671f6 | |||
| 8f78c6cd45 | |||
| fe291e36e4 | |||
| e56998b17c |
@@ -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
|
||||
|
||||
377
frontend/e2e/home-events.spec.ts
Normal file
377
frontend/e2e/home-events.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
BIN
frontend/src/assets/images/event-hero-placeholder.jpg
Normal file
BIN
frontend/src/assets/images/event-hero-placeholder.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
151
frontend/src/components/ConfirmDialog.vue
Normal file
151
frontend/src/components/ConfirmDialog.vue
Normal 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>
|
||||
49
frontend/src/components/CreateEventFab.vue
Normal file
49
frontend/src/components/CreateEventFab.vue
Normal 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>
|
||||
19
frontend/src/components/DateSubheader.vue
Normal file
19
frontend/src/components/DateSubheader.vue
Normal 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>
|
||||
31
frontend/src/components/EmptyState.vue
Normal file
31
frontend/src/components/EmptyState.vue
Normal 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>
|
||||
181
frontend/src/components/EventCard.vue
Normal file
181
frontend/src/components/EventCard.vue
Normal 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>
|
||||
94
frontend/src/components/EventList.vue
Normal file
94
frontend/src/components/EventList.vue
Normal 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>
|
||||
27
frontend/src/components/SectionHeader.vue
Normal file
27
frontend/src/components/SectionHeader.vue
Normal 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>
|
||||
111
frontend/src/components/__tests__/ConfirmDialog.spec.ts
Normal file
111
frontend/src/components/__tests__/ConfirmDialog.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
17
frontend/src/components/__tests__/DateSubheader.spec.ts
Normal file
17
frontend/src/components/__tests__/DateSubheader.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
35
frontend/src/components/__tests__/EmptyState.spec.ts
Normal file
35
frontend/src/components/__tests__/EmptyState.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
100
frontend/src/components/__tests__/EventCard.spec.ts
Normal file
100
frontend/src/components/__tests__/EventCard.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
140
frontend/src/components/__tests__/EventList.spec.ts
Normal file
140
frontend/src/components/__tests__/EventList.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
27
frontend/src/components/__tests__/SectionHeader.spec.ts
Normal file
27
frontend/src/components/__tests__/SectionHeader.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
158
frontend/src/components/__tests__/useEventGrouping.spec.ts
Normal file
158
frontend/src/components/__tests__/useEventGrouping.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
72
frontend/src/composables/__tests__/useRelativeTime.spec.ts
Normal file
72
frontend/src/composables/__tests__/useRelativeTime.spec.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
149
frontend/src/composables/useEventGrouping.ts
Normal file
149
frontend/src/composables/useEventGrouping.ts
Normal 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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
23
frontend/src/composables/useRelativeTime.ts
Normal file
23
frontend/src/composables/useRelativeTime.ts
Normal 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')
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,58 +1,75 @@
|
||||
<template>
|
||||
<main class="detail">
|
||||
<header class="detail__header">
|
||||
<RouterLink to="/" class="detail__back" aria-label="Back to home">←</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">←</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 & 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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
35
specs/009-list-events/checklists/requirements.md
Normal file
35
specs/009-list-events/checklists/requirements.md
Normal 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.
|
||||
99
specs/009-list-events/data-model.md
Normal file
99
specs/009-list-events/data-model.md
Normal 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)
|
||||
└────────────────────────────────┘
|
||||
```
|
||||
86
specs/009-list-events/plan.md
Normal file
86
specs/009-list-events/plan.md
Normal 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.
|
||||
110
specs/009-list-events/research.md
Normal file
110
specs/009-list-events/research.md
Normal 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)
|
||||
145
specs/009-list-events/spec.md
Normal file
145
specs/009-list-events/spec.md
Normal 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.
|
||||
215
specs/009-list-events/tasks.md
Normal file
215
specs/009-list-events/tasks.md
Normal 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 2–6 completion
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: Depends only on Phase 1 — no other story dependencies
|
||||
- **US2 (P2)**: Depends only on Phase 1 — independent of US1 but shares HomeView
|
||||
- **US4 (P2)**: Depends on US1 (extends EventCard with past styling)
|
||||
- **US3 (P3)**: Depends on US1 (extends EventList with delete flow)
|
||||
- **US5 (P3)**: Depends on US1 (extends EventCard with role badge)
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T003 + T004 + T005 can all run in parallel (different files)
|
||||
- T006 + T007 can run in parallel (different test files)
|
||||
- T008 can run in parallel with T006/T007 (component vs test files)
|
||||
- US4, US5 can start in parallel once US1 is done (both extend EventCard independently)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup composables
|
||||
2. Complete Phase 2: US1 — EventCard, EventList, HomeView refactor
|
||||
3. **STOP and VALIDATE**: Test the event list end-to-end
|
||||
4. Deploy/demo if ready
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Phase 1 → Composable layer ready
|
||||
2. Phase 2 (US1) → Event list works → MVP!
|
||||
3. Phase 3 (US2) → Empty state for new users
|
||||
4. Phase 4 (US4) → Past events faded
|
||||
5. Phase 5 (US3) → Remove events from list
|
||||
6. Phase 6 (US5) → Role badges
|
||||
7. Phase 7 → FAB, swipe, accessibility polish
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies
|
||||
- [Story] label maps task to specific user story for traceability
|
||||
- This is a **frontend-only** feature — no backend changes needed
|
||||
- All data comes from existing `useEventStorage` composable (localStorage)
|
||||
- E2E tests consolidated in single file `home-events.spec.ts` with separate `describe` blocks per story
|
||||
35
specs/010-event-list-grouping/checklists/requirements.md
Normal file
35
specs/010-event-list-grouping/checklists/requirements.md
Normal 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.
|
||||
91
specs/010-event-list-grouping/data-model.md
Normal file
91
specs/010-event-list-grouping/data-model.md
Normal 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.
|
||||
72
specs/010-event-list-grouping/plan.md
Normal file
72
specs/010-event-list-grouping/plan.md
Normal 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.
|
||||
118
specs/010-event-list-grouping/research.md
Normal file
118
specs/010-event-list-grouping/research.md
Normal 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.
|
||||
138
specs/010-event-list-grouping/spec.md
Normal file
138
specs/010-event-list-grouping/spec.md
Normal 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.
|
||||
189
specs/010-event-list-grouping/tasks.md
Normal file
189
specs/010-event-list-grouping/tasks.md
Normal 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
|
||||
Reference in New Issue
Block a user