Implement watch-event feature (017) with bookmark in RsvpBar
All checks were successful
CI / backend-test (push) Successful in 1m0s
CI / frontend-test (push) Successful in 27s
CI / frontend-e2e (push) Successful in 1m30s
CI / build-and-publish (push) Has been skipped

Add client-side watch/bookmark functionality: users can save events to
localStorage without RSVPing via a bookmark button next to the "I'm attending"
CTA. Watched events appear in the event list with a "Watching" label.
Bookmark is only visible for visitors (not attendees or organizers).

Includes spec, plan, research, tasks, unit tests, and E2E tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 22:20:57 +01:00
parent e01d5ee642
commit c450849e4d
22 changed files with 1266 additions and 31 deletions

View File

@@ -14,6 +14,9 @@ vi.mock('@/api/client', () => ({
const mockSaveRsvp = vi.fn()
const mockGetRsvp = vi.fn()
const mockGetOrganizerToken = vi.fn()
const mockSaveWatch = vi.fn()
const mockIsStored = vi.fn()
const mockRemoveEvent = vi.fn()
vi.mock('@/composables/useEventStorage', () => ({
useEventStorage: vi.fn(() => ({
@@ -22,7 +25,10 @@ vi.mock('@/composables/useEventStorage', () => ({
getOrganizerToken: mockGetOrganizerToken,
saveRsvp: mockSaveRsvp,
getRsvp: mockGetRsvp,
removeEvent: vi.fn(),
removeRsvp: vi.fn(),
saveWatch: mockSaveWatch,
isStored: mockIsStored,
removeEvent: mockRemoveEvent,
})),
}))
@@ -68,6 +74,9 @@ beforeEach(() => {
vi.restoreAllMocks()
mockGetRsvp.mockReturnValue(undefined)
mockGetOrganizerToken.mockReturnValue(undefined)
mockIsStored.mockReturnValue(false)
mockSaveWatch.mockClear()
mockRemoveEvent.mockClear()
})
describe('EventDetailView', () => {
@@ -366,4 +375,89 @@ describe('EventDetailView', () => {
expect(document.body.textContent).toContain('Could not submit RSVP. Please try again.')
wrapper.unmount()
})
// Bookmark — T007: bookmark state is passed to RsvpBar via props
it('passes bookmarked=false to RsvpBar when event is not in storage', async () => {
mockIsStored.mockReturnValue(false)
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
expect(rsvpBar.props('bookmarked')).toBe(false)
wrapper.unmount()
})
it('passes bookmarked=true to RsvpBar when event is in storage', async () => {
mockIsStored.mockReturnValue(true)
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
expect(rsvpBar.props('bookmarked')).toBe(true)
wrapper.unmount()
})
it('bookmark event emitted from RsvpBar calls saveWatch', async () => {
mockIsStored.mockReturnValue(false)
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
rsvpBar.vm.$emit('bookmark')
await flushPromises()
expect(mockSaveWatch).toHaveBeenCalledWith('test-token', 'Summer BBQ', '2026-03-15T20:00:00+01:00')
wrapper.unmount()
})
it('bookmark event emitted from RsvpBar calls removeEvent when user is watcher', async () => {
mockIsStored.mockReturnValue(true)
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
rsvpBar.vm.$emit('bookmark')
await flushPromises()
expect(mockRemoveEvent).toHaveBeenCalledWith('test-token')
wrapper.unmount()
})
it('bookmark event ignored when user is attendee', async () => {
mockGetRsvp.mockReturnValue({ rsvpToken: 'rsvp-1', rsvpName: 'Max' })
mockIsStored.mockReturnValue(true)
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
rsvpBar.vm.$emit('bookmark')
await flushPromises()
expect(mockRemoveEvent).not.toHaveBeenCalled()
expect(mockSaveWatch).not.toHaveBeenCalled()
wrapper.unmount()
})
it('passes bookmarked=true to RsvpBar after removeRsvp (event still in storage)', async () => {
mockIsStored.mockReturnValue(true)
mockGetRsvp.mockReturnValue(undefined)
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
expect(rsvpBar.props('bookmarked')).toBe(true)
wrapper.unmount()
})
})