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

@@ -35,21 +35,21 @@
<dl class="detail__meta">
<div class="detail__meta-item">
<dt class="detail__meta-icon glass" aria-label="Date and time">
<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 glass" aria-label="Location">
<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 glass" aria-label="Attendees">
<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>
@@ -120,8 +120,10 @@
<RsvpBar
v-if="state === 'loaded' && event && !isOrganizer && !event.cancelled"
:has-rsvp="!!rsvpName"
:bookmarked="eventIsStored"
@open="sheetOpen = true"
@cancel="confirmCancelOpen = true"
@bookmark="handleBookmarkClick"
/>
<!-- Cancel confirmation dialog -->
@@ -179,7 +181,7 @@ type GetEventResponse = components['schemas']['GetEventResponse']
type State = 'loading' | 'loaded' | 'not-found' | 'error'
const route = useRoute()
const { saveRsvp, getRsvp, removeRsvp, getOrganizerToken } = useEventStorage()
const { saveRsvp, getRsvp, removeRsvp, getOrganizerToken, saveWatch, isStored, removeEvent } = useEventStorage()
const state = ref<State>('loading')
const event = ref<GetEventResponse | null>(null)
@@ -202,6 +204,20 @@ const cancelReasonInput = ref('')
const cancelEventError = ref('')
const cancellingEvent = ref(false)
const eventToken = computed(() => route.params.eventToken as string)
const eventIsStored = computed(() => isStored(eventToken.value))
function handleBookmarkClick() {
if (!event.value) return
if (isOrganizer.value || rsvpName.value) return
if (eventIsStored.value) {
removeEvent(eventToken.value)
} else {
saveWatch(eventToken.value, event.value.title, event.value.dateTime)
}
}
const formattedDateTime = computed(() => {
if (!event.value) return ''
const formatted = new Intl.DateTimeFormat(undefined, {
@@ -469,6 +485,10 @@ onMounted(fetchEvent)
padding-top: 4rem;
}
.detail__meta-icon svg {
display: block;
}
/* Title */
.detail__title {
font-size: 2rem;
@@ -501,6 +521,11 @@ onMounted(fetchEvent)
justify-content: center;
border-radius: 10px;
color: var(--color-text-on-gradient);
line-height: 0;
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
border: 1px solid var(--color-glass-border);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.detail__meta-text {

View File

@@ -164,6 +164,8 @@ describe('EventCreateView', () => {
saveRsvp: vi.fn(),
getRsvp: vi.fn(),
removeRsvp: vi.fn(),
saveWatch: vi.fn(),
isStored: vi.fn(() => false),
removeEvent: vi.fn(),
})

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