Add event list feature (009-list-events)
Enable users to see all their saved events on the home screen, sorted by date with upcoming events first. Key capabilities: - EventCard with title, relative time display, and organizer/attendee role badge - Sortable EventList with past-event visual distinction (faded style) - Empty state when no events are stored - Swipe-to-delete gesture with confirmation dialog - Floating action button for quick event creation - Rename router param :token → :eventToken across all views - useRelativeTime composable (Intl.RelativeTimeFormat) - useEventStorage: add validation, removeEvent(), reactive versioning - Full E2E and unit test coverage for all new components Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
Reference in New Issue
Block a user