17 Commits

Author SHA1 Message Date
2f8b911af8 Fix datetime-local input overflow and invisible text on iOS Safari
All checks were successful
CI / backend-test (push) Successful in 1m2s
CI / frontend-test (push) Successful in 26s
CI / frontend-e2e (push) Successful in 1m36s
CI / build-and-publish (push) Successful in 1m16s
The native datetime-local picker on iOS Safari has an intrinsic min-width
that exceeds the form container, and its webkit pseudo-elements don't
inherit the glass text color, making the selected value invisible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:28:13 +01:00
e9791de4e2 Remove manual -webkit-backdrop-filter prefixes
LightningCSS (Vite 8) was stripping the unprefixed backdrop-filter when
it saw the manual -webkit- prefix, breaking blur effects in Firefox and
on production. Let LightningCSS handle prefixing automatically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:03:33 +01:00
3b4cc7fbb9 Add explicit browserslist to frontend package.json
Ensures deterministic CSS output across build environments (local vs
Docker/Alpine) by pinning browser targets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:03:15 +01:00
9c0e9249ce Upgrade Docker frontend stage from Node 24 to Node 25
Aligns the Docker build environment with the local development setup
which already uses Node 25.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:03:04 +01:00
5082ec1333 Merge pull request 'Add organizer cancel-event flow to EventList' (#41) from 018-cancel-event-list into master
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 26s
CI / frontend-e2e (push) Successful in 1m36s
CI / build-and-publish (push) Has been skipped
2026-03-13 16:27:50 +01:00
35b488a8be Merge pull request 'Update dependency vite-plugin-vue-devtools to v8.1.0' (#40) from renovate/vite-plugin-vue-devtools-8.x-lockfile into master
Some checks failed
CI / backend-test (push) Successful in 59s
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
CI / frontend-test (push) Has been cancelled
Reviewed-on: #40
2026-03-13 16:24:25 +01:00
b067c0ef1e Add organizer cancel-event flow to EventList
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 26s
CI / frontend-e2e (push) Successful in 1m35s
CI / build-and-publish (push) Has been skipped
Organizers can now cancel events directly from the event list via the
existing PATCH /events/{eventToken} API. The confirmation dialog shows
role-differentiated messaging: "Cancel event?" with a severity warning
for organizers vs. "Remove event?" for attendees. Responses 204, 409,
and 404 all result in successful removal from the local list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:23:04 +01:00
Renovate Bot
42686502d8 Update dependency vite-plugin-vue-devtools to v8.1.0
All checks were successful
CI / backend-test (push) Successful in 53s
CI / frontend-test (push) Successful in 26s
CI / frontend-e2e (push) Successful in 1m31s
CI / build-and-publish (push) Has been skipped
2026-03-13 02:02:01 +00:00
51ab99fc61 Introduce --color-danger-solid-* CSS variables and replace hardcoded values
All checks were successful
CI / backend-test (push) Successful in 55s
CI / frontend-test (push) Successful in 27s
CI / frontend-e2e (push) Successful in 1m30s
CI / build-and-publish (push) Successful in 58s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:24:58 +01:00
d52f51d6e1 Match cancel-event confirm button color with ConfirmDialog style
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:22:06 +01:00
c1760ae376 Apply consistent label color to cancel-event bottom sheet
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:19:26 +01:00
6d51327e56 Add touch drag-to-dismiss gesture to BottomSheet
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:15:30 +01:00
96044ae1ed Change create-event page title to "Great, a Party!"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:08:45 +01:00
f972a41e45 Extract BackLink component into App layout
Move back navigation (chevron + "fete" brand) from per-view
definitions into a shared BackLink component rendered in App.vue.
Shown on all pages except home. Hero overlay gets pointer-events:
none so the link stays clickable on the event detail page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:06:03 +01:00
13b01dfba8 Add exclamation mark to RSVP CTA button ("I'm attending!")
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:36:44 +01:00
fd8724db8f Rename role badges to present participle (Organizing, Attending)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:34:36 +01:00
8885dbd722 Soften RSVP cancellation dialog wording
Replace harsh "permanently cancelled" language with friendlier
"The organizer will no longer see you as attending" and rename
buttons from "Cancel attendance" to "Cancel RSVP".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:32:19 +01:00
28 changed files with 1034 additions and 143 deletions

View File

@@ -49,3 +49,10 @@ The following skills are available and should be used for their respective purpo
- 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
- TypeScript 5.x, Vue 3 (Composition API) + openapi-fetch, Vue Router, Vite (018-cancel-event-list)
- localStorage via `useEventStorage()` composable (018-cancel-event-list)
## Recent Changes
- 018-cancel-event-list: Added TypeScript 5.x, Vue 3 (Composition API) + openapi-fetch, Vue Router, Vite

View File

@@ -1,5 +1,5 @@
# Stage 1: Build frontend
FROM node:24-alpine AS frontend-build
FROM node:25-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci

View File

@@ -0,0 +1,210 @@
import { test, expect } from './msw-setup'
import type { StoredEvent } from '../src/composables/useEventStorage'
const STORAGE_KEY = 'fete:events'
const organizerEvent: StoredEvent = {
eventToken: 'org-event-aaa',
title: 'Summer BBQ',
dateTime: '2027-06-15T18:00:00Z',
organizerToken: 'org-secret-token',
}
const attendeeEvent: StoredEvent = {
eventToken: 'att-event-bbb',
title: 'Team Meeting',
dateTime: '2027-01-10T09:00:00Z',
rsvpToken: 'rsvp-token-1',
rsvpName: 'Alice',
}
function seedEvents(events: StoredEvent[]): string {
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
}
test.describe('US1: Organizer Cancels Event from List', () => {
test('T001: organizer taps delete, confirms, event is removed after successful API call', async ({
page,
network,
}) => {
await page.addInitScript(seedEvents([organizerEvent, attendeeEvent]))
const { http, HttpResponse } = await import('msw')
let patchCalled = false
network.use(
http.patch('*/api/events/:token', ({ request, params }) => {
const url = new URL(request.url)
if (
params['token'] === organizerEvent.eventToken &&
url.searchParams.get('organizerToken') === organizerEvent.organizerToken
) {
patchCalled = true
return new HttpResponse(null, { status: 204 })
}
return HttpResponse.json(
{ type: 'about:blank', title: 'Forbidden', status: 403 },
{ status: 403, headers: { 'Content-Type': 'application/problem+json' } },
)
}),
)
await page.goto('/')
await expect(page.getByText('Summer BBQ')).toBeVisible()
// Click delete on organizer event
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
// Confirmation dialog appears with organizer-specific text
await expect(page.getByRole('alertdialog')).toBeVisible()
// Confirm cancellation
await page.getByRole('button', { name: 'Remove', exact: true }).click()
// Event is removed from list
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
// Other event remains
await expect(page.getByText('Team Meeting')).toBeVisible()
expect(patchCalled).toBe(true)
})
test('T002: organizer confirms cancellation, API fails, event stays in list and error shown', async ({
page,
network,
}) => {
await page.addInitScript(seedEvents([organizerEvent]))
const { http, HttpResponse } = await import('msw')
network.use(
http.patch('*/api/events/:token', () => {
return HttpResponse.json(
{
type: 'about:blank',
title: 'Internal Server Error',
status: 500,
},
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } },
)
}),
)
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await expect(page.getByRole('alertdialog')).toBeVisible()
await page.getByRole('button', { name: 'Remove', exact: true }).click()
// Event stays in list
await expect(page.getByText('Summer BBQ')).toBeVisible()
})
test('T003: organizer confirms cancellation, API returns 409 Conflict, event is silently removed', async ({
page,
network,
}) => {
await page.addInitScript(seedEvents([organizerEvent]))
const { http, HttpResponse } = await import('msw')
network.use(
http.patch('*/api/events/:token', () => {
return HttpResponse.json(
{
type: 'about:blank',
title: 'Conflict',
status: 409,
detail: 'Event is already cancelled.',
},
{ status: 409, headers: { 'Content-Type': 'application/problem+json' } },
)
}),
)
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await expect(page.getByRole('alertdialog')).toBeVisible()
await page.getByRole('button', { name: 'Remove', exact: true }).click()
// 409 treated as success — event removed
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
})
test('T004: organizer opens cancel dialog then dismisses (cancel button), event remains', async ({
page,
}) => {
await page.addInitScript(seedEvents([organizerEvent]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await expect(page.getByRole('alertdialog')).toBeVisible()
// Dismiss via Cancel button
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByRole('alertdialog')).not.toBeVisible()
await expect(page.getByText('Summer BBQ')).toBeVisible()
})
test('T004b: organizer opens cancel dialog then dismisses via Escape', async ({ page }) => {
await page.addInitScript(seedEvents([organizerEvent]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await expect(page.getByRole('alertdialog')).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.getByRole('alertdialog')).not.toBeVisible()
await expect(page.getByText('Summer BBQ')).toBeVisible()
})
test('T004c: organizer opens cancel dialog then dismisses via overlay click', async ({
page,
}) => {
await page.addInitScript(seedEvents([organizerEvent]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await expect(page.getByRole('alertdialog')).toBeVisible()
// Click on overlay (outside dialog)
await page.locator('.confirm-dialog__overlay').click({ position: { x: 10, y: 10 } })
await expect(page.getByRole('alertdialog')).not.toBeVisible()
await expect(page.getByText('Summer BBQ')).toBeVisible()
})
})
test.describe('US2: Distinct Dialog for Organizer vs. Attendee', () => {
test('T011: organizer dialog shows event-cancellation warning', async ({ page }) => {
await page.addInitScript(seedEvents([organizerEvent]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
const dialog = page.getByRole('alertdialog')
await expect(dialog).toBeVisible()
// Organizer-specific title and message
await expect(dialog.locator('.confirm-dialog__title')).toHaveText('Cancel event?')
await expect(dialog.locator('.confirm-dialog__message')).toContainText(
'all attendees',
)
})
test('T012: attendee dialog preserves existing RSVP-cancellation message', async ({
page,
}) => {
await page.addInitScript(seedEvents([attendeeEvent]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Team Meeting/ }).click()
const dialog = page.getByRole('alertdialog')
await expect(dialog).toBeVisible()
// Attendee-specific title and message
await expect(dialog.locator('.confirm-dialog__title')).toHaveText('Remove event?')
await expect(dialog.locator('.confirm-dialog__message')).toContainText(
'attendance will be cancelled',
)
})
})

View File

@@ -43,7 +43,7 @@ test.describe('US1: Cancel RSVP from Event Detail View', () => {
await expect(statusBar).toBeVisible()
// Cancel button hidden initially
await expect(page.getByRole('button', { name: 'Cancel attendance' })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Cancel RSVP' })).not.toBeVisible()
})
test('tapping status bar reveals cancel button', async ({ page, network }) => {
@@ -57,7 +57,7 @@ test.describe('US1: Cancel RSVP from Event Detail View', () => {
await page.getByRole('button', { name: /You're attending/ }).click()
// Cancel button appears
await expect(page.getByRole('button', { name: 'Cancel attendance' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Cancel RSVP' })).toBeVisible()
})
test('confirm cancellation → localStorage cleared, count decremented, bar reset', async ({ page, network }) => {
@@ -70,13 +70,13 @@ test.describe('US1: Cancel RSVP from Event Detail View', () => {
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Expand → Cancel attendance → Confirm in dialog
// Expand → Cancel RSVP → Confirm in dialog
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
// Confirm dialog
await expect(page.getByText('Your attendance will be permanently cancelled.')).toBeVisible()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
await expect(page.getByText('The organizer will no longer see you as attending.')).toBeVisible()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
// Bar resets to CTA state
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
@@ -108,7 +108,7 @@ test.describe('US1: Cancel RSVP from Event Detail View', () => {
// Expand → Cancel → Confirm in dialog
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()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
// Error message
await expect(page.getByText('Could not cancel RSVP. Please try again.')).toBeVisible()
@@ -136,7 +136,7 @@ test.describe('US1: Cancel RSVP from Event Detail View', () => {
// Cancel first
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()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
// CTA should be back
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
@@ -162,20 +162,20 @@ test.describe('US2: Auto-Cancel on Event List Removal', () => {
await expect(page.getByText('your attendance will be cancelled')).toBeVisible()
})
test('removal of non-RSVP\'d event shows standard dialog', async ({ page }) => {
const noRsvp: StoredEvent = {
eventToken: 'no-rsvp-token',
title: 'No RSVP Event',
test('removal of non-RSVP\'d watcher event shows standard dialog', async ({ page }) => {
const watcherEvent: StoredEvent = {
eventToken: 'watcher-token',
title: 'Watcher Event',
dateTime: '2027-06-15T18:00:00Z',
organizerToken: 'org-123',
}
await page.addInitScript(seedEvents([noRsvp]))
await page.addInitScript(seedEvents([watcherEvent]))
await page.goto('/')
await page.getByRole('button', { name: /Remove No RSVP Event/ }).click()
// Watcher events are removed directly without dialog
await page.getByRole('button', { name: /Remove Watcher Event/ }).click()
await expect(page.getByText('This event will be removed from your list.')).toBeVisible()
await expect(page.getByText('attendance will be cancelled')).not.toBeVisible()
// Watcher removal is immediate — event disappears
await expect(page.getByText('Watcher Event')).not.toBeVisible()
})
test('confirm removal → DELETE called → event removed from list', async ({ page, network }) => {
@@ -244,7 +244,7 @@ test.describe('US3: Cancel RSVP with Stale/Invalid Token', () => {
// Cancel flow
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()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
// Treated as success — CTA returns
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()

View File

@@ -93,19 +93,30 @@ test.describe('US4: Past Events Appear Faded', () => {
})
test.describe('US3: Remove Event from List', () => {
test('delete icon triggers confirmation dialog, confirm removes event', async ({ page }) => {
test('delete icon triggers confirmation dialog, confirm removes event', async ({
page,
network,
}) => {
await page.addInitScript(seedEvents([futureEvent1, futureEvent2]))
const { http, HttpResponse } = await import('msw')
network.use(
http.patch('*/api/events/:token', () => {
return new HttpResponse(null, { status: 204 })
}),
)
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
// Click delete on Summer BBQ (organizer event)
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
// Confirmation dialog appears
await expect(page.getByText('Remove event?')).toBeVisible()
// Confirmation dialog appears (organizer event shows "Cancel event?")
await expect(page.getByText('Cancel event?')).toBeVisible()
// Confirm removal
await page.getByRole('button', { name: 'Remove', exact: true }).click()
@@ -120,13 +131,13 @@ test.describe('US3: Remove Event from List', () => {
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await expect(page.getByText('Remove event?')).toBeVisible()
await expect(page.getByText('Cancel 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('Cancel event?')).not.toBeVisible()
await expect(page.getByText('Summer BBQ')).toBeVisible()
})
})
@@ -139,7 +150,7 @@ test.describe('US5: Visual Distinction for Event Roles', () => {
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).toHaveText('Organizing')
await expect(badge).toHaveClass(/event-card__badge--organizer/)
})
@@ -150,7 +161,7 @@ test.describe('US5: Visual Distinction for Event Roles', () => {
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).toHaveText('Attending')
await expect(badge).toHaveClass(/event-card__badge--attendee/)
})

View File

@@ -65,7 +65,7 @@ test.describe('US1: Watch event from detail page', () => {
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
// Navigate to event list via back link
await page.locator('.detail__back').click()
await page.getByLabel('Back to home').click()
// Event appears with "Watching" label
await expect(page.getByText('Summer BBQ')).toBeVisible()
@@ -89,7 +89,7 @@ test.describe('US2: Un-watch event from detail page', () => {
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()
await page.getByLabel('Back to home').click()
// Event is gone
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
@@ -109,8 +109,8 @@ test.describe('US3: Bookmark reflects attending status', () => {
await expect(bookmark).not.toBeVisible()
// Navigate to list via back link
await page.locator('.detail__back').click()
await expect(page.getByText('Attendee')).toBeVisible()
await page.getByLabel('Back to home').click()
await expect(page.getByText('Attending')).toBeVisible()
await expect(page.getByText('Watching')).not.toBeVisible()
})
})
@@ -129,7 +129,7 @@ test.describe('US4: RSVP cancellation preserves watch status', () => {
// 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()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
// Bookmark reappears in CTA state, filled because event is still stored
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
@@ -137,9 +137,9 @@ test.describe('US4: RSVP cancellation preserves watch status', () => {
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
// Navigate to list via back link
await page.locator('.detail__back').click()
await page.getByLabel('Back to home').click()
await expect(page.getByText('Watching')).toBeVisible()
await expect(page.getByText('Attendee')).not.toBeVisible()
await expect(page.getByText('Attending')).not.toBeVisible()
})
})
@@ -211,8 +211,8 @@ test.describe('US7: Watcher upgrades to attendee', () => {
await expect(bookmark).not.toBeVisible()
// Navigate to list via back link
await page.locator('.detail__back').click()
await expect(page.getByText('Attendee')).toBeVisible()
await page.getByLabel('Back to home').click()
await expect(page.getByText('Attending')).toBeVisible()
await expect(page.getByText('Watching')).not.toBeVisible()
})
})

View File

@@ -3614,35 +3614,35 @@
}
},
"node_modules/@vue/devtools-core": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-core/-/devtools-core-8.0.7.tgz",
"integrity": "sha512-PmpiPxvg3Of80ODHVvyckxwEW1Z02VIAvARIZS1xegINn3VuNQLm9iHUmKD+o6cLkMNWV8OG8x7zo0kgydZgdg==",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@vue/devtools-core/-/devtools-core-8.1.0.tgz",
"integrity": "sha512-LvD1VgDpoHmYL00IgKRLKktF6SsPAb0yaV8wB8q2jRwsAWvqhS8+vsMLEGKNs7uoKyymXhT92dhxgf/wir6YGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^8.0.7",
"@vue/devtools-shared": "^8.0.7"
"@vue/devtools-kit": "^8.1.0",
"@vue/devtools-shared": "^8.1.0"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/@vue/devtools-kit": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.7.tgz",
"integrity": "sha512-H6esJGHGl5q0E9iV3m2EoBQHJ+V83WMW83A0/+Fn95eZ2iIvdsq4+UCS6yT/Fdd4cGZSchx/MdWDreM3WqMsDw==",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.0.tgz",
"integrity": "sha512-/NZlS4WtGIB54DA/z10gzk+n/V7zaqSzYZOVlg2CfdnpIKdB61bd7JDIMxf/zrtX41zod8E2/bbEBoW/d7x70Q==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^8.0.7",
"@vue/devtools-shared": "^8.1.0",
"birpc": "^2.6.1",
"hookable": "^5.5.3",
"perfect-debounce": "^2.0.0"
}
},
"node_modules/@vue/devtools-shared": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.7.tgz",
"integrity": "sha512-CgAb9oJH5NUmbQRdYDj/1zMiaICYSLtm+B1kxcP72LBrifGAjUmt8bx52dDH1gWRPlQgxGPqpAMKavzVirAEhA==",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.0.tgz",
"integrity": "sha512-h8uCb4Qs8UT8VdTT5yjY6tOJ//qH7EpxToixR0xqejR55t5OdISIg7AJ7eBkhBs8iu1qG5gY3QQNN1DF1EelAA==",
"license": "MIT"
},
"node_modules/@vue/eslint-config-typescript": {
@@ -7811,15 +7811,15 @@
}
},
"node_modules/vite-plugin-vue-devtools": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-8.0.7.tgz",
"integrity": "sha512-BWj/ykGpqVAJVdPyHmSTUm44buz3jPv+6jnvuFdQSRH0kAgP1cEIE4doHiFyqHXOmuB5EQVR/nh2g9YRiRNs9g==",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-8.1.0.tgz",
"integrity": "sha512-4AvNRePfni3+PqOunACmAImC6SJVpUv6f7/g4oakyre9hYdEMrvDYlNmTZQsJPzVLMcGzn1FvSEqJ/n4HQ9cDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/devtools-core": "^8.0.7",
"@vue/devtools-kit": "^8.0.7",
"@vue/devtools-shared": "^8.0.7",
"@vue/devtools-core": "^8.1.0",
"@vue/devtools-kit": "^8.1.0",
"@vue/devtools-shared": "^8.1.0",
"sirv": "^3.0.2",
"vite-plugin-inspect": "^11.3.3",
"vite-plugin-vue-inspector": "^5.3.2"
@@ -7828,7 +7828,7 @@
"node": ">=v14.21.3"
},
"peerDependencies": {
"vite": "^6.0.0 || ^7.0.0-0 || ^8.0.0-0"
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/vite-plugin-vue-inspector": {

View File

@@ -53,6 +53,12 @@
"vitest": "^4.0.18",
"vue-tsc": "^3.2.5"
},
"browserslist": [
">= 0.5%",
"last 2 versions",
"Firefox ESR",
"not dead"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}

View File

@@ -1,9 +1,26 @@
<template>
<div class="app-container">
<header v-if="route.name !== 'home'" class="app-header">
<BackLink />
</header>
<RouterView />
</div>
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
import { RouterView, useRoute } from 'vue-router'
import BackLink from '@/components/BackLink.vue'
const route = useRoute()
</script>
<style scoped>
.app-header {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
padding-top: var(--spacing-lg);
}
</style>

View File

@@ -25,6 +25,9 @@
--color-danger-bg-strong: rgba(220, 38, 38, 0.2);
--color-danger-border: rgba(220, 38, 38, 0.3);
--color-danger-border-strong: rgba(220, 38, 38, 0.4);
--color-danger-solid: #d32f2f;
--color-danger-solid-hover: #b71c1c;
--color-danger-solid-text: #fff;
/* Glass system */
--color-glass: rgba(255, 255, 255, 0.1);
@@ -159,11 +162,32 @@ textarea.form-field {
min-height: 5rem;
}
/* iOS Safari: datetime-local overflows container and shows empty when no value */
input[type="datetime-local"].form-field {
min-width: 0;
max-width: 100%;
overflow: hidden;
}
input[type="datetime-local"].form-field.glass::-webkit-date-and-time-value {
color: var(--color-text-on-gradient);
text-align: left;
}
input[type="datetime-local"].form-field.glass::-webkit-datetime-edit {
color: var(--color-text-on-gradient);
}
input[type="datetime-local"].form-field.glass::-webkit-datetime-edit-fields-wrapper {
color: var(--color-text-on-gradient);
}
/* Form group (label + input) */
.form-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
overflow: hidden;
}
.form-label {
@@ -214,7 +238,7 @@ textarea.form-field {
/* Error message */
.field-error {
color: #fff;
color: var(--color-danger-solid);
font-size: 0.875rem;
font-weight: 600;
padding-left: 0.25rem;
@@ -241,7 +265,6 @@ textarea.form-field {
border: 1px solid var(--color-glass-border);
box-shadow: var(--shadow-card);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.glass:hover:not(input):not(textarea):not(.btn-primary) {
@@ -253,7 +276,6 @@ textarea.form-field {
.glass-inner {
background: var(--color-glass-inner);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
/* Glow border: conic gradient wrapper with halo (static) */
@@ -325,7 +347,8 @@ textarea.form-field {
gap: var(--spacing-md);
}
.rsvp-form__label {
.rsvp-form__label,
.cancel-form__label {
font-size: 0.85rem;
font-weight: 700;
color: var(--color-text-on-gradient);
@@ -333,7 +356,7 @@ textarea.form-field {
}
.rsvp-form__field-error {
color: #d32f2f;
color: var(--color-danger-solid);
font-size: 0.875rem;
font-weight: 600;
padding-left: 0.25rem;

View File

@@ -0,0 +1,28 @@
<template>
<RouterLink to="/" class="back-link" aria-label="Back to home">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
<span class="back-link__brand">fete</span>
</RouterLink>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>
<style scoped>
.back-link {
display: inline-flex;
align-items: center;
gap: 0.15rem;
color: var(--color-text-on-gradient);
text-decoration: none;
line-height: 1;
}
.back-link__brand {
font-size: 1.3rem;
font-weight: 700;
}
</style>

View File

@@ -2,7 +2,18 @@
<Teleport to="body">
<Transition name="sheet">
<div v-if="open" class="sheet-backdrop" @click.self="$emit('close')" @keydown.escape="$emit('close')">
<div class="sheet" role="dialog" aria-modal="true" :aria-label="label" ref="sheetEl" tabindex="-1">
<div
class="sheet"
role="dialog"
aria-modal="true"
:aria-label="label"
ref="sheetEl"
tabindex="-1"
:style="dragStyle"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<div class="sheet__handle" aria-hidden="true" />
<slot />
</div>
@@ -12,14 +23,14 @@
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { ref, computed, watch, nextTick } from 'vue'
defineProps<{
open: boolean
label: string
}>()
defineEmits<{
const emit = defineEmits<{
close: []
}>()
@@ -39,6 +50,45 @@ watch(
}
},
)
/* ── Drag-to-dismiss ── */
const DISMISS_THRESHOLD = 100
const dragY = ref(0)
const dragging = ref(false)
let startY = 0
const dragStyle = computed(() => {
if (!dragging.value || dragY.value <= 0) return undefined
return {
transform: `translateY(${dragY.value}px)`,
transition: 'none',
}
})
function onTouchStart(e: TouchEvent) {
const touch = e.touches[0]
if (!touch) return
startY = touch.clientY
dragging.value = true
dragY.value = 0
}
function onTouchMove(e: TouchEvent) {
if (!dragging.value) return
const touch = e.touches[0]
if (!touch) return
const delta = touch.clientY - startY
if (delta > 0) e.preventDefault()
dragY.value = Math.max(0, delta)
}
function onTouchEnd() {
if (dragY.value >= DISMISS_THRESHOLD) {
emit('close')
}
dragging.value = false
dragY.value = 0
}
</script>
<style scoped>
@@ -57,7 +107,6 @@ watch(
border: 1px solid var(--color-glass-border);
border-bottom: none;
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-radius: 20px 20px 0 0;
padding: var(--spacing-lg) var(--spacing-xl) var(--spacing-2xl);
width: 100%;

View File

@@ -87,7 +87,6 @@ watch(
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
border: 1px solid var(--color-glass-border);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-radius: var(--radius-card);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
padding: var(--spacing-xl);
@@ -139,8 +138,8 @@ watch(
}
.confirm-dialog__btn--confirm {
background: #d32f2f;
color: #fff;
background: var(--color-danger-solid);
color: var(--color-danger-solid-text);
}
.confirm-dialog-enter-active,

View File

@@ -12,7 +12,7 @@
<span class="event-card__time">{{ displayTime }}</span>
</RouterLink>
<span v-if="eventRole" class="event-card__badge" :class="`event-card__badge--${eventRole}`">
{{ eventRole === 'organizer' ? 'Organizer' : eventRole === 'attendee' ? 'Attendee' : 'Watching' }}
{{ eventRole === 'organizer' ? 'Organizing' : eventRole === 'attendee' ? 'Attending' : 'Watching' }}
</span>
<button
class="event-card__delete"
@@ -175,7 +175,7 @@ function onTouchEnd() {
}
.event-card__delete:hover {
color: #d32f2f;
color: var(--color-danger-solid);
background: rgba(211, 47, 47, 0.08);
}

View File

@@ -27,7 +27,7 @@
</section>
<ConfirmDialog
:open="!!pendingDeleteToken"
title="Remove event?"
:title="deleteDialogTitle"
:message="deleteDialogMessage"
confirm-label="Remove"
cancel-label="Cancel"
@@ -49,13 +49,26 @@ import DateSubheader from './DateSubheader.vue'
import ConfirmDialog from './ConfirmDialog.vue'
import type { StoredEvent } from '../composables/useEventStorage'
const { getStoredEvents, getRsvp, removeEvent } = useEventStorage()
const { getStoredEvents, getRsvp, getOrganizerToken, removeEvent } = useEventStorage()
const pendingDeleteToken = ref<string | null>(null)
const deleteError = ref('')
const pendingDeleteRole = computed(() => {
if (!pendingDeleteToken.value) return null
const event = getStoredEvents().find((e) => e.eventToken === pendingDeleteToken.value)
return event ? getRole(event) : null
})
const deleteDialogTitle = computed(() => {
return pendingDeleteRole.value === 'organizer' ? 'Cancel event?' : 'Remove event?'
})
const deleteDialogMessage = computed(() => {
if (!pendingDeleteToken.value) return ''
if (pendingDeleteRole.value === 'organizer') {
return 'This will permanently cancel the event for all attendees.'
}
const rsvp = getRsvp(pendingDeleteToken.value)
if (rsvp) {
return 'This event will be removed from your list and your attendance will be cancelled.'
@@ -77,6 +90,32 @@ async function confirmDelete() {
if (!pendingDeleteToken.value) return
const eventToken = pendingDeleteToken.value
const organizerToken = getOrganizerToken(eventToken)
if (organizerToken) {
try {
const { response } = await api.PATCH('/events/{eventToken}', {
params: {
path: { eventToken },
query: { organizerToken },
},
body: { cancelled: true },
})
if (response.status !== 204 && response.status !== 409 && response.status !== 404) {
deleteError.value = 'Could not cancel event. Please try again.'
return
}
} catch {
deleteError.value = 'Could not cancel event. Please try again.'
return
}
removeEvent(eventToken)
pendingDeleteToken.value = null
return
}
const rsvp = getRsvp(eventToken)
if (rsvp) {

View File

@@ -25,7 +25,7 @@
type="button"
@click="$emit('cancel')"
>
Cancel attendance
Cancel RSVP
</button>
</Transition>
</div>
@@ -34,7 +34,7 @@
<div v-else class="rsvp-bar__row">
<div class="rsvp-bar__cta glow-border glow-border--animated">
<button class="rsvp-bar__cta-inner glass-inner" type="button" @click="$emit('open')">
I'm attending
I'm attending!
</button>
</div>
<div class="rsvp-bar__bookmark glow-border glow-border--animated">
@@ -154,7 +154,6 @@ watch(expanded, (isExpanded) => {
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);
border-radius: var(--radius-card);
padding: var(--spacing-md) var(--spacing-lg);
box-shadow: var(--shadow-card);
@@ -206,7 +205,6 @@ watch(expanded, (isExpanded) => {
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);
cursor: pointer;
text-align: center;
transition: background 0.15s ease;

View File

@@ -55,12 +55,12 @@ describe('EventCard', () => {
it('renders organizer badge when eventRole is organizer', () => {
const wrapper = mountCard({ eventRole: 'organizer' })
expect(wrapper.text()).toContain('Organizer')
expect(wrapper.text()).toContain('Organizing')
})
it('renders attendee badge when eventRole is attendee', () => {
const wrapper = mountCard({ eventRole: 'attendee' })
expect(wrapper.text()).toContain('Attendee')
expect(wrapper.text()).toContain('Attending')
})
it('renders watcher badge when eventRole is watcher', () => {

View File

@@ -1,8 +1,15 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import EventList from '../EventList.vue'
vi.mock('../../api/client', () => ({
api: {
PATCH: vi.fn(),
DELETE: vi.fn(),
},
}))
const router = createRouter({
history: createMemoryHistory(),
routes: [
@@ -24,6 +31,8 @@ const mockEvents = [
{ eventToken: 'rsvp-1', title: 'Attending Event', dateTime: '2026-03-11T20:00:00', rsvpToken: 'rsvp-token', rsvpName: 'Max' },
]
const removeEventMock = vi.fn()
vi.mock('../../composables/useEventStorage', () => ({
isValidStoredEvent: (e: unknown) => {
if (typeof e !== 'object' || e === null) return false
@@ -41,7 +50,14 @@ vi.mock('../../composables/useEventStorage', () => ({
}
return undefined
},
removeEvent: vi.fn(),
getOrganizerToken: (token: string) => {
const evt = mockEvents.find((e) => e.eventToken === token)
if (evt && 'organizerToken' in evt) {
return (evt as Record<string, unknown>).organizerToken as string
}
return undefined
},
removeEvent: removeEventMock,
}),
}))
@@ -60,7 +76,10 @@ vi.mock('../../composables/useRelativeTime', () => ({
function mountList() {
return mount(EventList, {
global: { plugins: [router] },
global: {
plugins: [router],
stubs: { Teleport: true },
},
})
}
@@ -160,13 +179,128 @@ describe('EventList', () => {
const wrapper = mountList()
const badge = wrapper.find('.event-card__badge--organizer')
expect(badge.exists()).toBe(true)
expect(badge.text()).toBe('Organizer')
expect(badge.text()).toBe('Organizing')
})
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')
expect(badge.text()).toBe('Attending')
})
})
describe('EventList — Organizer Cancel (US1)', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
afterEach(() => {
vi.useRealTimers()
})
it('T005: confirmDelete calls PATCH cancel-event API when role is organizer', async () => {
const { api } = await import('../../api/client')
const patchMock = vi.mocked(api.PATCH)
patchMock.mockResolvedValue({ response: { status: 204 } } as never)
const wrapper = mountList()
// Find the organizer event delete button and click it
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
expect(orgCard).toBeTruthy()
await orgCard!.find('.event-card__delete').trigger('click')
// Confirm the dialog
const confirmBtn = wrapper.find('.confirm-dialog__btn--confirm')
await confirmBtn.trigger('click')
await flushPromises()
expect(patchMock).toHaveBeenCalledWith(
'/events/{eventToken}',
expect.objectContaining({
params: expect.objectContaining({
path: { eventToken: 'org-1' },
query: { organizerToken: 'org-token' },
}),
body: { cancelled: true },
}),
)
})
it('T006: confirmDelete treats 409 response as success (removes event from list)', async () => {
const { api } = await import('../../api/client')
const patchMock = vi.mocked(api.PATCH)
patchMock.mockResolvedValue({ response: { status: 409 } } as never)
removeEventMock.mockClear()
const wrapper = mountList()
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
await orgCard!.find('.event-card__delete').trigger('click')
const confirmBtn = wrapper.find('.confirm-dialog__btn--confirm')
await confirmBtn.trigger('click')
await flushPromises()
// 409 should be treated as success — removeEvent should have been called
expect(removeEventMock).toHaveBeenCalledWith('org-1')
})
it('T006b: confirmDelete treats 404 response as success (removes event from list)', async () => {
const { api } = await import('../../api/client')
const patchMock = vi.mocked(api.PATCH)
patchMock.mockResolvedValue({ response: { status: 404 } } as never)
removeEventMock.mockClear()
const wrapper = mountList()
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
await orgCard!.find('.event-card__delete').trigger('click')
const confirmBtn = wrapper.find('.confirm-dialog__btn--confirm')
await confirmBtn.trigger('click')
await flushPromises()
expect(removeEventMock).toHaveBeenCalledWith('org-1')
})
})
describe('EventList — Dialog Differentiation (US2)', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
afterEach(() => {
vi.useRealTimers()
})
it('T013: deleteDialogTitle returns organizer-specific text when role is organizer', async () => {
const wrapper = mountList()
// Click delete on organizer event
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
await orgCard!.find('.event-card__delete').trigger('click')
const dialogTitle = wrapper.find('.confirm-dialog__title')
expect(dialogTitle.text()).toBe('Cancel event?')
})
it('T014: deleteDialogMessage returns existing attendee text when role is attendee', async () => {
const wrapper = mountList()
// Click delete on attendee event
const attCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Attending Event'))
await attCard!.find('.event-card__delete').trigger('click')
const dialogTitle = wrapper.find('.confirm-dialog__title')
expect(dialogTitle.text()).toBe('Remove event?')
const dialogMsg = wrapper.find('.confirm-dialog__message')
expect(dialogMsg.text()).toContain('attendance will be cancelled')
})
})

View File

@@ -6,7 +6,7 @@ describe('RsvpBar', () => {
it('renders CTA button when hasRsvp is false', () => {
const wrapper = mount(RsvpBar)
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
expect(wrapper.find('.rsvp-bar__cta-inner').text()).toBe("I'm attending")
expect(wrapper.find('.rsvp-bar__cta-inner').text()).toBe("I'm attending!")
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(false)
})

View File

@@ -1,9 +1,6 @@
<template>
<main class="create">
<header class="create__header">
<RouterLink to="/" class="create__back" aria-label="Back to home">&larr;</RouterLink>
<h1 class="create__title">Create</h1>
</header>
<h1 class="create__title">Great, a Party!</h1>
<form class="create__form" novalidate @submit.prevent="handleSubmit">
<div class="form-group">
@@ -76,7 +73,7 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useRouter } from 'vue-router'
import { api } from '@/api/client'
import { useEventStorage } from '@/composables/useEventStorage'
@@ -194,20 +191,7 @@ async function handleSubmit() {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
padding-top: var(--spacing-lg);
}
.create__header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.create__back {
color: var(--color-text-on-gradient);
font-size: 1.5rem;
text-decoration: none;
line-height: 1;
padding-top: calc(var(--spacing-lg) + 2.5rem);
}
.create__title {

View File

@@ -8,10 +8,6 @@
alt=""
/>
<div class="detail__hero-overlay" />
<header class="detail__header">
<RouterLink to="/" class="detail__back" aria-label="Back to home">&larr;</RouterLink>
<span class="detail__brand">fete</span>
</header>
</div>
<div class="detail__body">
@@ -129,9 +125,9 @@
<!-- Cancel confirmation dialog -->
<ConfirmDialog
:open="confirmCancelOpen"
title="Cancel attendance?"
message="Your attendance will be permanently cancelled."
confirm-label="Cancel attendance"
title="Cancel RSVP?"
message="The organizer will no longer see you as attending."
confirm-label="Cancel RSVP"
cancel-label="Keep"
@confirm="handleCancelRsvp"
@cancel="confirmCancelOpen = false"
@@ -168,7 +164,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { useRoute } from 'vue-router'
import { api } from '@/api/client'
import { useEventStorage } from '@/composables/useEventStorage'
import AttendeeList from '@/components/AttendeeList.vue'
@@ -437,32 +433,7 @@ onMounted(fetchEvent)
var(--color-glass-overlay) 0%,
transparent 50%
);
}
.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 {
color: var(--color-text-on-gradient);
font-size: 1.5rem;
text-decoration: none;
line-height: 1;
}
.detail__brand {
font-size: 1.3rem;
font-weight: 700;
color: var(--color-text-on-gradient);
pointer-events: none;
}
.detail__body {
@@ -525,7 +496,6 @@ onMounted(fetchEvent)
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 {
@@ -706,15 +676,15 @@ onMounted(fetchEvent)
font-family: inherit;
font-size: 1rem;
font-weight: 700;
color: var(--color-danger);
background: var(--color-danger-bg-strong);
border: 1px solid var(--color-danger-border);
color: var(--color-danger-solid-text);
background: var(--color-danger-solid);
border: none;
cursor: pointer;
transition: background 0.15s ease;
}
.cancel-form__confirm:hover {
background: var(--color-danger-bg-hover);
background: var(--color-danger-solid-hover);
}
.cancel-form__confirm:disabled {

View File

@@ -198,7 +198,7 @@ describe('EventDetailView', () => {
await flushPromises()
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending")
expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending!")
wrapper.unmount()
})

View File

@@ -0,0 +1,35 @@
# Specification Quality Checklist: Cancel Event from Event List
**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
- Assumptions section documents that no cancellation reason is needed from the list view (speed over detail). This is a reasonable default; can be revisited via `/speckit.clarify`.
- All items pass validation. Spec is ready for planning.

View File

@@ -0,0 +1,35 @@
# Data Model: Cancel Event from Event List
**Date**: 2026-03-12 | **Branch**: `018-cancel-event-list`
## No New Entities
This feature introduces no new entities, fields, or relationships. All required data structures already exist.
## Existing Entities Used
### StoredEvent (frontend localStorage)
| Field | Type | Notes |
|-------|------|-------|
| eventToken | string (UUID) | Used as path param for cancel API |
| organizerToken | string (UUID) \| undefined | Present only for organizer role; used as query param |
| rsvpToken | string (UUID) \| undefined | Present only for attendee role |
| rsvpName | string \| undefined | Attendee display name |
| title | string | Event title for dialog context |
| dateTime | string | Event date/time |
### Role Detection (derived, not stored)
| Role | Condition | Delete Action |
|------|-----------|---------------|
| organizer | `organizerToken` present | PATCH cancel-event API |
| attendee | `rsvpToken` present (no organizerToken) | DELETE cancel-rsvp API |
| watcher | neither token present | Direct localStorage removal |
### API Contracts Used
| Endpoint | Method | Auth | Body | Success | Already Cancelled |
|----------|--------|------|------|---------|-------------------|
| `/events/{eventToken}` | PATCH | `?organizerToken=...` | `{ cancelled: true }` | 204 | 409 (treat as success) |
| `/events/{eventToken}/rsvps/{rsvpToken}` | DELETE | rsvpToken in path | — | 204 | 204 (idempotent) |

View File

@@ -0,0 +1,69 @@
# Implementation Plan: Cancel Event from Event List
**Branch**: `018-cancel-event-list` | **Date**: 2026-03-12 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/018-cancel-event-list/spec.md`
## Summary
Enable organizers to cancel events directly from the event list page via the existing ConfirmDialog. The `EventList.vue` `confirmDelete` handler must detect the organizer role and call `PATCH /events/{eventToken}?organizerToken=...` with `{ cancelled: true }` instead of the existing RSVP deletion flow. The ConfirmDialog message must differentiate organizer cancellation (severe, affects all attendees) from attendee RSVP cancellation.
## Technical Context
**Language/Version**: TypeScript 5.x, Vue 3 (Composition API)
**Primary Dependencies**: openapi-fetch, Vue Router, Vite
**Storage**: localStorage via `useEventStorage()` composable
**Testing**: Vitest (unit), Playwright + MSW (E2E)
**Target Platform**: Mobile-first PWA (all modern browsers)
**Project Type**: Web application (frontend-only change)
**Performance Goals**: N/A (no new endpoints, minimal UI change)
**Constraints**: No backend changes required; cancel-event API already exists
**Scale/Scope**: ~50 lines of logic change in EventList.vue, dialog message updates
## 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 collected or stored |
| II. Test-Driven Methodology | PASS | Unit tests + E2E tests planned |
| III. API-First Development | PASS | Uses existing PATCH endpoint already in OpenAPI spec |
| IV. Simplicity & Quality | PASS | Extends existing delete flow, no new abstractions |
| V. Dependency Discipline | PASS | No new dependencies |
| VI. Accessibility | PASS | Reuses existing ConfirmDialog (already has aria-modal, alertdialog role, keyboard nav) |
All gates pass. No violations.
## Project Structure
### Documentation (this feature)
```text
specs/018-cancel-event-list/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output (minimal — no new entities)
└── tasks.md # Phase 2 output (/speckit.tasks command)
```
### Source Code (repository root)
```text
frontend/src/
├── components/
│ ├── EventList.vue # PRIMARY CHANGE: confirmDelete handler + dialog message
│ └── ConfirmDialog.vue # No changes needed
├── api/
│ ├── client.ts # No changes needed (openapi-fetch client)
│ └── schema.d.ts # Already has PATCH /events/{eventToken} types
└── composables/
└── useEventStorage.ts # No changes needed
frontend/e2e/
└── cancel-event-list.spec.ts # NEW: E2E tests for organizer cancellation
frontend/src/components/__tests__/
└── EventList.spec.ts # EXTEND: Unit tests for organizer cancel flow
```
**Structure Decision**: Frontend-only change. All logic changes in `EventList.vue`. No new components, composables, or API endpoints.

View File

@@ -0,0 +1,39 @@
# Research: Cancel Event from Event List
**Date**: 2026-03-12 | **Branch**: `018-cancel-event-list`
## Existing Cancel-Event API
- **Decision**: Reuse the existing `PATCH /events/{eventToken}?organizerToken=...` endpoint with `{ cancelled: true }` body.
- **Rationale**: The endpoint is fully implemented and documented in the OpenAPI spec. The EventDetailView already calls it successfully. No backend changes needed.
- **Alternatives considered**: None — the endpoint exists and fits the requirement exactly.
## EventList Delete Flow Architecture
- **Decision**: Extend the existing `confirmDelete()` handler in `EventList.vue` with a role-based branch: organizer → PATCH cancel-event, attendee → DELETE cancel-rsvp, watcher → direct remove.
- **Rationale**: The role detection (`getRole()`) already exists (lines 113-117). The current handler only covers attendee (RSVP deletion) and watcher (direct remove). Adding the organizer branch follows the same pattern.
- **Alternatives considered**: Creating a separate handler for organizer cancel — rejected because it would duplicate the dialog open/close and error handling logic.
## ConfirmDialog Message Differentiation
- **Decision**: Compute `deleteDialogMessage` and `deleteDialogTitle` based on `getRole(pendingDeleteEvent)`. Organizer gets a severe warning ("Cancel event? This will cancel the event for all attendees."), attendee keeps existing message.
- **Rationale**: The ConfirmDialog already accepts `title` and `message` props. The `deleteDialogMessage` computed property exists but currently only distinguishes RSVP vs watcher. Extend it to include organizer.
- **Alternatives considered**: Using a different dialog component for organizer — rejected (unnecessary, ConfirmDialog is sufficient and already styled with danger button).
## 409 Conflict Handling
- **Decision**: Treat 409 (event already cancelled) as success — silently remove event from local list.
- **Rationale**: Frontend does not track cancelled status. If the server says it's already cancelled, the user's intent (remove from list) is fulfilled either way.
- **Alternatives considered**: Showing an info message ("Event was already cancelled") — rejected per clarification session, silent removal is simpler and less confusing.
## In-Flight Behavior
- **Decision**: No loading indicator in ConfirmDialog. Dialog stays open until success (close + remove) or failure (stay open + error).
- **Rationale**: Consistent with all other ConfirmDialog-based flows in the project (cancel RSVP, delete event from list). The ConfirmDialog component has no loading state support and adding one would be scope creep.
- **Alternatives considered**: Adding `:disabled` + spinner to confirm button (like BottomSheet forms) — rejected for consistency with existing ConfirmDialog patterns.
## Confirm Button Styling
- **Decision**: Use `var(--color-danger-solid)` for the organizer cancel confirm button, consistent with existing ConfirmDialog danger styling.
- **Rationale**: The ConfirmDialog already uses danger-colored confirm buttons. No additional styling needed for the organizer flow.
- **Alternatives considered**: None — existing styling fits.

View File

@@ -0,0 +1,88 @@
# Feature Specification: Cancel Event from Event List
**Feature Branch**: `018-cancel-event-list`
**Created**: 2026-03-12
**Status**: Draft
**Input**: User description: "Wenn organisator das event auf der event listen seite löscht, kommt ein confirmation dialog mit einer warnung, dass das event abgesagt wird. Dann wird wirklich ein api call zum canceln des events gesendet"
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Organizer Cancels Event from Event List (Priority: P1)
As an organizer viewing their event list, I want to cancel an event directly from the list so that I don't have to navigate into the event detail page first. When I tap the delete button on one of my events, a confirmation dialog warns me that the event will be permanently cancelled for all attendees. If I confirm, the system sends a cancellation request and removes the event from my list.
**Why this priority**: This is the core and only feature — enabling organizers to cancel events directly from the event list with clear warning about the irreversible consequence.
**Independent Test**: Can be fully tested by creating an event, navigating to the event list, tapping delete on the organizer's event, confirming in the dialog, and verifying the API call is made and the event is removed from the list.
**Acceptance Scenarios**:
1. **Given** an organizer is on the event list page and has an active (non-cancelled) event, **When** they tap the delete button on that event, **Then** a confirmation dialog appears with a warning that the event will be cancelled for all attendees.
2. **Given** the confirmation dialog is open, **When** the organizer confirms the cancellation, **Then** the system sends a cancel-event API request and, on success, removes the event from the local list.
3. **Given** the confirmation dialog is open, **When** the organizer taps the cancel button or presses Escape, **Then** the dialog closes and the event remains unchanged.
4. **Given** the organizer confirms cancellation, **When** the API call fails (network error, server error), **Then** the event is not removed from the list and an error message is shown.
---
### User Story 2 - Distinct Dialog for Organizer vs. Attendee Delete (Priority: P2)
The confirmation dialog must clearly differentiate between the organizer deleting (which cancels the event for everyone) and an attendee deleting (which only cancels their personal RSVP). The dialog text and warning level must reflect the severity of each action.
**Why this priority**: Prevents organizers from accidentally cancelling an event when they only intended to remove it from their view. The existing attendee delete flow already works — this story ensures the organizer flow has appropriate, distinct messaging.
**Independent Test**: Can be tested by comparing the dialog text when deleting as an organizer versus as an attendee for the same event, verifying the organizer dialog contains a stronger warning.
**Acceptance Scenarios**:
1. **Given** an organizer taps delete on their event, **When** the confirmation dialog appears, **Then** the title and message clearly state that the event will be cancelled permanently and all attendees will be affected.
2. **Given** an attendee taps delete on an event they RSVP'd to, **When** the confirmation dialog appears, **Then** the existing behavior is preserved — the message says their attendance will be cancelled and the event removed from their list.
---
### Edge Cases
- What happens when the organizer tries to cancel an event that is already cancelled? The frontend does not track cancelled status, so the delete button remains visible. If the API returns a 409 Conflict, the event is silently removed from the local list (since it is already cancelled server-side).
- What happens if the network request is in-flight and the user navigates away? The cancellation request should complete in the background; the local list update happens on next visit.
- What is the in-flight UI behavior during the cancellation API call? The existing ConfirmDialog pattern is used: no loading indicator, dialog remains open until API success (close + remove) or failure (stay open + show error). This is consistent with all other ConfirmDialog-based flows in the project.
- What happens when the organizer has both an organizer token and an RSVP for the same event? The organizer role takes precedence — the dialog shows the event-cancellation warning, not the RSVP-cancellation message.
## Clarifications
### Session 2026-03-12
- Q: How should already-cancelled events be handled in the list? → A: Frontend does not track cancelled status; delete button remains visible. On 409 Conflict, silently remove event from local list.
- Q: What is the in-flight UI behavior during the cancellation API call? → A: Use existing ConfirmDialog pattern — no loading indicator, dialog stays open until success or failure. Consistent with all other ConfirmDialog flows.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: When an organizer taps delete on their event in the event list, the system MUST show a confirmation dialog before taking any action.
- **FR-002**: The confirmation dialog for organizer events MUST clearly warn that the event will be cancelled permanently and that all attendees will be affected.
- **FR-003**: Upon confirmation, the system MUST send a cancel-event API request using the event's organizer token.
- **FR-004**: On successful cancellation (API returns success), the system MUST remove the event from the organizer's local event list.
- **FR-005**: On failed cancellation (network error or API error), the system MUST keep the event in the list and display an error message to the user. The confirmation dialog remains open.
- **FR-005a**: If the API returns 409 Conflict (event already cancelled), the system MUST silently remove the event from the local list (treated as success).
- **FR-006**: The confirmation dialog MUST provide a clear way to abort (cancel button, Escape key, overlay click) without triggering the cancellation.
- **FR-007**: The existing attendee and watcher delete flows MUST remain unchanged.
### Key Entities
- **Event**: Has an event token, organizer token (present only for the organizer), and cancelled status.
- **Confirmation Dialog**: Reusable UI component that displays a title, message, and confirm/cancel actions.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Organizers can cancel an event from the event list in under 5 seconds (two taps: delete + confirm).
- **SC-002**: 100% of organizer cancellation attempts show the warning dialog before any API call is made.
- **SC-003**: After successful cancellation, the event disappears from the list immediately without requiring a page refresh.
- **SC-004**: Failed cancellation attempts preserve the event in the list and show a user-visible error message.
## Assumptions
- The cancel-event API endpoint (PATCH `/events/{eventToken}` with `cancelled: true`) already exists and is functional.
- The `ConfirmDialog` component already exists and can be reused with different title/message props.
- The `EventList` component already differentiates between organizer, attendee, and watcher roles using stored tokens.
- No cancellation reason is required when cancelling from the event list (unlike the event detail page, which offers an optional reason field). The list view prioritizes speed over detail.

View File

@@ -0,0 +1,150 @@
# Tasks: Cancel Event from Event List
**Input**: Design documents from `/specs/018-cancel-event-list/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md
**Tests**: Mandatory per constitution (TDD — Red → Green → Refactor).
**Organization**: Tasks are grouped by user story. No setup or foundational phases needed — all infrastructure exists.
## 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)
- Include exact file paths in descriptions
---
## Phase 1: User Story 1 — Organizer Cancels Event from List (Priority: P1) 🎯 MVP
**Goal**: Organizers can cancel an event directly from the event list via confirmation dialog and PATCH API call.
**Independent Test**: Create an event, navigate to event list, tap delete on organizer event, confirm in dialog, verify API call is made and event is removed from list.
### Tests for User Story 1 ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [X] T001 [P] [US1] E2E test: organizer taps delete, confirms, event is removed after successful API call in `frontend/e2e/cancel-event-list.spec.ts`
- [X] T002 [P] [US1] E2E test: organizer confirms cancellation, API fails, event stays in list and error message shown in `frontend/e2e/cancel-event-list.spec.ts`
- [X] T003 [P] [US1] E2E test: organizer confirms cancellation, API returns 409 Conflict, event is silently removed from list in `frontend/e2e/cancel-event-list.spec.ts`
- [X] T004 [P] [US1] E2E test: organizer opens cancel dialog then dismisses (cancel button, Escape, overlay click), event remains unchanged in `frontend/e2e/cancel-event-list.spec.ts`
- [X] T005 [P] [US1] Unit test: `confirmDelete` calls PATCH cancel-event API when role is organizer in `frontend/src/components/__tests__/EventList.spec.ts`
- [X] T006 [P] [US1] Unit test: `confirmDelete` treats 409 response as success (removes event from list) in `frontend/src/components/__tests__/EventList.spec.ts`
### Implementation for User Story 1
- [X] T007 [US1] Extend `confirmDelete` in `frontend/src/components/EventList.vue` to detect organizer role and call `api.PATCH('/events/{eventToken}')` with `{ cancelled: true }` and `organizerToken` query param
- [X] T008 [US1] Handle 409 Conflict as success (silently remove event from local list) in `frontend/src/components/EventList.vue`
- [X] T009 [US1] Handle API errors (keep dialog open, show error message) for organizer cancel in `frontend/src/components/EventList.vue`
- [X] T010 [US1] Add organizer-specific dialog title ("Cancel event?") and message ("This will permanently cancel the event for all attendees.") in `frontend/src/components/EventList.vue`
**Checkpoint**: Organizer can cancel events from the list. E2E and unit tests pass green.
---
## Phase 2: User Story 2 — Distinct Dialog for Organizer vs. Attendee (Priority: P2)
**Goal**: Confirmation dialog clearly differentiates between organizer cancellation (severe, affects everyone) and attendee RSVP cancellation (personal).
**Independent Test**: Compare dialog text when deleting as organizer vs. as attendee for the same event — organizer dialog must have stronger warning.
### Tests for User Story 2 ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [X] T011 [P] [US2] E2E test: organizer dialog shows event-cancellation warning (title + message distinct from attendee) in `frontend/e2e/cancel-event-list.spec.ts`
- [X] T012 [P] [US2] E2E test: attendee dialog preserves existing RSVP-cancellation message (no regression) in `frontend/e2e/cancel-event-list.spec.ts`
- [X] T013 [P] [US2] Unit test: `deleteDialogMessage` and `deleteDialogTitle` return organizer-specific text when `getRole()` is organizer in `frontend/src/components/__tests__/EventList.spec.ts`
- [X] T014 [P] [US2] Unit test: `deleteDialogMessage` returns existing attendee text unchanged when `getRole()` is attendee in `frontend/src/components/__tests__/EventList.spec.ts`
### Implementation for User Story 2
- [X] T015 [US2] Refactor `deleteDialogMessage` computed in `frontend/src/components/EventList.vue` to return role-differentiated text: organizer warning vs. existing attendee message
- [X] T016 [US2] Add `deleteDialogTitle` computed in `frontend/src/components/EventList.vue` returning "Cancel event?" for organizer, "Remove event?" for attendee
- [X] T017 [US2] Bind `deleteDialogTitle` to ConfirmDialog `:title` prop in `frontend/src/components/EventList.vue`
**Checkpoint**: Dialog messages are clearly differentiated by role. All E2E and unit tests pass green.
---
## Phase 3: Polish & Cross-Cutting Concerns
**Purpose**: Final validation and regression check
- [X] T018 Run full frontend unit test suite (`npm run test:unit`) — verify no regressions
- [X] T019 Run full E2E test suite (`npx playwright test`) — verify no regressions
- [X] T020 Verify existing attendee and watcher delete flows unchanged (FR-007) via E2E tests
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (US1)**: No dependencies — can start immediately (all infrastructure exists)
- **Phase 2 (US2)**: Depends on Phase 1 completion (US2 refines the dialog created in US1)
- **Phase 3 (Polish)**: Depends on Phase 1 + Phase 2 completion
### User Story Dependencies
- **US1 (P1)**: Independent — core cancel flow + initial dialog message
- **US2 (P2)**: Depends on US1 — refines dialog messaging created in US1
### Within Each User Story
- Tests MUST be written and FAIL before implementation (TDD)
- Implementation tasks are sequential within each story (T007 → T008 → T009 → T010)
- All test tasks within a story can run in parallel
### Parallel Opportunities
- T001T006 (US1 tests): All parallelizable — different test scenarios, same files but independent
- T011T014 (US2 tests): All parallelizable
- US1 and US2 are sequential (US2 depends on US1)
---
## Parallel Example: User Story 1
```bash
# Launch all US1 tests in parallel (TDD — write first, expect red):
Task: "E2E test: organizer cancels event successfully" (T001)
Task: "E2E test: organizer cancel fails, error shown" (T002)
Task: "E2E test: 409 Conflict handled as success" (T003)
Task: "E2E test: dismiss dialog, event unchanged" (T004)
Task: "Unit test: confirmDelete calls PATCH for organizer" (T005)
Task: "Unit test: 409 treated as success" (T006)
# Then implement sequentially:
Task: "Extend confirmDelete for organizer role" (T007)
Task: "Handle 409 Conflict" (T008)
Task: "Handle API errors" (T009)
Task: "Add organizer dialog title + message" (T010)
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Write US1 tests (T001T006) — all should fail (red)
2. Implement US1 (T007T010) — tests turn green
3. **STOP and VALIDATE**: Organizer can cancel events from list
4. Deploy/demo if ready
### Full Delivery
1. Complete US1 → MVP functional
2. Complete US2 → Dialog messaging polished
3. Complete Polish → Full regression validation
---
## Notes
- No backend changes required — existing PATCH `/events/{eventToken}` endpoint used
- No new components — ConfirmDialog reused as-is
- No new dependencies
- Primary change: ~50 lines in `EventList.vue` + tests