Compare commits
17 Commits
0.12.0
...
e6ee2c89ec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6ee2c89ec | ||
| b12106d3bf | |||
| d0ed6790ef | |||
| 92372b6a59 | |||
| 7817ad182b | |||
| 9483e9b1f7 | |||
| 75e6548403 | |||
| d4a1f0dc23 | |||
| 3d7efb14f7 | |||
| 2f8b911af8 | |||
| e9791de4e2 | |||
| 3b4cc7fbb9 | |||
| 9c0e9249ce | |||
| 5082ec1333 | |||
| 35b488a8be | |||
| b067c0ef1e | |||
|
|
42686502d8 |
@@ -84,31 +84,12 @@ Die folgenden Punkte wurden in Diskussionen bereits geklärt und sind verbindlic
|
||||
* (derzeit keine offenen Architekturentscheidungen)
|
||||
|
||||
## Nicht umgesetzte Feature-Ideen (ehemals Specs 009–026)
|
||||
|
||||
### 009 – Gästeliste
|
||||
Organisator sieht alle RSVPs (Name, Status) und kann einzelne Einträge löschen.
|
||||
* Nur mit gültigem Organizer-Token sichtbar
|
||||
* Gäste ohne Token sehen keine Gästeliste
|
||||
* Löschung serverseitig validiert
|
||||
|
||||
### 010 – Event bearbeiten
|
||||
Organisator kann Titel, Beschreibung, Datum, Ort und Ablaufdatum ändern.
|
||||
* Formular vorausgefüllt mit aktuellen Werten
|
||||
* Ablaufdatum muss in der Zukunft liegen
|
||||
* Ohne Organizer-Token kein Edit-UI sichtbar
|
||||
|
||||
### 011 – Event merken/bookmarken
|
||||
Gäste können Events lokal merken, ohne RSVP abzugeben — rein clientseitig via localStorage.
|
||||
* Kein Serverkontakt nötig
|
||||
* Unabhängig vom RSVP-Status
|
||||
* Auch bei abgelaufenen Events möglich
|
||||
|
||||
### 012 – Lokale Event-Übersicht
|
||||
Startseite (`/`) zeigt alle getrackten Events (erstellt, zugesagt, gemerkt) aus localStorage.
|
||||
* Zeigt Titel, Datum, Beziehungstyp (Organisator/Gast/Gemerkt)
|
||||
* Vergangene Events als "beendet" markiert
|
||||
* Einträge können entfernt werden
|
||||
|
||||
### 013 – Kalender-Export
|
||||
.ics-Download (RFC 5545) mit Event-Details, optional webcal:// für Live-Updates.
|
||||
* Stabile UID aus Event-Token (Re-Import aktualisiert statt dupliziert)
|
||||
@@ -137,19 +118,6 @@ Badge/Indikator bei ungelesenen Organisator-Updates, rein clientseitig via local
|
||||
Event-Seite zeigt QR-Code mit der öffentlichen Event-URL.
|
||||
* Serverseitig generiert (kein externer QR-Service)
|
||||
* Download als SVG oder hochauflösendes PNG
|
||||
* Auch bei abgelaufenen Events verfügbar
|
||||
|
||||
### 018 – Datenlöschung
|
||||
Automatische Löschung aller Event-Daten nach Ablaufdatum (Privacy-Garantie).
|
||||
* Scheduled Job oder Lazy Cleanup bei Zugriff
|
||||
* Löscht Event, RSVPs, Updates, Bilder, Metadaten
|
||||
* Idempotent, kein PII im Log
|
||||
|
||||
### 019 – Instanz-Limit
|
||||
`MAX_ACTIVE_EVENTS` als Env-Variable begrenzt aktive Events für Self-Hoster.
|
||||
* Nur nicht-abgelaufene Events zählen
|
||||
* Unset/leer = unbegrenzt
|
||||
* Serverseitige Durchsetzung bei Event-Erstellung
|
||||
|
||||
### 020 – PWA
|
||||
Web App Manifest + Service Worker für Installierbarkeit und Offline-Caching.
|
||||
@@ -169,27 +137,11 @@ Organisator sucht Headerbild über integrierte Unsplash-Suche.
|
||||
* Bild lokal gespeichert + Unsplash-Attribution
|
||||
* Feature deaktiviert wenn kein API-Key konfiguriert
|
||||
|
||||
### 023 – Dark Mode
|
||||
App erkennt `prefers-color-scheme` und bietet manuellen Toggle.
|
||||
* Manuelle Auswahl in localStorage gespeichert
|
||||
* Gilt für globales App-Chrome, nicht Event-Farbthemen
|
||||
* Beide Modi WCAG AA konform
|
||||
|
||||
### 024 – Event absagen
|
||||
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.
|
||||
* Entfernt alle zugehörigen Daten sofort
|
||||
* localStorage-Eintrag wird entfernt, Redirect zu `/`
|
||||
* Funktioniert in jedem Event-Status
|
||||
|
||||
### 026 – 404-Seite
|
||||
Catch-all Route für ungültige Pfade mit "Seite nicht gefunden" und Link zur Startseite.
|
||||
* Folgt dem Design System (Electric Dusk + Sora)
|
||||
* WCAG AA konform
|
||||
* Verhindert leere Seiten bei Fehlnavigation
|
||||
|
||||
### 027 - Update der EventListe
|
||||
* Irgendwie ein update der event liste, wenn man sie betritt oder wenn man mit touch die seite nach unten zieht (hier müssen wir noch überlegen, wie wir mit den verschiedenen update fällen umgehen und wie wir das update überhaupt requesten. Ich meine sowas wie: was ist, wenn das event nicht mehr gefunden wurde?)
|
||||
|
||||
@@ -48,4 +48,11 @@ 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).
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
|
||||
210
frontend/e2e/cancel-event-list.spec.ts
Normal file
210
frontend/e2e/cancel-event-list.spec.ts
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -64,12 +64,14 @@ test.describe('US1: Organizer cancels event with reason', () => {
|
||||
await page.addInitScript(seedEvents([organizerSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Cancel button visible for organizer
|
||||
const cancelBtn = page.getByRole('button', { name: /Cancel event/i })
|
||||
await expect(cancelBtn).toBeVisible()
|
||||
// Open kebab menu, then cancel event
|
||||
const kebabBtn = page.getByRole('button', { name: /Event actions/i })
|
||||
await expect(kebabBtn).toBeVisible()
|
||||
await kebabBtn.click()
|
||||
|
||||
// Open cancel bottom sheet
|
||||
await cancelBtn.click()
|
||||
const cancelItem = page.getByRole('menuitem', { name: /Cancel event/i })
|
||||
await expect(cancelItem).toBeVisible()
|
||||
await cancelItem.click()
|
||||
|
||||
// Fill in reason
|
||||
const reasonField = page.getByLabel(/reason/i)
|
||||
@@ -83,8 +85,8 @@ test.describe('US1: Organizer cancels event with reason', () => {
|
||||
await expect(page.getByText(/This event has been cancelled/i)).toBeVisible()
|
||||
await expect(page.getByText('Venue closed')).toBeVisible()
|
||||
|
||||
// Cancel button should be gone
|
||||
await expect(cancelBtn).not.toBeVisible()
|
||||
// Kebab menu should be gone (event is cancelled)
|
||||
await expect(kebabBtn).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -118,7 +120,8 @@ test.describe('US1: Organizer cancels event without reason', () => {
|
||||
await page.addInitScript(seedEvents([organizerSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
await page.getByRole('button', { name: /Cancel event/i }).click()
|
||||
await page.getByRole('button', { name: /Event actions/i }).click()
|
||||
await page.getByRole('menuitem', { name: /Cancel event/i }).click()
|
||||
|
||||
// Don't fill in reason, just confirm
|
||||
await page.getByRole('button', { name: /Confirm cancellation/i }).click()
|
||||
@@ -150,7 +153,8 @@ test.describe('US1: Cancel API failure', () => {
|
||||
await page.addInitScript(seedEvents([organizerSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
await page.getByRole('button', { name: /Cancel event/i }).click()
|
||||
await page.getByRole('button', { name: /Event actions/i }).click()
|
||||
await page.getByRole('menuitem', { name: /Cancel event/i }).click()
|
||||
await page.getByRole('button', { name: /Confirm cancellation/i }).click()
|
||||
|
||||
// Error message in bottom sheet
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
108
frontend/e2e/ical-download.spec.ts
Normal file
108
frontend/e2e/ical-download.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
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: 'Sommerfest am See',
|
||||
description: 'Bring your own drinks!',
|
||||
dateTime: '2026-07-15T18:00:00+02:00',
|
||||
timezone: 'Europe/Berlin',
|
||||
location: 'Stadtpark Berlin',
|
||||
attendeeCount: 12,
|
||||
cancelled: false,
|
||||
}
|
||||
|
||||
const cancelledEvent = {
|
||||
...fullEvent,
|
||||
cancelled: true,
|
||||
cancellationReason: 'Bad weather',
|
||||
}
|
||||
|
||||
function seedEvents(events: StoredEvent[]): string {
|
||||
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||
}
|
||||
|
||||
function rsvpSeed(): StoredEvent {
|
||||
return {
|
||||
eventToken: fullEvent.eventToken,
|
||||
title: fullEvent.title,
|
||||
dateTime: fullEvent.dateTime,
|
||||
rsvpToken: 'd4e5f6a7-b8c9-0123-4567-890abcdef012',
|
||||
rsvpName: 'Anna',
|
||||
}
|
||||
}
|
||||
|
||||
function organizerSeed(): StoredEvent {
|
||||
return {
|
||||
eventToken: fullEvent.eventToken,
|
||||
title: fullEvent.title,
|
||||
dateTime: fullEvent.dateTime,
|
||||
organizerToken: 'org-token-1234',
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('iCal download: calendar button visibility', () => {
|
||||
test('calendar button visible for pre-RSVP visitor', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
)
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
const calendarBtn = page.getByRole('button', { name: /add to calendar/i })
|
||||
await expect(calendarBtn).toBeVisible()
|
||||
})
|
||||
|
||||
test('calendar button visible for post-RSVP 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}`)
|
||||
|
||||
const calendarBtn = page.getByRole('button', { name: /add to calendar/i })
|
||||
await expect(calendarBtn).toBeVisible()
|
||||
})
|
||||
|
||||
test('calendar button visible for organizer', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
http.get('*/api/events/:token/attendees*', () =>
|
||||
HttpResponse.json({ attendees: [] }),
|
||||
),
|
||||
)
|
||||
await page.addInitScript(seedEvents([organizerSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
const calendarBtn = page.getByRole('button', { name: /add to calendar/i })
|
||||
await expect(calendarBtn).toBeVisible()
|
||||
})
|
||||
|
||||
test('calendar button NOT visible for cancelled event', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(cancelledEvent)),
|
||||
)
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
const calendarBtn = page.getByRole('button', { name: /add to calendar/i })
|
||||
await expect(calendarBtn).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('iCal download: file generation', () => {
|
||||
test('triggers download with correct filename', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
)
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Intercept the download by overriding the click-link mechanism
|
||||
const downloadPromise = page.waitForEvent('download')
|
||||
await page.getByRole('button', { name: /add to calendar/i }).click()
|
||||
const download = await downloadPromise
|
||||
|
||||
expect(download.suggestedFilename()).toBe('sommerfest-am-see.ics')
|
||||
})
|
||||
})
|
||||
@@ -56,7 +56,7 @@ test.describe('US1: Watch event from detail page', () => {
|
||||
)
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
|
||||
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||
await expect(bookmark).toBeVisible()
|
||||
await expect(bookmark).toHaveAttribute('aria-label', 'Watch this event')
|
||||
|
||||
@@ -81,7 +81,7 @@ test.describe('US2: Un-watch event from detail page', () => {
|
||||
await page.addInitScript(seedEvents([watchSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
|
||||
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
|
||||
|
||||
await bookmark.click()
|
||||
@@ -105,7 +105,7 @@ test.describe('US3: Bookmark reflects attending status', () => {
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Bookmark not shown for attendees — RsvpBar shows status state
|
||||
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
|
||||
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||
await expect(bookmark).not.toBeVisible()
|
||||
|
||||
// Navigate to list via back link
|
||||
@@ -132,7 +132,7 @@ test.describe('US4: RSVP cancellation preserves watch status', () => {
|
||||
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')
|
||||
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||
await expect(bookmark).toBeVisible()
|
||||
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
|
||||
|
||||
@@ -151,7 +151,7 @@ test.describe('US5: No bookmark for attendees and organizers', () => {
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
|
||||
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||
await expect(bookmark).not.toBeVisible()
|
||||
})
|
||||
|
||||
@@ -162,7 +162,7 @@ test.describe('US5: No bookmark for attendees and organizers', () => {
|
||||
await page.addInitScript(seedEvents([organizerSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
|
||||
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||
await expect(bookmark).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -197,7 +197,7 @@ test.describe('US7: Watcher upgrades to attendee', () => {
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Verify watching state — bookmark visible
|
||||
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
|
||||
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||
await expect(bookmark).toBeVisible()
|
||||
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
|
||||
|
||||
|
||||
1185
frontend/package-lock.json
generated
1185
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="app-container">
|
||||
<header v-if="route.name !== 'home'" class="app-header">
|
||||
<BackLink />
|
||||
<div id="header-actions"></div>
|
||||
</header>
|
||||
<RouterView />
|
||||
</div>
|
||||
@@ -16,11 +17,19 @@ const route = useRoute()
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
padding-top: var(--spacing-lg);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-lg) var(--content-padding);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-header :deep(*) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -162,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 {
|
||||
@@ -244,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) {
|
||||
@@ -256,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) */
|
||||
@@ -298,6 +317,72 @@ textarea.form-field {
|
||||
to { --glow-angle: 360deg; }
|
||||
}
|
||||
|
||||
/* ── Fixed Bottom Bar Components ── */
|
||||
|
||||
/* CTA wrapper (text button, e.g. "I'm attending!", "Post an update") */
|
||||
.bar-cta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-radius: var(--radius-button);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.bar-cta:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.bar-cta:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.bar-cta-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: calc(var(--radius-button) - 2px);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-on-gradient);
|
||||
text-align: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
/* Icon wrapper (e.g. calendar, bookmark buttons) */
|
||||
.bar-icon {
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-button);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.bar-icon:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.bar-icon:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.bar-icon-btn {
|
||||
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;
|
||||
}
|
||||
|
||||
.bar-icon-btn svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
|
||||
@@ -107,7 +107,6 @@ function onTouchEnd() {
|
||||
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%;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -3,20 +3,30 @@
|
||||
<div class="rsvp-bar__inner">
|
||||
<!-- Status state: already RSVPed -->
|
||||
<div v-if="hasRsvp" class="rsvp-bar__status-wrapper">
|
||||
<div
|
||||
class="rsvp-bar__status"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="expanded"
|
||||
aria-label="You're attending. Tap to show cancel option."
|
||||
@click="expanded = !expanded"
|
||||
@keydown.enter.prevent="expanded = !expanded"
|
||||
@keydown.space.prevent="expanded = !expanded"
|
||||
@keydown.escape="expanded = false"
|
||||
>
|
||||
<span class="rsvp-bar__check" aria-hidden="true">✓</span>
|
||||
<span class="rsvp-bar__text">You're attending!</span>
|
||||
<span class="rsvp-bar__chevron" :class="{ 'rsvp-bar__chevron--open': expanded }" aria-hidden="true">›</span>
|
||||
<div class="rsvp-bar__status-row">
|
||||
<div
|
||||
class="rsvp-bar__status"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="expanded"
|
||||
aria-label="You're attending. Tap to show cancel option."
|
||||
@click="expanded = !expanded"
|
||||
@keydown.enter.prevent="expanded = !expanded"
|
||||
@keydown.space.prevent="expanded = !expanded"
|
||||
@keydown.escape="expanded = false"
|
||||
>
|
||||
<span class="rsvp-bar__check" aria-hidden="true">✓</span>
|
||||
<span class="rsvp-bar__text">You're attending!</span>
|
||||
<span class="rsvp-bar__chevron" :class="{ 'rsvp-bar__chevron--open': expanded }" aria-hidden="true">›</span>
|
||||
</div>
|
||||
<button
|
||||
class="rsvp-bar__calendar-glass"
|
||||
type="button"
|
||||
aria-label="Add to calendar"
|
||||
@click="$emit('calendar')"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<Transition name="rsvp-bar-cancel">
|
||||
<button
|
||||
@@ -32,14 +42,9 @@
|
||||
|
||||
<!-- CTA state: no RSVP yet -->
|
||||
<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!
|
||||
</button>
|
||||
</div>
|
||||
<div class="rsvp-bar__bookmark glow-border glow-border--animated">
|
||||
<div class="bar-icon glow-border glow-border--animated">
|
||||
<button
|
||||
class="rsvp-bar__bookmark-inner glass-inner"
|
||||
class="bar-icon-btn glass-inner"
|
||||
type="button"
|
||||
:aria-label="bookmarked ? 'Stop watching this event' : 'Watch this event'"
|
||||
@click="$emit('bookmark')"
|
||||
@@ -47,6 +52,21 @@
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" :fill="bookmarked ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="bar-cta glow-border glow-border--animated">
|
||||
<button class="bar-cta-btn glass-inner" type="button" @click="$emit('open')">
|
||||
I'm attending!
|
||||
</button>
|
||||
</div>
|
||||
<div class="bar-icon glow-border glow-border--animated">
|
||||
<button
|
||||
class="bar-icon-btn glass-inner"
|
||||
type="button"
|
||||
aria-label="Add to calendar"
|
||||
@click="$emit('calendar')"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,6 +84,7 @@ defineEmits<{
|
||||
open: []
|
||||
cancel: []
|
||||
bookmark: []
|
||||
calendar: []
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
@@ -111,34 +132,6 @@ watch(expanded, (isExpanded) => {
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.rsvp-bar__cta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-radius: var(--radius-button);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.rsvp-bar__cta:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.rsvp-bar__cta:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.rsvp-bar__cta-inner {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: calc(var(--radius-button) - 2px);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-on-gradient);
|
||||
text-align: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rsvp-bar__status-wrapper {
|
||||
display: flex;
|
||||
@@ -146,7 +139,14 @@ watch(expanded, (isExpanded) => {
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.rsvp-bar__status-row {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.rsvp-bar__status {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -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;
|
||||
@@ -227,35 +225,35 @@ watch(expanded, (isExpanded) => {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.rsvp-bar__bookmark {
|
||||
|
||||
/* Calendar button — glassmorphic variant (post-RSVP status row) */
|
||||
.rsvp-bar__calendar-glass {
|
||||
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;
|
||||
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);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
color: var(--color-text-on-gradient);
|
||||
cursor: pointer;
|
||||
line-height: 0;
|
||||
transition: transform 0.1s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.rsvp-bar__bookmark-inner svg {
|
||||
.rsvp-bar__calendar-glass:hover {
|
||||
transform: scale(1.02);
|
||||
background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%);
|
||||
}
|
||||
|
||||
.rsvp-bar__calendar-glass:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.rsvp-bar__calendar-glass svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -170,3 +189,118 @@ describe('EventList', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,8 +5,8 @@ import RsvpBar from '../RsvpBar.vue'
|
||||
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('.bar-cta').exists()).toBe(true)
|
||||
expect(wrapper.find('.bar-cta-btn').text()).toBe("I'm attending!")
|
||||
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -14,17 +14,17 @@ describe('RsvpBar', () => {
|
||||
const wrapper = mount(RsvpBar, { props: { hasRsvp: true } })
|
||||
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(true)
|
||||
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
|
||||
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
||||
expect(wrapper.find('.bar-cta').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('emits open when CTA button is clicked', async () => {
|
||||
const wrapper = mount(RsvpBar)
|
||||
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||
await wrapper.find('.bar-cta-btn').trigger('click')
|
||||
expect(wrapper.emitted('open')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not render CTA button when hasRsvp is true', () => {
|
||||
const wrapper = mount(RsvpBar, { props: { hasRsvp: true } })
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
expect(wrapper.find('.bar-cta-btn').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
120
frontend/src/composables/__tests__/useIcalDownload.spec.ts
Normal file
120
frontend/src/composables/__tests__/useIcalDownload.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateIcs } from '../useIcalDownload'
|
||||
|
||||
describe('generateIcs', () => {
|
||||
const baseEvent = {
|
||||
eventToken: '550e8400-e29b-41d4-a716-446655440000',
|
||||
title: 'Sommerfest am See',
|
||||
dateTime: '2026-07-15T18:00:00+02:00',
|
||||
location: 'Stadtpark Berlin',
|
||||
description: 'Bring your own drinks',
|
||||
}
|
||||
|
||||
it('generates valid VCALENDAR wrapper', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toMatch(/^BEGIN:VCALENDAR\r\n/)
|
||||
expect(ics).toMatch(/\r\nEND:VCALENDAR\r\n$/)
|
||||
})
|
||||
|
||||
it('includes VERSION and PRODID', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toContain('VERSION:2.0\r\n')
|
||||
expect(ics).toContain('PRODID:-//fete//EN\r\n')
|
||||
})
|
||||
|
||||
it('generates valid VEVENT block', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toContain('BEGIN:VEVENT\r\n')
|
||||
expect(ics).toContain('END:VEVENT\r\n')
|
||||
})
|
||||
|
||||
it('sets UID from eventToken', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toContain('UID:550e8400-e29b-41d4-a716-446655440000@fete\r\n')
|
||||
})
|
||||
|
||||
it('sets DTSTART in UTC format', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toContain('DTSTART:20260715T160000Z\r\n')
|
||||
})
|
||||
|
||||
it('does NOT include DTEND or DURATION', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).not.toContain('DTEND')
|
||||
expect(ics).not.toContain('DURATION')
|
||||
})
|
||||
|
||||
it('sets SUMMARY from title', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toContain('SUMMARY:Sommerfest am See\r\n')
|
||||
})
|
||||
|
||||
it('sets LOCATION when present', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toContain('LOCATION:Stadtpark Berlin\r\n')
|
||||
})
|
||||
|
||||
it('sets DESCRIPTION when present', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toContain('DESCRIPTION:Bring your own drinks\r\n')
|
||||
})
|
||||
|
||||
it('omits LOCATION when not provided', () => {
|
||||
const { location: _location, ...noLocation } = baseEvent
|
||||
const ics = generateIcs(noLocation)
|
||||
expect(ics).not.toContain('LOCATION')
|
||||
})
|
||||
|
||||
it('omits DESCRIPTION when not provided', () => {
|
||||
const { description: _description, ...noDesc } = baseEvent
|
||||
const ics = generateIcs(noDesc)
|
||||
expect(ics).not.toContain('DESCRIPTION')
|
||||
})
|
||||
|
||||
it('includes SEQUENCE:0', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toContain('SEQUENCE:0\r\n')
|
||||
})
|
||||
|
||||
it('includes DTSTAMP in UTC format', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
expect(ics).toMatch(/DTSTAMP:\d{8}T\d{6}Z\r\n/)
|
||||
})
|
||||
|
||||
it('escapes commas in text fields', () => {
|
||||
const ics = generateIcs({ ...baseEvent, title: 'Hello, World' })
|
||||
expect(ics).toContain('SUMMARY:Hello\\, World\r\n')
|
||||
})
|
||||
|
||||
it('escapes semicolons in text fields', () => {
|
||||
const ics = generateIcs({ ...baseEvent, description: 'foo; bar' })
|
||||
expect(ics).toContain('DESCRIPTION:foo\\; bar\r\n')
|
||||
})
|
||||
|
||||
it('escapes backslashes in text fields', () => {
|
||||
const ics = generateIcs({ ...baseEvent, title: 'path\\to' })
|
||||
expect(ics).toContain('SUMMARY:path\\\\to\r\n')
|
||||
})
|
||||
|
||||
it('escapes newlines in text fields', () => {
|
||||
const ics = generateIcs({ ...baseEvent, description: 'line1\nline2' })
|
||||
expect(ics).toContain('DESCRIPTION:line1\\nline2\r\n')
|
||||
})
|
||||
|
||||
it('produces deterministic output for the same input', () => {
|
||||
const ics1 = generateIcs(baseEvent)
|
||||
const ics2 = generateIcs(baseEvent)
|
||||
// DTSTAMP changes with time, so strip it for comparison
|
||||
const strip = (s: string) => s.replace(/DTSTAMP:\d{8}T\d{6}Z\r\n/, '')
|
||||
expect(strip(ics1)).toBe(strip(ics2))
|
||||
})
|
||||
|
||||
it('uses CRLF line endings throughout', () => {
|
||||
const ics = generateIcs(baseEvent)
|
||||
const lines = ics.split('\r\n')
|
||||
// Every "line" split by CRLF should not contain a bare LF
|
||||
for (const line of lines) {
|
||||
expect(line).not.toContain('\n')
|
||||
}
|
||||
})
|
||||
})
|
||||
71
frontend/src/composables/useIcalDownload.ts
Normal file
71
frontend/src/composables/useIcalDownload.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { slugify } from '@/utils/slugify'
|
||||
|
||||
export interface IcalEvent {
|
||||
eventToken: string
|
||||
title: string
|
||||
dateTime: string
|
||||
location?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
function escapeText(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\n/g, '\\n')
|
||||
}
|
||||
|
||||
function toUtcString(isoDateTime: string): string {
|
||||
const d = new Date(isoDateTime)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return (
|
||||
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
|
||||
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`
|
||||
)
|
||||
}
|
||||
|
||||
export function generateIcs(event: IcalEvent): string {
|
||||
const lines: string[] = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//fete//EN',
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${event.eventToken}@fete`,
|
||||
`DTSTAMP:${toUtcString(new Date().toISOString())}`,
|
||||
`DTSTART:${toUtcString(event.dateTime)}`,
|
||||
`SUMMARY:${escapeText(event.title)}`,
|
||||
'SEQUENCE:0',
|
||||
]
|
||||
|
||||
if (event.location) {
|
||||
lines.push(`LOCATION:${escapeText(event.location)}`)
|
||||
}
|
||||
|
||||
if (event.description) {
|
||||
lines.push(`DESCRIPTION:${escapeText(event.description)}`)
|
||||
}
|
||||
|
||||
lines.push('END:VEVENT', 'END:VCALENDAR', '')
|
||||
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
export function useIcalDownload() {
|
||||
function download(event: IcalEvent) {
|
||||
const ics = generateIcs(event)
|
||||
const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
const filename = `${slugify(event.title) || 'event'}.ics`
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return { download }
|
||||
}
|
||||
69
frontend/src/utils/__tests__/slugify.spec.ts
Normal file
69
frontend/src/utils/__tests__/slugify.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { slugify } from '../slugify'
|
||||
|
||||
describe('slugify', () => {
|
||||
it('converts to lowercase', () => {
|
||||
expect(slugify('Hello World')).toBe('hello-world')
|
||||
})
|
||||
|
||||
it('replaces spaces with hyphens', () => {
|
||||
expect(slugify('foo bar baz')).toBe('foo-bar-baz')
|
||||
})
|
||||
|
||||
it('transliterates German umlauts', () => {
|
||||
expect(slugify('Ärger über Öl füßen')).toBe('aerger-ueber-oel-fuessen')
|
||||
})
|
||||
|
||||
it('transliterates uppercase umlauts', () => {
|
||||
expect(slugify('Ä Ö Ü')).toBe('ae-oe-ue')
|
||||
})
|
||||
|
||||
it('transliterates ß', () => {
|
||||
expect(slugify('Straße')).toBe('strasse')
|
||||
})
|
||||
|
||||
it('removes non-ASCII characters after transliteration', () => {
|
||||
expect(slugify('Café résumé')).toBe('caf-rsum')
|
||||
})
|
||||
|
||||
it('replaces special characters with hyphens', () => {
|
||||
expect(slugify('hello@world! #test')).toBe('hello-world-test')
|
||||
})
|
||||
|
||||
it('collapses consecutive hyphens', () => {
|
||||
expect(slugify('foo---bar')).toBe('foo-bar')
|
||||
})
|
||||
|
||||
it('trims leading and trailing hyphens', () => {
|
||||
expect(slugify('--hello--')).toBe('hello')
|
||||
})
|
||||
|
||||
it('truncates to 60 characters', () => {
|
||||
const long = 'a'.repeat(80)
|
||||
expect(slugify(long).length).toBeLessThanOrEqual(60)
|
||||
})
|
||||
|
||||
it('does not break mid-word when truncating', () => {
|
||||
// 60 chars of 'a' should just be 60 a's (no word boundary issue)
|
||||
const result = slugify('a'.repeat(65))
|
||||
expect(result.length).toBe(60)
|
||||
expect(result).toBe('a'.repeat(60))
|
||||
})
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(slugify('')).toBe('')
|
||||
})
|
||||
|
||||
it('handles string that becomes empty after processing', () => {
|
||||
expect(slugify('!@#$%')).toBe('')
|
||||
})
|
||||
|
||||
it('handles emoji', () => {
|
||||
const result = slugify('Party 🎉 time')
|
||||
expect(result).toBe('party-time')
|
||||
})
|
||||
|
||||
it('produces Sommerfest am See example from spec', () => {
|
||||
expect(slugify('Sommerfest am See')).toBe('sommerfest-am-see')
|
||||
})
|
||||
})
|
||||
28
frontend/src/utils/slugify.ts
Normal file
28
frontend/src/utils/slugify.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
const UMLAUT_MAP: Record<string, string> = {
|
||||
ä: 'ae',
|
||||
ö: 'oe',
|
||||
ü: 'ue',
|
||||
ß: 'ss',
|
||||
Ä: 'Ae',
|
||||
Ö: 'Oe',
|
||||
Ü: 'Ue',
|
||||
}
|
||||
|
||||
export function slugify(input: string): string {
|
||||
return (
|
||||
input
|
||||
// Transliterate German umlauts
|
||||
.replace(/[äöüßÄÖÜ]/g, (ch) => UMLAUT_MAP[ch] ?? ch)
|
||||
.toLowerCase()
|
||||
// Remove non-ASCII characters
|
||||
.replace(/[^\x20-\x7E]/g, '')
|
||||
// Replace non-alphanumeric characters with hyphens
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
// Collapse consecutive hyphens
|
||||
.replace(/-{2,}/g, '-')
|
||||
// Trim leading/trailing hyphens
|
||||
.replace(/^-|-$/g, '')
|
||||
// Truncate to 60 characters
|
||||
.slice(0, 60)
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,33 @@
|
||||
<div class="detail__hero-overlay" />
|
||||
</div>
|
||||
|
||||
<!-- Kebab menu (teleported into app header) -->
|
||||
<Teleport to="#header-actions">
|
||||
<div v-if="state === 'loaded' && event && isOrganizer && !event.cancelled" class="detail__kebab-wrapper">
|
||||
<button
|
||||
class="detail__kebab-btn"
|
||||
type="button"
|
||||
aria-label="Event actions"
|
||||
:aria-expanded="kebabOpen"
|
||||
@click="kebabOpen = !kebabOpen"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>
|
||||
</button>
|
||||
<Transition name="kebab-menu">
|
||||
<div v-if="kebabOpen" class="detail__kebab-menu" role="menu">
|
||||
<button
|
||||
class="detail__kebab-item detail__kebab-item--danger"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click="kebabOpen = false; cancelSheetOpen = true"
|
||||
>
|
||||
Cancel event
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div class="detail__body">
|
||||
<!-- Loading state -->
|
||||
<div v-if="state === 'loading'" class="detail__content" aria-busy="true" aria-label="Loading event details">
|
||||
@@ -72,11 +99,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel event button (organizer only, not already cancelled) -->
|
||||
<div v-if="state === 'loaded' && event && isOrganizer && !event.cancelled" class="detail__cancel-event">
|
||||
<button class="detail__cancel-event-btn" type="button" @click="cancelSheetOpen = true">
|
||||
Cancel event
|
||||
</button>
|
||||
<!-- Organizer bottom bar (not cancelled) -->
|
||||
<div v-if="state === 'loaded' && event && isOrganizer && !event.cancelled" class="detail__organizer-bar">
|
||||
<div class="detail__organizer-bar-inner">
|
||||
<div class="bar-cta glow-border glow-border--animated">
|
||||
<button class="bar-cta-btn glass-inner" type="button">
|
||||
Post an update
|
||||
</button>
|
||||
</div>
|
||||
<div class="bar-icon glow-border glow-border--animated">
|
||||
<button
|
||||
class="bar-icon-btn glass-inner"
|
||||
type="button"
|
||||
aria-label="Add to calendar"
|
||||
@click="handleCalendarDownload"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel event bottom sheet -->
|
||||
@@ -120,6 +161,7 @@
|
||||
@open="sheetOpen = true"
|
||||
@cancel="confirmCancelOpen = true"
|
||||
@bookmark="handleBookmarkClick"
|
||||
@calendar="handleCalendarDownload"
|
||||
/>
|
||||
|
||||
<!-- Cancel confirmation dialog -->
|
||||
@@ -163,10 +205,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api } from '@/api/client'
|
||||
import { useEventStorage } from '@/composables/useEventStorage'
|
||||
import { useIcalDownload } from '@/composables/useIcalDownload'
|
||||
import AttendeeList from '@/components/AttendeeList.vue'
|
||||
import BottomSheet from '@/components/BottomSheet.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
@@ -178,6 +221,7 @@ type State = 'loading' | 'loaded' | 'not-found' | 'error'
|
||||
|
||||
const route = useRoute()
|
||||
const { saveRsvp, getRsvp, removeRsvp, getOrganizerToken, saveWatch, isStored, removeEvent } = useEventStorage()
|
||||
const { download: downloadIcal } = useIcalDownload()
|
||||
|
||||
const state = ref<State>('loading')
|
||||
const event = ref<GetEventResponse | null>(null)
|
||||
@@ -194,6 +238,24 @@ const cancelError = ref('')
|
||||
const isOrganizer = ref(false)
|
||||
const attendeeNames = ref<string[] | null>(null)
|
||||
|
||||
// Kebab menu state
|
||||
const kebabOpen = ref(false)
|
||||
|
||||
function onKebabClickOutside(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.detail__kebab-wrapper')) {
|
||||
kebabOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(kebabOpen, (isOpen) => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('click', onKebabClickOutside, { capture: true })
|
||||
} else {
|
||||
document.removeEventListener('click', onKebabClickOutside, { capture: true })
|
||||
}
|
||||
})
|
||||
|
||||
// Cancel event state
|
||||
const cancelSheetOpen = ref(false)
|
||||
const cancelReasonInput = ref('')
|
||||
@@ -204,6 +266,17 @@ const eventToken = computed(() => route.params.eventToken as string)
|
||||
|
||||
const eventIsStored = computed(() => isStored(eventToken.value))
|
||||
|
||||
function handleCalendarDownload() {
|
||||
if (!event.value) return
|
||||
downloadIcal({
|
||||
eventToken: event.value.eventToken,
|
||||
title: event.value.title,
|
||||
dateTime: event.value.dateTime,
|
||||
location: event.value.location,
|
||||
description: event.value.description,
|
||||
})
|
||||
}
|
||||
|
||||
function handleBookmarkClick() {
|
||||
if (!event.value) return
|
||||
if (isOrganizer.value || rsvpName.value) return
|
||||
@@ -496,7 +569,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 {
|
||||
@@ -621,37 +693,97 @@ onMounted(fetchEvent)
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Cancel event button */
|
||||
.detail__cancel-event {
|
||||
/* Kebab menu (teleported into app header) */
|
||||
.detail__kebab-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detail__kebab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-on-gradient);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.detail__kebab-btn:hover {
|
||||
background: var(--color-glass-hover);
|
||||
}
|
||||
|
||||
.detail__kebab-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--spacing-xs));
|
||||
right: 0;
|
||||
min-width: 180px;
|
||||
padding: var(--spacing-xs) 0;
|
||||
border-radius: var(--radius-card);
|
||||
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);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.detail__kebab-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-on-gradient);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.detail__kebab-item:hover {
|
||||
background: var(--color-glass-hover);
|
||||
}
|
||||
|
||||
.detail__kebab-item--danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.kebab-menu-enter-active,
|
||||
.kebab-menu-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.kebab-menu-enter-from,
|
||||
.kebab-menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* Organizer bottom bar — mirrors RsvpBar layout */
|
||||
.detail__organizer-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: var(--spacing-md) var(--content-padding);
|
||||
padding-bottom: calc(var(--spacing-md) + env(safe-area-inset-bottom, 0px));
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
padding: var(--spacing-md) var(--content-padding);
|
||||
padding-bottom: calc(var(--spacing-md) + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.detail__cancel-event-btn {
|
||||
.detail__organizer-bar-inner {
|
||||
width: 100%;
|
||||
max-width: var(--content-max-width);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: var(--radius-button);
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-danger);
|
||||
background: var(--color-danger-bg);
|
||||
border: 1px solid var(--color-danger-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.detail__cancel-event-btn:hover {
|
||||
background: var(--color-danger-bg-hover);
|
||||
}
|
||||
|
||||
/* Cancel event form (inside bottom sheet) */
|
||||
.cancel-form__textarea {
|
||||
|
||||
@@ -77,6 +77,13 @@ beforeEach(() => {
|
||||
mockIsStored.mockReturnValue(false)
|
||||
mockSaveWatch.mockClear()
|
||||
mockRemoveEvent.mockClear()
|
||||
|
||||
// Provide Teleport target for kebab menu
|
||||
if (!document.getElementById('header-actions')) {
|
||||
const target = document.createElement('div')
|
||||
target.id = 'header-actions'
|
||||
document.body.appendChild(target)
|
||||
}
|
||||
})
|
||||
|
||||
describe('EventDetailView', () => {
|
||||
@@ -197,8 +204,8 @@ describe('EventDetailView', () => {
|
||||
const wrapper = await mountWithToken()
|
||||
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('.bar-cta').exists()).toBe(true)
|
||||
expect(wrapper.find('.bar-cta').text()).toBe("I'm attending!")
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -210,7 +217,6 @@ describe('EventDetailView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.rsvp-bar').exists()).toBe(false)
|
||||
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -223,7 +229,7 @@ describe('EventDetailView', () => {
|
||||
|
||||
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(true)
|
||||
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
|
||||
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
||||
expect(wrapper.find('.bar-cta').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -236,7 +242,7 @@ describe('EventDetailView', () => {
|
||||
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
|
||||
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||
await wrapper.find('.bar-cta-btn').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull()
|
||||
@@ -249,7 +255,7 @@ describe('EventDetailView', () => {
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||
await wrapper.find('.bar-cta-btn').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// Form is inside Teleport — find via document.body
|
||||
@@ -274,7 +280,7 @@ describe('EventDetailView', () => {
|
||||
await flushPromises()
|
||||
|
||||
// Open sheet
|
||||
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||
await wrapper.find('.bar-cta-btn').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// Fill name via Teleported input
|
||||
@@ -305,7 +311,7 @@ describe('EventDetailView', () => {
|
||||
|
||||
// Verify UI switched to status
|
||||
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
|
||||
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
||||
expect(wrapper.find('.bar-cta').exists()).toBe(false)
|
||||
|
||||
// Verify attendee count incremented
|
||||
expect(wrapper.text()).toContain('13')
|
||||
@@ -360,7 +366,7 @@ describe('EventDetailView', () => {
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||
await wrapper.find('.bar-cta-btn').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement
|
||||
|
||||
35
specs/018-cancel-event-list/checklists/requirements.md
Normal file
35
specs/018-cancel-event-list/checklists/requirements.md
Normal 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.
|
||||
35
specs/018-cancel-event-list/data-model.md
Normal file
35
specs/018-cancel-event-list/data-model.md
Normal 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) |
|
||||
69
specs/018-cancel-event-list/plan.md
Normal file
69
specs/018-cancel-event-list/plan.md
Normal 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.
|
||||
39
specs/018-cancel-event-list/research.md
Normal file
39
specs/018-cancel-event-list/research.md
Normal 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.
|
||||
88
specs/018-cancel-event-list/spec.md
Normal file
88
specs/018-cancel-event-list/spec.md
Normal 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.
|
||||
150
specs/018-cancel-event-list/tasks.md
Normal file
150
specs/018-cancel-event-list/tasks.md
Normal 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
|
||||
|
||||
- T001–T006 (US1 tests): All parallelizable — different test scenarios, same files but independent
|
||||
- T011–T014 (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 (T001–T006) — all should fail (red)
|
||||
2. Implement US1 (T007–T010) — 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
|
||||
36
specs/019-ical-download/checklists/requirements.md
Normal file
36
specs/019-ical-download/checklists/requirements.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: iCal Download
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-13
|
||||
**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 mentions "RFC 5545" and "Blob" which are standard/format references, not implementation details.
|
||||
- FR-004 (SEQUENCE number) depends on whether the backend exposes an update counter or timestamp — documented as assumption.
|
||||
- Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||
95
specs/019-ical-download/plan.md
Normal file
95
specs/019-ical-download/plan.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Implementation Plan: iCal Download
|
||||
|
||||
**Branch**: `019-ical-download` | **Date**: 2026-03-13 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/019-ical-download/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add a calendar download button to the event detail page that generates RFC 5545-compliant `.ics` files client-side. The button appears in the RsvpBar for all non-organizer users (not shown for cancelled events). No backend changes are required — all event data is already available in the frontend after fetching event details.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.x, Vue 3 (Composition API)
|
||||
**Primary Dependencies**: None new — uses existing Vue 3, openapi-fetch stack. iCal generation is hand-rolled (RFC 5545 is simple enough; no library needed).
|
||||
**Storage**: N/A (no persistence; generates file on demand)
|
||||
**Testing**: Vitest (unit tests for iCal generation + slug utility), Playwright + MSW (E2E for button behavior)
|
||||
**Target Platform**: PWA, mobile-first (320px–768px), all modern browsers
|
||||
**Project Type**: Web application (frontend-only change)
|
||||
**Performance Goals**: Instant download (< 50ms generation time, all client-side)
|
||||
**Constraints**: No external dependencies, no backend changes, UTF-8 encoded output
|
||||
**Scale/Scope**: 1 new composable, 1 utility, modifications to 2 existing components
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Privacy by Design | ✅ PASS | Client-side only, no data sent to external services, no tracking |
|
||||
| II. Test-Driven Methodology | ✅ PLAN | Unit tests for iCal generation + slug utility, E2E for button UX |
|
||||
| III. API-First Development | ✅ N/A | No new API endpoints — uses existing `GetEventResponse` data |
|
||||
| IV. Simplicity & Quality | ✅ PLAN | Hand-rolled iCal (no library for ~40 lines of format code), minimal changes to existing components |
|
||||
| V. Dependency Discipline | ✅ PASS | Zero new dependencies |
|
||||
| VI. Accessibility | ✅ PLAN | Aria labels on calendar button, keyboard navigable, WCAG AA contrast |
|
||||
|
||||
**Gate result**: PASS — no violations.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/019-ical-download/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
└── tasks.md # Phase 2 output (via /speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
frontend/src/
|
||||
├── composables/
|
||||
│ └── useIcalDownload.ts # NEW: iCal generation + download trigger
|
||||
├── utils/
|
||||
│ └── slugify.ts # NEW: ASCII slug for filename
|
||||
├── components/
|
||||
│ └── RsvpBar.vue # MODIFIED: add calendar button (2 visual states)
|
||||
└── views/
|
||||
└── EventDetailView.vue # MODIFIED: pass event data, handle calendar emit
|
||||
```
|
||||
|
||||
**Structure Decision**: Frontend-only changes. New composable for iCal logic (consistent with project pattern: `useEventStorage`, `useRelativeTime`). Slug utility in `utils/` since it's a pure function with no Vue reactivity.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### D1: No iCal library
|
||||
|
||||
**Decision**: Hand-roll iCal generation (~40 lines).
|
||||
|
||||
**Rationale**: RFC 5545 VEVENT with 8–10 properties is trivial. Adding a library (e.g., `ical-generator`, `ics`) would violate Principle V (dependency discipline) — we'd use < 5% of its features.
|
||||
|
||||
### D2: Calendar button visual states
|
||||
|
||||
Per FR-006, the calendar button has 2 visual contexts:
|
||||
|
||||
| State | Layout | Button Style |
|
||||
|-------|--------|-------------|
|
||||
| Before RSVP | Row: [bookmark] [CTA] [calendar] | glow-border + glass-inner (matches bookmark) |
|
||||
| After RSVP | Row: [status-bar (flex)] [calendar (fixed)] | glassmorphic bar style (matches status bar) |
|
||||
|
||||
The button is not shown for cancelled events (RsvpBar remains hidden when `event.cancelled`).
|
||||
|
||||
### D3: UID format
|
||||
|
||||
**Decision**: `{eventToken}@fete` — stable across re-downloads, enables calendar deduplication per FR-003.
|
||||
|
||||
### D4: SEQUENCE strategy
|
||||
|
||||
**Decision**: Always `0`. Per FR-004, a proper version counter requires backend changes (future scope).
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations to justify.
|
||||
80
specs/019-ical-download/spec.md
Normal file
80
specs/019-ical-download/spec.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Feature Specification: iCal Download
|
||||
|
||||
**Feature Branch**: `019-ical-download`
|
||||
**Created**: 2026-03-13
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Add iCal (.ics) calendar download button to event detail page"
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Download event as calendar file (Priority: P1)
|
||||
|
||||
As a user viewing an event, I want to add it to my personal calendar so I don't forget the date and time.
|
||||
|
||||
The user taps a calendar icon button in the bottom action bar. The browser downloads a `.ics` file containing the current event details. The user opens the file in their preferred calendar app (Apple Calendar, Google Calendar, Outlook, Thunderbird, etc.) and the event appears in their calendar.
|
||||
|
||||
**Why this priority**: Core value of the feature — getting the event into the user's calendar.
|
||||
|
||||
**Independent Test**: Can be fully tested by viewing an event, tapping the calendar button, and verifying the downloaded .ics file opens correctly in a calendar app.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user views an active event (as attendee, pre-RSVP visitor, or organizer), **When** they tap the calendar icon in the action bar, **Then** a valid `.ics` file is downloaded containing the event's title, date/time, location, and description.
|
||||
2. **Given** the event is cancelled, **When** the user views the event detail page, **Then** the calendar button is NOT shown.
|
||||
3. **Given** the downloaded `.ics` file, **When** opened in any major calendar app, **Then** the event is created without errors.
|
||||
4. **Given** the user downloads the `.ics` file multiple times, **Then** the generated file is identical each time (deterministic output from the same event data).
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the event has no location set? The `.ics` file omits the location field.
|
||||
- What happens when the event has no description? The `.ics` file omits the description field.
|
||||
- What happens when event title or description contains special characters (umlauts, emoji, newlines)? The `.ics` file uses proper UTF-8 encoding and RFC 5545 text escaping.
|
||||
- What happens on a browser that blocks Blob downloads? The download should work via standard browser download mechanisms; no special fallback is needed since all modern browsers support Blob downloads.
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-03-13
|
||||
|
||||
- Q: Event hat kein Endzeit-Feld — wie soll DTEND in der .ics gehandhabt werden? → A: Kein DTEND/DURATION — nur DTSTART (punktuelles Ereignis gemäß RFC 5545).
|
||||
- Q: Dateiname-Sanitierung bei Sonderzeichen, Umlauten, langen Titeln? → A: Slugify — ASCII-Transliteration (ä→ae etc.), Leerzeichen→Bindestrich, max 60 Zeichen.
|
||||
- Q: Gibt es ein Konzept von "aktualisierten" .ics-Dateien? → A: Nein. Jeder Download erzeugt die gleiche Datei aus den aktuellen Event-Daten. Kein Update-Mechanismus.
|
||||
- Q: Button bei abgesagten Events? → A: Nein, kein Button wenn Event cancelled.
|
||||
- Q: Nur für Attendees oder auch für Organisatoren? → A: Alle Rollen — Attendee, Besucher ohne RSVP, Organisator. Button an gleicher Position.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST generate a valid `.ics` file (RFC 5545 VEVENT) client-side without requiring backend changes.
|
||||
- **FR-002**: The `.ics` file MUST contain: UID, DTSTAMP, DTSTART, SUMMARY. DTEND and DURATION MUST NOT be included (the event has no end time; RFC 5545 treats a VEVENT with only DTSTART as a point-in-time event). It MUST include LOCATION and DESCRIPTION when those fields are present on the event.
|
||||
- **FR-003**: The UID MUST be derived from the event token (e.g. `{eventToken}@fete`) to produce a stable identifier for the calendar entry.
|
||||
- **FR-004**: The `.ics` file MUST include a SEQUENCE number of `0`.
|
||||
- **FR-006**: The calendar icon button MUST appear in the bottom action bar for all users (attendees, pre-RSVP visitors, and organizers), adapting its visual style to match the surrounding elements:
|
||||
- **Before RSVP (attendee)**: Button order (left to right): bookmark, "I'm attending!" CTA, calendar. The calendar button MUST use the same glow-border + glass-inner style as the bookmark button.
|
||||
- **After RSVP (attendee)**: The calendar button MUST appear to the right of the "You're attending!" status bar. It MUST use the same glassmorphic bar style (gradient background, glass border, backdrop blur) as the status bar — not the glow-border style. Layout: "You're attending!" status (flex), calendar icon button (fixed width).
|
||||
- **Organizer**: The calendar button MUST appear in the same fixed bottom position. Styling TBD (consistent with existing organizer UI).
|
||||
- **FR-007**: The calendar button MUST NOT be shown when the event is cancelled.
|
||||
- **FR-008**: The downloaded file MUST use UTF-8 encoding and the `text/calendar` MIME type.
|
||||
- **FR-009**: The filename MUST be human-readable, derived from the event title using ASCII slugification (e.g. `Sommerfest am See` → `sommerfest-am-see.ics`). Rules: lowercase, umlauts transliterated (ä→ae, ü→ue, ö→oe, ß→ss), non-ASCII characters removed, spaces/special chars replaced with hyphens, consecutive hyphens collapsed, max 60 characters before `.ics` extension.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **iCal Event (VEVENT)**: A calendar entry generated from fete event data. Key attributes: UID (from event token), SUMMARY (title), DTSTART (date/time, no DTEND — point-in-time event), LOCATION, DESCRIPTION, SEQUENCE, DTSTAMP.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Any user (attendee, visitor, organizer) can download a calendar file with 1 tap.
|
||||
- **SC-002**: Downloaded `.ics` files import successfully into Apple Calendar, Google Calendar, and Outlook without errors.
|
||||
- **SC-003**: The calendar button does not disrupt the existing bottom bar layout or interaction patterns on devices from 320px to 768px width.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The `.ics` file generation is entirely client-side (no new backend endpoints needed), since all required event data is already available in the frontend after fetching event details.
|
||||
- The generated `.ics` file is deterministic: same event data always produces the same output. There is no concept of "updated" files — each download is a fresh snapshot of the current event data.
|
||||
- SEQUENCE is always `0`.
|
||||
- The calendar button is visible for all user roles (attendee, visitor, organizer) on active events. Not shown for cancelled events.
|
||||
- All date/time values in the `.ics` file use UTC format (Z suffix) since the event times are already stored in UTC.
|
||||
109
specs/019-ical-download/tasks.md
Normal file
109
specs/019-ical-download/tasks.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Tasks: iCal Download
|
||||
|
||||
**Input**: Design documents from `/specs/019-ical-download/`
|
||||
**Prerequisites**: plan.md, spec.md
|
||||
|
||||
**Tests**: TDD is mandated by the project constitution. Tests are written first and must fail before implementation.
|
||||
|
||||
**Organization**: Single user story (US1). Foundational phase covers the two pure utility modules; US1 phase covers component integration.
|
||||
|
||||
## 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)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundational (Pure Utilities)
|
||||
|
||||
**Purpose**: iCal generation and slug utility — pure functions with no UI dependencies
|
||||
|
||||
### Tests (RED)
|
||||
|
||||
- [x] T001 [P] Write unit tests for slugify (umlaut transliteration, special chars, max length, empty input) in `frontend/src/utils/__tests__/slugify.spec.ts`
|
||||
- [x] T002 [P] Write unit tests for iCal generation (required fields, optional LOCATION/DESCRIPTION omission, UTF-8 text escaping, UID format, deterministic output, DTSTAMP) in `frontend/src/composables/__tests__/useIcalDownload.spec.ts`
|
||||
|
||||
### Implementation (GREEN)
|
||||
|
||||
- [x] T003 Implement slugify utility in `frontend/src/utils/slugify.ts` — ASCII transliteration (ä→ae, ü→ue, ö→oe, ß→ss), lowercase, non-ASCII removal, hyphens for spaces/special chars, collapse consecutive hyphens, max 60 chars
|
||||
- [x] T004 Implement `generateIcs()` function and `useIcalDownload()` composable in `frontend/src/composables/useIcalDownload.ts` — RFC 5545 VEVENT with UID (`{eventToken}@fete`), DTSTAMP, DTSTART (UTC), SUMMARY, SEQUENCE:0, optional LOCATION/DESCRIPTION, Blob download with `text/calendar` MIME type, slugified filename
|
||||
|
||||
**Checkpoint**: `npm run test:unit` passes — both utilities work in isolation
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 1 — Download event as calendar file (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Calendar icon button in the bottom action bar for all user roles (attendee pre-RSVP, attendee post-RSVP, organizer). Tap triggers `.ics` download. Not shown for cancelled events.
|
||||
|
||||
**Independent Test**: View any active event → tap calendar button → `.ics` file downloads → opens in calendar app.
|
||||
|
||||
### Tests (RED)
|
||||
|
||||
- [x] T005 Write E2E test for calendar download button in `frontend/e2e/ical-download.spec.ts` — verify button visible for pre-RSVP visitor, post-RSVP attendee, and organizer; verify button NOT visible for cancelled event; verify download triggers with correct filename
|
||||
|
||||
### Implementation (GREEN)
|
||||
|
||||
- [x] T006 [US1] Add calendar button and `calendar` emit to RsvpBar in `frontend/src/components/RsvpBar.vue` — pre-RSVP state: glow-border + glass-inner icon button after CTA; post-RSVP state: glassmorphic icon button right of status bar
|
||||
- [x] T007 [US1] Add calendar button for organizer view in `frontend/src/views/EventDetailView.vue` — fixed bottom position next to existing "Cancel event" button, consistent glassmorphic styling
|
||||
- [x] T008 [US1] Wire calendar download handler in `frontend/src/views/EventDetailView.vue` — import `useIcalDownload`, call on `@calendar` emit from RsvpBar and on organizer button click, pass event data
|
||||
|
||||
**Checkpoint**: All acceptance scenarios pass — any user on an active event can download a valid `.ics` file with 1 tap
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [ ] T009 Verify calendar button layout does not disrupt existing RsvpBar on 320px–768px viewports (visual check via `browser-interactive-testing` skill)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Foundational (Phase 1)**: No dependencies — can start immediately
|
||||
- **US1 (Phase 2)**: Depends on Phase 1 completion (T003, T004 must be done)
|
||||
- **Polish (Phase 3)**: Depends on Phase 2 completion
|
||||
|
||||
### Within Phases
|
||||
|
||||
- T001 and T002 are parallel (different files)
|
||||
- T003 before T004 (`useIcalDownload` imports `slugify`)
|
||||
- T005 can be written before T006–T008 (TDD: test fails first)
|
||||
- T006 and T007 are parallel (different files)
|
||||
- T008 depends on T006 and T007 (wires them together)
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
```text
|
||||
# Phase 1 tests (parallel):
|
||||
T001: slugify unit tests
|
||||
T002: iCal generation unit tests
|
||||
|
||||
# Phase 2 implementation (parallel after T005):
|
||||
T006: RsvpBar calendar button
|
||||
T007: Organizer calendar button
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP (Single Pass)
|
||||
|
||||
1. Complete Phase 1: Write tests → implement slugify → implement iCal generation
|
||||
2. Complete Phase 2: Write E2E → add buttons to RsvpBar + organizer view → wire handler
|
||||
3. Complete Phase 3: Visual verification
|
||||
4. **DONE**: Single user story, single deliverable
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- No backend changes required — all client-side
|
||||
- Zero new dependencies — hand-rolled iCal generation
|
||||
- `generateIcs()` must be a pure function (deterministic, no side effects) for easy testing
|
||||
- `useIcalDownload()` wraps `generateIcs()` + Blob download trigger
|
||||
- Calendar SVG icon: use a calendar outline matching the existing date/time meta icon style
|
||||
Reference in New Issue
Block a user