diff --git a/.specify/memory/ideen.md b/.specify/memory/ideen.md index b79196f..8cc0496 100644 --- a/.specify/memory/ideen.md +++ b/.specify/memory/ideen.md @@ -180,6 +180,7 @@ Organisator kann Event absagen (mit optionaler Nachricht, Einweg-Transition). * RSVPs werden nach Absage abgelehnt * Absage-Nachricht nachträglich editierbar * Kann nicht rückgängig gemacht werden +* Wenn Organisator Event auf der Eventlistenseite löscht, muss dabei das Event abgesagt werden (nicht nur lokal entfernen) ### 025 – Event löschen Organisator löscht Event permanent und unwiderruflich. diff --git a/CLAUDE.md b/CLAUDE.md index a98ff1d..994d010 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,15 +48,4 @@ The following skills are available and should be used for their respective purpo - Autonomous work is done via Ralph Loops. See [.claude/rules/ralph-loops.md](.claude/rules/ralph-loops.md) for documentation. - The loop runner is `ralph.sh`. Each run lives in its own directory under `.ralph/`. - Run directories contain: `instructions.md` (prompt), `chief-wiggum.md` (directives), `answers.md` (human answers), `questions.md` (Ralph's questions), `progress.txt` (iteration log), `meta.md` (metadata), `run.log` (execution log). -- Project specifications (user stories, setup tasks, personas, etc.) live in `specs/` (feature dirs) and `.specify/memory/` (cross-cutting docs). - -## 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) -- Java 25, Spring Boot 3.5.x + Spring Scheduling (`@Scheduled`), Spring Data JPA (for native query) (013-auto-delete-expired) -- PostgreSQL (existing, Liquibase migrations) (013-auto-delete-expired) - -## 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 +- Project specifications (user stories, setup tasks, personas, etc.) live in `specs/` (feature dirs) and `.specify/memory/` (cross-cutting docs). \ No newline at end of file diff --git a/frontend/e2e/home-events.spec.ts b/frontend/e2e/home-events.spec.ts index 13bff36..88ca1a8 100644 --- a/frontend/e2e/home-events.spec.ts +++ b/frontend/e2e/home-events.spec.ts @@ -154,12 +154,15 @@ test.describe('US5: Visual Distinction for Event Roles', () => { await expect(badge).toHaveClass(/event-card__badge--attendee/) }) - test('shows no badge for events without organizerToken or rsvpToken', async ({ page }) => { + test('shows watcher 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) + const badge = card.locator('.event-card__badge') + await expect(badge).toBeVisible() + await expect(badge).toHaveText('Watching') + await expect(badge).toHaveClass(/event-card__badge--watcher/) }) }) diff --git a/frontend/e2e/watch-event.spec.ts b/frontend/e2e/watch-event.spec.ts new file mode 100644 index 0000000..3a32bb1 --- /dev/null +++ b/frontend/e2e/watch-event.spec.ts @@ -0,0 +1,218 @@ +import { http, HttpResponse } from 'msw' +import { test, expect } from './msw-setup' +import type { StoredEvent } from '../src/composables/useEventStorage' + +const STORAGE_KEY = 'fete:events' + +const fullEvent = { + eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + title: 'Summer BBQ', + description: 'Bring your own drinks!', + dateTime: '2026-03-15T20:00:00+01:00', + timezone: 'Europe/Berlin', + location: 'Central Park, NYC', + attendeeCount: 12, + cancelled: false, +} + +const rsvpToken = 'd4e5f6a7-b8c9-0123-4567-890abcdef012' +const organizerToken = 'org-token-1234' + +function seedEvents(events: StoredEvent[]): string { + return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})` +} + +function watchSeed(): StoredEvent { + return { + eventToken: fullEvent.eventToken, + title: fullEvent.title, + dateTime: fullEvent.dateTime, + } +} + +function rsvpSeed(): StoredEvent { + return { + eventToken: fullEvent.eventToken, + title: fullEvent.title, + dateTime: fullEvent.dateTime, + rsvpToken, + rsvpName: 'Anna', + } +} + +function organizerSeed(): StoredEvent { + return { + eventToken: fullEvent.eventToken, + title: fullEvent.title, + dateTime: fullEvent.dateTime, + organizerToken, + } +} + +test.describe('US1: Watch event from detail page', () => { + test('bookmark unfilled by default, tapping watches the event', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)), + ) + await page.goto(`/events/${fullEvent.eventToken}`) + + const bookmark = page.locator('.rsvp-bar__bookmark-inner') + await expect(bookmark).toBeVisible() + await expect(bookmark).toHaveAttribute('aria-label', 'Watch this event') + + await bookmark.click() + + await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event') + + // Navigate to event list via back link + await page.locator('.detail__back').click() + + // Event appears with "Watching" label + await expect(page.getByText('Summer BBQ')).toBeVisible() + await expect(page.getByText('Watching')).toBeVisible() + }) +}) + +test.describe('US2: Un-watch event from detail page', () => { + test('tapping filled bookmark un-watches the event', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)), + ) + await page.addInitScript(seedEvents([watchSeed()])) + await page.goto(`/events/${fullEvent.eventToken}`) + + const bookmark = page.locator('.rsvp-bar__bookmark-inner') + await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event') + + await bookmark.click() + + await expect(bookmark).toHaveAttribute('aria-label', 'Watch this event') + + // Navigate to event list via back link (avoid page.goto re-running addInitScript) + await page.locator('.detail__back').click() + + // Event is gone + await expect(page.getByText('Summer BBQ')).not.toBeVisible() + }) +}) + +test.describe('US3: Bookmark reflects attending status', () => { + test('bookmark is not visible when user has RSVPed, list shows Attendee', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)), + ) + await page.addInitScript(seedEvents([rsvpSeed()])) + await page.goto(`/events/${fullEvent.eventToken}`) + + // Bookmark not shown for attendees — RsvpBar shows status state + const bookmark = page.locator('.rsvp-bar__bookmark-inner') + await expect(bookmark).not.toBeVisible() + + // Navigate to list via back link + await page.locator('.detail__back').click() + await expect(page.getByText('Attendee')).toBeVisible() + await expect(page.getByText('Watching')).not.toBeVisible() + }) +}) + +test.describe('US4: RSVP cancellation preserves watch status', () => { + test('cancel RSVP → bookmark reappears, list shows Watching', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)), + http.delete('*/api/events/:token/rsvps/:rsvpToken', () => { + return new HttpResponse(null, { status: 204 }) + }), + ) + await page.addInitScript(seedEvents([rsvpSeed()])) + await page.goto(`/events/${fullEvent.eventToken}`) + + // Cancel RSVP + await page.getByRole('button', { name: /You're attending/ }).click() + await page.locator('.rsvp-bar__cancel').click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click() + + // Bookmark reappears in CTA state, filled because event is still stored + const bookmark = page.locator('.rsvp-bar__bookmark-inner') + await expect(bookmark).toBeVisible() + await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event') + + // Navigate to list via back link + await page.locator('.detail__back').click() + await expect(page.getByText('Watching')).toBeVisible() + await expect(page.getByText('Attendee')).not.toBeVisible() + }) +}) + +test.describe('US5: No bookmark for attendees and organizers', () => { + test('attendee does not see bookmark', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)), + ) + await page.addInitScript(seedEvents([rsvpSeed()])) + await page.goto(`/events/${fullEvent.eventToken}`) + + const bookmark = page.locator('.rsvp-bar__bookmark-inner') + await expect(bookmark).not.toBeVisible() + }) + + test('organizer does not see bookmark', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)), + ) + await page.addInitScript(seedEvents([organizerSeed()])) + await page.goto(`/events/${fullEvent.eventToken}`) + + const bookmark = page.locator('.rsvp-bar__bookmark-inner') + await expect(bookmark).not.toBeVisible() + }) +}) + +test.describe('US6: Un-watch from event list', () => { + test('deleting a watched event skips confirmation dialog', async ({ page }) => { + await page.addInitScript(seedEvents([watchSeed()])) + await page.goto('/') + + await expect(page.getByText('Summer BBQ')).toBeVisible() + + await page.getByRole('button', { name: /Remove Summer BBQ/ }).click() + + // No confirmation dialog — event removed immediately + await expect(page.getByText('Remove event?')).not.toBeVisible() + await expect(page.getByText('Summer BBQ')).not.toBeVisible() + }) +}) + +test.describe('US7: Watcher upgrades to attendee', () => { + test('watch → RSVP → bookmark disappears, list shows Attendee', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)), + http.post('*/api/events/:token/rsvps', () => { + return HttpResponse.json( + { rsvpToken: 'new-rsvp-token', name: 'Max' }, + { status: 201 }, + ) + }), + ) + await page.addInitScript(seedEvents([watchSeed()])) + await page.goto(`/events/${fullEvent.eventToken}`) + + // Verify watching state — bookmark visible + const bookmark = page.locator('.rsvp-bar__bookmark-inner') + await expect(bookmark).toBeVisible() + await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event') + + // RSVP + await page.getByRole('button', { name: "I'm attending" }).click() + const dialog = page.getByRole('dialog', { name: 'RSVP' }) + await dialog.getByLabel('Your name').fill('Max') + await dialog.getByRole('button', { name: 'Count me in' }).click() + + // Bookmark gone — status bar shown instead + await expect(bookmark).not.toBeVisible() + + // Navigate to list via back link + await page.locator('.detail__back').click() + await expect(page.getByText('Attendee')).toBeVisible() + await expect(page.getByText('Watching')).not.toBeVisible() + }) +}) diff --git a/frontend/src/components/EventCard.vue b/frontend/src/components/EventCard.vue index 868720b..705870d 100644 --- a/frontend/src/components/EventCard.vue +++ b/frontend/src/components/EventCard.vue @@ -12,7 +12,7 @@ {{ displayTime }} - {{ eventRole === 'organizer' ? 'Organizer' : 'Attendee' }} + {{ eventRole === 'organizer' ? 'Organizer' : eventRole === 'attendee' ? 'Attendee' : 'Watching' }} +
+
+ +
+
+ +
@@ -45,11 +57,13 @@ import { ref, watch } from 'vue' const props = defineProps<{ hasRsvp?: boolean + bookmarked?: boolean }>() defineEmits<{ open: [] cancel: [] + bookmark: [] }>() const expanded = ref(false) @@ -92,8 +106,14 @@ watch(expanded, (isExpanded) => { max-width: var(--content-max-width); } +.rsvp-bar__row { + display: flex; + gap: var(--spacing-sm); +} + .rsvp-bar__cta { - width: 100%; + flex: 1; + min-width: 0; border-radius: var(--radius-button); transition: transform 0.1s ease; } @@ -206,4 +226,37 @@ watch(expanded, (isExpanded) => { opacity: 0; transform: translateY(-4px); } + +.rsvp-bar__bookmark { + flex-shrink: 0; + border-radius: var(--radius-button); + transition: transform 0.1s ease; +} + +.rsvp-bar__bookmark:hover { + transform: scale(1.02); +} + +.rsvp-bar__bookmark:active { + transform: scale(0.98); +} + +.rsvp-bar__bookmark-inner { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: var(--spacing-md); + border-radius: calc(var(--radius-button) - 2px); + border: none; + cursor: pointer; + color: var(--color-text-on-gradient); + line-height: 0; +} + +.rsvp-bar__bookmark-inner svg { + display: block; +} + diff --git a/frontend/src/components/__tests__/EventCard.spec.ts b/frontend/src/components/__tests__/EventCard.spec.ts index 382b92a..7b9bf2d 100644 --- a/frontend/src/components/__tests__/EventCard.spec.ts +++ b/frontend/src/components/__tests__/EventCard.spec.ts @@ -63,6 +63,12 @@ describe('EventCard', () => { expect(wrapper.text()).toContain('Attendee') }) + it('renders watcher badge when eventRole is watcher', () => { + const wrapper = mountCard({ eventRole: 'watcher' }) + expect(wrapper.find('.event-card__badge--watcher').exists()).toBe(true) + expect(wrapper.text()).toContain('Watching') + }) + it('renders no badge when eventRole is undefined', () => { const wrapper = mountCard({ eventRole: undefined }) expect(wrapper.find('.event-card__badge').exists()).toBe(false) diff --git a/frontend/src/components/__tests__/EventList.spec.ts b/frontend/src/components/__tests__/EventList.spec.ts index 2a2a865..8cb0004 100644 --- a/frontend/src/components/__tests__/EventList.spec.ts +++ b/frontend/src/components/__tests__/EventList.spec.ts @@ -20,6 +20,8 @@ const mockEvents = [ { eventToken: 'today-1', title: 'Today Event', dateTime: '2026-03-11T18:00:00' }, { eventToken: 'week-1', title: 'This Week Event', dateTime: '2026-03-13T10:00:00' }, { eventToken: 'nextweek-1', title: 'Next Week Event', dateTime: '2026-03-16T10:00:00' }, + { eventToken: 'org-1', title: 'Organized Event', dateTime: '2026-03-11T19:00:00', organizerToken: 'org-token' }, + { eventToken: 'rsvp-1', title: 'Attending Event', dateTime: '2026-03-11T20:00:00', rsvpToken: 'rsvp-token', rsvpName: 'Max' }, ] vi.mock('../../composables/useEventStorage', () => ({ @@ -32,6 +34,13 @@ vi.mock('../../composables/useEventStorage', () => ({ }, useEventStorage: () => ({ getStoredEvents: () => mockEvents, + getRsvp: (token: string) => { + const evt = mockEvents.find((e) => e.eventToken === token) + if (evt && 'rsvpToken' in evt && 'rsvpName' in evt) { + return { rsvpToken: evt.rsvpToken, rsvpName: evt.rsvpName } + } + return undefined + }, removeEvent: vi.fn(), }), })) @@ -40,7 +49,9 @@ 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-11T18')) return 'in 6 hours' + if (dateTime.includes('03-11T19')) return 'in 7 hours' + if (dateTime.includes('03-11T20')) return 'in 8 hours' if (dateTime.includes('03-13')) return 'in 2 days' if (dateTime.includes('03-16')) return 'in 5 days' return 'sometime' @@ -89,7 +100,7 @@ describe('EventList', () => { it('renders all valid events as cards', () => { const wrapper = mountList() const cards = wrapper.findAll('.event-card') - expect(cards).toHaveLength(5) + expect(cards).toHaveLength(7) }) it('marks past events with isPast class', () => { @@ -137,4 +148,25 @@ describe('EventList', () => { const pastSection = wrapper.findAll('.event-section')[4]! expect(pastSection.find('.date-subheader').exists()).toBe(true) }) + + it('assigns watcher role when event has no organizerToken and no rsvpToken', () => { + const wrapper = mountList() + const badges = wrapper.findAll('.event-card__badge--watcher') + expect(badges.length).toBeGreaterThanOrEqual(1) + expect(badges[0]!.text()).toBe('Watching') + }) + + it('assigns organizer role when event has organizerToken', () => { + const wrapper = mountList() + const badge = wrapper.find('.event-card__badge--organizer') + expect(badge.exists()).toBe(true) + expect(badge.text()).toBe('Organizer') + }) + + it('assigns attendee role when event has rsvpToken', () => { + const wrapper = mountList() + const badge = wrapper.find('.event-card__badge--attendee') + expect(badge.exists()).toBe(true) + expect(badge.text()).toBe('Attendee') + }) }) diff --git a/frontend/src/composables/__tests__/useEventStorage.spec.ts b/frontend/src/composables/__tests__/useEventStorage.spec.ts index 0ee521a..f4bfeda 100644 --- a/frontend/src/composables/__tests__/useEventStorage.spec.ts +++ b/frontend/src/composables/__tests__/useEventStorage.spec.ts @@ -194,6 +194,71 @@ describe('useEventStorage', () => { }) }) +describe('useEventStorage – saveWatch / isStored', () => { + beforeEach(() => { + clearStorage() + }) + + it('saves a watch-only event (no rsvpToken, no organizerToken)', () => { + const { saveWatch, getStoredEvents } = useEventStorage() + + saveWatch('watch-1', 'Concert', '2026-07-01T20:00:00+02:00') + + const events = getStoredEvents() + expect(events).toHaveLength(1) + expect(events[0]!.eventToken).toBe('watch-1') + expect(events[0]!.title).toBe('Concert') + expect(events[0]!.dateTime).toBe('2026-07-01T20:00:00+02:00') + expect(events[0]!.rsvpToken).toBeUndefined() + expect(events[0]!.organizerToken).toBeUndefined() + }) + + it('does not duplicate if event already stored', () => { + const { saveWatch, saveRsvp, getStoredEvents } = useEventStorage() + + saveRsvp('evt-1', 'rsvp-1', 'Max', 'Party', '2026-07-01T20:00:00+02:00') + saveWatch('evt-1', 'Party', '2026-07-01T20:00:00+02:00') + + expect(getStoredEvents()).toHaveLength(1) + expect(getStoredEvents()[0]!.rsvpToken).toBe('rsvp-1') + }) + + it('isStored returns true for watched events', () => { + const { saveWatch, isStored } = useEventStorage() + + saveWatch('watch-1', 'Concert', '2026-07-01T20:00:00+02:00') + + expect(isStored('watch-1')).toBe(true) + }) + + it('isStored returns true for attended events', () => { + const { saveRsvp, isStored } = useEventStorage() + + saveRsvp('evt-1', 'rsvp-1', 'Max', 'Party', '2026-07-01T20:00:00+02:00') + + expect(isStored('evt-1')).toBe(true) + }) + + it('isStored returns true for organized events', () => { + const { saveCreatedEvent, isStored } = useEventStorage() + + saveCreatedEvent({ + eventToken: 'evt-1', + organizerToken: 'org-1', + title: 'My Event', + dateTime: '2026-07-01T20:00:00+02:00', + }) + + expect(isStored('evt-1')).toBe(true) + }) + + it('isStored returns false for unknown tokens', () => { + const { isStored } = useEventStorage() + + expect(isStored('unknown')).toBe(false) + }) +}) + describe('isValidStoredEvent', () => { // Import directly since it's an exported function let isValidStoredEvent: (e: unknown) => boolean diff --git a/frontend/src/composables/useEventStorage.ts b/frontend/src/composables/useEventStorage.ts index 629d0dd..03cd6cb 100644 --- a/frontend/src/composables/useEventStorage.ts +++ b/frontend/src/composables/useEventStorage.ts @@ -88,10 +88,24 @@ export function useEventStorage() { } } + function saveWatch(eventToken: string, title: string, dateTime: string): void { + const events = readEvents() + const existing = events.find((e) => e.eventToken === eventToken) + if (!existing) { + events.push({ eventToken, title, dateTime }) + writeEvents(events) + } + } + + function isStored(eventToken: string): boolean { + void version.value + return readEvents().some((e) => e.eventToken === eventToken) + } + function removeEvent(eventToken: string): void { const events = readEvents().filter((e) => e.eventToken !== eventToken) writeEvents(events) } - return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp, removeRsvp, removeEvent } + return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp, removeRsvp, saveWatch, isStored, removeEvent } } diff --git a/frontend/src/views/EventDetailView.vue b/frontend/src/views/EventDetailView.vue index 2a342c6..c9c4e59 100644 --- a/frontend/src/views/EventDetailView.vue +++ b/frontend/src/views/EventDetailView.vue @@ -35,21 +35,21 @@
-
+
{{ formattedDateTime }}
-
+
{{ event.location }}
-
+
{{ event.attendeeCount }} going
@@ -120,8 +120,10 @@ @@ -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('loading') const event = ref(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 { diff --git a/frontend/src/views/__tests__/EventCreateView.spec.ts b/frontend/src/views/__tests__/EventCreateView.spec.ts index ce84aef..3aaeb72 100644 --- a/frontend/src/views/__tests__/EventCreateView.spec.ts +++ b/frontend/src/views/__tests__/EventCreateView.spec.ts @@ -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(), }) diff --git a/frontend/src/views/__tests__/EventDetailView.spec.ts b/frontend/src/views/__tests__/EventDetailView.spec.ts index b568fa4..816f3cc 100644 --- a/frontend/src/views/__tests__/EventDetailView.spec.ts +++ b/frontend/src/views/__tests__/EventDetailView.spec.ts @@ -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() + }) }) diff --git a/specs/017-watch-event/checklists/requirements.md b/specs/017-watch-event/checklists/requirements.md new file mode 100644 index 0000000..77c54e4 --- /dev/null +++ b/specs/017-watch-event/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Watch Event + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-12 +**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`. +- localStorage is mentioned as a storage mechanism — this is an existing project convention (not an implementation detail introduced by this spec). diff --git a/specs/017-watch-event/data-model.md b/specs/017-watch-event/data-model.md new file mode 100644 index 0000000..f6f1928 --- /dev/null +++ b/specs/017-watch-event/data-model.md @@ -0,0 +1,61 @@ +# Data Model: Watch Event + +**Feature**: 017-watch-event +**Date**: 2026-03-12 + +## Entities + +### StoredEvent (existing — localStorage) + +No schema change. The existing `StoredEvent` interface already supports the watcher state: + +``` +StoredEvent { + eventToken: string # Required — event identifier + organizerToken?: string # Present → organizer role + title: string # Required — display in event list + dateTime: string # Required — grouping/sorting + rsvpToken?: string # Present → attendee role + rsvpName?: string # Present with rsvpToken +} +``` + +**Watcher state**: A `StoredEvent` with only `eventToken`, `title`, and `dateTime` populated (no `organizerToken`, no `rsvpToken`). This state already occurs naturally after RSVP cancellation. + +## Role Hierarchy + +``` +organizerToken present → "Organizer" (highest precedence) +rsvpToken present → "Attendee" +neither present → "Watching" (new label, lowest precedence) +``` + +## State Transitions + +``` + ┌──────────────┐ + watch() │ │ un-watch() + (not stored) ───► │ Watching │ ───► (not stored) + │ │ + └──────┬───────┘ + │ RSVP + ▼ + ┌──────────────┐ + │ │ + │ Attending │ + │ │ + └──────┬───────┘ + │ Cancel RSVP + ▼ + ┌──────────────┐ + │ │ un-watch() + │ Watching │ ───► (not stored) + │ │ + └──────────────┘ +``` + +Note: Organizer state is set at event creation and cannot be removed through this feature. The bookmark icon is non-interactive for organizers. + +## No Backend Changes + +This feature does not introduce any new database entities, API endpoints, or server-side logic. All data is stored in the browser's localStorage. diff --git a/specs/017-watch-event/issues.md b/specs/017-watch-event/issues.md new file mode 100644 index 0000000..79dcd9b --- /dev/null +++ b/specs/017-watch-event/issues.md @@ -0,0 +1,72 @@ +# Visual Issues: Bookmark Icon on Event Detail Page + +**Date**: 2026-03-12 +**Branch**: `017-watch-event` +**File**: `frontend/src/views/EventDetailView.vue` + +## Issue 1: Meta icons have hover effect + +**Problem**: The `
` elements (date, location, attendees) change background/border color on hover. These are non-interactive `
` elements — they should not react to hover. + +**Root cause**: The global `.glass:hover` rule in `frontend/src/assets/main.css:247`: +```css +.glass:hover:not(input):not(textarea):not(.btn-primary) { + background: var(--color-glass-hover); + border-color: var(--color-glass-border-hover); +} +``` +This applies to ALL `.glass` elements including the static meta icons. Scoped CSS overrides don't win because the global rule has equal or higher specificity. + +**Fix options**: +- A) Remove `glass` class from meta icons, replicate the static glass styles in scoped CSS +- B) Add `.glass--static` modifier that opts out of hover, use it on meta icons +- C) Add `:not(.detail__meta-icon)` to the global rule (leaks component knowledge into global CSS — bad) + +Option A is cleanest — meta icons only need the static glass background, not the full interactive glass behavior. + +## Issue 2: Glow effect on bookmark is ugly + +**Problem**: The accent-colored `box-shadow` glow around the bookmark icon looks bad visually. + +**Current CSS**: +```css +.detail__bookmark { + border-color: var(--color-accent); + box-shadow: 0 0 6px rgba(255, 112, 67, 0.15); +} +.detail__bookmark--filled { + box-shadow: 0 0 8px rgba(255, 112, 67, 0.3); +} +``` + +**Fix**: Remove the glow entirely. Differentiate the bookmark from inert meta icons through a different, subtler approach — e.g. a slightly brighter/different border color, or rely solely on the cursor change and active/focus states. + +## Issue 3: Filled bookmark should use same icon color as unfilled + +**Problem**: Filled bookmark uses `color: var(--color-accent)` (orange), unfilled uses `color: var(--color-text-on-gradient)` (white/light). User wants both states to use the same color. + +**Current CSS**: +```css +.detail__bookmark--filled { + color: var(--color-accent); + border-color: var(--color-accent); +} +``` + +**Fix**: Remove `color: var(--color-accent)` from `.detail__bookmark--filled`. The SVG `fill` attribute is already controlled by `:fill="eventIsStored ? 'currentColor' : 'none'"` in the template — so filled state will use `currentColor` (which inherits from the parent), and unfilled state will be outline-only. Both will be the same color (`--color-text-on-gradient`). + +## Issue 4: Icons not centered in their boxes + +**Problem**: SVGs inside the 36x36 glass boxes (both bookmark and meta icons) are shifted slightly to the right. The centering is off despite `display: flex; align-items: center; justify-content: center`. + +**Root cause**: SVGs rendered inline have implicit `line-height` whitespace. The `line-height: 0` fix was added to `.detail__bookmark` and `.detail__meta-icon` but the meta icon override may not be applying due to specificity issues with the `glass` class, or the SVGs themselves may need `display: block`. + +**Context**: The `
` element defaults to `display: block` but the SVG inside is inline. The flex container should handle it, but browser rendering of inline SVGs inside flex containers can be inconsistent. + +**Fix options**: +- Add `display: block` to the SVGs directly via a scoped rule: `.detail__meta-icon svg, .detail__bookmark svg { display: block; }` +- Or ensure `line-height: 0` is actually applying (check specificity) + +## Screenshot reference + +User-provided screenshot showing the issues: `/home/nitrix/Pictures/Screenshot_20260312_215543.png` diff --git a/specs/017-watch-event/plan.md b/specs/017-watch-event/plan.md new file mode 100644 index 0000000..4cbf313 --- /dev/null +++ b/specs/017-watch-event/plan.md @@ -0,0 +1,77 @@ +# Implementation Plan: Watch Event + +**Branch**: `017-watch-event` | **Date**: 2026-03-12 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/017-watch-event/spec.md` + +## Summary + +Add a bookmark icon to the event detail page (left of event title) that lets users save events to localStorage without RSVPing. Watched events appear in the event list with a "Watching" label. The feature is entirely client-side — no backend changes required. + +## Technical Context + +**Language/Version**: TypeScript 5.9 (frontend only) +**Primary Dependencies**: Vue 3, Vue Router 5 +**Storage**: localStorage via `useEventStorage.ts` composable (existing) +**Testing**: Vitest for unit tests, Playwright + MSW for E2E +**Target Platform**: Mobile-first PWA (browser) +**Project Type**: Web application (frontend-only change) +**Performance Goals**: Instant toggle (no network requests) +**Constraints**: No backend involvement, no new dependencies +**Scale/Scope**: 4 modified files, 0 new files + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Privacy by Design | PASS | No data sent to server. Watch is purely localStorage. No tracking. | +| II. Test-Driven Methodology | PASS | Unit tests for composable changes, E2E tests for user stories. | +| III. API-First Development | PASS | No API changes needed. Feature is client-side only. | +| IV. Simplicity & Quality | PASS | Minimal changes to existing code. No new abstractions. | +| V. Dependency Discipline | PASS | No new dependencies introduced. | +| VI. Accessibility | PASS | Bookmark icon will use semantic button/ARIA, keyboard-operable. | + +No violations. Gate passes. + +## Project Structure + +### Documentation (this feature) + +```text +specs/017-watch-event/ +├── plan.md # This file +├── spec.md # Feature specification +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +└── tasks.md # Phase 2 output (/speckit.tasks) +``` + +### Source Code (files to modify) + +```text +frontend/src/ +├── composables/ +│ ├── useEventStorage.ts # Add saveWatch(), isStored() methods +│ └── __tests__/ +│ └── useEventStorage.spec.ts # Tests for new methods +├── components/ +│ ├── EventCard.vue # Add 'watcher' role + badge styling +│ ├── EventList.vue # Update getRole() to return 'watcher', adjust delete flow +│ └── __tests__/ +│ ├── EventCard.spec.ts # Tests for watcher badge +│ └── EventList.spec.ts # Tests for watcher delete behavior +├── views/ +│ ├── EventDetailView.vue # Add bookmark icon next to title +│ └── __tests__/ +│ └── EventDetailView.spec.ts # Tests for bookmark behavior +└── e2e/ + └── watch-event.spec.ts # E2E tests for all user stories +``` + +**Structure Decision**: Frontend-only changes. No new files needed — all modifications go into existing components and composables. One new E2E test file. + +## Complexity Tracking + +No constitution violations. No complexity justifications needed. diff --git a/specs/017-watch-event/quickstart.md b/specs/017-watch-event/quickstart.md new file mode 100644 index 0000000..2b9a40e --- /dev/null +++ b/specs/017-watch-event/quickstart.md @@ -0,0 +1,33 @@ +# Quickstart: Watch Event + +**Feature**: 017-watch-event +**Date**: 2026-03-12 + +## What This Feature Does + +Adds a bookmark icon to the event detail page that lets users save events locally without RSVPing. Saved events appear in the event list with a "Watching" label. + +## Files to Modify + +| File | Change | +|------|--------| +| `frontend/src/composables/useEventStorage.ts` | Add `saveWatch()` and `isStored()` methods | +| `frontend/src/views/EventDetailView.vue` | Add bookmark icon left of title, shake animation trigger | +| `frontend/src/components/EventList.vue` | Update `getRole()` to return `'watcher'`, skip confirmation for watchers | +| `frontend/src/components/EventCard.vue` | Add `'watcher'` to role type, add badge styling | + +## Implementation Order + +1. **useEventStorage** — Add `saveWatch()` and `isStored()` (unit tests first) +2. **EventCard** — Extend role type, add "Watching" badge with styling (unit tests first) +3. **EventList** — Update `getRole()`, adjust delete flow for watchers (unit tests first) +4. **EventDetailView** — Add bookmark icon with all states and shake animation +5. **E2E tests** — Cover all 7 user stories from spec + +## Key Design Decisions + +- **No new StoredEvent fields** — watcher state is the absence of both `organizerToken` and `rsvpToken` +- **No backend changes** — entirely client-side +- **Bookmark icon left of title** — flex container, vertically centered +- **Non-interactive for attendees/organizers** — tapping shakes the relevant bottom action button +- **No confirmation dialog for watcher deletion** from event list diff --git a/specs/017-watch-event/research.md b/specs/017-watch-event/research.md new file mode 100644 index 0000000..9886f9e --- /dev/null +++ b/specs/017-watch-event/research.md @@ -0,0 +1,56 @@ +# Research: Watch Event + +**Feature**: 017-watch-event +**Date**: 2026-03-12 + +## Research Questions + +### 1. How does the current role detection work? + +**Finding**: `EventList.vue` has a `getRole()` function that checks `organizerToken` first, then `rsvpToken`. Returns `undefined` when neither is present. `EventCard.vue` accepts an `eventRole` prop typed as `'organizer' | 'attendee' | undefined`. + +**Decision**: Extend `getRole()` to return `'watcher'` when the event is in localStorage but has no `organizerToken` and no `rsvpToken`. Extend `EventCard` prop type to include `'watcher'`. + +**Rationale**: This is the minimal change — the existing priority chain (organizer > attendee) already handles precedence. Adding watcher as the fallback case is natural. + +### 2. How to detect "is this event stored?" on the detail page? + +**Finding**: `useEventStorage` has `getStoredEvents()` which returns all events, and `getRsvp(eventToken)` / `getOrganizerToken(eventToken)` for specific lookups. There is no direct `isStored(eventToken)` check. + +**Decision**: Add a `isStored(eventToken)` method to `useEventStorage` that checks if an event exists in localStorage regardless of role. Add a `saveWatch(eventToken, title, dateTime)` method that creates a minimal StoredEvent entry (no rsvpToken, no organizerToken). + +**Rationale**: `saveWatch()` is semantically distinct from `saveRsvp()` and `saveCreatedEvent()`. The `isStored()` helper avoids filtering through the full event list for a simple boolean check. + +### 3. What happens to events after RSVP cancellation? + +**Finding**: `removeRsvp(eventToken)` deletes `rsvpToken` and `rsvpName` but keeps the event in localStorage. After cancellation, the event has no `rsvpToken` and no `organizerToken` — identical to a watched event. + +**Decision**: No change needed. The existing `removeRsvp()` behavior already produces the correct state for a "watcher" after cancellation. The `getRole()` update will automatically label these as "Watching". + +**Rationale**: This is the key insight — the post-RSVP-cancellation state is already semantically equivalent to "watching". We just need to label it. + +### 4. Bookmark icon placement and glow conflict + +**Finding**: The event title is a plain `

`. The RsvpBar CTA uses `glow-border glow-border--animated` with a `::before` pseudo-element that extends 12px beyond the button via `inset: -4px` + `blur(8px)`. The bookmark icon is positioned at the title area (top of page), far from the RsvpBar (fixed at bottom). No glow conflict. + +**Decision**: Place bookmark icon in a flex container with the title: `display: flex; align-items: center; gap: var(--spacing-sm)`. Icon to the left, title takes remaining space. + +**Rationale**: Vertically centered with flex is the simplest approach. No glow interference since the icon is nowhere near the RsvpBar. + +### 5. Delete confirmation behavior per role + +**Finding**: `EventList.vue` shows a `ConfirmDialog` for all deletions. The message text varies based on RSVP status. For events without RSVP, the message is generic ("This event will be removed from your list."). + +**Decision**: Skip the confirmation dialog entirely for watchers (no `rsvpToken`, no `organizerToken`). Call `removeEvent()` directly on swipe/delete. + +**Rationale**: Watching is low-commitment. The spec explicitly requires no confirmation for watcher deletion. + +### 6. Shake animation implementation + +**Finding**: No existing shake animation in the codebase. The RsvpBar status and cancel-event button are both `position: fixed; bottom: 0`. + +**Decision**: Add a CSS `@keyframes shake` animation (short horizontal oscillation, ~300ms). Apply via a reactive class that is toggled on bookmark tap when user is attendee/organizer. Use a ref + setTimeout to remove the class after animation completes. + +**Alternatives considered**: +- Web Animations API: More flexible but overkill for a simple shake. +- CSS transition: Insufficient for a multi-step oscillation. diff --git a/specs/017-watch-event/spec.md b/specs/017-watch-event/spec.md new file mode 100644 index 0000000..406cc2c --- /dev/null +++ b/specs/017-watch-event/spec.md @@ -0,0 +1,153 @@ +# Feature Specification: Watch Event + +**Feature Branch**: `017-watch-event` +**Created**: 2026-03-12 +**Status**: Draft +**Input**: Watch/bookmark events locally without RSVPing — bookmark icon on event detail page, watching label on event list + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Watch an event from the detail page (Priority: P1) + +A user receives a link to an event and opens the detail page. They are not ready to RSVP yet but want to save the event for later. They tap the bookmark icon to the left of the event title. The icon fills in, and the event is saved to their local device. Next time they open the app, the event appears in their event list with a "Watching" label. + +**Why this priority**: This is the core feature — without it, the only way to save an event is to RSVP. Many users want to "bookmark" events they're considering without committing. + +**Independent Test**: Can be fully tested by opening an event detail page, tapping the bookmark icon, verifying it fills in, and checking the event list shows the event with a "Watching" label. + +**Acceptance Scenarios**: + +1. **Given** a user visits an event detail page for the first time (no RSVP, no watch), **When** they look at the bookmark icon next to the title, **Then** the icon is displayed as an unfilled outline. +2. **Given** the bookmark icon is unfilled, **When** the user taps it, **Then** the icon becomes filled, and the event is saved to localStorage. +3. **Given** the user has watched an event, **When** they open the event list, **Then** the event appears with a "Watching" label. + +--- + +### User Story 2 - Un-watch an event from the detail page (Priority: P1) + +A user who is watching an event (but not attending) decides they are no longer interested. They tap the filled bookmark icon on the event detail page. The icon reverts to an outline, and the event is removed from localStorage. It disappears from the event list. + +**Why this priority**: Users must be able to undo the watch action. Without this, there is no way to remove a watched event from the detail page. + +**Independent Test**: Can be fully tested by watching an event, then tapping the bookmark icon again to un-watch, verifying the icon becomes unfilled and the event disappears from the event list. + +**Acceptance Scenarios**: + +1. **Given** a user is watching an event (not attending), **When** they tap the filled bookmark icon, **Then** the icon becomes unfilled and the event is removed from localStorage. +2. **Given** a user has un-watched an event, **When** they open the event list, **Then** the event no longer appears. + +--- + +### User Story 3 - Bookmark icon reflects attending status (Priority: P1) + +When a user RSVPs to an event, the event is automatically saved to localStorage. The bookmark icon on the detail page reflects this by appearing filled. Attending supersedes watching — the event list shows "Attendee" (not "Watching") as the label. + +**Why this priority**: Consistency — attending inherently means the event is saved locally, so the bookmark must reflect that. Without this, users would see a confusing unfilled bookmark despite having RSVPed. + +**Independent Test**: Can be fully tested by RSVPing to an event, verifying the bookmark icon is filled, and checking that the event list shows "Attendee" as the label. + +**Acceptance Scenarios**: + +1. **Given** a user has RSVPed to an event, **When** they view the event detail page, **Then** the bookmark icon is filled. +2. **Given** a user has RSVPed to an event, **When** they view the event list, **Then** the event shows "Attendee" as its label (not "Watching"). + +--- + +### User Story 4 - RSVP cancellation preserves watch status (Priority: P2) + +A user who RSVPed to an event cancels their attendance. The event remains in localStorage (existing behavior). The bookmark icon stays filled. The event list label changes from "Attendee" to "Watching". + +**Why this priority**: This ensures a smooth transition from attending to watching. Without it, users who cancel would see an event in their list with no label and an ambiguous bookmark state. + +**Independent Test**: Can be fully tested by RSVPing, cancelling the RSVP, then verifying the bookmark stays filled and the event list shows "Watching". + +**Acceptance Scenarios**: + +1. **Given** a user has RSVPed and then cancelled their RSVP, **When** they view the event detail page, **Then** the bookmark icon remains filled. +2. **Given** a user has RSVPed and then cancelled their RSVP, **When** they view the event list, **Then** the event shows "Watching" as its label. +3. **Given** a user cancelled their RSVP and the bookmark is filled, **When** they tap the bookmark icon, **Then** the icon becomes unfilled and the event is removed from localStorage. + +--- + +### User Story 5 - Bookmark icon is non-interactive for attendees and organizers (Priority: P2) + +When a user is an attendee or organizer, the bookmark icon is filled but not clickable (no pointer cursor, no hover effect). Tapping it triggers a short shake animation on the relevant fixed action button at the bottom of the screen (the "You're attending" bar for attendees, the "Cancel event" button for organizers) to signal that the user must act there first. + +**Why this priority**: Prevents confusion — removing a saved event while attending or organizing must go through the proper flow (cancel RSVP or cancel event), not through the bookmark. + +**Independent Test**: Can be fully tested by RSVPing to an event, tapping the bookmark icon, and verifying nothing happens except the bottom bar shaking briefly. + +**Acceptance Scenarios**: + +1. **Given** a user is an attendee, **When** they tap the bookmark icon, **Then** nothing changes, and the "You're attending" bar shakes briefly. +2. **Given** a user is an organizer, **When** they tap the bookmark icon, **Then** nothing changes, and the "Cancel event" button shakes briefly. +3. **Given** a user is an attendee or organizer, **When** they hover/focus the bookmark icon, **Then** no pointer cursor or interactive hover style is shown. + +--- + +### User Story 6 - Un-watch from event list (Priority: P2) + +A watcher removes an event from the event list using the existing swipe-to-delete gesture. Unlike attendees (who see a confirmation dialog warning about RSVP cancellation), watchers see no confirmation dialog — the event is removed immediately. + +**Why this priority**: Watching is a low-commitment action, so removing a watched event should be frictionless. + +**Independent Test**: Can be fully tested by watching an event, going to the event list, swiping to delete, and verifying the event is removed without a confirmation dialog. + +**Acceptance Scenarios**: + +1. **Given** a user is watching an event (no RSVP), **When** they swipe to delete it from the event list, **Then** the event is removed immediately without a confirmation dialog. +2. **Given** a user is attending an event, **When** they swipe to delete it from the event list, **Then** a confirmation dialog appears warning about RSVP cancellation (existing behavior, unchanged). + +--- + +### User Story 7 - Watcher upgrades to attendee (Priority: P2) + +A user who is watching an event decides to attend. They tap the "I'm attending" CTA button and complete the RSVP flow as usual. The bookmark icon remains filled. The event list label changes from "Watching" to "Attendee". + +**Why this priority**: Natural flow from browsing to commitment. The watch-to-attend transition must be seamless. + +**Independent Test**: Can be fully tested by watching an event, then RSVPing, and verifying the bookmark stays filled and the label updates. + +**Acceptance Scenarios**: + +1. **Given** a user is watching an event, **When** they complete the RSVP flow, **Then** the bookmark icon remains filled. +2. **Given** a user was watching and then RSVPed, **When** they view the event list, **Then** the event shows "Attendee" as its label (not "Watching"). + +--- + +### Edge Cases + +- What happens when a user opens an event that has been cancelled — can they still watch it? **Yes, watching is purely local and independent of event status.** +- What happens when a user watches an event that has expired? **Same behavior — expired events can be watched. They will appear in the "Past" section of the event list.** +- What happens when a user clears their browser localStorage? **All watched (and attended) events are lost. This is expected behavior for client-side-only storage.** +- What happens if the user visits the event page on a different device? **The watch status is device-specific. The bookmark appears unfilled on the new device.** + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST display a bookmark icon to the left of the event title on the event detail page, vertically centered with the title text. +- **FR-002**: The bookmark icon MUST appear as an unfilled outline when the event is not saved in localStorage. +- **FR-003**: The bookmark icon MUST appear as a filled icon when the event is saved in localStorage (regardless of whether the user is watching, attending, or organizing). +- **FR-004**: Tapping the unfilled bookmark icon MUST save the event to localStorage (eventToken, title, dateTime) and fill the icon. +- **FR-005**: Tapping the filled bookmark icon MUST remove the event from localStorage and revert the icon to unfilled — but only when the user is a watcher (no RSVP, no organizer token). +- **FR-006**: The bookmark icon MUST NOT be interactive (no pointer cursor, no hover effect) when the user is an attendee or organizer. +- **FR-007**: Tapping the bookmark icon as an attendee MUST trigger a brief shake animation on the fixed "You're attending" bar at the bottom. +- **FR-008**: Tapping the bookmark icon as an organizer MUST trigger a brief shake animation on the fixed "Cancel event" button at the bottom. +- **FR-009**: The event list MUST display a "Watching" label on events that are in localStorage but have no rsvpToken and no organizerToken. +- **FR-010**: The "Watching" label MUST have lower precedence than "Attendee" and "Organizer" labels. +- **FR-011**: Deleting a watched event (no RSVP) from the event list MUST NOT show a confirmation dialog — the event is removed immediately. +- **FR-012**: Deleting an attended event from the event list MUST continue to show the existing confirmation dialog with the RSVP cancellation warning. +- **FR-013**: The watch feature MUST be entirely client-side — no server requests are made when watching or un-watching. +- **FR-014**: When an attendee cancels their RSVP, the event MUST remain in localStorage and the bookmark icon MUST remain filled. The event list label MUST change from "Attendee" to "Watching". + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Users can watch an event in a single tap from the detail page. +- **SC-002**: Watched events appear in the event list with a "Watching" label immediately upon returning to the list. +- **SC-003**: Un-watching an event from the detail page takes a single tap and immediately updates the icon. +- **SC-004**: Deleting a watched event from the event list completes instantly with no confirmation step. +- **SC-005**: The bookmark icon correctly reflects the stored state on every page load (filled if saved, unfilled if not). +- **SC-006**: The transition from watching to attending (and back via RSVP cancellation) updates both the bookmark icon and the event list label without requiring a page reload. diff --git a/specs/017-watch-event/tasks.md b/specs/017-watch-event/tasks.md new file mode 100644 index 0000000..5fa14d9 --- /dev/null +++ b/specs/017-watch-event/tasks.md @@ -0,0 +1,235 @@ +# Tasks: Watch Event + +**Input**: Design documents from `/specs/017-watch-event/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md + +**Tests**: Included — constitution mandates TDD (Red → Green → Refactor). + +**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: Foundational (Composable & Data Layer) + +**Purpose**: Extend `useEventStorage` with watch capabilities and update role detection across list components. These changes are required by all user stories. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +### Tests + +- [x] T001 [P] Unit tests for `saveWatch()` and `isStored()` methods in `frontend/src/composables/__tests__/useEventStorage.spec.ts` — test saving a watch-only event (no rsvpToken, no organizerToken), test `isStored()` returns true for watched/attended/organized events and false for unknown tokens +- [x] T002 [P] Unit tests for watcher role detection in `frontend/src/components/__tests__/EventList.spec.ts` — test `getRole()` returns `'watcher'` when event has no organizerToken and no rsvpToken +- [x] T003 [P] Unit tests for watcher badge display in `frontend/src/components/__tests__/EventCard.spec.ts` — test that `eventRole="watcher"` renders badge with text "Watching" + +### Implementation + +- [x] T004 Add `saveWatch(eventToken, title, dateTime)` and `isStored(eventToken)` methods to `frontend/src/composables/useEventStorage.ts` — `saveWatch` creates a StoredEvent with only eventToken/title/dateTime, `isStored` checks if eventToken exists in storage +- [x] T005 Update `getRole()` in `frontend/src/components/EventList.vue` to return `'watcher'` as fallback when event has no organizerToken and no rsvpToken (role hierarchy: organizer > attendee > watcher) +- [x] T006 [P] Extend `eventRole` prop type in `frontend/src/components/EventCard.vue` from `'organizer' | 'attendee'` to `'organizer' | 'attendee' | 'watcher'`, add "Watching" label text and `.event-card__badge--watcher` styling (glass style, matching design system) + +**Checkpoint**: Composable supports watch storage, role detection returns 'watcher', event cards display "Watching" badge. + +--- + +## Phase 2: User Story 1 & 2 — Watch / Un-watch from Detail Page (Priority: P1) 🎯 MVP + +**Goal**: Add bookmark icon left of event title on detail page. Unfilled = not stored, filled = stored. Tapping toggles watch state for non-attendee/non-organizer users. + +**Independent Test**: Open an event detail page, tap bookmark to watch (icon fills, event appears in list with "Watching" label), tap again to un-watch (icon unfills, event disappears from list). + +### Tests + +- [x] T007 Unit tests for bookmark icon in `frontend/src/views/__tests__/EventDetailView.spec.ts` — test icon renders unfilled when event not in storage, test icon renders filled when event is in storage, test tapping unfilled icon calls `saveWatch()`, test tapping filled icon calls `removeEvent()` when user is watcher +- [x] T008 E2E test for US1 (watch) in `frontend/e2e/watch-event.spec.ts` — visit event detail page, verify bookmark is unfilled, tap bookmark, verify it fills, navigate to event list, verify event appears with "Watching" label +- [x] T009 E2E test for US2 (un-watch) in `frontend/e2e/watch-event.spec.ts` — watch an event, tap filled bookmark, verify it unfills, navigate to event list, verify event is gone + +### Implementation + +- [x] T010 [US1] [US2] Add bookmark icon to `frontend/src/views/EventDetailView.vue` — wrap title in flex container (`display: flex; align-items: center; gap: var(--spacing-sm)`), add bookmark button to the left of `

`, icon is unfilled outline when `!isStored(eventToken)` and filled when `isStored(eventToken)`. Tapping calls `saveWatch()` or `removeEvent()` based on current state. Use semantic `