44 Commits

Author SHA1 Message Date
Renovate Bot
b7ee680885 Update dependency msw to v2.12.14
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 27s
CI / frontend-e2e (push) Successful in 1m38s
CI / build-and-publish (push) Has been skipped
2026-03-21 02:02:38 +00:00
b12106d3bf Merge pull request 'Add iCal download for calendar integration' (#43) from 019-ical-download into master
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 28s
CI / frontend-e2e (push) Successful in 1m37s
CI / build-and-publish (push) Successful in 1m0s
2026-03-14 11:40:42 +01:00
d0ed6790ef Update E2E tests for kebab menu and add iCal download tests
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 27s
CI / frontend-e2e (push) Successful in 1m38s
CI / build-and-publish (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:40:51 +01:00
92372b6a59 Add organizer kebab menu, bottom bar, and iCal download integration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:40:40 +01:00
7817ad182b Unify header as fixed top bar with action slot
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:40:22 +01:00
9483e9b1f7 Extract shared bar component CSS and add calendar button to RsvpBar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:40:12 +01:00
75e6548403 Add iCal download composable with RFC 5545 VEVENT generation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:39:54 +01:00
d4a1f0dc23 Add slugify utility for filename sanitization
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:39:41 +01:00
3d7efb14f7 Add iCal download feature spec and clean up implemented ideas
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:39:31 +01:00
2f8b911af8 Fix datetime-local input overflow and invisible text on iOS Safari
All checks were successful
CI / backend-test (push) Successful in 1m2s
CI / frontend-test (push) Successful in 26s
CI / frontend-e2e (push) Successful in 1m36s
CI / build-and-publish (push) Successful in 1m16s
The native datetime-local picker on iOS Safari has an intrinsic min-width
that exceeds the form container, and its webkit pseudo-elements don't
inherit the glass text color, making the selected value invisible.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:32:19 +01:00
c51eacb261 Merge pull request 'Implement watch-event feature (017)' (#39) from 017-watch-event into master
All checks were successful
CI / backend-test (push) Successful in 56s
CI / frontend-test (push) Successful in 26s
CI / frontend-e2e (push) Successful in 1m30s
CI / build-and-publish (push) Has been skipped
2026-03-12 22:25:21 +01:00
c450849e4d Implement watch-event feature (017) with bookmark in RsvpBar
All checks were successful
CI / backend-test (push) Successful in 1m0s
CI / frontend-test (push) Successful in 27s
CI / frontend-e2e (push) Successful in 1m30s
CI / build-and-publish (push) Has been skipped
Add client-side watch/bookmark functionality: users can save events to
localStorage without RSVPing via a bookmark button next to the "I'm attending"
CTA. Watched events appear in the event list with a "Watching" label.
Bookmark is only visible for visitors (not attendees or organizers).

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:20:57 +01:00
e01d5ee642 Merge pull request 'Implement cancel-event feature (016)' (#38) from 016-cancel-event into master
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 38s
CI / frontend-e2e (push) Successful in 1m25s
CI / build-and-publish (push) Successful in 1m31s
2026-03-12 20:42:38 +01:00
d333ab3d39 Refactor domain models to records and move exceptions to sub-package
All checks were successful
CI / backend-test (push) Successful in 1m1s
CI / frontend-test (push) Successful in 26s
CI / frontend-e2e (push) Successful in 1m25s
CI / build-and-publish (push) Has been skipped
- Convert Event and Rsvp from mutable POJOs to Java records
- Move all 8 exception classes to application.service.exception sub-package
- Add ArchUnit rule enforcing domain models must be records

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:09:27 +01:00
541017965f Implement cancel-event feature (016)
Add PATCH /events/{eventToken} endpoint for organizers to cancel events,
cancellation banner for visitors, and RSVP rejection on cancelled events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:52:22 +01:00
981920f004 Merge pull request 'Update dependency eslint-plugin-oxlint to ~1.55.0' (#36) from renovate/oxlint-monorepo into master
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 26s
CI / frontend-e2e (push) Successful in 1m22s
CI / build-and-publish (push) Has been skipped
Reviewed-on: #36
2026-03-12 19:07:29 +01:00
3908c89998 Add spec, plan, and tasks for 016-cancel-event feature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:03:57 +01:00
bf0f4ffb7f Merge pull request 'Rename path parameter {token} to {eventToken}' (#37) from 015-rename-token-to-eventToken into master
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 27s
CI / frontend-e2e (push) Successful in 1m23s
CI / build-and-publish (push) Successful in 1m12s
2026-03-12 18:11:09 +01:00
58043d1507 Rename path parameter {token} to {eventToken} in OpenAPI spec
All checks were successful
CI / backend-test (push) Successful in 57s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m18s
CI / build-and-publish (push) Has been skipped
Aligns the path parameter naming with the value object convention
used throughout the codebase (eventToken, rsvpToken, organizerToken).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:07:44 +01:00
Renovate Bot
264c4ec21f Update dependency eslint-plugin-oxlint to ~1.55.0
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 26s
CI / frontend-e2e (push) Successful in 1m21s
CI / build-and-publish (push) Has been skipped
2026-03-12 17:02:27 +00:00
6d7a55fdb3 Merge pull request 'Update dependency vite to v8' (#31) from renovate/vite-8.x into master
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 27s
CI / frontend-e2e (push) Successful in 1m21s
CI / build-and-publish (push) Has been skipped
Merge pull request 'Update dependency vite to v8' (#31)
2026-03-12 17:55:13 +01:00
a8aacf4ee9 Merge pull request 'Update dependency vitest to v4.1.0' (#33) from renovate/vitest-monorepo into master
Some checks failed
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
CI / backend-test (push) Has been cancelled
Merge pull request 'Update dependency vitest to v4.1.0' (#33)
2026-03-12 17:55:02 +01:00
0a404ecde3 Merge pull request 'Update dependency @vitejs/plugin-vue to v6.0.5' (#32) from renovate/vitejs-plugin-vue-6.x-lockfile into master
Some checks failed
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
CI / backend-test (push) Has been cancelled
Merge pull request 'Update dependency @vitejs/plugin-vue to v6.0.5' (#32)
2026-03-12 17:54:54 +01:00
01f9e3dac1 Merge pull request 'Update dependency @vitest/eslint-plugin to v1.6.11' (#34) from renovate/vitest-eslint-plugin-1.x-lockfile into master
Some checks failed
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
CI / backend-test (push) Has been cancelled
Merge pull request 'Update dependency @vitest/eslint-plugin to v1.6.11' (#34)
2026-03-12 17:54:47 +01:00
Renovate Bot
7477a953c5 Update dependency @vitest/eslint-plugin to v1.6.11
All checks were successful
CI / backend-test (push) Successful in 1m1s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m9s
CI / build-and-publish (push) Has been skipped
2026-03-12 16:02:57 +00:00
Renovate Bot
7fb296b47f Update dependency vitest to v4.1.0
All checks were successful
CI / backend-test (push) Successful in 1m0s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m10s
CI / build-and-publish (push) Has been skipped
2026-03-12 15:02:35 +00:00
Renovate Bot
8ab7d345c8 Update dependency @vitejs/plugin-vue to v6.0.5
All checks were successful
CI / backend-test (push) Successful in 56s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m10s
CI / build-and-publish (push) Has been skipped
2026-03-12 15:02:20 +00:00
Renovate Bot
cf2139f229 Update dependency vite to v8
All checks were successful
CI / backend-test (push) Successful in 57s
CI / frontend-test (push) Successful in 27s
CI / frontend-e2e (push) Successful in 1m14s
CI / build-and-publish (push) Has been skipped
2026-03-12 14:02:26 +00:00
89 changed files with 5960 additions and 755 deletions

View File

@@ -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 009026)
### 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,26 +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
### 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?)

View File

@@ -51,12 +51,8 @@ The following skills are available and should be used for their respective purpo
- Project specifications (user stories, setup tasks, personas, etc.) live in `specs/` (feature dirs) and `.specify/memory/` (cross-cutting docs).
## Active Technologies
- Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript (007-view-event)
- PostgreSQL (JPA via Spring Data, Liquibase migrations) (007-view-event)
- TypeScript 5.9 (frontend only) + Vue 3, Vue Router 5 (existing — no additions) (010-event-list-grouping)
- localStorage via `useEventStorage.ts` composable (existing — no changes) (010-event-list-grouping)
- Java 25, Spring Boot 3.5.x + Spring Scheduling (`@Scheduled`), Spring Data JPA (for native query) (013-auto-delete-expired)
- PostgreSQL (existing, Liquibase migrations) (013-auto-delete-expired)
- 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
- 007-view-event: Added Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
- 018-cancel-event-list: Added TypeScript 5.x, Vue 3 (Composition API) + openapi-fetch, Vue Router, Vite

View File

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

View File

@@ -8,8 +8,9 @@ import de.fete.adapter.in.web.model.CreateRsvpRequest;
import de.fete.adapter.in.web.model.CreateRsvpResponse;
import de.fete.adapter.in.web.model.GetAttendeesResponse;
import de.fete.adapter.in.web.model.GetEventResponse;
import de.fete.application.service.EventNotFoundException;
import de.fete.application.service.InvalidTimezoneException;
import de.fete.adapter.in.web.model.PatchEventRequest;
import de.fete.application.service.exception.EventNotFoundException;
import de.fete.application.service.exception.InvalidTimezoneException;
import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
@@ -22,6 +23,7 @@ import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.in.CreateRsvpUseCase;
import de.fete.domain.port.in.GetAttendeesUseCase;
import de.fete.domain.port.in.GetEventUseCase;
import de.fete.domain.port.in.UpdateEventUseCase;
import java.time.DateTimeException;
import java.time.ZoneId;
import java.util.List;
@@ -40,6 +42,7 @@ public class EventController implements EventsApi {
private final CancelRsvpUseCase cancelRsvpUseCase;
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
private final GetAttendeesUseCase getAttendeesUseCase;
private final UpdateEventUseCase updateEventUseCase;
/** Creates a new controller with the given use cases. */
public EventController(
@@ -48,13 +51,15 @@ public class EventController implements EventsApi {
CreateRsvpUseCase createRsvpUseCase,
CancelRsvpUseCase cancelRsvpUseCase,
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
GetAttendeesUseCase getAttendeesUseCase) {
GetAttendeesUseCase getAttendeesUseCase,
UpdateEventUseCase updateEventUseCase) {
this.createEventUseCase = createEventUseCase;
this.getEventUseCase = getEventUseCase;
this.createRsvpUseCase = createRsvpUseCase;
this.cancelRsvpUseCase = cancelRsvpUseCase;
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
this.getAttendeesUseCase = getAttendeesUseCase;
this.updateEventUseCase = updateEventUseCase;
}
@Override
@@ -73,42 +78,55 @@ public class EventController implements EventsApi {
Event event = createEventUseCase.createEvent(command);
var response = new CreateEventResponse();
response.setEventToken(event.getEventToken().value());
response.setOrganizerToken(event.getOrganizerToken().value());
response.setTitle(event.getTitle());
response.setDateTime(event.getDateTime());
response.setTimezone(event.getTimezone().getId());
response.setEventToken(event.eventToken().value());
response.setOrganizerToken(event.organizerToken().value());
response.setTitle(event.title());
response.setDateTime(event.dateTime());
response.setTimezone(event.timezone().getId());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@Override
public ResponseEntity<GetEventResponse> getEvent(UUID token) {
var eventToken = new de.fete.domain.model.EventToken(token);
Event event = getEventUseCase.getByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(token));
public ResponseEntity<GetEventResponse> getEvent(UUID eventToken) {
var evtToken = new EventToken(eventToken);
Event event = getEventUseCase.getByEventToken(evtToken)
.orElseThrow(() -> new EventNotFoundException(eventToken));
var response = new GetEventResponse();
response.setEventToken(event.getEventToken().value());
response.setTitle(event.getTitle());
response.setDescription(event.getDescription());
response.setDateTime(event.getDateTime());
response.setTimezone(event.getTimezone().getId());
response.setLocation(event.getLocation());
response.setEventToken(event.eventToken().value());
response.setTitle(event.title());
response.setDescription(event.description());
response.setDateTime(event.dateTime());
response.setTimezone(event.timezone().getId());
response.setLocation(event.location());
response.setAttendeeCount(
(int) countAttendeesByEventUseCase.countByEvent(eventToken));
(int) countAttendeesByEventUseCase.countByEvent(evtToken));
response.setCancelled(event.cancelled());
response.setCancellationReason(event.cancellationReason());
return ResponseEntity.ok(response);
}
@Override
public ResponseEntity<Void> patchEvent(
UUID eventToken, UUID organizerToken, PatchEventRequest request) {
updateEventUseCase.cancelEvent(
new EventToken(eventToken),
new OrganizerToken(organizerToken),
request.getCancelled(),
request.getCancellationReason());
return ResponseEntity.noContent().build();
}
@Override
public ResponseEntity<GetAttendeesResponse> getAttendees(
UUID token, UUID organizerToken) {
var eventToken = new EventToken(token);
UUID eventToken, UUID organizerToken) {
var evtToken = new EventToken(eventToken);
var orgToken = new OrganizerToken(organizerToken);
List<String> names = getAttendeesUseCase
.getAttendeeNames(eventToken, orgToken);
.getAttendeeNames(evtToken, orgToken);
var attendees = names.stream()
.map(name -> new Attendee().name(name))
@@ -122,20 +140,20 @@ public class EventController implements EventsApi {
@Override
public ResponseEntity<CreateRsvpResponse> createRsvp(
UUID token, CreateRsvpRequest createRsvpRequest) {
var eventToken = new EventToken(token);
Rsvp rsvp = createRsvpUseCase.createRsvp(eventToken, createRsvpRequest.getName());
UUID eventToken, CreateRsvpRequest createRsvpRequest) {
var evtToken = new EventToken(eventToken);
Rsvp rsvp = createRsvpUseCase.createRsvp(evtToken, createRsvpRequest.getName());
var response = new CreateRsvpResponse();
response.setRsvpToken(rsvp.getRsvpToken().value());
response.setName(rsvp.getName());
response.setRsvpToken(rsvp.rsvpToken().value());
response.setName(rsvp.name());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@Override
public ResponseEntity<Void> cancelRsvp(UUID token, UUID rsvpToken) {
cancelRsvpUseCase.cancelRsvp(new EventToken(token), new RsvpToken(rsvpToken));
public ResponseEntity<Void> cancelRsvp(UUID eventToken, UUID rsvpToken) {
cancelRsvpUseCase.cancelRsvp(new EventToken(eventToken), new RsvpToken(rsvpToken));
return ResponseEntity.noContent().build();
}

View File

@@ -1,11 +1,13 @@
package de.fete.adapter.in.web;
import de.fete.application.service.EventExpiredException;
import de.fete.application.service.EventNotFoundException;
import de.fete.application.service.ExpiryDateBeforeEventException;
import de.fete.application.service.ExpiryDateInPastException;
import de.fete.application.service.InvalidOrganizerTokenException;
import de.fete.application.service.InvalidTimezoneException;
import de.fete.application.service.exception.EventAlreadyCancelledException;
import de.fete.application.service.exception.EventCancelledException;
import de.fete.application.service.exception.EventExpiredException;
import de.fete.application.service.exception.EventNotFoundException;
import de.fete.application.service.exception.ExpiryDateBeforeEventException;
import de.fete.application.service.exception.ExpiryDateInPastException;
import de.fete.application.service.exception.InvalidOrganizerTokenException;
import de.fete.application.service.exception.InvalidTimezoneException;
import java.net.URI;
import java.util.List;
import java.util.Map;
@@ -75,6 +77,32 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
.body(problemDetail);
}
/** Handles attempt to cancel an already cancelled event. */
@ExceptionHandler(EventAlreadyCancelledException.class)
public ResponseEntity<ProblemDetail> handleEventAlreadyCancelled(
EventAlreadyCancelledException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, ex.getMessage());
problemDetail.setTitle("Event Already Cancelled");
problemDetail.setType(URI.create("urn:problem-type:event-already-cancelled"));
return ResponseEntity.status(HttpStatus.CONFLICT)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Handles RSVP on cancelled event. */
@ExceptionHandler(EventCancelledException.class)
public ResponseEntity<ProblemDetail> handleEventCancelled(
EventCancelledException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, ex.getMessage());
problemDetail.setTitle("Event Cancelled");
problemDetail.setType(URI.create("urn:problem-type:event-cancelled"));
return ResponseEntity.status(HttpStatus.CONFLICT)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Handles RSVP on expired event. */
@ExceptionHandler(EventExpiredException.class)
public ResponseEntity<ProblemDetail> handleEventExpired(

View File

@@ -68,17 +68,17 @@ public class SpaController {
/** Serves SPA HTML with event-specific meta-tags. */
@GetMapping(
value = "/events/{token}",
value = "/events/{eventToken}",
produces = MediaType.TEXT_HTML_VALUE
)
@ResponseBody
public String serveEventPage(@PathVariable String token,
public String serveEventPage(@PathVariable String eventToken,
HttpServletRequest request) {
if (htmlTemplate == null) {
return "";
}
String baseUrl = getBaseUrl(request);
Map<String, String> meta = resolveEventMeta(token, baseUrl);
Map<String, String> meta = resolveEventMeta(eventToken, baseUrl);
return htmlTemplate.replace(PLACEHOLDER, renderTags(meta));
}
@@ -86,11 +86,11 @@ public class SpaController {
private Map<String, String> buildEventMeta(Event event, String baseUrl) {
var tags = new LinkedHashMap<String, String>();
String title = truncateTitle(event.getTitle());
String title = truncateTitle(event.title());
String description = formatDescription(event);
tags.put("og:title", title);
tags.put("og:description", description);
tags.put("og:url", baseUrl + "/events/" + event.getEventToken().value());
tags.put("og:url", baseUrl + "/events/" + event.eventToken().value());
tags.put("og:type", "website");
tags.put("og:site_name", GENERIC_TITLE);
tags.put("og:image", baseUrl + "/og-image.png");
@@ -138,16 +138,16 @@ public class SpaController {
}
private String formatDescription(Event event) {
ZonedDateTime zoned = event.getDateTime().atZoneSameInstant(event.getTimezone());
ZonedDateTime zoned = event.dateTime().atZoneSameInstant(event.timezone());
var sb = new StringBuilder();
sb.append("📅 ").append(zoned.format(DATE_FORMAT));
if (event.getLocation() != null && !event.getLocation().isBlank()) {
sb.append(" · 📍 ").append(event.getLocation());
if (event.location() != null && !event.location().isBlank()) {
sb.append(" · 📍 ").append(event.location());
}
if (event.getDescription() != null && !event.getDescription().isBlank()) {
sb.append("").append(event.getDescription());
if (event.description() != null && !event.description().isBlank()) {
sb.append("").append(event.description());
}
String result = sb.toString();

View File

@@ -46,6 +46,12 @@ public class EventJpaEntity {
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "cancelled", nullable = false)
private boolean cancelled;
@Column(name = "cancellation_reason", length = 2000)
private String cancellationReason;
/** Returns the internal database ID. */
public Long getId() {
return id;
@@ -145,4 +151,24 @@ public class EventJpaEntity {
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
/** Returns whether the event is cancelled. */
public boolean isCancelled() {
return cancelled;
}
/** Sets the cancelled flag. */
public void setCancelled(boolean cancelled) {
this.cancelled = cancelled;
}
/** Returns the cancellation reason. */
public String getCancellationReason() {
return cancellationReason;
}
/** Sets the cancellation reason. */
public void setCancellationReason(String cancellationReason) {
this.cancellationReason = cancellationReason;
}
}

View File

@@ -38,31 +38,34 @@ public class EventPersistenceAdapter implements EventRepository {
private EventJpaEntity toEntity(Event event) {
var entity = new EventJpaEntity();
entity.setId(event.getId());
entity.setEventToken(event.getEventToken().value());
entity.setOrganizerToken(event.getOrganizerToken().value());
entity.setTitle(event.getTitle());
entity.setDescription(event.getDescription());
entity.setDateTime(event.getDateTime());
entity.setTimezone(event.getTimezone().getId());
entity.setLocation(event.getLocation());
entity.setExpiryDate(event.getExpiryDate());
entity.setCreatedAt(event.getCreatedAt());
entity.setId(event.id());
entity.setEventToken(event.eventToken().value());
entity.setOrganizerToken(event.organizerToken().value());
entity.setTitle(event.title());
entity.setDescription(event.description());
entity.setDateTime(event.dateTime());
entity.setTimezone(event.timezone().getId());
entity.setLocation(event.location());
entity.setExpiryDate(event.expiryDate());
entity.setCreatedAt(event.createdAt());
entity.setCancelled(event.cancelled());
entity.setCancellationReason(event.cancellationReason());
return entity;
}
private Event toDomain(EventJpaEntity entity) {
var event = new Event();
event.setId(entity.getId());
event.setEventToken(new EventToken(entity.getEventToken()));
event.setOrganizerToken(new OrganizerToken(entity.getOrganizerToken()));
event.setTitle(entity.getTitle());
event.setDescription(entity.getDescription());
event.setDateTime(entity.getDateTime());
event.setTimezone(ZoneId.of(entity.getTimezone()));
event.setLocation(entity.getLocation());
event.setExpiryDate(entity.getExpiryDate());
event.setCreatedAt(entity.getCreatedAt());
return event;
return new Event(
entity.getId(),
new EventToken(entity.getEventToken()),
new OrganizerToken(entity.getOrganizerToken()),
entity.getTitle(),
entity.getDescription(),
entity.getDateTime(),
ZoneId.of(entity.getTimezone()),
entity.getLocation(),
entity.getExpiryDate(),
entity.getCreatedAt(),
entity.isCancelled(),
entity.getCancellationReason());
}
}

View File

@@ -43,19 +43,18 @@ public class RsvpPersistenceAdapter implements RsvpRepository {
private RsvpJpaEntity toEntity(Rsvp rsvp) {
var entity = new RsvpJpaEntity();
entity.setId(rsvp.getId());
entity.setRsvpToken(rsvp.getRsvpToken().value());
entity.setEventId(rsvp.getEventId());
entity.setName(rsvp.getName());
entity.setId(rsvp.id());
entity.setRsvpToken(rsvp.rsvpToken().value());
entity.setEventId(rsvp.eventId());
entity.setName(rsvp.name());
return entity;
}
private Rsvp toDomain(RsvpJpaEntity entity) {
var rsvp = new Rsvp();
rsvp.setId(entity.getId());
rsvp.setRsvpToken(new RsvpToken(entity.getRsvpToken()));
rsvp.setEventId(entity.getEventId());
rsvp.setName(entity.getName());
return rsvp;
return new Rsvp(
entity.getId(),
new RsvpToken(entity.getRsvpToken()),
entity.getEventId(),
entity.getName());
}
}

View File

@@ -1,21 +1,26 @@
package de.fete.application.service;
import de.fete.application.service.exception.EventAlreadyCancelledException;
import de.fete.application.service.exception.EventNotFoundException;
import de.fete.application.service.exception.InvalidOrganizerTokenException;
import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.in.GetEventUseCase;
import de.fete.domain.port.in.UpdateEventUseCase;
import de.fete.domain.port.out.EventRepository;
import java.time.Clock;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Application service implementing event creation and retrieval. */
@Service
public class EventService implements CreateEventUseCase, GetEventUseCase {
public class EventService implements CreateEventUseCase, GetEventUseCase, UpdateEventUseCase {
private static final int EXPIRY_DAYS_AFTER_EVENT = 7;
@@ -32,16 +37,19 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
public Event createEvent(CreateEventCommand command) {
LocalDate expiryDate = command.dateTime().toLocalDate().plusDays(EXPIRY_DAYS_AFTER_EVENT);
var event = new Event();
event.setEventToken(EventToken.generate());
event.setOrganizerToken(OrganizerToken.generate());
event.setTitle(command.title());
event.setDescription(command.description());
event.setDateTime(command.dateTime());
event.setTimezone(command.timezone());
event.setLocation(command.location());
event.setExpiryDate(expiryDate);
event.setCreatedAt(OffsetDateTime.now(clock));
var event = new Event(
null,
EventToken.generate(),
OrganizerToken.generate(),
command.title(),
command.description(),
command.dateTime(),
command.timezone(),
command.location(),
expiryDate,
OffsetDateTime.now(clock),
false,
null);
return eventRepository.save(event);
}
@@ -50,4 +58,27 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
public Optional<Event> getByEventToken(EventToken eventToken) {
return eventRepository.findByEventToken(eventToken);
}
@Transactional
@Override
public void cancelEvent(
EventToken eventToken, OrganizerToken organizerToken,
Boolean cancelled, String reason) {
if (!Boolean.TRUE.equals(cancelled)) {
return;
}
Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
if (!event.organizerToken().equals(organizerToken)) {
throw new InvalidOrganizerTokenException();
}
if (event.cancelled()) {
throw new EventAlreadyCancelledException(eventToken.value());
}
eventRepository.save(event.withCancellation(true, reason));
}
}

View File

@@ -1,5 +1,9 @@
package de.fete.application.service;
import de.fete.application.service.exception.EventCancelledException;
import de.fete.application.service.exception.EventExpiredException;
import de.fete.application.service.exception.EventNotFoundException;
import de.fete.application.service.exception.InvalidOrganizerTokenException;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
@@ -42,14 +46,15 @@ public class RsvpService
Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
if (!event.getExpiryDate().isAfter(LocalDate.now(clock))) {
if (event.cancelled()) {
throw new EventCancelledException(eventToken.value());
}
if (!event.expiryDate().isAfter(LocalDate.now(clock))) {
throw new EventExpiredException(eventToken.value());
}
var rsvp = new Rsvp();
rsvp.setRsvpToken(RsvpToken.generate());
rsvp.setEventId(event.getId());
rsvp.setName(name.strip());
var rsvp = new Rsvp(null, RsvpToken.generate(), event.id(), name.strip());
return rsvpRepository.save(rsvp);
}
@@ -59,14 +64,14 @@ public class RsvpService
public void cancelRsvp(EventToken eventToken, RsvpToken rsvpToken) {
eventRepository.findByEventToken(eventToken)
.ifPresent(event ->
rsvpRepository.deleteByEventIdAndRsvpToken(event.getId(), rsvpToken));
rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken));
}
@Override
public long countByEvent(EventToken eventToken) {
Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
return rsvpRepository.countByEventId(event.getId());
return rsvpRepository.countByEventId(event.id());
}
@Override
@@ -74,12 +79,12 @@ public class RsvpService
Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
if (!event.getOrganizerToken().equals(organizerToken)) {
if (!event.organizerToken().equals(organizerToken)) {
throw new InvalidOrganizerTokenException();
}
return rsvpRepository.findByEventId(event.getId()).stream()
.map(Rsvp::getName)
return rsvpRepository.findByEventId(event.id()).stream()
.map(Rsvp::name)
.toList();
}
}

View File

@@ -0,0 +1,12 @@
package de.fete.application.service.exception;
import java.util.UUID;
/** Thrown when attempting to cancel an event that is already cancelled. */
public class EventAlreadyCancelledException extends RuntimeException {
/** Creates a new exception for the given event token. */
public EventAlreadyCancelledException(UUID eventToken) {
super("Event is already cancelled: " + eventToken);
}
}

View File

@@ -0,0 +1,12 @@
package de.fete.application.service.exception;
import java.util.UUID;
/** Thrown when an RSVP is attempted on a cancelled event. */
public class EventCancelledException extends RuntimeException {
/** Creates a new exception for the given event token. */
public EventCancelledException(UUID eventToken) {
super("Event is cancelled: " + eventToken);
}
}

View File

@@ -1,4 +1,4 @@
package de.fete.application.service;
package de.fete.application.service.exception;
import java.util.UUID;

View File

@@ -1,4 +1,4 @@
package de.fete.application.service;
package de.fete.application.service.exception;
import java.util.UUID;

View File

@@ -1,4 +1,4 @@
package de.fete.application.service;
package de.fete.application.service.exception;
import java.time.LocalDate;
import java.time.OffsetDateTime;

View File

@@ -1,4 +1,4 @@
package de.fete.application.service;
package de.fete.application.service.exception;
import java.time.LocalDate;

View File

@@ -1,4 +1,4 @@
package de.fete.application.service;
package de.fete.application.service.exception;
/** Thrown when an invalid organizer token is provided. */
public class InvalidOrganizerTokenException extends RuntimeException {

View File

@@ -1,4 +1,4 @@
package de.fete.application.service;
package de.fete.application.service.exception;
/** Thrown when an invalid IANA timezone ID is provided. */
public class InvalidTimezoneException extends RuntimeException {

View File

@@ -0,0 +1,4 @@
/**
* Application-layer exceptions thrown by service use case implementations.
*/
package de.fete.application.service.exception;

View File

@@ -5,116 +5,26 @@ import java.time.OffsetDateTime;
import java.time.ZoneId;
/** Domain entity representing an event. */
public class Event {
public record Event(
Long id,
EventToken eventToken,
OrganizerToken organizerToken,
String title,
String description,
OffsetDateTime dateTime,
ZoneId timezone,
String location,
LocalDate expiryDate,
OffsetDateTime createdAt,
boolean cancelled,
String cancellationReason
) {
private Long id;
private EventToken eventToken;
private OrganizerToken organizerToken;
private String title;
private String description;
private OffsetDateTime dateTime;
private ZoneId timezone;
private String location;
private LocalDate expiryDate;
private OffsetDateTime createdAt;
/** Returns the internal database ID. */
public Long getId() {
return id;
}
/** Sets the internal database ID. */
public void setId(Long id) {
this.id = id;
}
/** Returns the public event token. */
public EventToken getEventToken() {
return eventToken;
}
/** Sets the public event token. */
public void setEventToken(EventToken eventToken) {
this.eventToken = eventToken;
}
/** Returns the secret organizer token. */
public OrganizerToken getOrganizerToken() {
return organizerToken;
}
/** Sets the secret organizer token. */
public void setOrganizerToken(OrganizerToken organizerToken) {
this.organizerToken = organizerToken;
}
/** Returns the event title. */
public String getTitle() {
return title;
}
/** Sets the event title. */
public void setTitle(String title) {
this.title = title;
}
/** Returns the event description. */
public String getDescription() {
return description;
}
/** Sets the event description. */
public void setDescription(String description) {
this.description = description;
}
/** Returns the event date and time with UTC offset. */
public OffsetDateTime getDateTime() {
return dateTime;
}
/** Sets the event date and time. */
public void setDateTime(OffsetDateTime dateTime) {
this.dateTime = dateTime;
}
/** Returns the IANA timezone. */
public ZoneId getTimezone() {
return timezone;
}
/** Sets the IANA timezone. */
public void setTimezone(ZoneId timezone) {
this.timezone = timezone;
}
/** Returns the event location. */
public String getLocation() {
return location;
}
/** Sets the event location. */
public void setLocation(String location) {
this.location = location;
}
/** Returns the expiry date after which event data is deleted. */
public LocalDate getExpiryDate() {
return expiryDate;
}
/** Sets the expiry date. */
public void setExpiryDate(LocalDate expiryDate) {
this.expiryDate = expiryDate;
}
/** Returns the creation timestamp. */
public OffsetDateTime getCreatedAt() {
return createdAt;
}
/** Sets the creation timestamp. */
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
/** Returns a copy of this event with cancellation applied. */
public Event withCancellation(boolean cancelled, String cancellationReason) {
return new Event(
id, eventToken, organizerToken, title, description,
dateTime, timezone, location, expiryDate, createdAt,
cancelled, cancellationReason);
}
}

View File

@@ -1,50 +1,9 @@
package de.fete.domain.model;
/** Domain entity representing an RSVP. */
public class Rsvp {
private Long id;
private RsvpToken rsvpToken;
private Long eventId;
private String name;
/** Returns the internal database ID. */
public Long getId() {
return id;
}
/** Sets the internal database ID. */
public void setId(Long id) {
this.id = id;
}
/** Returns the RSVP token. */
public RsvpToken getRsvpToken() {
return rsvpToken;
}
/** Sets the RSVP token. */
public void setRsvpToken(RsvpToken rsvpToken) {
this.rsvpToken = rsvpToken;
}
/** Returns the event ID this RSVP belongs to. */
public Long getEventId() {
return eventId;
}
/** Sets the event ID. */
public void setEventId(Long eventId) {
this.eventId = eventId;
}
/** Returns the guest's display name. */
public String getName() {
return name;
}
/** Sets the guest's display name. */
public void setName(String name) {
this.name = name;
}
}
public record Rsvp(
Long id,
RsvpToken rsvpToken,
Long eventId,
String name
) {}

View File

@@ -0,0 +1,13 @@
package de.fete.domain.port.in;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
/** Inbound port for updating an event. */
public interface UpdateEventUseCase {
/** Cancels the event identified by the given token. */
void cancelEvent(
EventToken eventToken, OrganizerToken organizerToken,
Boolean cancelled, String reason);
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="004-add-cancellation-columns" author="fete">
<addColumn tableName="events">
<column name="cancelled" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="cancellation_reason" type="VARCHAR(2000)"/>
</addColumn>
</changeSet>
</databaseChangeLog>

View File

@@ -9,5 +9,6 @@
<include file="db/changelog/001-create-events-table.xml"/>
<include file="db/changelog/002-add-timezone-column.xml"/>
<include file="db/changelog/003-create-rsvps-table.xml"/>
<include file="db/changelog/004-add-cancellation-columns.xml"/>
</databaseChangeLog>

View File

@@ -37,7 +37,7 @@ paths:
schema:
$ref: "#/components/schemas/ValidationProblemDetail"
/events/{token}/rsvps/{rsvpToken}:
/events/{eventToken}/rsvps/{rsvpToken}:
delete:
operationId: cancelRsvp
summary: Cancel RSVP
@@ -47,7 +47,7 @@ paths:
tags:
- events
parameters:
- name: token
- name: eventToken
in: path
required: true
schema:
@@ -69,14 +69,14 @@ paths:
"500":
description: Internal server error
/events/{token}/rsvps:
/events/{eventToken}/rsvps:
post:
operationId: createRsvp
summary: Submit an RSVP for an event
tags:
- events
parameters:
- name: token
- name: eventToken
in: path
required: true
schema:
@@ -115,14 +115,14 @@ paths:
schema:
$ref: "#/components/schemas/ProblemDetail"
/events/{token}/attendees:
/events/{eventToken}/attendees:
get:
operationId: getAttendees
summary: Get attendee list for an event (organizer only)
tags:
- events
parameters:
- name: token
- name: eventToken
in: path
required: true
schema:
@@ -156,14 +156,14 @@ paths:
schema:
$ref: "#/components/schemas/ProblemDetail"
/events/{token}:
/events/{eventToken}:
get:
operationId: getEvent
summary: Get public event details by token
tags:
- events
parameters:
- name: token
- name: eventToken
in: path
required: true
schema:
@@ -184,6 +184,58 @@ paths:
schema:
$ref: "#/components/schemas/ProblemDetail"
patch:
operationId: patchEvent
summary: Update an event (currently cancel)
description: |
Partial update of an event resource. Currently the only supported operation
is cancellation (setting cancelled to true). Requires the organizer token.
Cancellation is irreversible.
tags:
- events
parameters:
- name: eventToken
in: path
required: true
schema:
type: string
format: uuid
description: Public event token
- name: organizerToken
in: query
required: true
schema:
type: string
format: uuid
description: Organizer token for authorization
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PatchEventRequest"
responses:
"204":
description: Event updated successfully
"403":
description: Invalid organizer token
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"404":
description: Event not found
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"409":
description: Event is already cancelled
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
components:
schemas:
CreateEventRequest:
@@ -252,6 +304,7 @@ components:
- dateTime
- timezone
- attendeeCount
- cancelled
properties:
eventToken:
type: string
@@ -284,6 +337,31 @@ components:
minimum: 0
description: Number of confirmed attendees (attending=true)
example: 12
cancelled:
type: boolean
description: Whether the event has been cancelled
example: false
cancellationReason:
type:
- string
- "null"
description: Reason for cancellation, if provided
example: null
PatchEventRequest:
type: object
required:
- cancelled
properties:
cancelled:
type: boolean
description: Set to true to cancel the event (irreversible)
example: true
cancellationReason:
type: string
maxLength: 2000
description: Optional cancellation reason
example: "Unfortunately the venue is no longer available."
CreateRsvpRequest:
type: object

View File

@@ -4,10 +4,14 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.onionArchitecture;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
@AnalyzeClasses(packages = "de.fete", importOptions = ImportOption.DoNotIncludeTests.class)
class HexagonalArchitectureTest {
@@ -65,4 +69,24 @@ class HexagonalArchitectureTest {
static final ArchRule webAdapterMustNotDependOnOutboundPorts = noClasses()
.that().resideInAPackage("de.fete.adapter.in.web..")
.should().dependOnClassesThat().resideInAPackage("de.fete.domain.port.out..");
@ArchTest
static final ArchRule domainModelsMustBeRecords = classes()
.that().resideInAPackage("de.fete.domain.model..")
.and().doNotHaveSimpleName("package-info")
.should(beRecords());
private static ArchCondition<JavaClass> beRecords() {
return new ArchCondition<>("be records") {
@Override
public void check(JavaClass javaClass,
ConditionEvents events) {
boolean isRecord = javaClass.reflect().isRecord();
if (!isRecord) {
events.add(SimpleConditionEvent.violated(javaClass,
javaClass.getFullName() + " is not a record"));
}
}
};
}
}

View File

@@ -3,6 +3,7 @@ package de.fete.adapter.in.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -21,6 +22,7 @@ import de.fete.adapter.out.persistence.RsvpJpaRepository;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -431,6 +433,147 @@ class EventControllerIntegrationTest {
.andExpect(jsonPath("$.attendeeCount").value(1));
}
// --- Cancel Event tests ---
@Test
void cancelEventReturns204AndPersists() throws Exception {
EventJpaEntity event = seedEvent(
"Cancel Me", null, "Europe/Berlin", null, LocalDate.now().plusDays(30));
var body = Map.of(
"cancelled", true,
"cancellationReason", "Venue closed");
mockMvc.perform(patch("/api/events/" + event.getEventToken()
+ "?organizerToken=" + event.getOrganizerToken())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isNoContent());
EventJpaEntity persisted = jpaRepository
.findByEventToken(event.getEventToken()).orElseThrow();
assertThat(persisted.isCancelled()).isTrue();
assertThat(persisted.getCancellationReason()).isEqualTo("Venue closed");
}
@Test
void cancelEventWithoutReasonReturns204() throws Exception {
EventJpaEntity event = seedEvent(
"Cancel No Reason", null, "Europe/Berlin", null, LocalDate.now().plusDays(30));
var body = Map.of("cancelled", true);
mockMvc.perform(patch("/api/events/" + event.getEventToken()
+ "?organizerToken=" + event.getOrganizerToken())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isNoContent());
EventJpaEntity persisted = jpaRepository
.findByEventToken(event.getEventToken()).orElseThrow();
assertThat(persisted.isCancelled()).isTrue();
assertThat(persisted.getCancellationReason()).isNull();
}
@Test
void cancelEventWithWrongOrganizerTokenReturns403() throws Exception {
EventJpaEntity event = seedEvent(
"Wrong Token", null, "Europe/Berlin", null, LocalDate.now().plusDays(30));
var body = Map.of("cancelled", true);
mockMvc.perform(patch("/api/events/" + event.getEventToken()
+ "?organizerToken=" + UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isForbidden())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:invalid-organizer-token"));
assertThat(jpaRepository.findByEventToken(event.getEventToken())
.orElseThrow().isCancelled()).isFalse();
}
@Test
void cancelEventNotFoundReturns404() throws Exception {
var body = Map.of("cancelled", true);
mockMvc.perform(patch("/api/events/" + UUID.randomUUID()
+ "?organizerToken=" + UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isNotFound())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
}
@Test
void cancelAlreadyCancelledEventReturns409() throws Exception {
EventJpaEntity event = seedCancelledEvent("Already Cancelled");
var body = Map.of("cancelled", true);
mockMvc.perform(patch("/api/events/" + event.getEventToken()
+ "?organizerToken=" + event.getOrganizerToken())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isConflict())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:event-already-cancelled"));
}
@Test
void getEventReturnsCancelledFields() throws Exception {
EventJpaEntity event = seedCancelledEvent("Weather Event");
mockMvc.perform(get("/api/events/" + event.getEventToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.cancelled").value(true))
.andExpect(jsonPath("$.cancellationReason").value("Cancelled"));
}
@Test
void getEventReturnsNotCancelledByDefault() throws Exception {
EventJpaEntity event = seedEvent(
"Active Event", null, "Europe/Berlin", null, LocalDate.now().plusDays(30));
mockMvc.perform(get("/api/events/" + event.getEventToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.cancelled").value(false))
.andExpect(jsonPath("$.cancellationReason").doesNotExist());
}
@Test
void createRsvpOnCancelledEventReturns409() throws Exception {
EventJpaEntity event = seedCancelledEvent("Cancelled RSVP");
long countBefore = rsvpJpaRepository.count();
var request = new CreateRsvpRequest().name("Late Guest");
mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:event-cancelled"));
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
}
private EventJpaEntity seedCancelledEvent(String title) {
var entity = new EventJpaEntity();
entity.setEventToken(UUID.randomUUID());
entity.setOrganizerToken(UUID.randomUUID());
entity.setTitle(title);
entity.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
entity.setTimezone("Europe/Berlin");
entity.setExpiryDate(LocalDate.now().plusDays(30));
entity.setCreatedAt(OffsetDateTime.now());
entity.setCancelled(true);
entity.setCancellationReason("Cancelled");
return jpaRepository.save(entity);
}
private UUID seedRsvpAndGetToken(EventJpaEntity event, String name) {
var rsvp = new RsvpJpaEntity();
UUID token = UUID.randomUUID();

View File

@@ -42,7 +42,7 @@ class EventPersistenceAdapterIntegrationTest {
eventRepository.deleteExpired();
assertThat(eventRepository.findByEventToken(saved.getEventToken())).isPresent();
assertThat(eventRepository.findByEventToken(saved.eventToken())).isPresent();
}
@Test
@@ -52,7 +52,7 @@ class EventPersistenceAdapterIntegrationTest {
eventRepository.deleteExpired();
assertThat(eventRepository.findByEventToken(saved.getEventToken())).isPresent();
assertThat(eventRepository.findByEventToken(saved.eventToken())).isPresent();
}
@Test
@@ -66,16 +66,18 @@ class EventPersistenceAdapterIntegrationTest {
}
private Event buildEvent(String title, LocalDate expiryDate) {
var event = new Event();
event.setEventToken(EventToken.generate());
event.setOrganizerToken(OrganizerToken.generate());
event.setTitle(title);
event.setDescription("Test description");
event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
event.setTimezone(ZoneId.of("Europe/Berlin"));
event.setLocation("Test Location");
event.setExpiryDate(expiryDate);
event.setCreatedAt(OffsetDateTime.now());
return event;
return new Event(
null,
EventToken.generate(),
OrganizerToken.generate(),
title,
"Test description",
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
ZoneId.of("Europe/Berlin"),
"Test Location",
expiryDate,
OffsetDateTime.now(),
false,
null);
}
}

View File

@@ -30,8 +30,8 @@ class EventPersistenceAdapterTest {
Event saved = eventRepository.save(event);
assertThat(saved.getId()).isNotNull();
assertThat(saved.getTitle()).isEqualTo("Test Event");
assertThat(saved.id()).isNotNull();
assertThat(saved.title()).isEqualTo("Test Event");
}
@Test
@@ -39,11 +39,11 @@ class EventPersistenceAdapterTest {
Event event = buildEvent();
Event saved = eventRepository.save(event);
Optional<Event> found = eventRepository.findByEventToken(saved.getEventToken());
Optional<Event> found = eventRepository.findByEventToken(saved.eventToken());
assertThat(found).isPresent();
assertThat(found.get().getTitle()).isEqualTo("Test Event");
assertThat(found.get().getId()).isEqualTo(saved.getId());
assertThat(found.get().title()).isEqualTo("Test Event");
assertThat(found.get().id()).isEqualTo(saved.id());
}
@Test
@@ -61,42 +61,47 @@ class EventPersistenceAdapterTest {
OffsetDateTime createdAt =
OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC);
var event = new Event();
event.setEventToken(EventToken.generate());
event.setOrganizerToken(OrganizerToken.generate());
event.setTitle("Full Event");
event.setDescription("A detailed description");
event.setDateTime(dateTime);
event.setTimezone(ZoneId.of("Europe/Berlin"));
event.setLocation("Berlin, Germany");
event.setExpiryDate(expiryDate);
event.setCreatedAt(createdAt);
var event = new Event(
null,
EventToken.generate(),
OrganizerToken.generate(),
"Full Event",
"A detailed description",
dateTime,
ZoneId.of("Europe/Berlin"),
"Berlin, Germany",
expiryDate,
createdAt,
false,
null);
Event saved = eventRepository.save(event);
Event found = eventRepository.findByEventToken(saved.getEventToken()).orElseThrow();
Event found = eventRepository.findByEventToken(saved.eventToken()).orElseThrow();
assertThat(found.getEventToken()).isEqualTo(event.getEventToken());
assertThat(found.getOrganizerToken()).isEqualTo(event.getOrganizerToken());
assertThat(found.getTitle()).isEqualTo("Full Event");
assertThat(found.getDescription()).isEqualTo("A detailed description");
assertThat(found.getDateTime().toInstant()).isEqualTo(dateTime.toInstant());
assertThat(found.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin"));
assertThat(found.getLocation()).isEqualTo("Berlin, Germany");
assertThat(found.getExpiryDate()).isEqualTo(expiryDate);
assertThat(found.getCreatedAt().toInstant()).isEqualTo(createdAt.toInstant());
assertThat(found.eventToken()).isEqualTo(event.eventToken());
assertThat(found.organizerToken()).isEqualTo(event.organizerToken());
assertThat(found.title()).isEqualTo("Full Event");
assertThat(found.description()).isEqualTo("A detailed description");
assertThat(found.dateTime().toInstant()).isEqualTo(dateTime.toInstant());
assertThat(found.timezone()).isEqualTo(ZoneId.of("Europe/Berlin"));
assertThat(found.location()).isEqualTo("Berlin, Germany");
assertThat(found.expiryDate()).isEqualTo(expiryDate);
assertThat(found.createdAt().toInstant()).isEqualTo(createdAt.toInstant());
}
private Event buildEvent() {
var event = new Event();
event.setEventToken(EventToken.generate());
event.setOrganizerToken(OrganizerToken.generate());
event.setTitle("Test Event");
event.setDescription("Test description");
event.setDateTime(OffsetDateTime.now().plusDays(7));
event.setTimezone(ZoneId.of("Europe/Berlin"));
event.setLocation("Somewhere");
event.setExpiryDate(LocalDate.now().plusDays(30));
event.setCreatedAt(OffsetDateTime.now());
return event;
return new Event(
null,
EventToken.generate(),
OrganizerToken.generate(),
"Test Event",
"Test description",
OffsetDateTime.now().plusDays(7),
ZoneId.of("Europe/Berlin"),
"Somewhere",
LocalDate.now().plusDays(30),
OffsetDateTime.now(),
false,
null);
}
}

View File

@@ -0,0 +1,133 @@
package de.fete.application.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import de.fete.application.service.exception.EventAlreadyCancelledException;
import de.fete.application.service.exception.EventNotFoundException;
import de.fete.application.service.exception.InvalidOrganizerTokenException;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
import de.fete.domain.port.out.EventRepository;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class EventServiceCancelTest {
private static final ZoneId ZONE = ZoneId.of("Europe/Berlin");
private static final Instant FIXED_INSTANT =
LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant();
private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE);
@Mock
private EventRepository eventRepository;
private EventService eventService;
@BeforeEach
void setUp() {
eventService = new EventService(eventRepository, FIXED_CLOCK);
}
@Test
void cancelEventDelegatesToDomainAndSaves() {
EventToken eventToken = EventToken.generate();
OrganizerToken organizerToken = OrganizerToken.generate();
var event = new Event(null, eventToken, organizerToken, null, null, null, null, null, null,
null, false, null);
when(eventRepository.findByEventToken(eventToken))
.thenReturn(Optional.of(event));
when(eventRepository.save(any(Event.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
eventService.cancelEvent(eventToken, organizerToken, true, "Venue unavailable");
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
verify(eventRepository).save(captor.capture());
assertThat(captor.getValue().cancelled()).isTrue();
assertThat(captor.getValue().cancellationReason()).isEqualTo("Venue unavailable");
}
@Test
void cancelEventWithNullReason() {
EventToken eventToken = EventToken.generate();
OrganizerToken organizerToken = OrganizerToken.generate();
var event = new Event(null, eventToken, organizerToken, null, null, null, null, null, null,
null, false, null);
when(eventRepository.findByEventToken(eventToken))
.thenReturn(Optional.of(event));
when(eventRepository.save(any(Event.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
eventService.cancelEvent(eventToken, organizerToken, true, null);
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
verify(eventRepository).save(captor.capture());
assertThat(captor.getValue().cancelled()).isTrue();
assertThat(captor.getValue().cancellationReason()).isNull();
}
@Test
void cancelEventThrows404WhenNotFound() {
EventToken eventToken = EventToken.generate();
OrganizerToken organizerToken = OrganizerToken.generate();
when(eventRepository.findByEventToken(eventToken))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> eventService.cancelEvent(eventToken, organizerToken, true, null))
.isInstanceOf(EventNotFoundException.class);
verify(eventRepository, never()).save(any());
}
@Test
void cancelEventThrows403WhenWrongOrganizerToken() {
EventToken eventToken = EventToken.generate();
OrganizerToken correctToken = OrganizerToken.generate();
var event = new Event(null, eventToken, correctToken, null, null, null, null, null, null,
null, false, null);
when(eventRepository.findByEventToken(eventToken))
.thenReturn(Optional.of(event));
final OrganizerToken wrongToken = OrganizerToken.generate();
assertThatThrownBy(() -> eventService.cancelEvent(eventToken, wrongToken, true, null))
.isInstanceOf(InvalidOrganizerTokenException.class);
verify(eventRepository, never()).save(any());
}
@Test
void cancelEventThrows409WhenAlreadyCancelled() {
EventToken eventToken = EventToken.generate();
OrganizerToken organizerToken = OrganizerToken.generate();
var event = new Event(null, eventToken, organizerToken, null, null, null, null, null, null,
null, true, null);
when(eventRepository.findByEventToken(eventToken))
.thenReturn(Optional.of(event));
assertThatThrownBy(() -> eventService.cancelEvent(eventToken, organizerToken, true, null))
.isInstanceOf(EventAlreadyCancelledException.class);
verify(eventRepository, never()).save(any());
}
}

View File

@@ -57,13 +57,13 @@ class EventServiceTest {
Event result = eventService.createEvent(command);
assertThat(result.getTitle()).isEqualTo("Birthday Party");
assertThat(result.getDescription()).isEqualTo("Come celebrate!");
assertThat(result.getTimezone()).isEqualTo(ZONE);
assertThat(result.getLocation()).isEqualTo("Berlin");
assertThat(result.getEventToken()).isNotNull();
assertThat(result.getOrganizerToken()).isNotNull();
assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE));
assertThat(result.title()).isEqualTo("Birthday Party");
assertThat(result.description()).isEqualTo("Come celebrate!");
assertThat(result.timezone()).isEqualTo(ZONE);
assertThat(result.location()).isEqualTo("Berlin");
assertThat(result.eventToken()).isNotNull();
assertThat(result.organizerToken()).isNotNull();
assertThat(result.createdAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE));
}
@Test
@@ -80,7 +80,7 @@ class EventServiceTest {
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
verify(eventRepository, times(1)).save(captor.capture());
assertThat(captor.getValue().getTitle()).isEqualTo("Test");
assertThat(captor.getValue().title()).isEqualTo("Test");
}
@Test
@@ -96,7 +96,7 @@ class EventServiceTest {
Event result = eventService.createEvent(command);
assertThat(result.getExpiryDate()).isEqualTo(eventDate.plusDays(7));
assertThat(result.expiryDate()).isEqualTo(eventDate.plusDays(7));
}
// --- GetEventUseCase tests (T004) ---
@@ -104,16 +104,15 @@ class EventServiceTest {
@Test
void getByEventTokenReturnsEvent() {
EventToken token = EventToken.generate();
var event = new Event();
event.setEventToken(token);
event.setTitle("Found Event");
var event = new Event(null, token, null, "Found Event", null, null, null, null, null, null,
false, null);
when(eventRepository.findByEventToken(token))
.thenReturn(Optional.of(event));
Optional<Event> result = eventService.getByEventToken(token);
assertThat(result).isPresent();
assertThat(result.get().getTitle()).isEqualTo("Found Event");
assertThat(result.get().title()).isEqualTo("Found Event");
}
@Test
@@ -142,6 +141,6 @@ class EventServiceTest {
Event result = eventService.createEvent(command);
assertThat(result.getTimezone()).isEqualTo(ZoneId.of("America/New_York"));
assertThat(result.timezone()).isEqualTo(ZoneId.of("America/New_York"));
}
}

View File

@@ -6,6 +6,10 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import de.fete.application.service.exception.EventCancelledException;
import de.fete.application.service.exception.EventExpiredException;
import de.fete.application.service.exception.EventNotFoundException;
import de.fete.application.service.exception.InvalidOrganizerTokenException;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
@@ -51,23 +55,23 @@ class RsvpServiceTest {
@Test
void createRsvpSucceedsForActiveEvent() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
Event event = buildActiveEvent(TODAY.plusDays(30));
EventToken token = event.eventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
when(rsvpRepository.save(any(Rsvp.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
Rsvp result = rsvpService.createRsvp(token, "Max Mustermann");
assertThat(result.getName()).isEqualTo("Max Mustermann");
assertThat(result.getRsvpToken()).isNotNull();
assertThat(result.getEventId()).isEqualTo(event.getId());
assertThat(result.name()).isEqualTo("Max Mustermann");
assertThat(result.rsvpToken()).isNotNull();
assertThat(result.eventId()).isEqualTo(event.id());
}
@Test
void createRsvpPersistsViaRepository() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
Event event = buildActiveEvent(TODAY.plusDays(30));
EventToken token = event.eventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
when(rsvpRepository.save(any(Rsvp.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@@ -76,8 +80,8 @@ class RsvpServiceTest {
ArgumentCaptor<Rsvp> captor = ArgumentCaptor.forClass(Rsvp.class);
verify(rsvpRepository).save(captor.capture());
assertThat(captor.getValue().getName()).isEqualTo("Test Guest");
assertThat(captor.getValue().getEventId()).isEqualTo(event.getId());
assertThat(captor.getValue().name()).isEqualTo("Test Guest");
assertThat(captor.getValue().eventId()).isEqualTo(event.id());
}
@Test
@@ -91,22 +95,21 @@ class RsvpServiceTest {
@Test
void createRsvpTrimsName() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
Event event = buildActiveEvent(TODAY.plusDays(30));
EventToken token = event.eventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
when(rsvpRepository.save(any(Rsvp.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
Rsvp result = rsvpService.createRsvp(token, " Max ");
assertThat(result.getName()).isEqualTo("Max");
assertThat(result.name()).isEqualTo("Max");
}
@Test
void createRsvpThrowsWhenEventExpired() {
var event = buildActiveEvent();
event.setExpiryDate(TODAY.minusDays(1));
EventToken token = event.getEventToken();
Event event = buildActiveEvent(TODAY.minusDays(1));
EventToken token = event.eventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
@@ -115,9 +118,8 @@ class RsvpServiceTest {
@Test
void createRsvpThrowsWhenEventExpiresToday() {
var event = buildActiveEvent();
event.setExpiryDate(TODAY);
EventToken token = event.getEventToken();
Event event = buildActiveEvent(TODAY);
EventToken token = event.eventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
@@ -126,12 +128,12 @@ class RsvpServiceTest {
@Test
void getAttendeeNamesReturnsNamesInOrder() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
OrganizerToken orgToken = event.getOrganizerToken();
Event event = buildActiveEvent(TODAY.plusDays(30));
EventToken token = event.eventToken();
OrganizerToken orgToken = event.organizerToken();
when(eventRepository.findByEventToken(token))
.thenReturn(Optional.of(event));
when(rsvpRepository.findByEventId(event.getId()))
when(rsvpRepository.findByEventId(event.id()))
.thenReturn(List.of(
buildRsvp(1L, "Alice"),
buildRsvp(2L, "Bob"),
@@ -144,12 +146,12 @@ class RsvpServiceTest {
@Test
void getAttendeeNamesReturnsEmptyListWhenNoRsvps() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
OrganizerToken orgToken = event.getOrganizerToken();
Event event = buildActiveEvent(TODAY.plusDays(30));
EventToken token = event.eventToken();
OrganizerToken orgToken = event.organizerToken();
when(eventRepository.findByEventToken(token))
.thenReturn(Optional.of(event));
when(rsvpRepository.findByEventId(event.getId()))
when(rsvpRepository.findByEventId(event.id()))
.thenReturn(List.of());
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
@@ -171,8 +173,8 @@ class RsvpServiceTest {
@Test
void getAttendeeNamesThrowsWhenOrganizerTokenInvalid() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
Event event = buildActiveEvent(TODAY.plusDays(30));
EventToken token = event.eventToken();
OrganizerToken wrongToken = OrganizerToken.generate();
when(eventRepository.findByEventToken(token))
.thenReturn(Optional.of(event));
@@ -183,38 +185,33 @@ class RsvpServiceTest {
}
private Rsvp buildRsvp(Long id, String name) {
var rsvp = new Rsvp();
rsvp.setId(id);
rsvp.setRsvpToken(RsvpToken.generate());
rsvp.setEventId(1L);
rsvp.setName(name);
return rsvp;
return new Rsvp(id, RsvpToken.generate(), 1L, name);
}
@Test
void cancelRsvpDeletesWhenEventAndRsvpExist() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
Event event = buildActiveEvent(TODAY.plusDays(30));
EventToken token = event.eventToken();
RsvpToken rsvpToken = RsvpToken.generate();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
when(rsvpRepository.deleteByEventIdAndRsvpToken(event.getId(), rsvpToken)).thenReturn(true);
when(rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken)).thenReturn(true);
rsvpService.cancelRsvp(token, rsvpToken);
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.getId(), rsvpToken);
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.id(), rsvpToken);
}
@Test
void cancelRsvpSucceedsWhenRsvpNotFound() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
Event event = buildActiveEvent(TODAY.plusDays(30));
EventToken token = event.eventToken();
RsvpToken rsvpToken = RsvpToken.generate();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
when(rsvpRepository.deleteByEventIdAndRsvpToken(event.getId(), rsvpToken)).thenReturn(false);
when(rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken)).thenReturn(false);
rsvpService.cancelRsvp(token, rsvpToken);
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.getId(), rsvpToken);
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.id(), rsvpToken);
}
@Test
@@ -226,16 +223,19 @@ class RsvpServiceTest {
rsvpService.cancelRsvp(token, rsvpToken);
}
private Event buildActiveEvent() {
var event = new Event();
event.setId(1L);
event.setEventToken(EventToken.generate());
event.setOrganizerToken(OrganizerToken.generate());
event.setTitle("Test Event");
event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
event.setTimezone(ZONE);
event.setExpiryDate(TODAY.plusDays(30));
event.setCreatedAt(OffsetDateTime.now());
return event;
private Event buildActiveEvent(LocalDate expiryDate) {
return new Event(
1L,
EventToken.generate(),
OrganizerToken.generate(),
"Test Event",
null,
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
ZONE,
null,
expiryDate,
OffsetDateTime.now(),
false,
null);
}
}

View File

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

View File

@@ -0,0 +1,166 @@
import { http, HttpResponse } from 'msw'
import { test, expect } from './msw-setup'
import type { StoredEvent } from '../src/composables/useEventStorage'
const STORAGE_KEY = 'fete:events'
const fullEvent = {
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
title: 'Summer BBQ',
description: 'Bring your own drinks!',
dateTime: '2026-03-15T20:00:00+01:00',
timezone: 'Europe/Berlin',
location: 'Central Park, NYC',
attendeeCount: 12,
cancelled: false,
cancellationReason: null,
}
const organizerToken = '550e8400-e29b-41d4-a716-446655440001'
function seedEvents(events: StoredEvent[]): string {
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
}
function organizerSeed(): StoredEvent {
return {
eventToken: fullEvent.eventToken,
organizerToken,
title: fullEvent.title,
dateTime: fullEvent.dateTime,
}
}
test.describe('US1: Organizer cancels event with reason', () => {
test('organizer opens cancel bottom sheet, enters reason, confirms — event shows as cancelled on reload', async ({
page,
network,
}) => {
let cancelled = false
network.use(
http.get('*/api/events/:token', () => {
if (cancelled) {
return HttpResponse.json({
...fullEvent,
cancelled: true,
cancellationReason: 'Venue closed',
})
}
return HttpResponse.json(fullEvent)
}),
http.patch('*/api/events/:token', ({ request }) => {
const url = new URL(request.url)
const token = url.searchParams.get('organizerToken')
if (token === organizerToken) {
cancelled = true
return new HttpResponse(null, { status: 204 })
}
return HttpResponse.json(
{ type: 'urn:problem-type:invalid-organizer-token', title: 'Forbidden', status: 403 },
{ status: 403 },
)
}),
)
await page.addInitScript(seedEvents([organizerSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Open kebab menu, then cancel event
const kebabBtn = page.getByRole('button', { name: /Event actions/i })
await expect(kebabBtn).toBeVisible()
await kebabBtn.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)
await expect(reasonField).toBeVisible()
await reasonField.fill('Venue closed')
// Confirm cancellation
await page.getByRole('button', { name: /Confirm cancellation/i }).click()
// Event should show as cancelled
await expect(page.getByText(/This event has been cancelled/i)).toBeVisible()
await expect(page.getByText('Venue closed')).toBeVisible()
// Kebab menu should be gone (event is cancelled)
await expect(kebabBtn).not.toBeVisible()
})
})
test.describe('US1: Organizer cancels event without reason', () => {
test('organizer cancels without reason — event shows as cancelled', async ({
page,
network,
}) => {
let cancelled = false
network.use(
http.get('*/api/events/:token', () => {
if (cancelled) {
return HttpResponse.json({
...fullEvent,
cancelled: true,
cancellationReason: null,
})
}
return HttpResponse.json(fullEvent)
}),
http.patch('*/api/events/:token', ({ request }) => {
const url = new URL(request.url)
const token = url.searchParams.get('organizerToken')
if (token === organizerToken) {
cancelled = true
return new HttpResponse(null, { status: 204 })
}
return HttpResponse.json({}, { status: 403 })
}),
)
await page.addInitScript(seedEvents([organizerSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
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()
// Event should show as cancelled without reason text
await expect(page.getByText(/This event has been cancelled/i)).toBeVisible()
})
})
test.describe('US1: Cancel API failure', () => {
test('cancel API fails — error displayed in bottom sheet, button re-enabled for retry', async ({
page,
network,
}) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
http.patch('*/api/events/:token', () => {
return HttpResponse.json(
{
type: 'about:blank',
title: 'Internal Server Error',
status: 500,
detail: 'Something went wrong',
},
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } },
)
}),
)
await page.addInitScript(seedEvents([organizerSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
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
await expect(page.getByText(/Could not cancel event/i)).toBeVisible()
// Confirm button should be re-enabled
await expect(page.getByRole('button', { name: /Confirm cancellation/i })).toBeEnabled()
})
})

View File

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

View File

@@ -0,0 +1,74 @@
import { http, HttpResponse } from 'msw'
import { test, expect } from './msw-setup'
const cancelledEventWithReason = {
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
title: 'Summer BBQ',
description: 'Bring your own drinks!',
dateTime: '2026-03-15T20:00:00+01:00',
timezone: 'Europe/Berlin',
location: 'Central Park, NYC',
attendeeCount: 12,
cancelled: true,
cancellationReason: 'Venue no longer available',
}
const cancelledEventWithoutReason = {
...cancelledEventWithReason,
cancellationReason: null,
}
test.describe('US2: Visitor sees cancelled event with reason', () => {
test('visitor sees red banner with cancellation reason on cancelled event', async ({
page,
network,
}) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(cancelledEventWithReason)),
)
await page.goto(`/events/${cancelledEventWithReason.eventToken}`)
await expect(page.getByText(/This event has been cancelled/i)).toBeVisible()
await expect(page.getByText('Venue no longer available')).toBeVisible()
})
})
test.describe('US2: Visitor sees cancelled event without reason', () => {
test('visitor sees red banner without reason when no reason was provided', async ({
page,
network,
}) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(cancelledEventWithoutReason)),
)
await page.goto(`/events/${cancelledEventWithoutReason.eventToken}`)
await expect(page.getByText(/This event has been cancelled/i)).toBeVisible()
// No reason text shown
await expect(page.getByText('Venue no longer available')).not.toBeVisible()
})
})
test.describe('US2: RSVP buttons hidden on cancelled event', () => {
test('RSVP buttons hidden on cancelled event, other details remain visible', async ({
page,
network,
}) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(cancelledEventWithReason)),
)
await page.goto(`/events/${cancelledEventWithReason.eventToken}`)
// Event details are still visible
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
await expect(page.getByText('Bring your own drinks!')).toBeVisible()
await expect(page.getByText('Central Park, NYC')).toBeVisible()
await expect(page.getByText('12 going')).toBeVisible()
// RSVP bar is NOT visible
await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible()
})
})

View File

@@ -93,19 +93,30 @@ test.describe('US4: Past Events Appear Faded', () => {
})
test.describe('US3: Remove Event from List', () => {
test('delete icon triggers confirmation dialog, confirm removes event', async ({ page }) => {
test('delete icon triggers confirmation dialog, confirm removes event', async ({
page,
network,
}) => {
await page.addInitScript(seedEvents([futureEvent1, futureEvent2]))
const { http, HttpResponse } = await import('msw')
network.use(
http.patch('*/api/events/:token', () => {
return new HttpResponse(null, { status: 204 })
}),
)
await page.goto('/')
// Both events visible
await expect(page.getByText('Summer BBQ')).toBeVisible()
await expect(page.getByText('Team Meeting')).toBeVisible()
// Click delete on Summer BBQ
// Click delete on Summer BBQ (organizer event)
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
// Confirmation dialog appears
await expect(page.getByText('Remove event?')).toBeVisible()
// Confirmation dialog appears (organizer event shows "Cancel event?")
await expect(page.getByText('Cancel event?')).toBeVisible()
// Confirm removal
await page.getByRole('button', { name: 'Remove', exact: true }).click()
@@ -120,13 +131,13 @@ test.describe('US3: Remove Event from List', () => {
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await expect(page.getByText('Remove event?')).toBeVisible()
await expect(page.getByText('Cancel event?')).toBeVisible()
// Cancel
await page.getByRole('button', { name: 'Cancel' }).click()
// Dialog gone, event still there
await expect(page.getByText('Remove event?')).not.toBeVisible()
await expect(page.getByText('Cancel event?')).not.toBeVisible()
await expect(page.getByText('Summer BBQ')).toBeVisible()
})
})
@@ -139,7 +150,7 @@ test.describe('US5: Visual Distinction for Event Roles', () => {
const card = page.locator('.event-card').filter({ hasText: 'Summer BBQ' })
const badge = card.locator('.event-card__badge')
await expect(badge).toBeVisible()
await expect(badge).toHaveText('Organizer')
await expect(badge).toHaveText('Organizing')
await expect(badge).toHaveClass(/event-card__badge--organizer/)
})
@@ -150,16 +161,19 @@ test.describe('US5: Visual Distinction for Event Roles', () => {
const card = page.locator('.event-card').filter({ hasText: 'Team Meeting' })
const badge = card.locator('.event-card__badge')
await expect(badge).toBeVisible()
await expect(badge).toHaveText('Attendee')
await expect(badge).toHaveText('Attending')
await expect(badge).toHaveClass(/event-card__badge--attendee/)
})
test('shows no badge for events without organizerToken or rsvpToken', async ({ page }) => {
test('shows watcher badge for events without organizerToken or rsvpToken', async ({ page }) => {
await page.addInitScript(seedEvents([pastEvent]))
await page.goto('/')
const card = page.locator('.event-card').filter({ hasText: 'New Year Party' })
await expect(card.locator('.event-card__badge')).toHaveCount(0)
const badge = card.locator('.event-card__badge')
await expect(badge).toBeVisible()
await expect(badge).toHaveText('Watching')
await expect(badge).toHaveClass(/event-card__badge--watcher/)
})
})

View 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')
})
})

View File

@@ -0,0 +1,218 @@
import { http, HttpResponse } from 'msw'
import { test, expect } from './msw-setup'
import type { StoredEvent } from '../src/composables/useEventStorage'
const STORAGE_KEY = 'fete:events'
const fullEvent = {
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
title: 'Summer BBQ',
description: 'Bring your own drinks!',
dateTime: '2026-03-15T20:00:00+01:00',
timezone: 'Europe/Berlin',
location: 'Central Park, NYC',
attendeeCount: 12,
cancelled: false,
}
const rsvpToken = 'd4e5f6a7-b8c9-0123-4567-890abcdef012'
const organizerToken = 'org-token-1234'
function seedEvents(events: StoredEvent[]): string {
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
}
function watchSeed(): StoredEvent {
return {
eventToken: fullEvent.eventToken,
title: fullEvent.title,
dateTime: fullEvent.dateTime,
}
}
function rsvpSeed(): StoredEvent {
return {
eventToken: fullEvent.eventToken,
title: fullEvent.title,
dateTime: fullEvent.dateTime,
rsvpToken,
rsvpName: 'Anna',
}
}
function organizerSeed(): StoredEvent {
return {
eventToken: fullEvent.eventToken,
title: fullEvent.title,
dateTime: fullEvent.dateTime,
organizerToken,
}
}
test.describe('US1: Watch event from detail page', () => {
test('bookmark unfilled by default, tapping watches the event', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
)
await page.goto(`/events/${fullEvent.eventToken}`)
const bookmark = page.getByLabel(/watch.*this event/i)
await expect(bookmark).toBeVisible()
await expect(bookmark).toHaveAttribute('aria-label', 'Watch this event')
await bookmark.click()
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
// Navigate to event list via back link
await page.getByLabel('Back to home').click()
// Event appears with "Watching" label
await expect(page.getByText('Summer BBQ')).toBeVisible()
await expect(page.getByText('Watching')).toBeVisible()
})
})
test.describe('US2: Un-watch event from detail page', () => {
test('tapping filled bookmark un-watches the event', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
)
await page.addInitScript(seedEvents([watchSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
const bookmark = page.getByLabel(/watch.*this event/i)
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
await bookmark.click()
await expect(bookmark).toHaveAttribute('aria-label', 'Watch this event')
// Navigate to event list via back link (avoid page.goto re-running addInitScript)
await page.getByLabel('Back to home').click()
// Event is gone
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
})
})
test.describe('US3: Bookmark reflects attending status', () => {
test('bookmark is not visible when user has RSVPed, list shows Attendee', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Bookmark not shown for attendees — RsvpBar shows status state
const bookmark = page.getByLabel(/watch.*this event/i)
await expect(bookmark).not.toBeVisible()
// Navigate to list via back link
await page.getByLabel('Back to home').click()
await expect(page.getByText('Attending')).toBeVisible()
await expect(page.getByText('Watching')).not.toBeVisible()
})
})
test.describe('US4: RSVP cancellation preserves watch status', () => {
test('cancel RSVP → bookmark reappears, list shows Watching', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return new HttpResponse(null, { status: 204 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Cancel RSVP
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
// Bookmark reappears in CTA state, filled because event is still stored
const bookmark = page.getByLabel(/watch.*this event/i)
await expect(bookmark).toBeVisible()
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
// Navigate to list via back link
await page.getByLabel('Back to home').click()
await expect(page.getByText('Watching')).toBeVisible()
await expect(page.getByText('Attending')).not.toBeVisible()
})
})
test.describe('US5: No bookmark for attendees and organizers', () => {
test('attendee does not see bookmark', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
const bookmark = page.getByLabel(/watch.*this event/i)
await expect(bookmark).not.toBeVisible()
})
test('organizer does not see bookmark', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
)
await page.addInitScript(seedEvents([organizerSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
const bookmark = page.getByLabel(/watch.*this event/i)
await expect(bookmark).not.toBeVisible()
})
})
test.describe('US6: Un-watch from event list', () => {
test('deleting a watched event skips confirmation dialog', async ({ page }) => {
await page.addInitScript(seedEvents([watchSeed()]))
await page.goto('/')
await expect(page.getByText('Summer BBQ')).toBeVisible()
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
// No confirmation dialog — event removed immediately
await expect(page.getByText('Remove event?')).not.toBeVisible()
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
})
})
test.describe('US7: Watcher upgrades to attendee', () => {
test('watch → RSVP → bookmark disappears, list shows Attendee', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
http.post('*/api/events/:token/rsvps', () => {
return HttpResponse.json(
{ rsvpToken: 'new-rsvp-token', name: 'Max' },
{ status: 201 },
)
}),
)
await page.addInitScript(seedEvents([watchSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Verify watching state — bookmark visible
const bookmark = page.getByLabel(/watch.*this event/i)
await expect(bookmark).toBeVisible()
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
// RSVP
await page.getByRole('button', { name: "I'm attending" }).click()
const dialog = page.getByRole('dialog', { name: 'RSVP' })
await dialog.getByLabel('Your name').fill('Max')
await dialog.getByRole('button', { name: 'Count me in' }).click()
// Bookmark gone — status bar shown instead
await expect(bookmark).not.toBeVisible()
// Navigate to list via back link
await page.getByLabel('Back to home').click()
await expect(page.getByText('Attending')).toBeVisible()
await expect(page.getByText('Watching')).not.toBeVisible()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,7 @@
"@vue/tsconfig": "^0.9.0",
"eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.54.0",
"eslint-plugin-oxlint": "~1.55.0",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1",
"jsdom": "^28.1.0",
@@ -48,11 +48,17 @@
"oxlint": "~1.55.0",
"prettier": "3.8.1",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vite": "^8.0.0",
"vite-plugin-vue-devtools": "^8.0.6",
"vitest": "^4.0.18",
"vue-tsc": "^3.2.5"
},
"browserslist": [
">= 0.5%",
"last 2 versions",
"Firefox ESR",
"not dead"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}

View File

@@ -1,9 +1,35 @@
<template>
<div class="app-container">
<header v-if="route.name !== 'home'" class="app-header">
<BackLink />
<div id="header-actions"></div>
</header>
<RouterView />
</div>
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
import { RouterView, useRoute } from 'vue-router'
import BackLink from '@/components/BackLink.vue'
const route = useRoute()
</script>
<style scoped>
.app-header {
position: fixed;
top: 0;
left: 0;
right: 0;
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>

View File

@@ -18,6 +18,17 @@
--color-card: #ffffff;
--color-dark-base: #1B1730;
/* Danger / destructive actions */
--color-danger: #fca5a5;
--color-danger-bg: rgba(220, 38, 38, 0.15);
--color-danger-bg-hover: rgba(220, 38, 38, 0.25);
--color-danger-bg-strong: rgba(220, 38, 38, 0.2);
--color-danger-border: rgba(220, 38, 38, 0.3);
--color-danger-border-strong: rgba(220, 38, 38, 0.4);
--color-danger-solid: #d32f2f;
--color-danger-solid-hover: #b71c1c;
--color-danger-solid-text: #fff;
/* Glass system */
--color-glass: rgba(255, 255, 255, 0.1);
--color-glass-strong: rgba(255, 255, 255, 0.15);
@@ -151,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 {
@@ -206,7 +238,7 @@ textarea.form-field {
/* Error message */
.field-error {
color: #fff;
color: var(--color-danger-solid);
font-size: 0.875rem;
font-weight: 600;
padding-left: 0.25rem;
@@ -233,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) {
@@ -245,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) */
@@ -287,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;
@@ -317,7 +413,8 @@ textarea.form-field {
gap: var(--spacing-md);
}
.rsvp-form__label {
.rsvp-form__label,
.cancel-form__label {
font-size: 0.85rem;
font-weight: 700;
color: var(--color-text-on-gradient);
@@ -325,7 +422,7 @@ textarea.form-field {
}
.rsvp-form__field-error {
color: #d32f2f;
color: var(--color-danger-solid);
font-size: 0.875rem;
font-weight: 600;
padding-left: 0.25rem;

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@
<span class="event-card__time">{{ displayTime }}</span>
</RouterLink>
<span v-if="eventRole" class="event-card__badge" :class="`event-card__badge--${eventRole}`">
{{ eventRole === 'organizer' ? 'Organizer' : 'Attendee' }}
{{ eventRole === 'organizer' ? 'Organizing' : eventRole === 'attendee' ? 'Attending' : 'Watching' }}
</span>
<button
class="event-card__delete"
@@ -34,7 +34,7 @@ const props = defineProps<{
title: string
relativeTime: string
isPast: boolean
eventRole?: 'organizer' | 'attendee'
eventRole?: 'organizer' | 'attendee' | 'watcher'
timeDisplayMode?: 'clock' | 'relative'
dateTime?: string
}>()
@@ -152,6 +152,12 @@ function onTouchEnd() {
color: var(--color-text-bright);
}
.event-card__badge--watcher {
background: var(--color-glass);
color: var(--color-text-secondary);
border: 1px solid var(--color-glass-border);
}
.event-card__delete {
flex-shrink: 0;
width: 28px;
@@ -169,7 +175,7 @@ function onTouchEnd() {
}
.event-card__delete:hover {
color: #d32f2f;
color: var(--color-danger-solid);
background: rgba(211, 47, 47, 0.08);
}

View File

@@ -27,7 +27,7 @@
</section>
<ConfirmDialog
:open="!!pendingDeleteToken"
title="Remove event?"
:title="deleteDialogTitle"
:message="deleteDialogMessage"
confirm-label="Remove"
cancel-label="Cancel"
@@ -49,13 +49,26 @@ import DateSubheader from './DateSubheader.vue'
import ConfirmDialog from './ConfirmDialog.vue'
import type { StoredEvent } from '../composables/useEventStorage'
const { getStoredEvents, getRsvp, removeEvent } = useEventStorage()
const { getStoredEvents, getRsvp, getOrganizerToken, removeEvent } = useEventStorage()
const pendingDeleteToken = ref<string | null>(null)
const deleteError = ref('')
const pendingDeleteRole = computed(() => {
if (!pendingDeleteToken.value) return null
const event = getStoredEvents().find((e) => e.eventToken === pendingDeleteToken.value)
return event ? getRole(event) : null
})
const deleteDialogTitle = computed(() => {
return pendingDeleteRole.value === 'organizer' ? 'Cancel event?' : 'Remove event?'
})
const deleteDialogMessage = computed(() => {
if (!pendingDeleteToken.value) return ''
if (pendingDeleteRole.value === 'organizer') {
return 'This will permanently cancel the event for all attendees.'
}
const rsvp = getRsvp(pendingDeleteToken.value)
if (rsvp) {
return 'This event will be removed from your list and your attendance will be cancelled.'
@@ -65,6 +78,11 @@ const deleteDialogMessage = computed(() => {
function requestDelete(eventToken: string) {
deleteError.value = ''
const role = getRole(getStoredEvents().find((e) => e.eventToken === eventToken)!)
if (role === 'watcher') {
removeEvent(eventToken)
return
}
pendingDeleteToken.value = eventToken
}
@@ -72,14 +90,40 @@ 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) {
try {
const { response } = await api.DELETE('/events/{token}/rsvps/{rsvpToken}', {
const { response } = await api.DELETE('/events/{eventToken}/rsvps/{rsvpToken}', {
params: {
path: {
token: eventToken,
eventToken: eventToken,
rsvpToken: rsvp.rsvpToken,
},
},
@@ -105,10 +149,10 @@ function cancelDelete() {
pendingDeleteToken.value = null
}
function getRole(event: StoredEvent): 'organizer' | 'attendee' | undefined {
function getRole(event: StoredEvent): 'organizer' | 'attendee' | 'watcher' {
if (event.organizerToken) return 'organizer'
if (event.rsvpToken) return 'attendee'
return undefined
return 'watcher'
}
const groupedSections = computed(() => {

View File

@@ -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
@@ -25,16 +35,38 @@
type="button"
@click="$emit('cancel')"
>
Cancel attendance
Cancel RSVP
</button>
</Transition>
</div>
<!-- CTA state: no RSVP yet -->
<div v-else 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 v-else class="rsvp-bar__row">
<div class="bar-icon glow-border glow-border--animated">
<button
class="bar-icon-btn glass-inner"
type="button"
:aria-label="bookmarked ? 'Stop watching this event' : 'Watch this event'"
@click="$emit('bookmark')"
>
<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>
@@ -45,11 +77,14 @@ import { ref, watch } from 'vue'
const props = defineProps<{
hasRsvp?: boolean
bookmarked?: boolean
}>()
defineEmits<{
open: []
cancel: []
bookmark: []
calendar: []
}>()
const expanded = ref(false)
@@ -92,33 +127,11 @@ watch(expanded, (isExpanded) => {
max-width: var(--content-max-width);
}
.rsvp-bar__cta {
width: 100%;
border-radius: var(--radius-button);
transition: transform 0.1s ease;
.rsvp-bar__row {
display: flex;
gap: var(--spacing-sm);
}
.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;
@@ -126,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;
@@ -134,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);
@@ -186,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;
@@ -206,4 +224,37 @@ watch(expanded, (isExpanded) => {
opacity: 0;
transform: translateY(-4px);
}
/* Calendar button — glassmorphic variant (post-RSVP status row) */
.rsvp-bar__calendar-glass {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md);
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__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;
}
</style>

View File

@@ -55,12 +55,18 @@ describe('EventCard', () => {
it('renders organizer badge when eventRole is organizer', () => {
const wrapper = mountCard({ eventRole: 'organizer' })
expect(wrapper.text()).toContain('Organizer')
expect(wrapper.text()).toContain('Organizing')
})
it('renders attendee badge when eventRole is attendee', () => {
const wrapper = mountCard({ eventRole: 'attendee' })
expect(wrapper.text()).toContain('Attendee')
expect(wrapper.text()).toContain('Attending')
})
it('renders watcher badge when eventRole is watcher', () => {
const wrapper = mountCard({ eventRole: 'watcher' })
expect(wrapper.find('.event-card__badge--watcher').exists()).toBe(true)
expect(wrapper.text()).toContain('Watching')
})
it('renders no badge when eventRole is undefined', () => {

View File

@@ -1,8 +1,15 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import EventList from '../EventList.vue'
vi.mock('../../api/client', () => ({
api: {
PATCH: vi.fn(),
DELETE: vi.fn(),
},
}))
const router = createRouter({
history: createMemoryHistory(),
routes: [
@@ -20,8 +27,12 @@ const mockEvents = [
{ eventToken: 'today-1', title: 'Today Event', dateTime: '2026-03-11T18:00:00' },
{ eventToken: 'week-1', title: 'This Week Event', dateTime: '2026-03-13T10:00:00' },
{ eventToken: 'nextweek-1', title: 'Next Week Event', dateTime: '2026-03-16T10:00:00' },
{ eventToken: 'org-1', title: 'Organized Event', dateTime: '2026-03-11T19:00:00', organizerToken: 'org-token' },
{ eventToken: 'rsvp-1', title: 'Attending Event', dateTime: '2026-03-11T20:00:00', rsvpToken: 'rsvp-token', rsvpName: 'Max' },
]
const removeEventMock = vi.fn()
vi.mock('../../composables/useEventStorage', () => ({
isValidStoredEvent: (e: unknown) => {
if (typeof e !== 'object' || e === null) return false
@@ -32,7 +43,21 @@ vi.mock('../../composables/useEventStorage', () => ({
},
useEventStorage: () => ({
getStoredEvents: () => mockEvents,
removeEvent: vi.fn(),
getRsvp: (token: string) => {
const evt = mockEvents.find((e) => e.eventToken === token)
if (evt && 'rsvpToken' in evt && 'rsvpName' in evt) {
return { rsvpToken: evt.rsvpToken, rsvpName: evt.rsvpName }
}
return undefined
},
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,
}),
}))
@@ -40,7 +65,9 @@ vi.mock('../../composables/useRelativeTime', () => ({
formatRelativeTime: (dateTime: string) => {
if (dateTime.includes('03-01')) return '10 days ago'
if (dateTime.includes('06-15')) return 'in 1 year'
if (dateTime.includes('03-11')) return 'in 6 hours'
if (dateTime.includes('03-11T18')) return 'in 6 hours'
if (dateTime.includes('03-11T19')) return 'in 7 hours'
if (dateTime.includes('03-11T20')) return 'in 8 hours'
if (dateTime.includes('03-13')) return 'in 2 days'
if (dateTime.includes('03-16')) return 'in 5 days'
return 'sometime'
@@ -49,7 +76,10 @@ vi.mock('../../composables/useRelativeTime', () => ({
function mountList() {
return mount(EventList, {
global: { plugins: [router] },
global: {
plugins: [router],
stubs: { Teleport: true },
},
})
}
@@ -89,7 +119,7 @@ describe('EventList', () => {
it('renders all valid events as cards', () => {
const wrapper = mountList()
const cards = wrapper.findAll('.event-card')
expect(cards).toHaveLength(5)
expect(cards).toHaveLength(7)
})
it('marks past events with isPast class', () => {
@@ -137,4 +167,140 @@ describe('EventList', () => {
const pastSection = wrapper.findAll('.event-section')[4]!
expect(pastSection.find('.date-subheader').exists()).toBe(true)
})
it('assigns watcher role when event has no organizerToken and no rsvpToken', () => {
const wrapper = mountList()
const badges = wrapper.findAll('.event-card__badge--watcher')
expect(badges.length).toBeGreaterThanOrEqual(1)
expect(badges[0]!.text()).toBe('Watching')
})
it('assigns organizer role when event has organizerToken', () => {
const wrapper = mountList()
const badge = wrapper.find('.event-card__badge--organizer')
expect(badge.exists()).toBe(true)
expect(badge.text()).toBe('Organizing')
})
it('assigns attendee role when event has rsvpToken', () => {
const wrapper = mountList()
const badge = wrapper.find('.event-card__badge--attendee')
expect(badge.exists()).toBe(true)
expect(badge.text()).toBe('Attending')
})
})
describe('EventList — Organizer Cancel (US1)', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
afterEach(() => {
vi.useRealTimers()
})
it('T005: confirmDelete calls PATCH cancel-event API when role is organizer', async () => {
const { api } = await import('../../api/client')
const patchMock = vi.mocked(api.PATCH)
patchMock.mockResolvedValue({ response: { status: 204 } } as never)
const wrapper = mountList()
// Find the organizer event delete button and click it
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
expect(orgCard).toBeTruthy()
await orgCard!.find('.event-card__delete').trigger('click')
// Confirm the dialog
const confirmBtn = wrapper.find('.confirm-dialog__btn--confirm')
await confirmBtn.trigger('click')
await flushPromises()
expect(patchMock).toHaveBeenCalledWith(
'/events/{eventToken}',
expect.objectContaining({
params: expect.objectContaining({
path: { eventToken: 'org-1' },
query: { organizerToken: 'org-token' },
}),
body: { cancelled: true },
}),
)
})
it('T006: confirmDelete treats 409 response as success (removes event from list)', async () => {
const { api } = await import('../../api/client')
const patchMock = vi.mocked(api.PATCH)
patchMock.mockResolvedValue({ response: { status: 409 } } as never)
removeEventMock.mockClear()
const wrapper = mountList()
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
await orgCard!.find('.event-card__delete').trigger('click')
const confirmBtn = wrapper.find('.confirm-dialog__btn--confirm')
await confirmBtn.trigger('click')
await flushPromises()
// 409 should be treated as success — removeEvent should have been called
expect(removeEventMock).toHaveBeenCalledWith('org-1')
})
it('T006b: confirmDelete treats 404 response as success (removes event from list)', async () => {
const { api } = await import('../../api/client')
const patchMock = vi.mocked(api.PATCH)
patchMock.mockResolvedValue({ response: { status: 404 } } as never)
removeEventMock.mockClear()
const wrapper = mountList()
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
await orgCard!.find('.event-card__delete').trigger('click')
const confirmBtn = wrapper.find('.confirm-dialog__btn--confirm')
await confirmBtn.trigger('click')
await flushPromises()
expect(removeEventMock).toHaveBeenCalledWith('org-1')
})
})
describe('EventList — Dialog Differentiation (US2)', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
afterEach(() => {
vi.useRealTimers()
})
it('T013: deleteDialogTitle returns organizer-specific text when role is organizer', async () => {
const wrapper = mountList()
// Click delete on organizer event
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
await orgCard!.find('.event-card__delete').trigger('click')
const dialogTitle = wrapper.find('.confirm-dialog__title')
expect(dialogTitle.text()).toBe('Cancel event?')
})
it('T014: deleteDialogMessage returns existing attendee text when role is attendee', async () => {
const wrapper = mountList()
// Click delete on attendee event
const attCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Attending Event'))
await attCard!.find('.event-card__delete').trigger('click')
const dialogTitle = wrapper.find('.confirm-dialog__title')
expect(dialogTitle.text()).toBe('Remove event?')
const dialogMsg = wrapper.find('.confirm-dialog__message')
expect(dialogMsg.text()).toContain('attendance will be cancelled')
})
})

View File

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

View File

@@ -194,6 +194,71 @@ describe('useEventStorage', () => {
})
})
describe('useEventStorage saveWatch / isStored', () => {
beforeEach(() => {
clearStorage()
})
it('saves a watch-only event (no rsvpToken, no organizerToken)', () => {
const { saveWatch, getStoredEvents } = useEventStorage()
saveWatch('watch-1', 'Concert', '2026-07-01T20:00:00+02:00')
const events = getStoredEvents()
expect(events).toHaveLength(1)
expect(events[0]!.eventToken).toBe('watch-1')
expect(events[0]!.title).toBe('Concert')
expect(events[0]!.dateTime).toBe('2026-07-01T20:00:00+02:00')
expect(events[0]!.rsvpToken).toBeUndefined()
expect(events[0]!.organizerToken).toBeUndefined()
})
it('does not duplicate if event already stored', () => {
const { saveWatch, saveRsvp, getStoredEvents } = useEventStorage()
saveRsvp('evt-1', 'rsvp-1', 'Max', 'Party', '2026-07-01T20:00:00+02:00')
saveWatch('evt-1', 'Party', '2026-07-01T20:00:00+02:00')
expect(getStoredEvents()).toHaveLength(1)
expect(getStoredEvents()[0]!.rsvpToken).toBe('rsvp-1')
})
it('isStored returns true for watched events', () => {
const { saveWatch, isStored } = useEventStorage()
saveWatch('watch-1', 'Concert', '2026-07-01T20:00:00+02:00')
expect(isStored('watch-1')).toBe(true)
})
it('isStored returns true for attended events', () => {
const { saveRsvp, isStored } = useEventStorage()
saveRsvp('evt-1', 'rsvp-1', 'Max', 'Party', '2026-07-01T20:00:00+02:00')
expect(isStored('evt-1')).toBe(true)
})
it('isStored returns true for organized events', () => {
const { saveCreatedEvent, isStored } = useEventStorage()
saveCreatedEvent({
eventToken: 'evt-1',
organizerToken: 'org-1',
title: 'My Event',
dateTime: '2026-07-01T20:00:00+02:00',
})
expect(isStored('evt-1')).toBe(true)
})
it('isStored returns false for unknown tokens', () => {
const { isStored } = useEventStorage()
expect(isStored('unknown')).toBe(false)
})
})
describe('isValidStoredEvent', () => {
// Import directly since it's an exported function
let isValidStoredEvent: (e: unknown) => boolean

View 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')
}
})
})

View File

@@ -88,10 +88,24 @@ export function useEventStorage() {
}
}
function saveWatch(eventToken: string, title: string, dateTime: string): void {
const events = readEvents()
const existing = events.find((e) => e.eventToken === eventToken)
if (!existing) {
events.push({ eventToken, title, dateTime })
writeEvents(events)
}
}
function isStored(eventToken: string): boolean {
void version.value
return readEvents().some((e) => e.eventToken === eventToken)
}
function removeEvent(eventToken: string): void {
const events = readEvents().filter((e) => e.eventToken !== eventToken)
writeEvents(events)
}
return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp, removeRsvp, removeEvent }
return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp, removeRsvp, saveWatch, isStored, removeEvent }
}

View 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 }
}

View 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')
})
})

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

View File

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

View File

@@ -8,12 +8,35 @@
alt=""
/>
<div class="detail__hero-overlay" />
<header class="detail__header">
<RouterLink to="/" class="detail__back" aria-label="Back to home">&larr;</RouterLink>
<span class="detail__brand">fete</span>
</header>
</div>
<!-- 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">
@@ -25,25 +48,31 @@
<!-- Loaded state -->
<div v-else-if="state === 'loaded' && event" class="detail__content">
<!-- Cancellation banner -->
<div v-if="event.cancelled" class="detail__cancelled-banner" role="alert">
<p class="detail__cancelled-banner-title">This event has been cancelled</p>
<p v-if="event.cancellationReason" class="detail__cancelled-banner-reason">{{ event.cancellationReason }}</p>
</div>
<h1 class="detail__title">{{ event.title }}</h1>
<dl class="detail__meta">
<div class="detail__meta-item">
<dt class="detail__meta-icon glass" aria-label="Date and time">
<dt class="detail__meta-icon" aria-label="Date and time">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</dt>
<dd class="detail__meta-text">{{ formattedDateTime }}</dd>
</div>
<div v-if="event.location" class="detail__meta-item">
<dt class="detail__meta-icon glass" aria-label="Location">
<dt class="detail__meta-icon" aria-label="Location">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
</dt>
<dd class="detail__meta-text">{{ event.location }}</dd>
</div>
<div class="detail__meta-item">
<dt class="detail__meta-icon glass" aria-label="Attendees">
<dt class="detail__meta-icon" aria-label="Attendees">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</dt>
<dd class="detail__meta-text">{{ event.attendeeCount }} going</dd>
@@ -70,25 +99,77 @@
</div>
</div>
<!-- Cancel error message -->
<!-- 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 -->
<BottomSheet :open="cancelSheetOpen" label="Cancel event" @close="cancelSheetOpen = false">
<h2 class="sheet-title">Cancel event</h2>
<form class="cancel-form" @submit.prevent="handleCancelEvent" novalidate>
<div class="form-group">
<label class="cancel-form__label" for="cancel-reason">Reason (optional)</label>
<textarea
id="cancel-reason"
v-model.trim="cancelReasonInput"
class="form-field glass cancel-form__textarea"
placeholder="e.g. Venue no longer available"
maxlength="2000"
rows="3"
@input="cancelEventError = ''"
/>
<span class="cancel-form__counter">{{ cancelReasonInput.length }} / 2000</span>
</div>
<button
class="cancel-form__confirm glass-inner"
type="submit"
:disabled="cancellingEvent"
>
{{ cancellingEvent ? 'Cancelling…' : 'Confirm cancellation' }}
</button>
<p v-if="cancelEventError" class="cancel-form__error" role="alert">{{ cancelEventError }}</p>
</form>
</BottomSheet>
<!-- Cancel RSVP error message -->
<div v-if="cancelError" class="detail__cancel-error" role="alert">
<p>{{ cancelError }}</p>
</div>
<!-- RSVP bar -->
<!-- RSVP bar (hidden when cancelled) -->
<RsvpBar
v-if="state === 'loaded' && event && !isOrganizer"
v-if="state === 'loaded' && event && !isOrganizer && !event.cancelled"
:has-rsvp="!!rsvpName"
:bookmarked="eventIsStored"
@open="sheetOpen = true"
@cancel="confirmCancelOpen = true"
@bookmark="handleBookmarkClick"
@calendar="handleCalendarDownload"
/>
<!-- Cancel confirmation dialog -->
<ConfirmDialog
:open="confirmCancelOpen"
title="Cancel attendance?"
message="Your attendance will be permanently cancelled."
confirm-label="Cancel attendance"
title="Cancel RSVP?"
message="The organizer will no longer see you as attending."
confirm-label="Cancel RSVP"
cancel-label="Keep"
@confirm="handleCancelRsvp"
@cancel="confirmCancelOpen = false"
@@ -124,10 +205,11 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
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'
@@ -138,7 +220,8 @@ type GetEventResponse = components['schemas']['GetEventResponse']
type State = 'loading' | 'loaded' | 'not-found' | 'error'
const route = useRoute()
const { saveRsvp, getRsvp, removeRsvp, getOrganizerToken } = useEventStorage()
const { saveRsvp, getRsvp, removeRsvp, getOrganizerToken, saveWatch, isStored, removeEvent } = useEventStorage()
const { download: downloadIcal } = useIcalDownload()
const state = ref<State>('loading')
const event = ref<GetEventResponse | null>(null)
@@ -155,6 +238,55 @@ 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('')
const cancelEventError = ref('')
const cancellingEvent = ref(false)
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
if (eventIsStored.value) {
removeEvent(eventToken.value)
} else {
saveWatch(eventToken.value, event.value.title, event.value.dateTime)
}
}
const formattedDateTime = computed(() => {
if (!event.value) return ''
const formatted = new Intl.DateTimeFormat(undefined, {
@@ -169,8 +301,8 @@ async function fetchEvent() {
event.value = null
try {
const { data, error, response } = await api.GET('/events/{token}', {
params: { path: { token: route.params.eventToken as string } },
const { data, error, response } = await api.GET('/events/{eventToken}', {
params: { path: { eventToken: route.params.eventToken as string } },
})
if (error) {
@@ -217,8 +349,8 @@ async function submitRsvp() {
submitting.value = true
try {
const { data, error } = await api.POST('/events/{token}/rsvps', {
params: { path: { token: route.params.eventToken as string } },
const { data, error } = await api.POST('/events/{eventToken}/rsvps', {
params: { path: { eventToken: route.params.eventToken as string } },
body: { name: nameInput.value },
})
@@ -256,10 +388,10 @@ async function handleCancelRsvp() {
if (!stored) return
try {
const { response } = await api.DELETE('/events/{token}/rsvps/{rsvpToken}', {
const { response } = await api.DELETE('/events/{eventToken}/rsvps/{rsvpToken}', {
params: {
path: {
token: route.params.eventToken as string,
eventToken: route.params.eventToken as string,
rsvpToken: stored.rsvpToken,
},
},
@@ -279,11 +411,45 @@ async function handleCancelRsvp() {
}
}
async function handleCancelEvent() {
cancelEventError.value = ''
cancellingEvent.value = true
const orgToken = getOrganizerToken(route.params.eventToken as string)
if (!orgToken) return
try {
const { error } = await api.PATCH('/events/{eventToken}', {
params: {
path: { eventToken: route.params.eventToken as string },
query: { organizerToken: orgToken },
},
body: {
cancelled: true,
cancellationReason: cancelReasonInput.value || undefined,
},
})
if (error) {
cancelEventError.value = 'Could not cancel event. Please try again.'
return
}
cancelSheetOpen.value = false
cancelReasonInput.value = ''
await fetchEvent()
} catch {
cancelEventError.value = 'Could not cancel event. Please try again.'
} finally {
cancellingEvent.value = false
}
}
async function fetchAttendees(eventToken: string, organizerToken: string) {
try {
const { data, error } = await api.GET('/events/{token}/attendees', {
const { data, error } = await api.GET('/events/{eventToken}/attendees', {
params: {
path: { token: eventToken },
path: { eventToken: eventToken },
query: { organizerToken },
},
})
@@ -340,32 +506,7 @@ onMounted(fetchEvent)
var(--color-glass-overlay) 0%,
transparent 50%
);
}
.detail__header {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-lg) var(--content-padding);
padding-top: env(safe-area-inset-top, var(--spacing-lg));
z-index: 1;
}
.detail__back {
color: var(--color-text-on-gradient);
font-size: 1.5rem;
text-decoration: none;
line-height: 1;
}
.detail__brand {
font-size: 1.3rem;
font-weight: 700;
color: var(--color-text-on-gradient);
pointer-events: none;
}
.detail__body {
@@ -388,6 +529,10 @@ onMounted(fetchEvent)
padding-top: 4rem;
}
.detail__meta-icon svg {
display: block;
}
/* Title */
.detail__title {
font-size: 2rem;
@@ -420,6 +565,10 @@ onMounted(fetchEvent)
justify-content: center;
border-radius: 10px;
color: var(--color-text-on-gradient);
line-height: 0;
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
border: 1px solid var(--color-glass-border);
backdrop-filter: blur(16px);
}
.detail__meta-text {
@@ -521,4 +670,165 @@ onMounted(fetchEvent)
opacity: 0.6;
cursor: not-allowed;
}
/* Cancellation banner */
.detail__cancelled-banner {
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius-card);
background: var(--color-danger-bg);
border: 1px solid var(--color-danger-border-strong);
text-align: center;
}
.detail__cancelled-banner-title {
font-weight: 700;
font-size: 0.95rem;
color: var(--color-danger);
}
.detail__cancelled-banner-reason {
margin-top: var(--spacing-xs);
font-size: 0.85rem;
color: var(--color-text-soft);
word-break: break-word;
}
/* 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;
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__organizer-bar-inner {
width: 100%;
max-width: var(--content-max-width);
display: flex;
gap: var(--spacing-sm);
}
/* Cancel event form (inside bottom sheet) */
.cancel-form__textarea {
resize: vertical;
min-height: 4rem;
font-family: inherit;
}
.cancel-form__counter {
display: block;
text-align: right;
font-size: 0.75rem;
color: var(--color-text-muted);
margin-top: var(--spacing-xs);
}
.cancel-form__confirm {
display: block;
width: 100%;
margin-top: var(--spacing-md);
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius-button);
font-family: inherit;
font-size: 1rem;
font-weight: 700;
color: var(--color-danger-solid-text);
background: var(--color-danger-solid);
border: none;
cursor: pointer;
transition: background 0.15s ease;
}
.cancel-form__confirm:hover {
background: var(--color-danger-solid-hover);
}
.cancel-form__confirm:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.cancel-form__error {
margin-top: var(--spacing-sm);
font-size: 0.85rem;
color: var(--color-danger);
text-align: center;
}
</style>

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
# Specification Quality Checklist: Cancel Event
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-12
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
- Feature scope is deliberately tight: cancel + display. No notifications, no undo, no event-list changes.
- Both user stories are P1 because they are two sides of the same coin (cancel action + display result).

View File

@@ -0,0 +1,78 @@
# OpenAPI additions for Cancel Event feature
# To be merged into backend/src/main/resources/openapi/api.yaml
# PATCH method added to existing /events/{eventToken} path
# Under paths./events/{eventToken}:
# --- Add PATCH method to existing path ---
# /events/{eventToken}:
# patch:
# operationId: patchEvent
# summary: Update an event (currently: cancel)
# description: |
# Partial update of an event resource. Currently the only supported operation
# is cancellation (setting cancelled to true). Requires the organizer token.
# Cancellation is irreversible.
# tags: [Events]
# parameters:
# - $ref: '#/components/parameters/EventToken'
# requestBody:
# required: true
# content:
# application/json:
# schema:
# $ref: '#/components/schemas/PatchEventRequest'
# responses:
# '204':
# description: Event updated successfully
# '403':
# description: Invalid organizer token
# content:
# application/json:
# schema:
# $ref: '#/components/schemas/ErrorResponse'
# '404':
# description: Event not found
# content:
# application/json:
# schema:
# $ref: '#/components/schemas/ErrorResponse'
# '409':
# description: Event is already cancelled
# content:
# application/json:
# schema:
# $ref: '#/components/schemas/ErrorResponse'
# --- New schemas ---
# PatchEventRequest:
# type: object
# required: [organizerToken, cancelled]
# properties:
# organizerToken:
# type: string
# format: uuid
# description: The organizer token proving ownership of the event
# example: "550e8400-e29b-41d4-a716-446655440001"
# cancelled:
# type: boolean
# description: Set to true to cancel the event (irreversible)
# example: true
# cancellationReason:
# type: string
# maxLength: 2000
# description: Optional cancellation reason
# example: "Unfortunately the venue is no longer available."
# --- Extended schema: GetEventResponse ---
# Add to existing GetEventResponse properties:
# cancelled:
# type: boolean
# description: Whether the event has been cancelled
# example: false
# cancellationReason:
# type: string
# nullable: true
# description: Reason for cancellation, if provided
# example: null

View File

@@ -0,0 +1,82 @@
# Data Model: Cancel Event
**Feature Branch**: `016-cancel-event` | **Date**: 2026-03-12
## Entity Changes
### Event (extended)
Two new fields added to the existing Event entity:
| Field | Type | Constraints | Description |
|--------------------|----------------|------------------------------|--------------------------------------------------|
| cancelled | boolean | NOT NULL, DEFAULT false | Whether the event has been cancelled |
| cancellationReason | String (2000) | Nullable | Optional reason provided by organizer |
### State Transition
```
ACTIVE ──cancel()──► CANCELLED
```
- One-way transition only. No path from CANCELLED back to ACTIVE.
- `cancel()` sets `cancelled = true` and optionally sets `cancellationReason`.
- Once cancelled, the event remains visible but RSVP creation is blocked.
### Validation Rules
- `cancellationReason` max length: 2000 characters (matches description field).
- `cancellationReason` is plain text only (no HTML/markdown).
- `cancelled` can only transition from `false` to `true`, never back.
- Existing RSVPs are preserved when an event is cancelled (no cascade).
## Database Migration (Liquibase Changeset 004)
```xml
<changeSet id="004-add-cancellation-columns" author="fete">
<addColumn tableName="events">
<column name="cancelled" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="cancellation_reason" type="VARCHAR(2000)"/>
</addColumn>
</changeSet>
```
## Domain Model Impact
### Event.java (domain)
Add fields:
```java
private boolean cancelled;
private String cancellationReason;
```
Add method:
```java
public void cancel(String reason) {
if (this.cancelled) {
throw new EventAlreadyCancelledException();
}
this.cancelled = true;
this.cancellationReason = reason;
}
```
### EventJpaEntity.java (persistence)
Add columns:
```java
@Column(name = "cancelled", nullable = false)
private boolean cancelled;
@Column(name = "cancellation_reason", length = 2000)
private String cancellationReason;
```
## RSVP Impact
- `POST /events/{eventToken}/rsvps` must check `event.isCancelled()` before accepting.
- If cancelled → return `409 Conflict`.
- Existing RSVPs remain untouched — no delete, no status change.

View File

@@ -0,0 +1,79 @@
# Implementation Plan: Cancel Event
**Branch**: `016-cancel-event` | **Date**: 2026-03-12 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/016-cancel-event/spec.md`
## Summary
Allow organizers to permanently cancel events via a bottom sheet UI. Cancelled events display a red banner to visitors and block new RSVPs. Implementation adds a `PATCH /events/{eventToken}` endpoint, extends the Event entity with `cancelled` and `cancellationReason` fields, and reuses the existing `BottomSheet.vue` component for the cancel interaction.
## Technical Context
**Language/Version**: Java 25 (backend), TypeScript 5.9 (frontend)
**Primary Dependencies**: Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
**Storage**: PostgreSQL (JPA via Spring Data, Liquibase migrations)
**Testing**: JUnit (backend), Vitest (frontend unit), Playwright + MSW (frontend E2E)
**Target Platform**: Self-hosted Linux server, mobile-first PWA
**Project Type**: Web application (REST API + SPA)
**Performance Goals**: N/A — simple state transition, no performance-critical path
**Constraints**: Privacy by design (no analytics/tracking), WCAG AA, mobile-first
**Scale/Scope**: Single new endpoint, 2 new DB columns, 1 view extension
## 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 collection beyond organizer-provided reason. No analytics. |
| II. Test-Driven Methodology | PASS | TDD enforced: tests before implementation, E2E mandatory for both user stories. |
| III. API-First Development | PASS | OpenAPI spec updated first, types generated before implementation. `example:` fields included. |
| IV. Simplicity & Quality | PASS | Minimal change: 2 columns, 1 endpoint, reuse existing BottomSheet. No over-engineering. |
| V. Dependency Discipline | PASS | No new dependencies required. |
| VI. Accessibility | PASS | Reuses accessible BottomSheet component. Banner uses semantic HTML + ARIA. |
**Gate result: PASS** — no violations.
## Project Structure
### Documentation (this feature)
```text
specs/016-cancel-event/
├── plan.md # This file
├── spec.md # Feature specification
├── research.md # Phase 0 output — design decisions
├── data-model.md # Phase 1 output — entity changes
├── quickstart.md # Phase 1 output — implementation overview
├── contracts/ # Phase 1 output — API contract additions
│ └── patch-event-endpoint.yaml
└── tasks.md # Phase 2 output (/speckit.tasks command)
```
### Source Code (repository root)
```text
backend/
├── src/main/java/de/fete/
│ ├── domain/model/Event.java # + cancelled, cancellationReason, cancel()
│ ├── application/service/EventService.java # + CancelEventUseCase implementation
│ ├── adapter/in/web/EventController.java # + cancelEvent endpoint
│ └── adapter/out/persistence/
│ ├── EventJpaEntity.java # + cancelled, cancellation_reason columns
│ └── EventPersistenceAdapter.java # + mapper updates
├── src/main/resources/
│ ├── openapi/api.yaml # + cancel endpoint, request/response schemas
│ └── db/changelog/004-add-cancellation-columns.xml # New migration
└── src/test/java/de/fete/ # Unit + integration tests
frontend/
├── src/
│ └── views/EventDetailView.vue # + cancel button, bottom sheet, banner
└── e2e/ # E2E tests for both user stories
```
**Structure Decision**: Web application (Option 2) — matches existing project layout with `backend/` and `frontend/` at repository root.
## Complexity Tracking
> No violations — table not applicable.

View File

@@ -0,0 +1,48 @@
# Quickstart: Cancel Event
**Feature Branch**: `016-cancel-event`
## What This Feature Does
Adds the ability for an organizer to permanently cancel an event. Cancelled events display a red banner to visitors and block new RSVPs.
## Implementation Scope
### Backend
1. **Liquibase migration** (003): Add `cancelled` (boolean) and `cancellation_reason` (varchar 2000) columns to `events` table.
2. **Domain model**: Extend `Event.java` with `cancelled` and `cancellationReason` fields + `cancel()` method.
3. **JPA entity**: Extend `EventJpaEntity.java` with matching columns and mapper updates.
4. **OpenAPI spec**: Add `PATCH /events/{eventToken}` endpoint + extend `GetEventResponse` with cancellation fields.
5. **Use case**: New `CancelEventUseCase` interface + implementation in `EventService`.
6. **Controller**: Implement `cancelEvent` in `EventController`.
7. **RSVP guard**: Add cancelled check to RSVP creation (return 409).
### Frontend
1. **Cancel bottom sheet**: Add cancel button (organizer-only) + bottom sheet with textarea and confirm button in `EventDetailView.vue`.
2. **Cancellation banner**: Red banner at top of event detail when `cancelled === true`.
3. **RSVP hiding**: Hide `RsvpBar` when event is cancelled.
4. **API client**: Use generated types from updated OpenAPI spec.
### Testing
1. **Backend unit tests**: Cancel use case, RSVP rejection on cancelled events.
2. **Backend integration tests**: Full cancel flow via API.
3. **Frontend unit tests**: Cancel bottom sheet, banner display, RSVP hiding.
4. **E2E tests**: Organizer cancels event, attendee sees cancelled event.
## Key Files to Modify
| File | Change |
|------|--------|
| `backend/src/main/resources/openapi/api.yaml` | New endpoint + schema extensions |
| `backend/src/main/resources/db/changelog/` | New changeset 003 |
| `backend/src/main/java/de/fete/domain/model/Event.java` | Add cancelled fields + cancel() |
| `backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java` | Add columns |
| `backend/src/main/java/de/fete/application/service/EventService.java` | Implement cancel |
| `backend/src/main/java/de/fete/adapter/in/web/EventController.java` | Implement endpoint |
| `frontend/src/views/EventDetailView.vue` | Cancel button, bottom sheet, banner |
## Prerequisites
- Existing RSVP bottom sheet pattern (already implemented)
- Organizer token stored in localStorage (already implemented)
- `BottomSheet.vue` component (already exists)

View File

@@ -0,0 +1,87 @@
# Research: Cancel Event
**Feature Branch**: `016-cancel-event` | **Date**: 2026-03-12
## Decision 1: API Endpoint Design
**Decision**: Use `PATCH /events/{eventToken}` with organizer token and cancellation fields in request body.
**Rationale**:
- PATCH is standard REST for partial resource updates — cancellation is a state change on the event resource.
- The event is not removed, so DELETE is not appropriate. The event remains visible with a cancellation banner.
- The organizer token is sent in the request body to keep it out of URL/query strings and server access logs.
- Request body: `{ "organizerToken": "uuid", "cancelled": true, "cancellationReason": "optional string" }`.
- Response: `204 No Content` on success.
- Error responses: `404` if event not found, `403` if organizer token is wrong, `409` if already cancelled.
- Currently the only supported PATCH operation is cancellation. The endpoint validates that `cancelled` is `true` and rejects requests that attempt to set other fields.
**Alternatives considered**:
- `POST /events/{eventToken}/cancel` — rejected because a dedicated sub-resource endpoint is RPC-style, not RESTful. PATCH on the resource itself is the standard approach.
- `DELETE /events/{eventToken}` — rejected because the event is not deleted, it remains visible with a cancellation banner.
## Decision 2: Database Schema Extension
**Decision**: Add two columns to the `events` table: `cancelled BOOLEAN NOT NULL DEFAULT FALSE` and `cancellation_reason VARCHAR(2000)`.
**Rationale**:
- Boolean flag is the simplest representation of the cancelled state.
- 2000 chars matches the existing description field limit — consistent and generous.
- DEFAULT FALSE ensures backward compatibility with existing rows.
- A Liquibase changeset (003) adds both columns.
**Alternatives considered**:
- Enum status field (`ACTIVE`, `CANCELLED`) — rejected as over-engineering for a binary state with no other planned transitions.
- Separate cancellation table — rejected as unnecessary complexity for two columns.
## Decision 3: RSVP Blocking on Cancelled Events
**Decision**: The RSVP creation endpoint (`POST /events/{eventToken}/rsvps`) checks the event's cancelled flag and returns `409 Conflict` if the event is cancelled.
**Rationale**:
- Server-side enforcement is required (FR-006) — frontend hiding the button is not sufficient.
- 409 Conflict is semantically correct: the request conflicts with the current state of the resource.
- Existing RSVPs are preserved (FR-007) — no cascade or cleanup needed.
**Alternatives considered**:
- 400 Bad Request — rejected because the request itself is well-formed; the conflict is with resource state.
- 422 Unprocessable Entity — rejected because the issue is not validation but state conflict.
## Decision 4: Frontend Cancel Bottom Sheet
**Decision**: Reuse the existing `BottomSheet.vue` component. Add cancel-specific content (textarea + confirm button) directly in `EventDetailView.vue`, similar to how the RSVP form is embedded.
**Rationale**:
- The spec explicitly requires the bottom sheet pattern consistent with RSVP flow (FR-002).
- `BottomSheet.vue` is already a generic, accessible, glassmorphism-styled container.
- No need for a separate component — the cancel form is simple (textarea + button + error message).
- Error handling follows the same pattern as RSVP: inline error in the sheet, button re-enabled.
**Alternatives considered**:
- Separate `CancelBottomSheet.vue` component — rejected as unnecessary extraction for a simple form.
- ConfirmDialog instead of BottomSheet — rejected because spec explicitly requires bottom sheet.
## Decision 5: Organizer Token Authorization
**Decision**: The cancel endpoint receives the organizer token in the request body. The frontend retrieves it from localStorage via `useEventStorage.getOrganizerToken()`.
**Rationale**:
- Consistent with how organizer identity works throughout the app — token-based, no auth system.
- The organizer token is already stored in localStorage when the event is created.
- Body parameter keeps the token out of URL/query strings and server access logs.
**Alternatives considered**:
- Authorization header — rejected because there's no auth system; the organizer token is not a session token.
- Query parameter — rejected to keep token out of server logs (same reason the attendee endpoint should eventually be migrated away from query params).
## Decision 6: GetEventResponse Extension
**Decision**: Add `cancelled: boolean` and `cancellationReason: string | null` to the `GetEventResponse` schema.
**Rationale**:
- The frontend needs to know whether an event is cancelled to show the banner and hide RSVP buttons.
- Both fields are always returned (no separate endpoint needed).
- `cancelled` defaults to `false` for existing events.
**Alternatives considered**:
- Separate endpoint for cancellation status — rejected as unnecessary network overhead.
- Only return cancellation info for cancelled events — rejected because the frontend needs the boolean regardless to decide UI state.

View File

@@ -0,0 +1,97 @@
# Feature Specification: Cancel Event
**Feature Branch**: `016-cancel-event`
**Created**: 2026-03-12
**Status**: Draft
**Input**: User description: "As an organizer, I want to cancel an event so that attendees know the event will not take place"
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Organizer Cancels an Event (Priority: P1)
An organizer navigates to their event page (identified by having the organizer token). They decide to cancel the event. They tap a "Cancel Event" button, which opens a bottom sheet (visually similar to the attend/RSVP flow). The bottom sheet contains a text area for an optional cancellation reason and a confirm button. After confirming, the event is permanently marked as cancelled. This action is irreversible.
**Why this priority**: This is the core action of the feature. Without it, nothing else matters.
**Independent Test**: Can be fully tested by viewing the event with the organizer token, tapping cancel, optionally entering a reason, and confirming. The event's cancelled state persists on reload.
**Acceptance Scenarios**:
1. **Given** an active event viewed by someone who has the organizer token, **When** the organizer taps "Cancel Event", **Then** a bottom sheet opens with a text area and a confirm button.
2. **Given** the cancel bottom sheet is open, **When** the organizer enters a cancellation reason and taps the confirm button, **Then** the event is marked as cancelled with the provided reason.
3. **Given** the cancel bottom sheet is open, **When** the organizer taps confirm without entering a reason, **Then** the event is marked as cancelled without a reason.
4. **Given** a cancelled event, **When** the organizer revisits the event page, **Then** the event remains cancelled (irreversible).
5. **Given** the cancel bottom sheet is open, **When** the organizer taps confirm and the API call fails, **Then** an error message is displayed in the bottom sheet, the sheet remains open, and the confirm button is re-enabled for retry.
---
### User Story 2 - Attendee Sees Cancelled Event (Priority: P1)
An attendee (or any visitor) opens the event detail page for a cancelled event. A prominent red banner is displayed at the top of the page, clearly communicating that the event has been cancelled. If the organizer provided a cancellation reason, it is shown within the banner. The RSVP buttons are hidden — no new RSVPs can be submitted.
**Why this priority**: Equal to P1 because the cancellation must be visible to attendees for the feature to deliver value. Without this, cancelling has no effect from the attendee's perspective.
**Independent Test**: Can be tested by viewing a cancelled event's detail page and verifying the banner appears, the reason is displayed (if provided), and RSVP buttons are hidden.
**Acceptance Scenarios**:
1. **Given** a cancelled event with a cancellation reason, **When** a visitor opens the event detail page, **Then** a prominent red banner is displayed showing that the event is cancelled along with the reason.
2. **Given** a cancelled event without a cancellation reason, **When** a visitor opens the event detail page, **Then** a prominent red banner is displayed showing that the event is cancelled, without a reason text.
3. **Given** a cancelled event, **When** a visitor opens the event detail page, **Then** the RSVP buttons are not visible.
4. **Given** a cancelled event, **When** a visitor opens the event detail page, **Then** all other event details (title, date, location, description) remain visible.
---
### Edge Cases
- What happens when the organizer tries to cancel an already cancelled event? The cancel button is not available on an already cancelled event.
- What happens to existing RSVPs when an event is cancelled? They are preserved as-is but no new RSVPs can be submitted.
- What happens when the event is both cancelled and expired? The auto-delete mechanism (feature 013) continues to apply normally — cancelled events are deleted on the same schedule as non-cancelled events.
- What happens when the cancellation API call fails (network error, server error)? The bottom sheet remains open, a visible error message is displayed within the sheet, and the confirm button is re-enabled so the organizer can retry.
- How are cancelled events displayed in the event list? Out of scope for this feature — the event list view is not affected.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST allow the organizer to cancel an event via the organizer view.
- **FR-002**: The cancellation interaction MUST use a bottom sheet pattern consistent with the existing RSVP/attend flow.
- **FR-003**: The bottom sheet MUST contain a text area for an optional cancellation reason and a confirm button.
- **FR-004**: Cancellation MUST be irreversible — once cancelled, there is no way to undo it.
- **FR-005**: System MUST store a cancelled flag and an optional cancellation reason for the event.
- **FR-006**: System MUST NOT allow new RSVPs for a cancelled event.
- **FR-007**: System MUST preserve existing RSVPs when an event is cancelled.
- **FR-008**: The event detail page MUST display a prominent red banner for cancelled events.
- **FR-009**: The banner MUST include the cancellation reason when one was provided.
- **FR-010**: The RSVP buttons MUST be hidden on a cancelled event's detail page.
- **FR-011**: All other event information MUST remain visible on a cancelled event's detail page.
- **FR-012**: The cancel button MUST NOT be shown on an already cancelled event.
- **FR-013**: There MUST be no push notifications, emails, or any active notification mechanism for cancellations.
- **FR-014**: If the cancellation API call fails, the bottom sheet MUST remain open, display an error message, and allow the organizer to retry.
- **FR-015**: Changes to the event list view for cancelled events are explicitly OUT OF SCOPE for this feature.
### Key Entities
- **Event** (extended): Gains a cancelled state (boolean) and an optional cancellation reason (free text). An event can transition from active to cancelled, but not back.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Organizer can cancel an event in under 30 seconds (open bottom sheet, optionally type reason, confirm).
- **SC-002**: 100% of visitors to a cancelled event's detail page see the cancellation banner without scrolling.
- **SC-003**: 0% of cancelled events accept new RSVPs.
- **SC-004**: Existing RSVPs are fully preserved after cancellation (no data loss).
## Clarifications
### Session 2026-03-12
- Q: How should cancelled events appear in the event list view? → A: Out of scope for this feature — event list view is not affected.
- Q: What should happen when the cancellation API call fails? → A: Error message displayed in the bottom sheet, sheet remains open, confirm button re-enabled for retry.
## Assumptions
- The bottom sheet component pattern already exists from the RSVP/attend flow and can be reused.
- The cancellation reason has a maximum length of 2000 characters (consistent with the event description field).
- The cancellation reason is plain text (no formatting or markup).

View File

@@ -0,0 +1,96 @@
# Tasks: Cancel Event
**Input**: Design documents from `/specs/016-cancel-event/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/patch-event-endpoint.yaml
**Tests**: Included — constitution mandates TDD (tests before implementation) and E2E for every frontend user story.
**Organization**: Tasks grouped by user story. Both stories are P1 but US2 depends on US1's backend work.
## 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: Setup (Shared Infrastructure)
**Purpose**: OpenAPI spec, database migration, and domain model changes that both user stories depend on.
- [X] T001 Update OpenAPI spec with PATCH endpoint on `/events/{eventToken}` (organizerToken as query param), PatchEventRequest schema (`cancelled`, `cancellationReason`), and extend GetEventResponse with `cancelled`/`cancellationReason` fields in `backend/src/main/resources/openapi/api.yaml`
- [X] T002 Add Liquibase changeset 004 adding `cancelled` (BOOLEAN NOT NULL DEFAULT FALSE) and `cancellation_reason` (VARCHAR 2000) columns to `events` table in `backend/src/main/resources/db/changelog/004-add-cancellation-columns.xml` and register it in `db.changelog-master.xml`
- [X] T003 [P] Extend domain model `Event.java` with `cancelled`, `cancellationReason` fields and `cancel(String reason)` method (throws `EventAlreadyCancelledException`). Create `EventAlreadyCancelledException` in `backend/src/main/java/de/fete/domain/model/`. Domain model: `backend/src/main/java/de/fete/domain/model/Event.java`
- [X] T004 [P] Extend JPA entity `EventJpaEntity.java` with `cancelled` and `cancellation_reason` columns and update mapper in `backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java`
- [X] T005 Regenerate frontend TypeScript types from updated OpenAPI spec via `cd frontend && npm run generate:api`
**Checkpoint**: Schema, migration, and domain model ready. Both user stories can now proceed.
---
## Phase 2: User Story 1 — Organizer Cancels an Event (Priority: P1) MVP
**Goal**: Organizer can cancel an event via a bottom sheet with optional reason. Cancellation is irreversible and persists on reload.
**Independent Test**: View event with organizer token, tap cancel, optionally enter reason, confirm. Event remains cancelled on reload.
### Tests for User Story 1
> **Write these tests FIRST — ensure they FAIL before implementation**
- [X] ~~T006~~ Removed (EventTest.java unnecessary — cancel() tested via service/integration tests)
- [X] T007 [P] [US1] Write unit test for cancel use case in EventService (delegates to domain, saves, 403/404/409 cases) in `backend/src/test/java/de/fete/application/service/EventServiceCancelTest.java`
- [X] T008 [P] [US1] Write integration tests for `PATCH /events/{eventToken}` endpoint (204 success, 403 wrong token, 404 not found, 409 already cancelled) in `backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java`
- [X] T009 [P] [US1] Write E2E test: organizer opens cancel bottom sheet, enters reason, confirms — event shows as cancelled on reload in `frontend/e2e/cancel-event.spec.ts`
- [X] T010 [P] [US1] Write E2E test: organizer cancels without reason — event shows as cancelled in `frontend/e2e/cancel-event.spec.ts`
- [X] T011 [P] [US1] Write E2E test: cancel API fails — error displayed in bottom sheet, button re-enabled for retry in `frontend/e2e/cancel-event.spec.ts`
### Implementation for User Story 1
- [X] T012 [US1] Create `UpdateEventUseCase` interface in `backend/src/main/java/de/fete/domain/port/in/UpdateEventUseCase.java`
- [X] T013 [US1] Implement cancel logic in `EventService.java` — load event, verify organizer token, call `event.cancel(reason)`, persist in `backend/src/main/java/de/fete/application/service/EventService.java`
- [X] T014 [US1] Implement `patchEvent` endpoint in `EventController.java` — PATCH handler, query param organizerToken, request body binding, error mapping (403/404/409) in `backend/src/main/java/de/fete/adapter/in/web/EventController.java`
- [X] T015 [US1] Add cancel button (visible only when organizer token exists and event not cancelled — covers FR-012) and cancel bottom sheet (textarea with 2000 char limit + confirm button + inline error) to `frontend/src/views/EventDetailView.vue`
- [X] T016 [US1] Wire cancel bottom sheet confirm action to `PATCH /events/{eventToken}` API call via openapi-fetch, handle success (reload event data) and error (show inline message, re-enable button) in `frontend/src/views/EventDetailView.vue`
**Checkpoint**: Organizer can cancel an event. All US1 acceptance scenarios pass.
---
## Phase 3: User Story 2 — Attendee Sees Cancelled Event (Priority: P1)
**Goal**: Visitors see a prominent red cancellation banner on cancelled events. RSVP buttons are hidden. All other event details remain visible.
**Independent Test**: View a cancelled event's detail page — banner visible (with reason if provided), RSVP buttons hidden, other details intact.
### Tests for User Story 2
> **Write these tests FIRST — ensure they FAIL before implementation**
- [X] ~~T017~~ Removed (RsvpServiceCancelledTest unnecessary — covered by integration test)
- [X] T018 [P] [US2] Write integration test for `POST /events/{eventToken}/rsvps` returning 409 when event is cancelled in `backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java`
- [X] T019 [P] [US2] Write E2E test: visitor sees red banner with cancellation reason on cancelled event in `frontend/e2e/cancelled-event-visitor.spec.ts`
- [X] T020 [P] [US2] Write E2E test: visitor sees red banner without reason when no reason was provided in `frontend/e2e/cancelled-event-visitor.spec.ts`
- [X] T021 [P] [US2] Write E2E test: RSVP buttons hidden on cancelled event, other details remain visible in `frontend/e2e/cancelled-event-visitor.spec.ts`
### Implementation for User Story 2
- [X] T022 [US2] Add cancelled-event guard to RSVP creation — check `event.isCancelled()`, return 409 Conflict in `backend/src/main/java/de/fete/application/service/RsvpService.java`
- [X] T023 [US2] Add cancellation banner component/section (red, prominent, includes reason if present, WCAG AA contrast) to `frontend/src/views/EventDetailView.vue`
- [X] T024 [US2] Hide RSVP buttons (`RsvpBar` or equivalent) when `event.cancelled === true` in `frontend/src/views/EventDetailView.vue`
- [X] ~~T025~~ Merged into T015 (cancel button v-if already handles FR-012)
**Checkpoint**: Both user stories fully functional. All acceptance scenarios pass.
---
## Phase 4: Polish & Cross-Cutting Concerns
**Purpose**: Validation, edge cases, and final cleanup.
- [X] T026 Verify cancellationReason max length (2000 chars) is enforced at API level (OpenAPI `maxLength`), domain level in `Event.java`, and UI level (textarea maxlength/counter)
- [X] T027 Run full backend test suite (`cd backend && ./mvnw verify`) and fix any failures
- [X] T028 Run full frontend test suite (`cd frontend && npm run test:unit`) and fix any failures
- [X] T029 Run E2E tests (`cd frontend && npx playwright test`) and fix any failures
- [X] T030 Run backend checkstyle (`cd backend && ./mvnw checkstyle:check`) and fix violations

View File

@@ -0,0 +1,35 @@
# Specification Quality Checklist: Watch Event
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-12
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
- localStorage is mentioned as a storage mechanism — this is an existing project convention (not an implementation detail introduced by this spec).

View File

@@ -0,0 +1,61 @@
# Data Model: Watch Event
**Feature**: 017-watch-event
**Date**: 2026-03-12
## Entities
### StoredEvent (existing — localStorage)
No schema change. The existing `StoredEvent` interface already supports the watcher state:
```
StoredEvent {
eventToken: string # Required — event identifier
organizerToken?: string # Present → organizer role
title: string # Required — display in event list
dateTime: string # Required — grouping/sorting
rsvpToken?: string # Present → attendee role
rsvpName?: string # Present with rsvpToken
}
```
**Watcher state**: A `StoredEvent` with only `eventToken`, `title`, and `dateTime` populated (no `organizerToken`, no `rsvpToken`). This state already occurs naturally after RSVP cancellation.
## Role Hierarchy
```
organizerToken present → "Organizer" (highest precedence)
rsvpToken present → "Attendee"
neither present → "Watching" (new label, lowest precedence)
```
## State Transitions
```
┌──────────────┐
watch() │ │ un-watch()
(not stored) ───► │ Watching │ ───► (not stored)
│ │
└──────┬───────┘
│ RSVP
┌──────────────┐
│ │
│ Attending │
│ │
└──────┬───────┘
│ Cancel RSVP
┌──────────────┐
│ │ un-watch()
│ Watching │ ───► (not stored)
│ │
└──────────────┘
```
Note: Organizer state is set at event creation and cannot be removed through this feature. The bookmark icon is non-interactive for organizers.
## No Backend Changes
This feature does not introduce any new database entities, API endpoints, or server-side logic. All data is stored in the browser's localStorage.

View File

@@ -0,0 +1,72 @@
# Visual Issues: Bookmark Icon on Event Detail Page
**Date**: 2026-03-12
**Branch**: `017-watch-event`
**File**: `frontend/src/views/EventDetailView.vue`
## Issue 1: Meta icons have hover effect
**Problem**: The `<dt class="detail__meta-icon glass">` elements (date, location, attendees) change background/border color on hover. These are non-interactive `<dt>` elements — they should not react to hover.
**Root cause**: The global `.glass:hover` rule in `frontend/src/assets/main.css:247`:
```css
.glass:hover:not(input):not(textarea):not(.btn-primary) {
background: var(--color-glass-hover);
border-color: var(--color-glass-border-hover);
}
```
This applies to ALL `.glass` elements including the static meta icons. Scoped CSS overrides don't win because the global rule has equal or higher specificity.
**Fix options**:
- A) Remove `glass` class from meta icons, replicate the static glass styles in scoped CSS
- B) Add `.glass--static` modifier that opts out of hover, use it on meta icons
- C) Add `:not(.detail__meta-icon)` to the global rule (leaks component knowledge into global CSS — bad)
Option A is cleanest — meta icons only need the static glass background, not the full interactive glass behavior.
## Issue 2: Glow effect on bookmark is ugly
**Problem**: The accent-colored `box-shadow` glow around the bookmark icon looks bad visually.
**Current CSS**:
```css
.detail__bookmark {
border-color: var(--color-accent);
box-shadow: 0 0 6px rgba(255, 112, 67, 0.15);
}
.detail__bookmark--filled {
box-shadow: 0 0 8px rgba(255, 112, 67, 0.3);
}
```
**Fix**: Remove the glow entirely. Differentiate the bookmark from inert meta icons through a different, subtler approach — e.g. a slightly brighter/different border color, or rely solely on the cursor change and active/focus states.
## Issue 3: Filled bookmark should use same icon color as unfilled
**Problem**: Filled bookmark uses `color: var(--color-accent)` (orange), unfilled uses `color: var(--color-text-on-gradient)` (white/light). User wants both states to use the same color.
**Current CSS**:
```css
.detail__bookmark--filled {
color: var(--color-accent);
border-color: var(--color-accent);
}
```
**Fix**: Remove `color: var(--color-accent)` from `.detail__bookmark--filled`. The SVG `fill` attribute is already controlled by `:fill="eventIsStored ? 'currentColor' : 'none'"` in the template — so filled state will use `currentColor` (which inherits from the parent), and unfilled state will be outline-only. Both will be the same color (`--color-text-on-gradient`).
## Issue 4: Icons not centered in their boxes
**Problem**: SVGs inside the 36x36 glass boxes (both bookmark and meta icons) are shifted slightly to the right. The centering is off despite `display: flex; align-items: center; justify-content: center`.
**Root cause**: SVGs rendered inline have implicit `line-height` whitespace. The `line-height: 0` fix was added to `.detail__bookmark` and `.detail__meta-icon` but the meta icon override may not be applying due to specificity issues with the `glass` class, or the SVGs themselves may need `display: block`.
**Context**: The `<dt>` element defaults to `display: block` but the SVG inside is inline. The flex container should handle it, but browser rendering of inline SVGs inside flex containers can be inconsistent.
**Fix options**:
- Add `display: block` to the SVGs directly via a scoped rule: `.detail__meta-icon svg, .detail__bookmark svg { display: block; }`
- Or ensure `line-height: 0` is actually applying (check specificity)
## Screenshot reference
User-provided screenshot showing the issues: `/home/nitrix/Pictures/Screenshot_20260312_215543.png`

View File

@@ -0,0 +1,77 @@
# Implementation Plan: Watch Event
**Branch**: `017-watch-event` | **Date**: 2026-03-12 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/017-watch-event/spec.md`
## Summary
Add a bookmark icon to the event detail page (left of event title) that lets users save events to localStorage without RSVPing. Watched events appear in the event list with a "Watching" label. The feature is entirely client-side — no backend changes required.
## Technical Context
**Language/Version**: TypeScript 5.9 (frontend only)
**Primary Dependencies**: Vue 3, Vue Router 5
**Storage**: localStorage via `useEventStorage.ts` composable (existing)
**Testing**: Vitest for unit tests, Playwright + MSW for E2E
**Target Platform**: Mobile-first PWA (browser)
**Project Type**: Web application (frontend-only change)
**Performance Goals**: Instant toggle (no network requests)
**Constraints**: No backend involvement, no new dependencies
**Scale/Scope**: 4 modified files, 0 new files
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| I. Privacy by Design | PASS | No data sent to server. Watch is purely localStorage. No tracking. |
| II. Test-Driven Methodology | PASS | Unit tests for composable changes, E2E tests for user stories. |
| III. API-First Development | PASS | No API changes needed. Feature is client-side only. |
| IV. Simplicity & Quality | PASS | Minimal changes to existing code. No new abstractions. |
| V. Dependency Discipline | PASS | No new dependencies introduced. |
| VI. Accessibility | PASS | Bookmark icon will use semantic button/ARIA, keyboard-operable. |
No violations. Gate passes.
## Project Structure
### Documentation (this feature)
```text
specs/017-watch-event/
├── plan.md # This file
├── spec.md # Feature specification
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
└── tasks.md # Phase 2 output (/speckit.tasks)
```
### Source Code (files to modify)
```text
frontend/src/
├── composables/
│ ├── useEventStorage.ts # Add saveWatch(), isStored() methods
│ └── __tests__/
│ └── useEventStorage.spec.ts # Tests for new methods
├── components/
│ ├── EventCard.vue # Add 'watcher' role + badge styling
│ ├── EventList.vue # Update getRole() to return 'watcher', adjust delete flow
│ └── __tests__/
│ ├── EventCard.spec.ts # Tests for watcher badge
│ └── EventList.spec.ts # Tests for watcher delete behavior
├── views/
│ ├── EventDetailView.vue # Add bookmark icon next to title
│ └── __tests__/
│ └── EventDetailView.spec.ts # Tests for bookmark behavior
└── e2e/
└── watch-event.spec.ts # E2E tests for all user stories
```
**Structure Decision**: Frontend-only changes. No new files needed — all modifications go into existing components and composables. One new E2E test file.
## Complexity Tracking
No constitution violations. No complexity justifications needed.

View File

@@ -0,0 +1,33 @@
# Quickstart: Watch Event
**Feature**: 017-watch-event
**Date**: 2026-03-12
## What This Feature Does
Adds a bookmark icon to the event detail page that lets users save events locally without RSVPing. Saved events appear in the event list with a "Watching" label.
## Files to Modify
| File | Change |
|------|--------|
| `frontend/src/composables/useEventStorage.ts` | Add `saveWatch()` and `isStored()` methods |
| `frontend/src/views/EventDetailView.vue` | Add bookmark icon left of title, shake animation trigger |
| `frontend/src/components/EventList.vue` | Update `getRole()` to return `'watcher'`, skip confirmation for watchers |
| `frontend/src/components/EventCard.vue` | Add `'watcher'` to role type, add badge styling |
## Implementation Order
1. **useEventStorage** — Add `saveWatch()` and `isStored()` (unit tests first)
2. **EventCard** — Extend role type, add "Watching" badge with styling (unit tests first)
3. **EventList** — Update `getRole()`, adjust delete flow for watchers (unit tests first)
4. **EventDetailView** — Add bookmark icon with all states and shake animation
5. **E2E tests** — Cover all 7 user stories from spec
## Key Design Decisions
- **No new StoredEvent fields** — watcher state is the absence of both `organizerToken` and `rsvpToken`
- **No backend changes** — entirely client-side
- **Bookmark icon left of title** — flex container, vertically centered
- **Non-interactive for attendees/organizers** — tapping shakes the relevant bottom action button
- **No confirmation dialog for watcher deletion** from event list

View File

@@ -0,0 +1,56 @@
# Research: Watch Event
**Feature**: 017-watch-event
**Date**: 2026-03-12
## Research Questions
### 1. How does the current role detection work?
**Finding**: `EventList.vue` has a `getRole()` function that checks `organizerToken` first, then `rsvpToken`. Returns `undefined` when neither is present. `EventCard.vue` accepts an `eventRole` prop typed as `'organizer' | 'attendee' | undefined`.
**Decision**: Extend `getRole()` to return `'watcher'` when the event is in localStorage but has no `organizerToken` and no `rsvpToken`. Extend `EventCard` prop type to include `'watcher'`.
**Rationale**: This is the minimal change — the existing priority chain (organizer > attendee) already handles precedence. Adding watcher as the fallback case is natural.
### 2. How to detect "is this event stored?" on the detail page?
**Finding**: `useEventStorage` has `getStoredEvents()` which returns all events, and `getRsvp(eventToken)` / `getOrganizerToken(eventToken)` for specific lookups. There is no direct `isStored(eventToken)` check.
**Decision**: Add a `isStored(eventToken)` method to `useEventStorage` that checks if an event exists in localStorage regardless of role. Add a `saveWatch(eventToken, title, dateTime)` method that creates a minimal StoredEvent entry (no rsvpToken, no organizerToken).
**Rationale**: `saveWatch()` is semantically distinct from `saveRsvp()` and `saveCreatedEvent()`. The `isStored()` helper avoids filtering through the full event list for a simple boolean check.
### 3. What happens to events after RSVP cancellation?
**Finding**: `removeRsvp(eventToken)` deletes `rsvpToken` and `rsvpName` but keeps the event in localStorage. After cancellation, the event has no `rsvpToken` and no `organizerToken` — identical to a watched event.
**Decision**: No change needed. The existing `removeRsvp()` behavior already produces the correct state for a "watcher" after cancellation. The `getRole()` update will automatically label these as "Watching".
**Rationale**: This is the key insight — the post-RSVP-cancellation state is already semantically equivalent to "watching". We just need to label it.
### 4. Bookmark icon placement and glow conflict
**Finding**: The event title is a plain `<h1 class="detail__title">`. The RsvpBar CTA uses `glow-border glow-border--animated` with a `::before` pseudo-element that extends 12px beyond the button via `inset: -4px` + `blur(8px)`. The bookmark icon is positioned at the title area (top of page), far from the RsvpBar (fixed at bottom). No glow conflict.
**Decision**: Place bookmark icon in a flex container with the title: `display: flex; align-items: center; gap: var(--spacing-sm)`. Icon to the left, title takes remaining space.
**Rationale**: Vertically centered with flex is the simplest approach. No glow interference since the icon is nowhere near the RsvpBar.
### 5. Delete confirmation behavior per role
**Finding**: `EventList.vue` shows a `ConfirmDialog` for all deletions. The message text varies based on RSVP status. For events without RSVP, the message is generic ("This event will be removed from your list.").
**Decision**: Skip the confirmation dialog entirely for watchers (no `rsvpToken`, no `organizerToken`). Call `removeEvent()` directly on swipe/delete.
**Rationale**: Watching is low-commitment. The spec explicitly requires no confirmation for watcher deletion.
### 6. Shake animation implementation
**Finding**: No existing shake animation in the codebase. The RsvpBar status and cancel-event button are both `position: fixed; bottom: 0`.
**Decision**: Add a CSS `@keyframes shake` animation (short horizontal oscillation, ~300ms). Apply via a reactive class that is toggled on bookmark tap when user is attendee/organizer. Use a ref + setTimeout to remove the class after animation completes.
**Alternatives considered**:
- Web Animations API: More flexible but overkill for a simple shake.
- CSS transition: Insufficient for a multi-step oscillation.

View File

@@ -0,0 +1,153 @@
# Feature Specification: Watch Event
**Feature Branch**: `017-watch-event`
**Created**: 2026-03-12
**Status**: Draft
**Input**: Watch/bookmark events locally without RSVPing — bookmark icon on event detail page, watching label on event list
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Watch an event from the detail page (Priority: P1)
A user receives a link to an event and opens the detail page. They are not ready to RSVP yet but want to save the event for later. They tap the bookmark icon to the left of the event title. The icon fills in, and the event is saved to their local device. Next time they open the app, the event appears in their event list with a "Watching" label.
**Why this priority**: This is the core feature — without it, the only way to save an event is to RSVP. Many users want to "bookmark" events they're considering without committing.
**Independent Test**: Can be fully tested by opening an event detail page, tapping the bookmark icon, verifying it fills in, and checking the event list shows the event with a "Watching" label.
**Acceptance Scenarios**:
1. **Given** a user visits an event detail page for the first time (no RSVP, no watch), **When** they look at the bookmark icon next to the title, **Then** the icon is displayed as an unfilled outline.
2. **Given** the bookmark icon is unfilled, **When** the user taps it, **Then** the icon becomes filled, and the event is saved to localStorage.
3. **Given** the user has watched an event, **When** they open the event list, **Then** the event appears with a "Watching" label.
---
### User Story 2 - Un-watch an event from the detail page (Priority: P1)
A user who is watching an event (but not attending) decides they are no longer interested. They tap the filled bookmark icon on the event detail page. The icon reverts to an outline, and the event is removed from localStorage. It disappears from the event list.
**Why this priority**: Users must be able to undo the watch action. Without this, there is no way to remove a watched event from the detail page.
**Independent Test**: Can be fully tested by watching an event, then tapping the bookmark icon again to un-watch, verifying the icon becomes unfilled and the event disappears from the event list.
**Acceptance Scenarios**:
1. **Given** a user is watching an event (not attending), **When** they tap the filled bookmark icon, **Then** the icon becomes unfilled and the event is removed from localStorage.
2. **Given** a user has un-watched an event, **When** they open the event list, **Then** the event no longer appears.
---
### User Story 3 - Bookmark icon reflects attending status (Priority: P1)
When a user RSVPs to an event, the event is automatically saved to localStorage. The bookmark icon on the detail page reflects this by appearing filled. Attending supersedes watching — the event list shows "Attendee" (not "Watching") as the label.
**Why this priority**: Consistency — attending inherently means the event is saved locally, so the bookmark must reflect that. Without this, users would see a confusing unfilled bookmark despite having RSVPed.
**Independent Test**: Can be fully tested by RSVPing to an event, verifying the bookmark icon is filled, and checking that the event list shows "Attendee" as the label.
**Acceptance Scenarios**:
1. **Given** a user has RSVPed to an event, **When** they view the event detail page, **Then** the bookmark icon is filled.
2. **Given** a user has RSVPed to an event, **When** they view the event list, **Then** the event shows "Attendee" as its label (not "Watching").
---
### User Story 4 - RSVP cancellation preserves watch status (Priority: P2)
A user who RSVPed to an event cancels their attendance. The event remains in localStorage (existing behavior). The bookmark icon stays filled. The event list label changes from "Attendee" to "Watching".
**Why this priority**: This ensures a smooth transition from attending to watching. Without it, users who cancel would see an event in their list with no label and an ambiguous bookmark state.
**Independent Test**: Can be fully tested by RSVPing, cancelling the RSVP, then verifying the bookmark stays filled and the event list shows "Watching".
**Acceptance Scenarios**:
1. **Given** a user has RSVPed and then cancelled their RSVP, **When** they view the event detail page, **Then** the bookmark icon remains filled.
2. **Given** a user has RSVPed and then cancelled their RSVP, **When** they view the event list, **Then** the event shows "Watching" as its label.
3. **Given** a user cancelled their RSVP and the bookmark is filled, **When** they tap the bookmark icon, **Then** the icon becomes unfilled and the event is removed from localStorage.
---
### User Story 5 - Bookmark icon is non-interactive for attendees and organizers (Priority: P2)
When a user is an attendee or organizer, the bookmark icon is filled but not clickable (no pointer cursor, no hover effect). Tapping it triggers a short shake animation on the relevant fixed action button at the bottom of the screen (the "You're attending" bar for attendees, the "Cancel event" button for organizers) to signal that the user must act there first.
**Why this priority**: Prevents confusion — removing a saved event while attending or organizing must go through the proper flow (cancel RSVP or cancel event), not through the bookmark.
**Independent Test**: Can be fully tested by RSVPing to an event, tapping the bookmark icon, and verifying nothing happens except the bottom bar shaking briefly.
**Acceptance Scenarios**:
1. **Given** a user is an attendee, **When** they tap the bookmark icon, **Then** nothing changes, and the "You're attending" bar shakes briefly.
2. **Given** a user is an organizer, **When** they tap the bookmark icon, **Then** nothing changes, and the "Cancel event" button shakes briefly.
3. **Given** a user is an attendee or organizer, **When** they hover/focus the bookmark icon, **Then** no pointer cursor or interactive hover style is shown.
---
### User Story 6 - Un-watch from event list (Priority: P2)
A watcher removes an event from the event list using the existing swipe-to-delete gesture. Unlike attendees (who see a confirmation dialog warning about RSVP cancellation), watchers see no confirmation dialog — the event is removed immediately.
**Why this priority**: Watching is a low-commitment action, so removing a watched event should be frictionless.
**Independent Test**: Can be fully tested by watching an event, going to the event list, swiping to delete, and verifying the event is removed without a confirmation dialog.
**Acceptance Scenarios**:
1. **Given** a user is watching an event (no RSVP), **When** they swipe to delete it from the event list, **Then** the event is removed immediately without a confirmation dialog.
2. **Given** a user is attending an event, **When** they swipe to delete it from the event list, **Then** a confirmation dialog appears warning about RSVP cancellation (existing behavior, unchanged).
---
### User Story 7 - Watcher upgrades to attendee (Priority: P2)
A user who is watching an event decides to attend. They tap the "I'm attending" CTA button and complete the RSVP flow as usual. The bookmark icon remains filled. The event list label changes from "Watching" to "Attendee".
**Why this priority**: Natural flow from browsing to commitment. The watch-to-attend transition must be seamless.
**Independent Test**: Can be fully tested by watching an event, then RSVPing, and verifying the bookmark stays filled and the label updates.
**Acceptance Scenarios**:
1. **Given** a user is watching an event, **When** they complete the RSVP flow, **Then** the bookmark icon remains filled.
2. **Given** a user was watching and then RSVPed, **When** they view the event list, **Then** the event shows "Attendee" as its label (not "Watching").
---
### Edge Cases
- What happens when a user opens an event that has been cancelled — can they still watch it? **Yes, watching is purely local and independent of event status.**
- What happens when a user watches an event that has expired? **Same behavior — expired events can be watched. They will appear in the "Past" section of the event list.**
- What happens when a user clears their browser localStorage? **All watched (and attended) events are lost. This is expected behavior for client-side-only storage.**
- What happens if the user visits the event page on a different device? **The watch status is device-specific. The bookmark appears unfilled on the new device.**
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST display a bookmark icon to the left of the event title on the event detail page, vertically centered with the title text.
- **FR-002**: The bookmark icon MUST appear as an unfilled outline when the event is not saved in localStorage.
- **FR-003**: The bookmark icon MUST appear as a filled icon when the event is saved in localStorage (regardless of whether the user is watching, attending, or organizing).
- **FR-004**: Tapping the unfilled bookmark icon MUST save the event to localStorage (eventToken, title, dateTime) and fill the icon.
- **FR-005**: Tapping the filled bookmark icon MUST remove the event from localStorage and revert the icon to unfilled — but only when the user is a watcher (no RSVP, no organizer token).
- **FR-006**: The bookmark icon MUST NOT be interactive (no pointer cursor, no hover effect) when the user is an attendee or organizer.
- **FR-007**: Tapping the bookmark icon as an attendee MUST trigger a brief shake animation on the fixed "You're attending" bar at the bottom.
- **FR-008**: Tapping the bookmark icon as an organizer MUST trigger a brief shake animation on the fixed "Cancel event" button at the bottom.
- **FR-009**: The event list MUST display a "Watching" label on events that are in localStorage but have no rsvpToken and no organizerToken.
- **FR-010**: The "Watching" label MUST have lower precedence than "Attendee" and "Organizer" labels.
- **FR-011**: Deleting a watched event (no RSVP) from the event list MUST NOT show a confirmation dialog — the event is removed immediately.
- **FR-012**: Deleting an attended event from the event list MUST continue to show the existing confirmation dialog with the RSVP cancellation warning.
- **FR-013**: The watch feature MUST be entirely client-side — no server requests are made when watching or un-watching.
- **FR-014**: When an attendee cancels their RSVP, the event MUST remain in localStorage and the bookmark icon MUST remain filled. The event list label MUST change from "Attendee" to "Watching".
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Users can watch an event in a single tap from the detail page.
- **SC-002**: Watched events appear in the event list with a "Watching" label immediately upon returning to the list.
- **SC-003**: Un-watching an event from the detail page takes a single tap and immediately updates the icon.
- **SC-004**: Deleting a watched event from the event list completes instantly with no confirmation step.
- **SC-005**: The bookmark icon correctly reflects the stored state on every page load (filled if saved, unfilled if not).
- **SC-006**: The transition from watching to attending (and back via RSVP cancellation) updates both the bookmark icon and the event list label without requiring a page reload.

View File

@@ -0,0 +1,235 @@
# Tasks: Watch Event
**Input**: Design documents from `/specs/017-watch-event/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md
**Tests**: Included — constitution mandates TDD (Red → Green → Refactor).
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
---
## Phase 1: Foundational (Composable & Data Layer)
**Purpose**: Extend `useEventStorage` with watch capabilities and update role detection across list components. These changes are required by all user stories.
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
### Tests
- [x] T001 [P] Unit tests for `saveWatch()` and `isStored()` methods in `frontend/src/composables/__tests__/useEventStorage.spec.ts` — test saving a watch-only event (no rsvpToken, no organizerToken), test `isStored()` returns true for watched/attended/organized events and false for unknown tokens
- [x] T002 [P] Unit tests for watcher role detection in `frontend/src/components/__tests__/EventList.spec.ts` — test `getRole()` returns `'watcher'` when event has no organizerToken and no rsvpToken
- [x] T003 [P] Unit tests for watcher badge display in `frontend/src/components/__tests__/EventCard.spec.ts` — test that `eventRole="watcher"` renders badge with text "Watching"
### Implementation
- [x] T004 Add `saveWatch(eventToken, title, dateTime)` and `isStored(eventToken)` methods to `frontend/src/composables/useEventStorage.ts``saveWatch` creates a StoredEvent with only eventToken/title/dateTime, `isStored` checks if eventToken exists in storage
- [x] T005 Update `getRole()` in `frontend/src/components/EventList.vue` to return `'watcher'` as fallback when event has no organizerToken and no rsvpToken (role hierarchy: organizer > attendee > watcher)
- [x] T006 [P] Extend `eventRole` prop type in `frontend/src/components/EventCard.vue` from `'organizer' | 'attendee'` to `'organizer' | 'attendee' | 'watcher'`, add "Watching" label text and `.event-card__badge--watcher` styling (glass style, matching design system)
**Checkpoint**: Composable supports watch storage, role detection returns 'watcher', event cards display "Watching" badge.
---
## Phase 2: User Story 1 & 2 — Watch / Un-watch from Detail Page (Priority: P1) 🎯 MVP
**Goal**: Add bookmark icon left of event title on detail page. Unfilled = not stored, filled = stored. Tapping toggles watch state for non-attendee/non-organizer users.
**Independent Test**: Open an event detail page, tap bookmark to watch (icon fills, event appears in list with "Watching" label), tap again to un-watch (icon unfills, event disappears from list).
### Tests
- [x] T007 Unit tests for bookmark icon in `frontend/src/views/__tests__/EventDetailView.spec.ts` — test icon renders unfilled when event not in storage, test icon renders filled when event is in storage, test tapping unfilled icon calls `saveWatch()`, test tapping filled icon calls `removeEvent()` when user is watcher
- [x] T008 E2E test for US1 (watch) in `frontend/e2e/watch-event.spec.ts` — visit event detail page, verify bookmark is unfilled, tap bookmark, verify it fills, navigate to event list, verify event appears with "Watching" label
- [x] T009 E2E test for US2 (un-watch) in `frontend/e2e/watch-event.spec.ts` — watch an event, tap filled bookmark, verify it unfills, navigate to event list, verify event is gone
### Implementation
- [x] T010 [US1] [US2] Add bookmark icon to `frontend/src/views/EventDetailView.vue` — wrap title in flex container (`display: flex; align-items: center; gap: var(--spacing-sm)`), add bookmark button to the left of `<h1>`, icon is unfilled outline when `!isStored(eventToken)` and filled when `isStored(eventToken)`. Tapping calls `saveWatch()` or `removeEvent()` based on current state. Use semantic `<button>` with `aria-label` ("Watch this event" / "Stop watching this event"). Include keyboard support (Enter/Space).
**Checkpoint**: Users can watch and un-watch events from the detail page. Watched events appear in the event list with "Watching" label.
---
## Phase 3: User Story 3 — Bookmark Reflects Attending Status (Priority: P1)
**Goal**: Bookmark icon appears filled when user has RSVPed (attending = automatically watched). Event list shows "Attendee" label, not "Watching".
**Independent Test**: RSVP to an event, verify bookmark is filled on detail page, verify event list shows "Attendee" label.
### Tests
- [x] T011 Unit test in `frontend/src/views/__tests__/EventDetailView.spec.ts` — test bookmark icon is filled when event has rsvpToken in storage
- [x] T012 E2E test for US3 in `frontend/e2e/watch-event.spec.ts` — RSVP to event, verify bookmark is filled, navigate to list, verify "Attendee" label (not "Watching")
### Implementation
- [x] T013 [US3] Verify bookmark icon state in `frontend/src/views/EventDetailView.vue` correctly uses `isStored(eventToken)` which returns true for RSVPed events (since `saveRsvp()` already stores the event). No code change expected — this should work from T010 implementation. If not, adjust `isStored()` logic.
**Checkpoint**: Attending users see filled bookmark. Label priority (Attendee > Watching) works correctly.
---
## Phase 4: User Story 4 — RSVP Cancellation Preserves Watch Status (Priority: P2)
**Goal**: After cancelling RSVP, event stays in localStorage, bookmark stays filled, list label changes from "Attendee" to "Watching".
**Independent Test**: RSVP, cancel RSVP, verify bookmark stays filled and list shows "Watching". Then un-watch via bookmark.
### Tests
- [x] T014 Unit test in `frontend/src/views/__tests__/EventDetailView.spec.ts` — test bookmark stays filled after `removeRsvp()` is called (event still in storage)
- [x] T015 E2E test for US4 in `frontend/e2e/watch-event.spec.ts` — RSVP, cancel attendance, verify bookmark filled, verify list label is "Watching", tap bookmark to un-watch, verify unfilled
### Implementation
- [x] T016 [US4] Verify existing `removeRsvp()` behavior in `frontend/src/composables/useEventStorage.ts` preserves event in storage. No code change expected — `removeRsvp()` already only deletes rsvpToken/rsvpName. The `getRole()` update from T005 will automatically label these as "watcher". If behavior differs, adjust.
**Checkpoint**: RSVP cancel → watch transition works seamlessly.
---
## Phase 5: User Story 5 — Non-Interactive Bookmark for Attendees & Organizers (Priority: P2)
**Goal**: Bookmark icon is visually filled but non-clickable for attendees and organizers. Tapping triggers a shake animation on the relevant fixed bottom button.
**Independent Test**: RSVP to event, tap bookmark, verify nothing changes and "You're attending" bar shakes. Same test for organizer with "Cancel event" button.
### Tests
- [x] T017 Unit test in `frontend/src/views/__tests__/EventDetailView.spec.ts` — test bookmark has no pointer cursor when user is attendee, test tapping bookmark as attendee does not call `removeEvent()`, test shake class is applied to RsvpBar ref
- [x] T018 E2E test for US5 in `frontend/e2e/watch-event.spec.ts` — RSVP to event, tap bookmark, verify bookmark unchanged, verify attending bar has shake animation class. Test organizer: open as organizer, tap bookmark, verify cancel-event button shakes.
### Implementation
- [x] T019 [US5] Add shake animation CSS keyframes in `frontend/src/views/EventDetailView.vue``@keyframes shake` with short horizontal oscillation (~300ms). Add `.detail__shake` class that applies the animation.
- [x] T020 [US5] Update bookmark icon behavior in `frontend/src/views/EventDetailView.vue` — when user is attendee or organizer: remove pointer cursor, remove hover effects, on tap apply shake class to the RsvpBar (attendee) or cancel-event button (organizer) via template ref. Use `setTimeout` to remove shake class after animation completes.
**Checkpoint**: Attendees and organizers cannot un-watch via bookmark. Clear visual feedback via shake.
---
## Phase 6: User Story 6 — Un-watch from Event List (Priority: P2)
**Goal**: Swiping to delete a watched event removes it immediately without a confirmation dialog.
**Independent Test**: Watch an event, go to event list, swipe to delete, verify event removed instantly (no dialog).
### Tests
- [x] T021 Unit test in `frontend/src/components/__tests__/EventList.spec.ts` — test that deleting a watcher event (no rsvpToken) calls `removeEvent()` directly without showing ConfirmDialog
- [x] T022 E2E test for US6 in `frontend/e2e/watch-event.spec.ts` — watch event, navigate to list, swipe to delete, verify no confirmation dialog appears, verify event removed
### Implementation
- [x] T023 [US6] Update delete flow in `frontend/src/components/EventList.vue` — when event has no rsvpToken and no organizerToken (watcher role), skip `showConfirmDialog` and call `removeEvent()` directly. Keep existing confirmation for attendees.
**Checkpoint**: Watcher deletion is frictionless. Attendee deletion unchanged.
---
## Phase 7: User Story 7 — Watcher Upgrades to Attendee (Priority: P2)
**Goal**: A watcher who RSVPs sees bookmark stay filled and list label change from "Watching" to "Attendee".
**Independent Test**: Watch event, RSVP, verify bookmark stays filled, verify list shows "Attendee".
### Tests
- [x] T024 E2E test for US7 in `frontend/e2e/watch-event.spec.ts` — watch event (verify "Watching" in list), RSVP (verify bookmark stays filled), navigate to list (verify "Attendee" label)
### Implementation
- [x] T025 [US7] Verify watch-to-attend transition in `frontend/src/views/EventDetailView.vue` — existing `saveRsvp()` call updates the StoredEvent with rsvpToken/rsvpName. The `getRole()` update from T005 gives "attendee" precedence over "watcher". No code change expected — verify via E2E test.
**Checkpoint**: Watch → attend transition is seamless.
---
## Phase 8: Polish & Cross-Cutting Concerns
**Purpose**: Accessibility, visual refinement, and final validation
- [x] T026 Accessibility audit of bookmark icon in `frontend/src/views/EventDetailView.vue` — verify ARIA labels update reactively ("Watch this event" ↔ "Stop watching this event"), verify keyboard navigation (Tab focus, Enter/Space activation), verify WCAG AA contrast for icon in both states
- [x] T027 Visual consistency check — verify "Watching" badge styling is consistent with existing "Organizer" and "Attendee" badges in `frontend/src/components/EventCard.vue`, follows design system tokens
- [x] T028 Run full E2E suite `frontend/e2e/watch-event.spec.ts` to verify all 7 user stories pass together
---
## Dependencies & Execution Order
### Phase Dependencies
- **Foundational (Phase 1)**: No dependencies — can start immediately
- **US1/US2 (Phase 2)**: Depends on Phase 1 — BLOCKS all other user stories
- **US3 (Phase 3)**: Depends on Phase 2 (bookmark icon must exist)
- **US4 (Phase 4)**: Depends on Phase 2 (bookmark icon must exist)
- **US5 (Phase 5)**: Depends on Phase 2 (bookmark icon must exist)
- **US6 (Phase 6)**: Depends on Phase 1 (getRole must return 'watcher')
- **US7 (Phase 7)**: Depends on Phase 2 (bookmark icon must exist)
- **Polish (Phase 8)**: Depends on all phases complete
### User Story Dependencies
- **US1/US2 (P1)**: Core MVP — can start after Foundational
- **US3 (P1)**: Can start after US1/US2
- **US4 (P2)**: Can start after US1/US2 — independent of US3
- **US5 (P2)**: Can start after US1/US2 — independent of US3/US4
- **US6 (P2)**: Can start after Foundational — independent of all other stories
- **US7 (P2)**: Can start after US1/US2 — independent of US3-US6
### Parallel Opportunities
- **Phase 1**: T001, T002, T003 can run in parallel (different test files)
- **Phase 1**: T005 and T006 can run in parallel (different component files)
- **After Phase 2**: US3, US4, US5, US7 can run in parallel (independent stories)
- **US6**: Can run in parallel with Phase 2 (only depends on Phase 1)
---
## Parallel Example: Phase 1
```text
# All unit tests in parallel:
T001: "Unit tests for saveWatch/isStored in useEventStorage.spec.ts"
T002: "Unit tests for watcher role in EventList.spec.ts"
T003: "Unit tests for watcher badge in EventCard.spec.ts"
# Implementation in parallel (after tests):
T005: "Update getRole() in EventList.vue"
T006: "Extend eventRole in EventCard.vue"
# T004 (useEventStorage) should go first — T005/T006 depend on its types
```
---
## Implementation Strategy
### MVP First (US1 + US2 Only)
1. Complete Phase 1: Foundational (composable + card + list)
2. Complete Phase 2: US1/US2 (bookmark icon toggle)
3. **STOP and VALIDATE**: Watch/un-watch works, "Watching" label appears
4. This alone delivers the core value
### Incremental Delivery
1. Phase 1 → Foundational ready
2. Phase 2 → US1/US2 → Watch/un-watch from detail page (MVP!)
3. Phase 3 → US3 → Bookmark reflects attending (consistency)
4. Phase 4-7 → US4-US7 → Edge cases and transitions
5. Phase 8 → Polish → Accessibility and visual refinement
---
## Notes
- Most "implementation" in US3, US4, US7 is verification — the foundational changes in Phase 1 and the bookmark icon in Phase 2 handle the logic. These stories primarily need E2E tests to confirm correct behavior.
- No backend changes. No new files except `frontend/e2e/watch-event.spec.ts`.
- Total: 28 tasks across 8 phases.

View File

@@ -0,0 +1,35 @@
# Specification Quality Checklist: Cancel Event from Event List
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-12
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Assumptions section documents that no cancellation reason is needed from the list view (speed over detail). This is a reasonable default; can be revisited via `/speckit.clarify`.
- All items pass validation. Spec is ready for planning.

View File

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

View File

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

View File

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

View File

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

View File

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

View 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`.

View 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 (320px768px), 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 810 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.

View 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.

View 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 320px768px 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 T006T008 (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