Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f8b911af8 | |||
| e9791de4e2 | |||
| 3b4cc7fbb9 | |||
| 9c0e9249ce | |||
| 5082ec1333 | |||
| 35b488a8be | |||
| b067c0ef1e | |||
|
|
42686502d8 | ||
| 51ab99fc61 | |||
| d52f51d6e1 | |||
| c1760ae376 | |||
| 6d51327e56 | |||
| 96044ae1ed | |||
| f972a41e45 | |||
| 13b01dfba8 | |||
| fd8724db8f | |||
| 8885dbd722 | |||
| c51eacb261 | |||
| c450849e4d | |||
| e01d5ee642 | |||
| d333ab3d39 | |||
| 541017965f | |||
| 981920f004 | |||
| 3908c89998 | |||
| bf0f4ffb7f | |||
| 58043d1507 | |||
|
|
264c4ec21f | ||
| 6d7a55fdb3 | |||
| a8aacf4ee9 | |||
| 0a404ecde3 | |||
| 01f9e3dac1 | |||
| ad607afe83 | |||
| f0424223de | |||
| 7ab9068c14 | |||
| 41bb17d5c9 | |||
|
|
a44b938f08 | ||
|
|
7477a953c5 | ||
|
|
7fb296b47f | ||
|
|
8ab7d345c8 | ||
|
|
cf2139f229 | ||
|
|
79f33d659c | ||
| e5b71f8fb8 | |||
|
|
60649ae4de | ||
| e90aefae15 | |||
|
|
622932418d | ||
| a1855ff8d6 | |||
| 4bfaee685c | |||
| 2a6a658df9 | |||
| 37d378ca59 | |||
| 0441ca0c33 | |||
|
|
e6711b33d4 | ||
| 6b3a06a72c | |||
| 448e801ca3 | |||
| 751201617d | |||
| fa34223c10 | |||
| e6ea9405a6 | |||
| 32f96e4c6f | |||
| e6c4a21f65 | |||
| 831ffc071a | |||
| 5dd7cb3fb8 | |||
| 64816558c1 | |||
| 019ead7be3 | |||
| 29974704d0 | |||
| 877c869a22 | |||
| a9743025a7 | |||
| 9f82275c63 | |||
| e203ecf687 | |||
| aa3ea04bfc | |||
|
|
27ca8ab4b8 | ||
| 752d153cd4 | |||
| 763811fce6 | |||
| d7ed28e036 | |||
| a52d0cd1d3 | |||
| 373f3671f6 | |||
| 8f78c6cd45 |
@@ -180,6 +180,7 @@ Organisator kann Event absagen (mit optionaler Nachricht, Einweg-Transition).
|
||||
* RSVPs werden nach Absage abgelehnt
|
||||
* Absage-Nachricht nachträglich editierbar
|
||||
* Kann nicht rückgängig gemacht werden
|
||||
* Wenn Organisator Event auf der Eventlistenseite löscht, muss dabei das Event abgesagt werden (nicht nur lokal entfernen)
|
||||
|
||||
### 025 – Event löschen
|
||||
Organisator löscht Event permanent und unwiderruflich.
|
||||
|
||||
37
.specify/memory/research/modern-ui-effects.md
Normal file
37
.specify/memory/research/modern-ui-effects.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Modern UI Effects Research (2025-2026)
|
||||
|
||||
## Liquid Glass (Apple WWDC 2025)
|
||||
Evolved glassmorphism with directional lighting. Three-layer approach: highlight, shadow, illumination.
|
||||
- `backdrop-filter: blur(20px) saturate(1.5)` — higher saturation than basic glass
|
||||
- `inset 0 1px 0 rgba(255,255,255,0.15)` — top highlight (light direction)
|
||||
- `inset 0 -1px 0 rgba(0,0,0,0.1)` — bottom shadow
|
||||
- Outer drop shadow for depth: `0 8px 32px rgba(0,0,0,0.3)`
|
||||
- Advanced: SVG `feTurbulence` + `feSpecularLighting` for refraction (Chromium only)
|
||||
- Browser support: `backdrop-filter` ~88%, Firefox since v103
|
||||
|
||||
## Aurora / Gradient Mesh Backgrounds
|
||||
Stacked animated radial gradients simulating northern lights. Pairs well with glass cards on dark backgrounds.
|
||||
- Multiple `radial-gradient(ellipse ...)` layers with partial opacity
|
||||
- Animated via `background-position` shift (GPU-friendly)
|
||||
- `@property` rule enables direct gradient color animation (broad support since 2024)
|
||||
- Best for ambient background movement, not for content areas
|
||||
|
||||
## Animated Glow Borders
|
||||
Rotating `conic-gradient` borders with blur halo. Striking on dark backgrounds.
|
||||
- Outer wrapper with `conic-gradient(from var(--angle), color1, color2, color3, color1)`
|
||||
- `::before` pseudo with `filter: blur(12px)` and `opacity: 0.5` for glow halo
|
||||
- `@property --angle` trick to animate custom property inside `conic-gradient`
|
||||
- Use sparingly — best for single highlight elements (FAB, CTA), not all cards
|
||||
|
||||
## Modern Neumorphism (2025-2026 revision)
|
||||
Subtler than the original trend. Higher contrast, less extreme extrusion, combined with accent colors.
|
||||
- Light and dark shadow pair: `6px 6px 12px rgba(0,0,0,0.5)` + `-6px -6px 12px rgba(60,50,80,0.15)`
|
||||
- `border: 1px solid rgba(255,255,255,0.05)` for definition
|
||||
- Works on dark backgrounds with slightly lighter "uplift" shadow direction
|
||||
- Better suited for interactive elements (buttons, toggles) than content cards
|
||||
|
||||
## Sources
|
||||
- Apple Liquid Glass CSS: dev.to/gruszdev, dev.to/kevinbism, css-tricks.com, kube.io
|
||||
- Aurora: dev.to/oobleck, daltonwalsh.com, github.com/mattnewdavid
|
||||
- Glow borders: frontendmasters.com (Kevin Powell), docode.co.in
|
||||
- Trends overview: medium.com/design-bootcamp, index.dev, bighuman.com
|
||||
@@ -51,8 +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.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
|
||||
|
||||
@@ -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
|
||||
|
||||
23
README.md
23
README.md
@@ -1,6 +1,25 @@
|
||||
# fete
|
||||
<p align="center">
|
||||
<img src="frontend/public/og-image.png" alt="fete" width="100%" />
|
||||
</p>
|
||||
|
||||
A privacy-focused, self-hostable web app for event announcements and RSVPs. An alternative to Facebook Events or Telegram groups — reduced to the essentials.
|
||||
<p align="center">
|
||||
<strong>Privacy-focused, self-hostable event announcements and RSVPs.</strong><br>
|
||||
An alternative to Facebook Events or Telegram groups — reduced to the essentials.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/01-create-event.png" alt="Create Event" width="230" />
|
||||
|
||||
<img src="docs/screenshots/02-event-detail.png" alt="Event Detail" width="230" />
|
||||
|
||||
<img src="docs/screenshots/03-rsvp.png" alt="RSVP" width="230" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>Create events · Share with guests · Collect RSVPs</sub>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.13/apache-maven-3.9.13-bin.zip
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip
|
||||
|
||||
@@ -7,4 +7,8 @@
|
||||
<Match>
|
||||
<Package name="de.fete.adapter.in.web.model"/>
|
||||
</Match>
|
||||
<!-- Constructor-injected Spring beans storing interfaces/proxies are not a real exposure risk -->
|
||||
<Match>
|
||||
<Bug pattern="EI_EXPOSE_REP2"/>
|
||||
</Match>
|
||||
</FindBugsFilter>
|
||||
|
||||
@@ -2,9 +2,11 @@ package de.fete;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/** Spring Boot entry point for the fete application. */
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class FeteApplication {
|
||||
|
||||
/** Starts the application. */
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
package de.fete.adapter.in.web;
|
||||
|
||||
import de.fete.adapter.in.web.api.EventsApi;
|
||||
import de.fete.adapter.in.web.model.Attendee;
|
||||
import de.fete.adapter.in.web.model.CreateEventRequest;
|
||||
import de.fete.adapter.in.web.model.CreateEventResponse;
|
||||
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;
|
||||
import de.fete.domain.model.OrganizerToken;
|
||||
import de.fete.domain.model.Rsvp;
|
||||
import de.fete.domain.model.RsvpToken;
|
||||
import de.fete.domain.port.in.CancelRsvpUseCase;
|
||||
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
|
||||
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 java.time.Clock;
|
||||
import de.fete.domain.port.in.UpdateEventUseCase;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -32,21 +39,27 @@ public class EventController implements EventsApi {
|
||||
private final CreateEventUseCase createEventUseCase;
|
||||
private final GetEventUseCase getEventUseCase;
|
||||
private final CreateRsvpUseCase createRsvpUseCase;
|
||||
private final CancelRsvpUseCase cancelRsvpUseCase;
|
||||
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
|
||||
private final Clock clock;
|
||||
private final GetAttendeesUseCase getAttendeesUseCase;
|
||||
private final UpdateEventUseCase updateEventUseCase;
|
||||
|
||||
/** Creates a new controller with the given use cases and clock. */
|
||||
/** Creates a new controller with the given use cases. */
|
||||
public EventController(
|
||||
CreateEventUseCase createEventUseCase,
|
||||
GetEventUseCase getEventUseCase,
|
||||
CreateRsvpUseCase createRsvpUseCase,
|
||||
CancelRsvpUseCase cancelRsvpUseCase,
|
||||
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
|
||||
Clock clock) {
|
||||
GetAttendeesUseCase getAttendeesUseCase,
|
||||
UpdateEventUseCase updateEventUseCase) {
|
||||
this.createEventUseCase = createEventUseCase;
|
||||
this.getEventUseCase = getEventUseCase;
|
||||
this.createRsvpUseCase = createRsvpUseCase;
|
||||
this.cancelRsvpUseCase = cancelRsvpUseCase;
|
||||
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
|
||||
this.clock = clock;
|
||||
this.getAttendeesUseCase = getAttendeesUseCase;
|
||||
this.updateEventUseCase = updateEventUseCase;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -59,57 +72,91 @@ public class EventController implements EventsApi {
|
||||
request.getDescription(),
|
||||
request.getDateTime(),
|
||||
zoneId,
|
||||
request.getLocation(),
|
||||
request.getExpiryDate()
|
||||
request.getLocation()
|
||||
);
|
||||
|
||||
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.setExpiryDate(event.getExpiryDate());
|
||||
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));
|
||||
response.setExpired(
|
||||
event.getExpiryDate().isBefore(LocalDate.now(clock)));
|
||||
(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 eventToken, UUID organizerToken) {
|
||||
var evtToken = new EventToken(eventToken);
|
||||
var orgToken = new OrganizerToken(organizerToken);
|
||||
|
||||
List<String> names = getAttendeesUseCase
|
||||
.getAttendeeNames(evtToken, orgToken);
|
||||
|
||||
var attendees = names.stream()
|
||||
.map(name -> new Attendee().name(name))
|
||||
.toList();
|
||||
|
||||
var response = new GetAttendeesResponse();
|
||||
response.setAttendees(attendees);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@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 eventToken, UUID rsvpToken) {
|
||||
cancelRsvpUseCase.cancelRsvp(new EventToken(eventToken), new RsvpToken(rsvpToken));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private static ZoneId parseTimezone(String timezone) {
|
||||
try {
|
||||
return ZoneId.of(timezone);
|
||||
|
||||
@@ -1,10 +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.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;
|
||||
@@ -74,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(
|
||||
@@ -87,6 +116,19 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||
.body(problemDetail);
|
||||
}
|
||||
|
||||
/** Handles invalid organizer token. */
|
||||
@ExceptionHandler(InvalidOrganizerTokenException.class)
|
||||
public ResponseEntity<ProblemDetail> handleInvalidOrganizerToken(
|
||||
InvalidOrganizerTokenException ex) {
|
||||
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.FORBIDDEN, ex.getMessage());
|
||||
problemDetail.setTitle("Forbidden");
|
||||
problemDetail.setType(URI.create("urn:problem-type:invalid-organizer-token"));
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||
.body(problemDetail);
|
||||
}
|
||||
|
||||
/** Handles event not found. */
|
||||
@ExceptionHandler(EventNotFoundException.class)
|
||||
public ResponseEntity<ProblemDetail> handleEventNotFound(
|
||||
|
||||
188
backend/src/main/java/de/fete/adapter/in/web/SpaController.java
Normal file
188
backend/src/main/java/de/fete/adapter/in/web/SpaController.java
Normal file
@@ -0,0 +1,188 @@
|
||||
package de.fete.adapter.in.web;
|
||||
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.model.EventToken;
|
||||
import de.fete.domain.port.in.GetEventUseCase;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||
|
||||
/** Serves the SPA index.html with injected Open Graph and Twitter Card meta-tags. */
|
||||
@Controller
|
||||
public class SpaController {
|
||||
|
||||
private static final String PLACEHOLDER = "<!-- OG_META_TAGS -->";
|
||||
private static final int MAX_TITLE_LENGTH = 70;
|
||||
private static final int MAX_DESCRIPTION_LENGTH = 200;
|
||||
private static final String GENERIC_TITLE = "fete";
|
||||
private static final String GENERIC_DESCRIPTION =
|
||||
"Privacy-focused event planning. Create and share events without accounts.";
|
||||
private static final DateTimeFormatter DATE_FORMAT =
|
||||
DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy 'at' h:mm a", Locale.ENGLISH);
|
||||
|
||||
private final GetEventUseCase getEventUseCase;
|
||||
private String htmlTemplate;
|
||||
|
||||
/** Creates a new SpaController. */
|
||||
public SpaController(GetEventUseCase getEventUseCase) {
|
||||
this.getEventUseCase = getEventUseCase;
|
||||
}
|
||||
|
||||
/** Loads and caches the index.html template at startup. */
|
||||
@PostConstruct
|
||||
void loadTemplate() throws IOException {
|
||||
var resource = new ClassPathResource("/static/index.html");
|
||||
if (resource.exists()) {
|
||||
htmlTemplate = resource.getContentAsString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
/** Serves SPA HTML with generic meta-tags for non-event routes. */
|
||||
@GetMapping(
|
||||
value = {"/", "/create", "/events"},
|
||||
produces = MediaType.TEXT_HTML_VALUE
|
||||
)
|
||||
@ResponseBody
|
||||
public String serveGenericPage(HttpServletRequest request) {
|
||||
if (htmlTemplate == null) {
|
||||
return "";
|
||||
}
|
||||
String baseUrl = getBaseUrl(request);
|
||||
return htmlTemplate.replace(PLACEHOLDER, renderTags(buildGenericMeta(baseUrl)));
|
||||
}
|
||||
|
||||
/** Serves SPA HTML with event-specific meta-tags. */
|
||||
@GetMapping(
|
||||
value = "/events/{eventToken}",
|
||||
produces = MediaType.TEXT_HTML_VALUE
|
||||
)
|
||||
@ResponseBody
|
||||
public String serveEventPage(@PathVariable String eventToken,
|
||||
HttpServletRequest request) {
|
||||
if (htmlTemplate == null) {
|
||||
return "";
|
||||
}
|
||||
String baseUrl = getBaseUrl(request);
|
||||
Map<String, String> meta = resolveEventMeta(eventToken, baseUrl);
|
||||
return htmlTemplate.replace(PLACEHOLDER, renderTags(meta));
|
||||
}
|
||||
|
||||
// --- Meta-tag composition ---
|
||||
|
||||
private Map<String, String> buildEventMeta(Event event, String baseUrl) {
|
||||
var tags = new LinkedHashMap<String, String>();
|
||||
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.eventToken().value());
|
||||
tags.put("og:type", "website");
|
||||
tags.put("og:site_name", GENERIC_TITLE);
|
||||
tags.put("og:image", baseUrl + "/og-image.png");
|
||||
tags.put("twitter:card", "summary");
|
||||
tags.put("twitter:title", title);
|
||||
tags.put("twitter:description", description);
|
||||
return tags;
|
||||
}
|
||||
|
||||
private Map<String, String> buildGenericMeta(String baseUrl) {
|
||||
var tags = new LinkedHashMap<String, String>();
|
||||
tags.put("og:title", GENERIC_TITLE);
|
||||
tags.put("og:description", GENERIC_DESCRIPTION);
|
||||
tags.put("og:url", baseUrl);
|
||||
tags.put("og:type", "website");
|
||||
tags.put("og:site_name", GENERIC_TITLE);
|
||||
tags.put("og:image", baseUrl + "/og-image.png");
|
||||
tags.put("twitter:card", "summary");
|
||||
tags.put("twitter:title", GENERIC_TITLE);
|
||||
tags.put("twitter:description", GENERIC_DESCRIPTION);
|
||||
return tags;
|
||||
}
|
||||
|
||||
private Map<String, String> resolveEventMeta(String token, String baseUrl) {
|
||||
try {
|
||||
UUID uuid = UUID.fromString(token);
|
||||
Optional<Event> event =
|
||||
getEventUseCase.getByEventToken(new EventToken(uuid));
|
||||
if (event.isPresent()) {
|
||||
return buildEventMeta(event.get(), baseUrl);
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Invalid UUID — fall back to generic
|
||||
}
|
||||
return buildGenericMeta(baseUrl);
|
||||
}
|
||||
|
||||
// --- Description formatting ---
|
||||
|
||||
private String truncateTitle(String title) {
|
||||
if (title.length() <= MAX_TITLE_LENGTH) {
|
||||
return title;
|
||||
}
|
||||
return title.substring(0, MAX_TITLE_LENGTH - 3) + "...";
|
||||
}
|
||||
|
||||
private String formatDescription(Event event) {
|
||||
ZonedDateTime zoned = event.dateTime().atZoneSameInstant(event.timezone());
|
||||
var sb = new StringBuilder();
|
||||
sb.append("📅 ").append(zoned.format(DATE_FORMAT));
|
||||
|
||||
if (event.location() != null && !event.location().isBlank()) {
|
||||
sb.append(" · 📍 ").append(event.location());
|
||||
}
|
||||
|
||||
if (event.description() != null && !event.description().isBlank()) {
|
||||
sb.append(" — ").append(event.description());
|
||||
}
|
||||
|
||||
String result = sb.toString();
|
||||
if (result.length() > MAX_DESCRIPTION_LENGTH) {
|
||||
return result.substring(0, MAX_DESCRIPTION_LENGTH - 3) + "...";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- HTML rendering ---
|
||||
|
||||
private String renderTags(Map<String, String> tags) {
|
||||
var sb = new StringBuilder();
|
||||
for (var entry : tags.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
String value = escapeHtml(entry.getValue());
|
||||
String attr = key.startsWith("twitter:") ? "name" : "property";
|
||||
sb.append("<meta ").append(attr).append("=\"").append(key)
|
||||
.append("\" content=\"").append(value).append("\">\n");
|
||||
}
|
||||
return sb.toString().stripTrailing();
|
||||
}
|
||||
|
||||
private String escapeHtml(String input) {
|
||||
return input
|
||||
.replace("&", "&")
|
||||
.replace("\"", """)
|
||||
.replace("<", "<")
|
||||
.replace(">", ">");
|
||||
}
|
||||
|
||||
private String getBaseUrl(HttpServletRequest request) {
|
||||
return ServletUriComponentsBuilder.fromRequestUri(request)
|
||||
.replacePath("")
|
||||
.build()
|
||||
.toUriString();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,17 @@ package de.fete.adapter.out.persistence;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
/** Spring Data JPA repository for event entities. */
|
||||
public interface EventJpaRepository extends JpaRepository<EventJpaEntity, Long> {
|
||||
|
||||
/** Finds an event by its public event token. */
|
||||
Optional<EventJpaEntity> findByEventToken(UUID eventToken);
|
||||
|
||||
/** Deletes all events whose expiry date is before today. Returns the number of deleted rows. */
|
||||
@Modifying
|
||||
@Query(value = "DELETE FROM events WHERE expiry_date < CURRENT_DATE", nativeQuery = true)
|
||||
int deleteExpired();
|
||||
}
|
||||
|
||||
@@ -31,33 +31,41 @@ public class EventPersistenceAdapter implements EventRepository {
|
||||
return jpaRepository.findByEventToken(eventToken.value()).map(this::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteExpired() {
|
||||
return jpaRepository.deleteExpired();
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,10 @@ public interface RsvpJpaRepository extends JpaRepository<RsvpJpaEntity, Long> {
|
||||
|
||||
/** Counts RSVPs for the given event. */
|
||||
long countByEventId(Long eventId);
|
||||
|
||||
/** Finds all RSVPs for the given event, ordered by ID ascending. */
|
||||
java.util.List<RsvpJpaEntity> findAllByEventIdOrderByIdAsc(Long eventId);
|
||||
|
||||
/** Deletes an RSVP by event ID and RSVP token. Returns count of deleted rows. */
|
||||
long deleteByEventIdAndRsvpToken(Long eventId, UUID rsvpToken);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.fete.adapter.out.persistence;
|
||||
import de.fete.domain.model.Rsvp;
|
||||
import de.fete.domain.model.RsvpToken;
|
||||
import de.fete.domain.port.out.RsvpRepository;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/** Persistence adapter implementing the RsvpRepository outbound port. */
|
||||
@@ -28,21 +29,32 @@ public class RsvpPersistenceAdapter implements RsvpRepository {
|
||||
return jpaRepository.countByEventId(eventId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Rsvp> findByEventId(Long eventId) {
|
||||
return jpaRepository.findAllByEventIdOrderByIdAsc(eventId).stream()
|
||||
.map(this::toDomain)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteByEventIdAndRsvpToken(Long eventId, RsvpToken rsvpToken) {
|
||||
return jpaRepository.deleteByEventIdAndRsvpToken(eventId, rsvpToken.value()) > 0;
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
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;
|
||||
|
||||
private final EventRepository eventRepository;
|
||||
private final Clock clock;
|
||||
@@ -28,24 +35,21 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
|
||||
|
||||
@Override
|
||||
public Event createEvent(CreateEventCommand command) {
|
||||
if (!command.expiryDate().isAfter(LocalDate.now(clock))) {
|
||||
throw new ExpiryDateInPastException(command.expiryDate());
|
||||
}
|
||||
LocalDate expiryDate = command.dateTime().toLocalDate().plusDays(EXPIRY_DAYS_AFTER_EVENT);
|
||||
|
||||
if (!command.expiryDate().isAfter(command.dateTime().toLocalDate())) {
|
||||
throw new ExpiryDateBeforeEventException(command.expiryDate(), command.dateTime());
|
||||
}
|
||||
|
||||
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(command.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);
|
||||
}
|
||||
@@ -54,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.fete.application.service;
|
||||
|
||||
import de.fete.domain.port.out.EventRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Scheduled job that deletes events whose expiry date is in the past. */
|
||||
@Component
|
||||
public class ExpiredEventCleanupJob {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ExpiredEventCleanupJob.class);
|
||||
|
||||
private final EventRepository eventRepository;
|
||||
|
||||
/** Creates a new cleanup job with the given event repository. */
|
||||
public ExpiredEventCleanupJob(EventRepository eventRepository) {
|
||||
this.eventRepository = eventRepository;
|
||||
}
|
||||
|
||||
/** Runs daily at 03:00 and deletes all expired events. */
|
||||
@Scheduled(cron = "0 0 3 * * *")
|
||||
@Transactional
|
||||
public void deleteExpiredEvents() {
|
||||
int deleted = eventRepository.deleteExpired();
|
||||
log.info("Expired event cleanup: deleted {} event(s)", deleted);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,31 @@
|
||||
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;
|
||||
import de.fete.domain.model.Rsvp;
|
||||
import de.fete.domain.model.RsvpToken;
|
||||
import de.fete.domain.port.in.CancelRsvpUseCase;
|
||||
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
|
||||
import de.fete.domain.port.in.CreateRsvpUseCase;
|
||||
import de.fete.domain.port.in.GetAttendeesUseCase;
|
||||
import de.fete.domain.port.out.EventRepository;
|
||||
import de.fete.domain.port.out.RsvpRepository;
|
||||
import jakarta.transaction.Transactional;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Application service implementing RSVP creation. */
|
||||
/** Application service implementing RSVP operations. */
|
||||
@Service
|
||||
public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseCase {
|
||||
public class RsvpService
|
||||
implements CreateRsvpUseCase, CancelRsvpUseCase, CountAttendeesByEventUseCase,
|
||||
GetAttendeesUseCase {
|
||||
|
||||
private final EventRepository eventRepository;
|
||||
private final RsvpRepository rsvpRepository;
|
||||
@@ -35,22 +46,45 @@ public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseC
|
||||
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);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void cancelRsvp(EventToken eventToken, RsvpToken rsvpToken) {
|
||||
eventRepository.findByEventToken(eventToken)
|
||||
.ifPresent(event ->
|
||||
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
|
||||
public List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken) {
|
||||
Event event = eventRepository.findByEventToken(eventToken)
|
||||
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||
|
||||
if (!event.organizerToken().equals(organizerToken)) {
|
||||
throw new InvalidOrganizerTokenException();
|
||||
}
|
||||
|
||||
return rsvpRepository.findByEventId(event.id()).stream()
|
||||
.map(Rsvp::name)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.fete.application.service;
|
||||
package de.fete.application.service.exception;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.fete.application.service;
|
||||
package de.fete.application.service.exception;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.fete.application.service;
|
||||
package de.fete.application.service.exception;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.fete.application.service;
|
||||
package de.fete.application.service.exception;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.fete.application.service.exception;
|
||||
|
||||
/** Thrown when an invalid organizer token is provided. */
|
||||
public class InvalidOrganizerTokenException extends RuntimeException {
|
||||
|
||||
/** Creates a new exception for an invalid organizer token. */
|
||||
public InvalidOrganizerTokenException() {
|
||||
super("Invalid organizer token.");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Application-layer exceptions thrown by service use case implementations.
|
||||
*/
|
||||
package de.fete.application.service.exception;
|
||||
@@ -1,21 +1,17 @@
|
||||
package de.fete.config;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.config.annotation.PathMatchConfigurer;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
|
||||
/** Configures API path prefix and SPA static resource serving. */
|
||||
/** Configures API path prefix. Static resources served by default Spring Boot handler. */
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
/** Provides a system clock bean for time-dependent services. */
|
||||
@Bean
|
||||
Clock clock() {
|
||||
return Clock.systemDefaultZone();
|
||||
@@ -25,23 +21,4 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
public void configurePathMatch(PathMatchConfigurer configurer) {
|
||||
configurer.addPathPrefix("/api", c -> c.isAnnotationPresent(RestController.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/**")
|
||||
.addResourceLocations("classpath:/static/")
|
||||
.resourceChain(true)
|
||||
.addResolver(new PathResourceResolver() {
|
||||
@Override
|
||||
protected Resource getResource(String resourcePath,
|
||||
Resource location) throws IOException {
|
||||
Resource requested = location.createRelative(resourcePath);
|
||||
if (requested.exists() && requested.isReadable()) {
|
||||
return requested;
|
||||
}
|
||||
Resource index = new ClassPathResource("/static/index.html");
|
||||
return (index.exists() && index.isReadable()) ? index : null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,5 @@ public record CreateEventCommand(
|
||||
String description,
|
||||
OffsetDateTime dateTime,
|
||||
ZoneId timezone,
|
||||
String location,
|
||||
LocalDate expiryDate
|
||||
String location
|
||||
) {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package de.fete.domain.port.in;
|
||||
|
||||
import de.fete.domain.model.EventToken;
|
||||
import de.fete.domain.model.RsvpToken;
|
||||
|
||||
/** Inbound port for cancelling an RSVP. */
|
||||
public interface CancelRsvpUseCase {
|
||||
|
||||
/** Cancels the RSVP identified by the given tokens. Idempotent — no error if not found. */
|
||||
void cancelRsvp(EventToken eventToken, RsvpToken rsvpToken);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.fete.domain.port.in;
|
||||
|
||||
import de.fete.domain.model.EventToken;
|
||||
import de.fete.domain.model.OrganizerToken;
|
||||
import java.util.List;
|
||||
|
||||
/** Inbound port for retrieving attendee names of an event. */
|
||||
public interface GetAttendeesUseCase {
|
||||
|
||||
/** Returns attendee names ordered by RSVP submission time. */
|
||||
List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -12,4 +12,7 @@ public interface EventRepository {
|
||||
|
||||
/** Finds an event by its public event token. */
|
||||
Optional<Event> findByEventToken(EventToken eventToken);
|
||||
|
||||
/** Deletes all events whose expiry date is in the past. Returns the number of deleted events. */
|
||||
int deleteExpired();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package de.fete.domain.port.out;
|
||||
|
||||
import de.fete.domain.model.Rsvp;
|
||||
import de.fete.domain.model.RsvpToken;
|
||||
import java.util.List;
|
||||
|
||||
/** Outbound port for persisting and querying RSVPs. */
|
||||
public interface RsvpRepository {
|
||||
@@ -10,4 +12,10 @@ public interface RsvpRepository {
|
||||
|
||||
/** Counts the number of RSVPs for the given event. */
|
||||
long countByEventId(Long eventId);
|
||||
|
||||
/** Finds all RSVPs for the given event, ordered by ID ascending. */
|
||||
List<Rsvp> findByEventId(Long eventId);
|
||||
|
||||
/** Deletes an RSVP by event ID and RSVP token. Returns true if a record was deleted. */
|
||||
boolean deleteByEventIdAndRsvpToken(Long eventId, RsvpToken rsvpToken);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ spring.jpa.open-in-view=false
|
||||
# Liquibase
|
||||
spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml
|
||||
|
||||
# Proxy headers
|
||||
server.forward-headers-strategy=framework
|
||||
|
||||
# Actuator
|
||||
management.endpoints.web.exposure.include=health
|
||||
management.endpoint.health.show-details=never
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -37,14 +37,46 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ValidationProblemDetail"
|
||||
|
||||
/events/{token}/rsvps:
|
||||
/events/{eventToken}/rsvps/{rsvpToken}:
|
||||
delete:
|
||||
operationId: cancelRsvp
|
||||
summary: Cancel RSVP
|
||||
description: |
|
||||
Permanently deletes an RSVP identified by the RSVP token.
|
||||
Idempotent: returns 204 whether the RSVP existed or not.
|
||||
tags:
|
||||
- events
|
||||
parameters:
|
||||
- name: eventToken
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Event token (UUID)
|
||||
- name: rsvpToken
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: RSVP token (UUID) identifying the attendance to cancel
|
||||
responses:
|
||||
"204":
|
||||
description: >
|
||||
RSVP successfully cancelled (or was already cancelled).
|
||||
No response body.
|
||||
"500":
|
||||
description: Internal server error
|
||||
|
||||
/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:
|
||||
@@ -83,14 +115,55 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
|
||||
/events/{token}:
|
||||
/events/{eventToken}/attendees:
|
||||
get:
|
||||
operationId: getAttendees
|
||||
summary: Get attendee list for an event (organizer only)
|
||||
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
|
||||
responses:
|
||||
"200":
|
||||
description: Attendee list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetAttendeesResponse"
|
||||
"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"
|
||||
|
||||
/events/{eventToken}:
|
||||
get:
|
||||
operationId: getEvent
|
||||
summary: Get public event details by token
|
||||
tags:
|
||||
- events
|
||||
parameters:
|
||||
- name: token
|
||||
- name: eventToken
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
@@ -111,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:
|
||||
@@ -119,7 +244,6 @@ components:
|
||||
- title
|
||||
- dateTime
|
||||
- timezone
|
||||
- expiryDate
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
@@ -140,11 +264,6 @@ components:
|
||||
location:
|
||||
type: string
|
||||
maxLength: 500
|
||||
expiryDate:
|
||||
type: string
|
||||
format: date
|
||||
description: Date after which event data is deleted. Must be in the future.
|
||||
example: "2026-06-15"
|
||||
|
||||
CreateEventResponse:
|
||||
type: object
|
||||
@@ -154,7 +273,6 @@ components:
|
||||
- title
|
||||
- dateTime
|
||||
- timezone
|
||||
- expiryDate
|
||||
properties:
|
||||
eventToken:
|
||||
type: string
|
||||
@@ -177,10 +295,6 @@ components:
|
||||
type: string
|
||||
description: IANA timezone of the organizer
|
||||
example: "Europe/Berlin"
|
||||
expiryDate:
|
||||
type: string
|
||||
format: date
|
||||
example: "2026-06-15"
|
||||
|
||||
GetEventResponse:
|
||||
type: object
|
||||
@@ -190,7 +304,7 @@ components:
|
||||
- dateTime
|
||||
- timezone
|
||||
- attendeeCount
|
||||
- expired
|
||||
- cancelled
|
||||
properties:
|
||||
eventToken:
|
||||
type: string
|
||||
@@ -223,10 +337,31 @@ components:
|
||||
minimum: 0
|
||||
description: Number of confirmed attendees (attending=true)
|
||||
example: 12
|
||||
expired:
|
||||
cancelled:
|
||||
type: boolean
|
||||
description: Whether the event's expiry date has passed
|
||||
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
|
||||
@@ -256,6 +391,30 @@ components:
|
||||
description: Guest's display name as stored
|
||||
example: "Max Mustermann"
|
||||
|
||||
GetAttendeesResponse:
|
||||
type: object
|
||||
required:
|
||||
- attendees
|
||||
properties:
|
||||
attendees:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Attendee"
|
||||
example:
|
||||
- name: "Alice"
|
||||
- name: "Bob"
|
||||
|
||||
Attendee:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 100
|
||||
example: "Alice"
|
||||
|
||||
ProblemDetail:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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;
|
||||
@@ -20,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;
|
||||
@@ -55,8 +58,7 @@ class EventControllerIntegrationTest {
|
||||
.description("Come celebrate!")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.location("Berlin")
|
||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||
.location("Berlin");
|
||||
|
||||
var result = mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -67,7 +69,6 @@ class EventControllerIntegrationTest {
|
||||
.andExpect(jsonPath("$.title").value("Birthday Party"))
|
||||
.andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
|
||||
.andExpect(jsonPath("$.dateTime").isNotEmpty())
|
||||
.andExpect(jsonPath("$.expiryDate").isNotEmpty())
|
||||
.andReturn();
|
||||
|
||||
var response = objectMapper.readValue(
|
||||
@@ -79,7 +80,7 @@ class EventControllerIntegrationTest {
|
||||
assertThat(persisted.getDescription()).isEqualTo("Come celebrate!");
|
||||
assertThat(persisted.getTimezone()).isEqualTo("Europe/Berlin");
|
||||
assertThat(persisted.getLocation()).isEqualTo("Berlin");
|
||||
assertThat(persisted.getExpiryDate()).isEqualTo(request.getExpiryDate());
|
||||
assertThat(persisted.getExpiryDate()).isEqualTo(LocalDate.of(2026, 6, 22));
|
||||
assertThat(persisted.getDateTime().toInstant())
|
||||
.isEqualTo(request.getDateTime().toInstant());
|
||||
assertThat(persisted.getOrganizerToken()).isNotNull();
|
||||
@@ -91,8 +92,7 @@ class EventControllerIntegrationTest {
|
||||
var request = new CreateEventRequest()
|
||||
.title("Minimal Event")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("UTC")
|
||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||
.timezone("UTC");
|
||||
|
||||
var result = mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -119,8 +119,7 @@ class EventControllerIntegrationTest {
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||
.timezone("Europe/Berlin");
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -139,26 +138,6 @@ class EventControllerIntegrationTest {
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.title("No Date")
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventMissingExpiryDateReturns400() throws Exception {
|
||||
long countBefore = jpaRepository.count();
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.title("No Expiry")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin");
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
@@ -171,93 +150,12 @@ class EventControllerIntegrationTest {
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventExpiryDateInPastReturns400() throws Exception {
|
||||
long countBefore = jpaRepository.count();
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.title("Past Expiry")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.of(2025, 1, 1));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
||||
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventExpiryDateTodayReturns400() throws Exception {
|
||||
long countBefore = jpaRepository.count();
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.title("Today Expiry")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.now());
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
||||
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventExpiryDateBeforeEventDateReturns400() throws Exception {
|
||||
long countBefore = jpaRepository.count();
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.title("Bad Expiry")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.of(2026, 6, 10));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-before-event"));
|
||||
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventExpiryDateSameAsEventDateReturns400() throws Exception {
|
||||
long countBefore = jpaRepository.count();
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.title("Same Day Expiry")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.of(2026, 6, 15));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-before-event"));
|
||||
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void errorResponseContentTypeIsProblemJson() throws Exception {
|
||||
var request = new CreateEventRequest()
|
||||
.title("")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||
.timezone("Europe/Berlin");
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -273,8 +171,7 @@ class EventControllerIntegrationTest {
|
||||
var request = new CreateEventRequest()
|
||||
.title("Bad TZ")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Not/A/Zone")
|
||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||
.timezone("Not/A/Zone");
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -302,7 +199,6 @@ class EventControllerIntegrationTest {
|
||||
.andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
|
||||
.andExpect(jsonPath("$.location").value("Central Park"))
|
||||
.andExpect(jsonPath("$.attendeeCount").value(0))
|
||||
.andExpect(jsonPath("$.expired").value(false))
|
||||
.andExpect(jsonPath("$.dateTime").isNotEmpty());
|
||||
}
|
||||
|
||||
@@ -327,18 +223,6 @@ class EventControllerIntegrationTest {
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getExpiredEventReturnsExpiredTrue() throws Exception {
|
||||
EventJpaEntity entity = seedEvent(
|
||||
"Past Event", "It happened", "Europe/Berlin",
|
||||
"Old Venue", LocalDate.now().minusDays(1));
|
||||
|
||||
mockMvc.perform(get("/api/events/" + entity.getEventToken()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.title").value("Past Event"))
|
||||
.andExpect(jsonPath("$.expired").value(true));
|
||||
}
|
||||
|
||||
// --- RSVP tests ---
|
||||
|
||||
@Test
|
||||
@@ -439,6 +323,275 @@ class EventControllerIntegrationTest {
|
||||
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
// --- GET /events/{token}/attendees tests ---
|
||||
|
||||
@Test
|
||||
void getAttendeesReturnsNamesForOrganizer() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Party", null, "Europe/Berlin", null,
|
||||
LocalDate.now().plusDays(30));
|
||||
seedRsvp(event, "Alice");
|
||||
seedRsvp(event, "Bob");
|
||||
|
||||
mockMvc.perform(get("/api/events/" + event.getEventToken()
|
||||
+ "/attendees?organizerToken=" + event.getOrganizerToken()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.attendees").isArray())
|
||||
.andExpect(jsonPath("$.attendees.length()").value(2))
|
||||
.andExpect(jsonPath("$.attendees[0].name").value("Alice"))
|
||||
.andExpect(jsonPath("$.attendees[1].name").value("Bob"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAttendeesReturnsEmptyListWhenNoRsvps() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Empty Party", null, "Europe/Berlin", null,
|
||||
LocalDate.now().plusDays(30));
|
||||
|
||||
mockMvc.perform(get("/api/events/" + event.getEventToken()
|
||||
+ "/attendees?organizerToken=" + event.getOrganizerToken()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.attendees").isArray())
|
||||
.andExpect(jsonPath("$.attendees.length()").value(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAttendeesReturns403ForInvalidOrganizerToken() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Secret Party", null, "Europe/Berlin", null,
|
||||
LocalDate.now().plusDays(30));
|
||||
|
||||
mockMvc.perform(get("/api/events/" + event.getEventToken()
|
||||
+ "/attendees?organizerToken=" + UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(content().contentTypeCompatibleWith(
|
||||
"application/problem+json"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAttendeesReturns404ForUnknownEvent() throws Exception {
|
||||
mockMvc.perform(get("/api/events/" + UUID.randomUUID()
|
||||
+ "/attendees?organizerToken=" + UUID.randomUUID()))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(content().contentTypeCompatibleWith(
|
||||
"application/problem+json"));
|
||||
}
|
||||
|
||||
// --- Cancel RSVP tests ---
|
||||
|
||||
@Test
|
||||
void cancelRsvpReturns204AndDeletesRow() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Cancel Event", null, "Europe/Berlin",
|
||||
null, LocalDate.now().plusDays(30));
|
||||
UUID rsvpToken = seedRsvpAndGetToken(event, "Departing Guest");
|
||||
|
||||
long countBefore = rsvpJpaRepository.count();
|
||||
|
||||
mockMvc.perform(delete("/api/events/" + event.getEventToken()
|
||||
+ "/rsvps/" + rsvpToken))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore - 1);
|
||||
assertThat(rsvpJpaRepository.findByRsvpToken(rsvpToken)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelRsvpReturns204WhenAlreadyDeleted() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Idempotent Event", null, "Europe/Berlin",
|
||||
null, LocalDate.now().plusDays(30));
|
||||
|
||||
mockMvc.perform(delete("/api/events/" + event.getEventToken()
|
||||
+ "/rsvps/" + UUID.randomUUID()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelRsvpReturns204WhenEventNotFound() throws Exception {
|
||||
mockMvc.perform(delete("/api/events/" + UUID.randomUUID()
|
||||
+ "/rsvps/" + UUID.randomUUID()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void attendeeCountDecreasesAfterCancelRsvp() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Count Cancel Event", null, "Europe/Berlin",
|
||||
null, LocalDate.now().plusDays(30));
|
||||
UUID rsvpToken = seedRsvpAndGetToken(event, "Leaving Guest");
|
||||
seedRsvp(event, "Staying Guest");
|
||||
|
||||
mockMvc.perform(get("/api/events/" + event.getEventToken()))
|
||||
.andExpect(jsonPath("$.attendeeCount").value(2));
|
||||
|
||||
mockMvc.perform(delete("/api/events/" + event.getEventToken()
|
||||
+ "/rsvps/" + rsvpToken))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
mockMvc.perform(get("/api/events/" + event.getEventToken()))
|
||||
.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();
|
||||
rsvp.setRsvpToken(token);
|
||||
rsvp.setEventId(event.getId());
|
||||
rsvp.setName(name);
|
||||
rsvpJpaRepository.save(rsvp);
|
||||
return token;
|
||||
}
|
||||
|
||||
private void seedRsvp(EventJpaEntity event, String name) {
|
||||
var rsvp = new RsvpJpaEntity();
|
||||
rsvp.setRsvpToken(UUID.randomUUID());
|
||||
rsvp.setEventId(event.getId());
|
||||
rsvp.setName(name);
|
||||
rsvpJpaRepository.save(rsvp);
|
||||
}
|
||||
|
||||
private EventJpaEntity seedEvent(
|
||||
String title, String description, String timezone,
|
||||
String location, LocalDate expiryDate) {
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package de.fete.adapter.in.web;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import de.fete.TestcontainersConfig;
|
||||
import de.fete.adapter.out.persistence.EventJpaEntity;
|
||||
import de.fete.adapter.out.persistence.EventJpaRepository;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import(TestcontainersConfig.class)
|
||||
class SpaControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private EventJpaRepository eventJpaRepository;
|
||||
|
||||
// --- Phase 2: Base functionality ---
|
||||
|
||||
@Test
|
||||
void rootServesHtml() throws Exception {
|
||||
mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rootHtmlDoesNotContainPlaceholder() throws Exception {
|
||||
String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
assertThat(html).doesNotContain("<!-- OG_META_TAGS -->");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRouteServesHtml() throws Exception {
|
||||
mockMvc.perform(get("/create").accept(MediaType.TEXT_HTML))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML));
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventsRouteServesHtml() throws Exception {
|
||||
mockMvc.perform(get("/events").accept(MediaType.TEXT_HTML))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML));
|
||||
}
|
||||
|
||||
// --- Phase 4 (US2): Generic OG meta-tags ---
|
||||
|
||||
@Test
|
||||
void rootContainsGenericOgTitle() throws Exception {
|
||||
String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
assertThat(html).contains("og:title");
|
||||
assertThat(html).contains("content=\"fete\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRouteContainsGenericOgDescription() throws Exception {
|
||||
String html = mockMvc.perform(get("/create").accept(MediaType.TEXT_HTML))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
assertThat(html).contains("og:description");
|
||||
assertThat(html).contains("Privacy-focused event planning");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownRouteReturns404() throws Exception {
|
||||
mockMvc.perform(get("/unknown/path").accept(MediaType.TEXT_HTML))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
// --- Phase 5 (US3): Twitter Card meta-tags ---
|
||||
|
||||
@Test
|
||||
void eventRouteContainsTwitterCardTags() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Twitter Test", "Testing cards",
|
||||
"Europe/Berlin", "Berlin", LocalDate.now().plusDays(30));
|
||||
|
||||
String html = mockMvc.perform(
|
||||
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
assertThat(html).contains("twitter:card");
|
||||
assertThat(html).contains("twitter:title");
|
||||
assertThat(html).contains("twitter:description");
|
||||
}
|
||||
|
||||
@Test
|
||||
void genericRouteContainsTwitterCardTags() throws Exception {
|
||||
String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
assertThat(html).contains("twitter:card");
|
||||
assertThat(html).contains("content=\"summary\"");
|
||||
}
|
||||
|
||||
// --- Phase 3 (US1): Event-specific OG meta-tags ---
|
||||
|
||||
@Test
|
||||
void eventRouteContainsEventSpecificOgTitle() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Birthday Party", "Come celebrate!",
|
||||
"Europe/Berlin", "Berlin", LocalDate.now().plusDays(30));
|
||||
|
||||
String html = mockMvc.perform(
|
||||
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
assertThat(html).contains("og:title");
|
||||
assertThat(html).contains("Birthday Party");
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventRouteContainsOgDescription() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"BBQ", "Bring drinks!",
|
||||
"Europe/Berlin", "Central Park", LocalDate.now().plusDays(30));
|
||||
|
||||
String html = mockMvc.perform(
|
||||
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
assertThat(html).contains("og:description");
|
||||
assertThat(html).contains("Central Park");
|
||||
assertThat(html).contains("Bring drinks!");
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventRouteContainsOgUrl() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Party", null,
|
||||
"Europe/Berlin", null, LocalDate.now().plusDays(30));
|
||||
|
||||
String html = mockMvc.perform(
|
||||
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
assertThat(html).contains("og:url");
|
||||
assertThat(html).contains("/events/" + event.getEventToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventRouteContainsOgImage() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Party", null,
|
||||
"Europe/Berlin", null, LocalDate.now().plusDays(30));
|
||||
|
||||
String html = mockMvc.perform(
|
||||
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
assertThat(html).contains("og:image");
|
||||
assertThat(html).contains("/og-image.png");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownEventTokenFallsBackToGenericMeta() throws Exception {
|
||||
String html = mockMvc.perform(
|
||||
get("/events/" + UUID.randomUUID()).accept(MediaType.TEXT_HTML))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
assertThat(html).contains("og:title");
|
||||
assertThat(html).contains("content=\"fete\"");
|
||||
}
|
||||
|
||||
// --- HTML escaping ---
|
||||
|
||||
@Test
|
||||
void specialCharactersAreHtmlEscaped() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Tom & Jerry's \"Party\"", "Fun <times> & more",
|
||||
"Europe/Berlin", "O'Brien's Pub", LocalDate.now().plusDays(30));
|
||||
|
||||
String html = mockMvc.perform(
|
||||
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
assertThat(html).contains("Tom & Jerry");
|
||||
assertThat(html).contains("& more");
|
||||
assertThat(html).contains("<times>");
|
||||
assertThat(html).doesNotContain("content=\"Tom & Jerry");
|
||||
}
|
||||
|
||||
// --- Title truncation ---
|
||||
|
||||
@Test
|
||||
void longTitleIsTruncatedTo70Chars() throws Exception {
|
||||
String longTitle = "A".repeat(80);
|
||||
EventJpaEntity event = seedEvent(
|
||||
longTitle, "Desc",
|
||||
"Europe/Berlin", null, LocalDate.now().plusDays(30));
|
||||
|
||||
String html = mockMvc.perform(
|
||||
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
assertThat(html).contains("A".repeat(67) + "...");
|
||||
assertThat(html).doesNotContain("A".repeat(68));
|
||||
}
|
||||
|
||||
// --- Description formatting ---
|
||||
|
||||
@Test
|
||||
void eventWithoutLocationOmitsPinEmoji() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Online Meetup", "Virtual gathering",
|
||||
"Europe/Berlin", null, LocalDate.now().plusDays(30));
|
||||
|
||||
String html = mockMvc.perform(
|
||||
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
assertThat(html).doesNotContain("📍");
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventWithoutDescriptionOmitsDash() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Silent Event", null,
|
||||
"Europe/Berlin", "Berlin", LocalDate.now().plusDays(30));
|
||||
|
||||
String html = mockMvc.perform(
|
||||
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
assertThat(html).contains("📅");
|
||||
assertThat(html).contains("Berlin");
|
||||
assertThat(html).doesNotContain(" — ");
|
||||
}
|
||||
|
||||
private EventJpaEntity seedEvent(
|
||||
String title, String description, String timezone,
|
||||
String location, LocalDate expiryDate) {
|
||||
var entity = new EventJpaEntity();
|
||||
entity.setEventToken(UUID.randomUUID());
|
||||
entity.setOrganizerToken(UUID.randomUUID());
|
||||
entity.setTitle(title);
|
||||
entity.setDescription(description);
|
||||
entity.setDateTime(
|
||||
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
|
||||
entity.setTimezone(timezone);
|
||||
entity.setLocation(location);
|
||||
entity.setExpiryDate(expiryDate);
|
||||
entity.setCreatedAt(OffsetDateTime.now());
|
||||
return eventJpaRepository.save(entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package de.fete.adapter.out.persistence;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import de.fete.TestcontainersConfig;
|
||||
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.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@SpringBootTest
|
||||
@Import(TestcontainersConfig.class)
|
||||
@Transactional
|
||||
class EventPersistenceAdapterIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private EventRepository eventRepository;
|
||||
|
||||
@Test
|
||||
void deleteExpiredRemovesExpiredEvents() {
|
||||
Event expired = buildEvent("Expired Party", LocalDate.now().minusDays(1));
|
||||
eventRepository.save(expired);
|
||||
|
||||
int deleted = eventRepository.deleteExpired();
|
||||
|
||||
assertThat(deleted).isGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteExpiredKeepsNonExpiredEvents() {
|
||||
Event future = buildEvent("Future Party", LocalDate.now().plusDays(30));
|
||||
Event saved = eventRepository.save(future);
|
||||
|
||||
eventRepository.deleteExpired();
|
||||
|
||||
assertThat(eventRepository.findByEventToken(saved.eventToken())).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteExpiredKeepsEventsExpiringToday() {
|
||||
Event today = buildEvent("Today Party", LocalDate.now());
|
||||
Event saved = eventRepository.save(today);
|
||||
|
||||
eventRepository.deleteExpired();
|
||||
|
||||
assertThat(eventRepository.findByEventToken(saved.eventToken())).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteExpiredReturnsZeroWhenNoneExpired() {
|
||||
// Only save a future event
|
||||
buildEvent("Future Only", LocalDate.now().plusDays(60));
|
||||
|
||||
int deleted = eventRepository.deleteExpired();
|
||||
|
||||
assertThat(deleted).isGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
private Event buildEvent(String title, LocalDate expiryDate) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
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.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -53,19 +52,18 @@ class EventServiceTest {
|
||||
"Come celebrate!",
|
||||
TODAY.plusDays(90).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||
ZONE,
|
||||
"Berlin",
|
||||
TODAY.plusDays(120)
|
||||
"Berlin"
|
||||
);
|
||||
|
||||
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
|
||||
@@ -75,98 +73,30 @@ class EventServiceTest {
|
||||
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
||||
TODAY.plusDays(11)
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null
|
||||
);
|
||||
|
||||
eventService.createEvent(command);
|
||||
|
||||
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
|
||||
void expiryDateTodayThrowsException() {
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
||||
TODAY
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||
.isInstanceOf(ExpiryDateInPastException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiryDateInPastThrowsException() {
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
||||
TODAY.minusDays(5)
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||
.isInstanceOf(ExpiryDateInPastException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiryDateTomorrowSucceeds() {
|
||||
void expiryDateIsEventDatePlusSevenDays() {
|
||||
when(eventRepository.save(any(Event.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
var eventDate = TODAY.plusDays(10);
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
TODAY.plusDays(1).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
||||
TODAY.plusDays(2)
|
||||
eventDate.atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null
|
||||
);
|
||||
|
||||
Event result = eventService.createEvent(command);
|
||||
|
||||
assertThat(result.getExpiryDate()).isEqualTo(TODAY.plusDays(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiryDateSameAsEventDateThrowsException() {
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||
ZONE, null,
|
||||
TODAY.plusDays(10)
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||
.isInstanceOf(ExpiryDateBeforeEventException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiryDateBeforeEventDateThrowsException() {
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||
ZONE, null,
|
||||
TODAY.plusDays(5)
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||
.isInstanceOf(ExpiryDateBeforeEventException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiryDateDayAfterEventDateSucceeds() {
|
||||
when(eventRepository.save(any(Event.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||
ZONE, null,
|
||||
TODAY.plusDays(11)
|
||||
);
|
||||
|
||||
Event result = eventService.createEvent(command);
|
||||
|
||||
assertThat(result.getExpiryDate()).isEqualTo(TODAY.plusDays(11));
|
||||
assertThat(result.expiryDate()).isEqualTo(eventDate.plusDays(7));
|
||||
}
|
||||
|
||||
// --- GetEventUseCase tests (T004) ---
|
||||
@@ -174,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
|
||||
@@ -207,12 +136,11 @@ class EventServiceTest {
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||
ZoneId.of("America/New_York"), null,
|
||||
TODAY.plusDays(11)
|
||||
ZoneId.of("America/New_York"), null
|
||||
);
|
||||
|
||||
Event result = eventService.createEvent(command);
|
||||
|
||||
assertThat(result.getTimezone()).isEqualTo(ZoneId.of("America/New_York"));
|
||||
assertThat(result.timezone()).isEqualTo(ZoneId.of("America/New_York"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,15 @@ 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;
|
||||
import de.fete.domain.model.Rsvp;
|
||||
import de.fete.domain.model.RsvpToken;
|
||||
import de.fete.domain.port.out.EventRepository;
|
||||
import de.fete.domain.port.out.RsvpRepository;
|
||||
import java.time.Clock;
|
||||
@@ -18,6 +23,7 @@ import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -49,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));
|
||||
@@ -74,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
|
||||
@@ -89,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"))
|
||||
@@ -113,25 +118,124 @@ 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"))
|
||||
.isInstanceOf(EventExpiredException.class);
|
||||
}
|
||||
|
||||
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;
|
||||
@Test
|
||||
void getAttendeeNamesReturnsNamesInOrder() {
|
||||
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.id()))
|
||||
.thenReturn(List.of(
|
||||
buildRsvp(1L, "Alice"),
|
||||
buildRsvp(2L, "Bob"),
|
||||
buildRsvp(3L, "Charlie")));
|
||||
|
||||
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
|
||||
|
||||
assertThat(names).containsExactly("Alice", "Bob", "Charlie");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAttendeeNamesReturnsEmptyListWhenNoRsvps() {
|
||||
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.id()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
|
||||
|
||||
assertThat(names).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAttendeeNamesThrowsWhenEventNotFound() {
|
||||
EventToken token = EventToken.generate();
|
||||
OrganizerToken orgToken = OrganizerToken.generate();
|
||||
when(eventRepository.findByEventToken(token))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> rsvpService.getAttendeeNames(token, orgToken))
|
||||
.isInstanceOf(EventNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAttendeeNamesThrowsWhenOrganizerTokenInvalid() {
|
||||
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||
EventToken token = event.eventToken();
|
||||
OrganizerToken wrongToken = OrganizerToken.generate();
|
||||
when(eventRepository.findByEventToken(token))
|
||||
.thenReturn(Optional.of(event));
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> rsvpService.getAttendeeNames(token, wrongToken))
|
||||
.isInstanceOf(InvalidOrganizerTokenException.class);
|
||||
}
|
||||
|
||||
private Rsvp buildRsvp(Long id, String name) {
|
||||
return new Rsvp(id, RsvpToken.generate(), 1L, name);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelRsvpDeletesWhenEventAndRsvpExist() {
|
||||
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.id(), rsvpToken)).thenReturn(true);
|
||||
|
||||
rsvpService.cancelRsvp(token, rsvpToken);
|
||||
|
||||
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.id(), rsvpToken);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelRsvpSucceedsWhenRsvpNotFound() {
|
||||
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.id(), rsvpToken)).thenReturn(false);
|
||||
|
||||
rsvpService.cancelRsvp(token, rsvpToken);
|
||||
|
||||
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.id(), rsvpToken);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelRsvpSucceedsWhenEventNotFound() {
|
||||
EventToken token = EventToken.generate();
|
||||
RsvpToken rsvpToken = RsvpToken.generate();
|
||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.empty());
|
||||
|
||||
rsvpService.cancelRsvp(token, rsvpToken);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,10 @@ class WebConfigTest {
|
||||
|
||||
@Test
|
||||
void apiPrefixNotAccessibleWithoutIt() throws Exception {
|
||||
// /events without /api prefix should not resolve to the API endpoint
|
||||
mockMvc.perform(get("/events"))
|
||||
.andExpect(status().isNotFound());
|
||||
// /events without /api prefix should not resolve to the REST API endpoint;
|
||||
// it is served by SpaController as HTML instead
|
||||
mockMvc.perform(get("/events")
|
||||
.accept("text/html"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
}
|
||||
|
||||
13
backend/src/test/resources/static/index.html
Normal file
13
backend/src/test/resources/static/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<!-- OG_META_TAGS -->
|
||||
<title>fete</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
docs/screenshots/01-create-event.png
Normal file
BIN
docs/screenshots/01-create-event.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
BIN
docs/screenshots/02-event-detail.png
Normal file
BIN
docs/screenshots/02-event-detail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 346 KiB |
BIN
docs/screenshots/03-rsvp.png
Normal file
BIN
docs/screenshots/03-rsvp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 309 KiB |
210
frontend/e2e/cancel-event-list.spec.ts
Normal file
210
frontend/e2e/cancel-event-list.spec.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { test, expect } from './msw-setup'
|
||||
import type { StoredEvent } from '../src/composables/useEventStorage'
|
||||
|
||||
const STORAGE_KEY = 'fete:events'
|
||||
|
||||
const organizerEvent: StoredEvent = {
|
||||
eventToken: 'org-event-aaa',
|
||||
title: 'Summer BBQ',
|
||||
dateTime: '2027-06-15T18:00:00Z',
|
||||
organizerToken: 'org-secret-token',
|
||||
}
|
||||
|
||||
const attendeeEvent: StoredEvent = {
|
||||
eventToken: 'att-event-bbb',
|
||||
title: 'Team Meeting',
|
||||
dateTime: '2027-01-10T09:00:00Z',
|
||||
rsvpToken: 'rsvp-token-1',
|
||||
rsvpName: 'Alice',
|
||||
}
|
||||
|
||||
function seedEvents(events: StoredEvent[]): string {
|
||||
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||
}
|
||||
|
||||
test.describe('US1: Organizer Cancels Event from List', () => {
|
||||
test('T001: organizer taps delete, confirms, event is removed after successful API call', async ({
|
||||
page,
|
||||
network,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent, attendeeEvent]))
|
||||
|
||||
const { http, HttpResponse } = await import('msw')
|
||||
let patchCalled = false
|
||||
network.use(
|
||||
http.patch('*/api/events/:token', ({ request, params }) => {
|
||||
const url = new URL(request.url)
|
||||
if (
|
||||
params['token'] === organizerEvent.eventToken &&
|
||||
url.searchParams.get('organizerToken') === organizerEvent.organizerToken
|
||||
) {
|
||||
patchCalled = true
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}
|
||||
return HttpResponse.json(
|
||||
{ type: 'about:blank', title: 'Forbidden', status: 403 },
|
||||
{ status: 403, headers: { 'Content-Type': 'application/problem+json' } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
|
||||
// Click delete on organizer event
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
|
||||
// Confirmation dialog appears with organizer-specific text
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||
|
||||
// Confirm cancellation
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
|
||||
// Event is removed from list
|
||||
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||
// Other event remains
|
||||
await expect(page.getByText('Team Meeting')).toBeVisible()
|
||||
|
||||
expect(patchCalled).toBe(true)
|
||||
})
|
||||
|
||||
test('T002: organizer confirms cancellation, API fails, event stays in list and error shown', async ({
|
||||
page,
|
||||
network,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent]))
|
||||
|
||||
const { http, HttpResponse } = await import('msw')
|
||||
network.use(
|
||||
http.patch('*/api/events/:token', () => {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
type: 'about:blank',
|
||||
title: 'Internal Server Error',
|
||||
status: 500,
|
||||
},
|
||||
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
|
||||
// Event stays in list
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
|
||||
test('T003: organizer confirms cancellation, API returns 409 Conflict, event is silently removed', async ({
|
||||
page,
|
||||
network,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent]))
|
||||
|
||||
const { http, HttpResponse } = await import('msw')
|
||||
network.use(
|
||||
http.patch('*/api/events/:token', () => {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
type: 'about:blank',
|
||||
title: 'Conflict',
|
||||
status: 409,
|
||||
detail: 'Event is already cancelled.',
|
||||
},
|
||||
{ status: 409, headers: { 'Content-Type': 'application/problem+json' } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
|
||||
// 409 treated as success — event removed
|
||||
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('T004: organizer opens cancel dialog then dismisses (cancel button), event remains', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||
|
||||
// Dismiss via Cancel button
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByRole('alertdialog')).not.toBeVisible()
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
|
||||
test('T004b: organizer opens cancel dialog then dismisses via Escape', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await expect(page.getByRole('alertdialog')).not.toBeVisible()
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
|
||||
test('T004c: organizer opens cancel dialog then dismisses via overlay click', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||
|
||||
// Click on overlay (outside dialog)
|
||||
await page.locator('.confirm-dialog__overlay').click({ position: { x: 10, y: 10 } })
|
||||
|
||||
await expect(page.getByRole('alertdialog')).not.toBeVisible()
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US2: Distinct Dialog for Organizer vs. Attendee', () => {
|
||||
test('T011: organizer dialog shows event-cancellation warning', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
const dialog = page.getByRole('alertdialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
// Organizer-specific title and message
|
||||
await expect(dialog.locator('.confirm-dialog__title')).toHaveText('Cancel event?')
|
||||
await expect(dialog.locator('.confirm-dialog__message')).toContainText(
|
||||
'all attendees',
|
||||
)
|
||||
})
|
||||
|
||||
test('T012: attendee dialog preserves existing RSVP-cancellation message', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([attendeeEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Team Meeting/ }).click()
|
||||
const dialog = page.getByRole('alertdialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
// Attendee-specific title and message
|
||||
await expect(dialog.locator('.confirm-dialog__title')).toHaveText('Remove event?')
|
||||
await expect(dialog.locator('.confirm-dialog__message')).toContainText(
|
||||
'attendance will be cancelled',
|
||||
)
|
||||
})
|
||||
})
|
||||
162
frontend/e2e/cancel-event.spec.ts
Normal file
162
frontend/e2e/cancel-event.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
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}`)
|
||||
|
||||
// Cancel button visible for organizer
|
||||
const cancelBtn = page.getByRole('button', { name: /Cancel event/i })
|
||||
await expect(cancelBtn).toBeVisible()
|
||||
|
||||
// Open cancel bottom sheet
|
||||
await cancelBtn.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()
|
||||
|
||||
// Cancel button should be gone
|
||||
await expect(cancelBtn).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: /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: /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()
|
||||
})
|
||||
})
|
||||
276
frontend/e2e/cancel-rsvp.spec.ts
Normal file
276
frontend/e2e/cancel-rsvp.spec.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
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,
|
||||
}
|
||||
|
||||
const rsvpToken = 'd4e5f6a7-b8c9-0123-4567-890abcdef012'
|
||||
|
||||
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,
|
||||
rsvpName: 'Anna',
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('US1: Cancel RSVP from Event Detail View', () => {
|
||||
test('status bar shows cancel affordance when RSVP\'d', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Status bar visible
|
||||
const statusBar = page.getByRole('button', { name: /You're attending/ })
|
||||
await expect(statusBar).toBeVisible()
|
||||
|
||||
// Cancel button hidden initially
|
||||
await expect(page.getByRole('button', { name: 'Cancel RSVP' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('tapping status bar reveals cancel button', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Tap status bar
|
||||
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||
|
||||
// Cancel button appears
|
||||
await expect(page.getByRole('button', { name: 'Cancel RSVP' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('confirm cancellation → localStorage cleared, count decremented, bar reset', 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}`)
|
||||
|
||||
// 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('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()
|
||||
await expect(page.getByText("You're attending!")).not.toBeVisible()
|
||||
|
||||
// Attendee count decremented
|
||||
await expect(page.getByText('11 going')).toBeVisible()
|
||||
|
||||
// localStorage cleared
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem('fete:events')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
const event = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
|
||||
expect(event?.rsvpToken).toBeUndefined()
|
||||
expect(event?.rsvpName).toBeUndefined()
|
||||
})
|
||||
|
||||
test('server error → error message, state unchanged', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return HttpResponse.json({ error: 'fail' }, { status: 500 })
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// 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 RSVP' }).click()
|
||||
|
||||
// Error message
|
||||
await expect(page.getByText('Could not cancel RSVP. Please try again.')).toBeVisible()
|
||||
|
||||
// Attendee count unchanged
|
||||
await expect(page.getByText('12 going')).toBeVisible()
|
||||
})
|
||||
|
||||
test('re-RSVP after cancel works', 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 })
|
||||
}),
|
||||
http.post('*/api/events/:token/rsvps', () => {
|
||||
return HttpResponse.json(
|
||||
{ rsvpToken: 'new-rsvp-token', name: 'Max' },
|
||||
{ status: 201 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// 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 RSVP' }).click()
|
||||
|
||||
// CTA should be back
|
||||
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
|
||||
|
||||
// Re-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()
|
||||
|
||||
// Status bar returns
|
||||
await expect(page.getByText("You're attending!")).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US2: Auto-Cancel on Event List Removal', () => {
|
||||
test('removal of RSVP\'d event shows attendance warning in dialog', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
|
||||
await expect(page.getByText('your attendance will be cancelled')).toBeVisible()
|
||||
})
|
||||
|
||||
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',
|
||||
}
|
||||
await page.addInitScript(seedEvents([watcherEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
// Watcher events are removed directly without dialog
|
||||
await page.getByRole('button', { name: /Remove Watcher Event/ }).click()
|
||||
|
||||
// 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 }) => {
|
||||
network.use(
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
|
||||
// Event gone
|
||||
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||
|
||||
// localStorage updated
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem('fete:events')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
const found = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
|
||||
expect(found).toBeUndefined()
|
||||
})
|
||||
|
||||
test('server error on DELETE → error message, event stays in list', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return HttpResponse.json({ error: 'fail' }, { status: 500 })
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
|
||||
// Event still in list
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
|
||||
test('dismiss dialog → no changes', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
// Event still there
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US3: Cancel RSVP with Stale/Invalid Token', () => {
|
||||
test('cancel from detail view with stale token (404) → treated as success', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return HttpResponse.json({ error: 'not found' }, { status: 404 })
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// 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 RSVP' }).click()
|
||||
|
||||
// Treated as success — CTA returns
|
||||
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
|
||||
|
||||
// localStorage cleaned
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem('fete:events')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
const event = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
|
||||
expect(event?.rsvpToken).toBeUndefined()
|
||||
})
|
||||
|
||||
test('event list removal with stale token (404) → treated as success', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||
return HttpResponse.json({ error: 'not found' }, { status: 404 })
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
|
||||
// Event removed from list
|
||||
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
74
frontend/e2e/cancelled-event-visitor.spec.ts
Normal file
74
frontend/e2e/cancelled-event-visitor.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,6 @@ test.describe('US-1: Create an event', () => {
|
||||
|
||||
await expect(page.getByText('Title is required.')).toBeVisible()
|
||||
await expect(page.getByText('Date and time are required.')).toBeVisible()
|
||||
await expect(page.getByText('Expiry date is required.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('creates an event and redirects to event detail page', async ({ page }) => {
|
||||
@@ -19,7 +18,6 @@ test.describe('US-1: Create an event', () => {
|
||||
await page.getByLabel(/description/i).fill('Bring your own drinks')
|
||||
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
|
||||
await page.getByLabel(/location/i).fill('Central Park')
|
||||
await page.getByLabel(/expiry/i).fill('2026-06-15')
|
||||
|
||||
await page.getByRole('button', { name: /create event/i }).click()
|
||||
|
||||
@@ -31,7 +29,6 @@ test.describe('US-1: Create an event', () => {
|
||||
|
||||
await page.getByLabel(/title/i).fill('Summer BBQ')
|
||||
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
|
||||
await page.getByLabel(/expiry/i).fill('2026-06-15')
|
||||
|
||||
await page.getByRole('button', { name: /create event/i }).click()
|
||||
await expect(page).toHaveURL(/\/events\/.+/)
|
||||
@@ -59,7 +56,6 @@ test.describe('US-1: Create an event', () => {
|
||||
await page.goto('/create')
|
||||
await page.getByLabel(/title/i).fill('Test')
|
||||
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
|
||||
await page.getByLabel(/expiry/i).fill('2026-06-15')
|
||||
|
||||
await page.getByRole('button', { name: /create event/i }).click()
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ const fullEvent = {
|
||||
timezone: 'Europe/Berlin',
|
||||
location: 'Central Park, NYC',
|
||||
attendeeCount: 12,
|
||||
expired: false,
|
||||
}
|
||||
|
||||
test.describe('US1: RSVP submission flow', () => {
|
||||
@@ -170,16 +169,4 @@ test.describe('US1: RSVP submission flow', () => {
|
||||
await expect(page.getByText("You're attending!")).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('does not show RSVP bar on expired event', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json({ ...fullEvent, expired: true })
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
await expect(page.getByText('This event has ended.')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,6 @@ const fullEvent = {
|
||||
timezone: 'Europe/Berlin',
|
||||
location: 'Central Park, NYC',
|
||||
attendeeCount: 12,
|
||||
expired: false,
|
||||
}
|
||||
|
||||
test.describe('US-1: View event details', () => {
|
||||
@@ -52,20 +51,6 @@ test.describe('US-1: View event details', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US-2: View expired event', () => {
|
||||
test('shows "event has ended" banner for expired event', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json({ ...fullEvent, expired: true })
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
await expect(page.getByText('This event has ended.')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US-4: Event not found', () => {
|
||||
test('shows "event not found" for unknown token', async ({ page, network }) => {
|
||||
network.use(
|
||||
|
||||
@@ -7,7 +7,6 @@ const futureEvent1: StoredEvent = {
|
||||
eventToken: 'future-aaa',
|
||||
title: 'Summer BBQ',
|
||||
dateTime: '2027-06-15T18:00:00Z',
|
||||
expiryDate: '2027-06-16T00:00:00Z',
|
||||
organizerToken: 'org-token-1',
|
||||
}
|
||||
|
||||
@@ -15,7 +14,6 @@ const futureEvent2: StoredEvent = {
|
||||
eventToken: 'future-bbb',
|
||||
title: 'Team Meeting',
|
||||
dateTime: '2027-01-10T09:00:00Z',
|
||||
expiryDate: '2027-01-11T00:00:00Z',
|
||||
rsvpToken: 'rsvp-token-1',
|
||||
rsvpName: 'Alice',
|
||||
}
|
||||
@@ -24,7 +22,6 @@ const pastEvent: StoredEvent = {
|
||||
eventToken: 'past-ccc',
|
||||
title: 'New Year Party',
|
||||
dateTime: '2025-01-01T00:00:00Z',
|
||||
expiryDate: '2025-01-02T00:00:00Z',
|
||||
}
|
||||
|
||||
function seedEvents(events: StoredEvent[]): string {
|
||||
@@ -85,7 +82,6 @@ test.describe('US4: Past Events Appear Faded', () => {
|
||||
location: '',
|
||||
timezone: 'UTC',
|
||||
attendeeCount: 0,
|
||||
expired: true,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -97,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()
|
||||
@@ -124,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()
|
||||
})
|
||||
})
|
||||
@@ -143,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/)
|
||||
})
|
||||
|
||||
@@ -154,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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -191,6 +201,129 @@ test.describe('FAB: Create Event Button', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Temporal Grouping: Section Headers', () => {
|
||||
test('events are distributed under correct section headers', async ({ page }) => {
|
||||
// Use dates relative to "now" to ensure correct section assignment
|
||||
const now = new Date()
|
||||
const todayEvent: StoredEvent = {
|
||||
eventToken: 'today-1',
|
||||
title: 'Today Standup',
|
||||
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0, 0).toISOString(),
|
||||
}
|
||||
const laterEvent: StoredEvent = {
|
||||
eventToken: 'later-1',
|
||||
title: 'Future Conference',
|
||||
dateTime: new Date(now.getFullYear() + 1, 0, 15, 10, 0, 0).toISOString(),
|
||||
}
|
||||
await page.addInitScript(seedEvents([todayEvent, laterEvent, pastEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
// Verify section headers appear
|
||||
await expect(page.getByRole('heading', { name: 'Today', level: 2 })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Later', level: 2 })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Past', level: 2 })).toBeVisible()
|
||||
|
||||
// Events are in the correct sections
|
||||
const sections = page.locator('.event-section')
|
||||
const todaySection = sections.filter({ has: page.getByRole('heading', { name: 'Today', level: 2 }) })
|
||||
await expect(todaySection.getByText('Today Standup')).toBeVisible()
|
||||
|
||||
const laterSection = sections.filter({ has: page.getByRole('heading', { name: 'Later', level: 2 }) })
|
||||
await expect(laterSection.getByText('Future Conference')).toBeVisible()
|
||||
|
||||
const pastSection = sections.filter({ has: page.getByRole('heading', { name: 'Past', level: 2 }) })
|
||||
await expect(pastSection.getByText('New Year Party')).toBeVisible()
|
||||
})
|
||||
|
||||
test('empty sections are not rendered', async ({ page }) => {
|
||||
// Only a past event — no Today, This Week, or Later sections
|
||||
await page.addInitScript(seedEvents([pastEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Past', level: 2 })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Today', level: 2 })).toHaveCount(0)
|
||||
await expect(page.getByRole('heading', { name: 'This Week', level: 2 })).toHaveCount(0)
|
||||
await expect(page.getByRole('heading', { name: 'Next Week', level: 2 })).toHaveCount(0)
|
||||
await expect(page.getByRole('heading', { name: 'Later', level: 2 })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Today section header has emphasis CSS class', async ({ page }) => {
|
||||
const now = new Date()
|
||||
const todayEvent: StoredEvent = {
|
||||
eventToken: 'today-emph',
|
||||
title: 'Emphasis Test',
|
||||
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 20, 0, 0).toISOString(),
|
||||
}
|
||||
await page.addInitScript(seedEvents([todayEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
const todayHeader = page.getByRole('heading', { name: 'Today', level: 2 })
|
||||
await expect(todayHeader).toHaveClass(/section-header--emphasized/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Temporal Grouping: Date Subheaders', () => {
|
||||
test('no date subheader in Today section', async ({ page }) => {
|
||||
const now = new Date()
|
||||
const todayEvent: StoredEvent = {
|
||||
eventToken: 'today-sub',
|
||||
title: 'No Subheader Test',
|
||||
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 19, 0, 0).toISOString(),
|
||||
}
|
||||
await page.addInitScript(seedEvents([todayEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
const todaySection = page.locator('.event-section').filter({
|
||||
has: page.getByRole('heading', { name: 'Today', level: 2 }),
|
||||
})
|
||||
await expect(todaySection.locator('.date-subheader')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('date subheaders appear in Later section', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([futureEvent1, futureEvent2]))
|
||||
await page.goto('/')
|
||||
|
||||
const laterSection = page.locator('.event-section').filter({
|
||||
has: page.getByRole('heading', { name: 'Later', level: 2 }),
|
||||
})
|
||||
// Both future events are on different dates, so expect subheaders
|
||||
const subheaders = laterSection.locator('.date-subheader')
|
||||
await expect(subheaders).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('date subheaders appear in Past section', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([pastEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
const pastSection = page.locator('.event-section').filter({
|
||||
has: page.getByRole('heading', { name: 'Past', level: 2 }),
|
||||
})
|
||||
await expect(pastSection.locator('.date-subheader')).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Temporal Grouping: Time Display', () => {
|
||||
test('future event cards show clock time', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([futureEvent1]))
|
||||
await page.goto('/')
|
||||
|
||||
const timeLabel = page.locator('.event-card__time')
|
||||
const text = await timeLabel.first().textContent()
|
||||
// Should show clock time (e.g., "18:00" or "6:00 PM"), not relative time
|
||||
expect(text).toMatch(/\d{1,2}[:.]\d{2}/)
|
||||
})
|
||||
|
||||
test('past event cards show relative time', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([pastEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
const timeLabel = page.locator('.event-card__time')
|
||||
const text = await timeLabel.first().textContent()
|
||||
// Should show relative time like "X years ago" or "last year"
|
||||
expect(text).toMatch(/ago|last|yesterday/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US1: View My Events', () => {
|
||||
test('displays all stored events with title and relative time', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([futureEvent1, futureEvent2, pastEvent]))
|
||||
@@ -228,7 +361,6 @@ test.describe('US1: View My Events', () => {
|
||||
location: '',
|
||||
timezone: 'UTC',
|
||||
attendeeCount: 0,
|
||||
expired: false,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
99
frontend/e2e/view-attendee-list.spec.ts
Normal file
99
frontend/e2e/view-attendee-list.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { test, expect } from './msw-setup'
|
||||
|
||||
const eventToken = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
const organizerToken = 'f9e8d7c6-b5a4-3210-fedc-ba9876543210'
|
||||
|
||||
const fullEvent = {
|
||||
eventToken,
|
||||
title: 'Summer BBQ',
|
||||
description: 'Bring your own drinks!',
|
||||
dateTime: '2026-03-15T20:00:00+01:00',
|
||||
timezone: 'Europe/Berlin',
|
||||
location: 'Central Park, NYC',
|
||||
attendeeCount: 3,
|
||||
expired: false,
|
||||
}
|
||||
|
||||
const attendeesResponse = {
|
||||
attendees: [
|
||||
{ name: 'Alice' },
|
||||
{ name: 'Bob' },
|
||||
{ name: 'Charlie' },
|
||||
],
|
||||
}
|
||||
|
||||
test.describe('US-1: View attendee list as organizer', () => {
|
||||
test('organizer sees attendee names', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json(fullEvent)
|
||||
}),
|
||||
http.get('*/api/events/:token/attendees', () => {
|
||||
return HttpResponse.json(attendeesResponse)
|
||||
}),
|
||||
)
|
||||
|
||||
// Set organizer token in localStorage before navigating
|
||||
await page.goto('/')
|
||||
await page.evaluate(
|
||||
([et, ot]) => {
|
||||
localStorage.setItem(
|
||||
'fete:events',
|
||||
JSON.stringify([{ eventToken: et, organizerToken: ot, title: 'Summer BBQ', dateTime: '2026-03-15T20:00:00+01:00', expiryDate: '' }]),
|
||||
)
|
||||
},
|
||||
[eventToken, organizerToken],
|
||||
)
|
||||
|
||||
await page.goto(`/events/${eventToken}`)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||
await expect(page.getByText('3 Attendees')).toBeVisible()
|
||||
await expect(page.getByText('Alice')).toBeVisible()
|
||||
await expect(page.getByText('Bob')).toBeVisible()
|
||||
await expect(page.getByText('Charlie')).toBeVisible()
|
||||
})
|
||||
|
||||
test('visitor does not see attendee list', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json(fullEvent)
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto(`/events/${eventToken}`)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||
await expect(page.getByText('3 going')).toBeVisible()
|
||||
await expect(page.locator('.attendee-list')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('organizer sees empty state when no attendees', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json({ ...fullEvent, attendeeCount: 0 })
|
||||
}),
|
||||
http.get('*/api/events/:token/attendees', () => {
|
||||
return HttpResponse.json({ attendees: [] })
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto('/')
|
||||
await page.evaluate(
|
||||
([et, ot]) => {
|
||||
localStorage.setItem(
|
||||
'fete:events',
|
||||
JSON.stringify([{ eventToken: et, organizerToken: ot, title: 'Summer BBQ', dateTime: '2026-03-15T20:00:00+01:00', expiryDate: '' }]),
|
||||
)
|
||||
},
|
||||
[eventToken, organizerToken],
|
||||
)
|
||||
|
||||
await page.goto(`/events/${eventToken}`)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||
await expect(page.getByText('0 Attendees')).toBeVisible()
|
||||
await expect(page.getByText('No attendees yet.')).toBeVisible()
|
||||
})
|
||||
})
|
||||
218
frontend/e2e/watch-event.spec.ts
Normal file
218
frontend/e2e/watch-event.spec.ts
Normal 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.locator('.rsvp-bar__bookmark-inner')
|
||||
await expect(bookmark).toBeVisible()
|
||||
await expect(bookmark).toHaveAttribute('aria-label', 'Watch this event')
|
||||
|
||||
await bookmark.click()
|
||||
|
||||
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
|
||||
|
||||
// Navigate to event list via back link
|
||||
await page.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.locator('.rsvp-bar__bookmark-inner')
|
||||
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
|
||||
|
||||
await bookmark.click()
|
||||
|
||||
await expect(bookmark).toHaveAttribute('aria-label', 'Watch this event')
|
||||
|
||||
// Navigate to event list via back link (avoid page.goto re-running addInitScript)
|
||||
await page.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.locator('.rsvp-bar__bookmark-inner')
|
||||
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.locator('.rsvp-bar__bookmark-inner')
|
||||
await expect(bookmark).toBeVisible()
|
||||
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
|
||||
|
||||
// Navigate to list via back link
|
||||
await page.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.locator('.rsvp-bar__bookmark-inner')
|
||||
await expect(bookmark).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('organizer does not see bookmark', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
)
|
||||
await page.addInitScript(seedEvents([organizerSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
|
||||
await expect(bookmark).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US6: Un-watch from event list', () => {
|
||||
test('deleting a watched event skips confirmation dialog', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([watchSeed()]))
|
||||
await page.goto('/')
|
||||
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
|
||||
// No confirmation dialog — event removed immediately
|
||||
await expect(page.getByText('Remove event?')).not.toBeVisible()
|
||||
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US7: Watcher upgrades to attendee', () => {
|
||||
test('watch → RSVP → bookmark disappears, list shows Attendee', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||
http.post('*/api/events/:token/rsvps', () => {
|
||||
return HttpResponse.json(
|
||||
{ rsvpToken: 'new-rsvp-token', name: 'Max' },
|
||||
{ status: 201 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(seedEvents([watchSeed()]))
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Verify watching state — bookmark visible
|
||||
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
|
||||
await expect(bookmark).toBeVisible()
|
||||
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
|
||||
|
||||
// RSVP
|
||||
await page.getByRole('button', { name: "I'm attending" }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'RSVP' })
|
||||
await dialog.getByLabel('Your name').fill('Max')
|
||||
await dialog.getByRole('button', { name: 'Count me in' }).click()
|
||||
|
||||
// Bookmark gone — status bar shown instead
|
||||
await expect(bookmark).not.toBeVisible()
|
||||
|
||||
// Navigate to list via back link
|
||||
await page.getByLabel('Back to home').click()
|
||||
await expect(page.getByText('Attending')).toBeVisible()
|
||||
await expect(page.getByText('Watching')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<!-- OG_META_TAGS -->
|
||||
<title>fete</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
1240
frontend/package-lock.json
generated
1240
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -38,21 +38,27 @@
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.51.0",
|
||||
"eslint-plugin-oxlint": "~1.55.0",
|
||||
"eslint-plugin-vue": "~10.8.0",
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "^28.1.0",
|
||||
"msw": "^2.12.10",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"oxlint": "~1.51.0",
|
||||
"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"
|
||||
}
|
||||
|
||||
3
frontend/public/favicon.svg
Normal file
3
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<text y="0.9em" font-size="80" x="50%" text-anchor="middle">🎉</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 144 B |
BIN
frontend/public/og-image.png
Normal file
BIN
frontend/public/og-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
@@ -1,9 +1,26 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<header v-if="route.name !== 'home'" class="app-header">
|
||||
<BackLink />
|
||||
</header>
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import BackLink from '@/components/BackLink.vue'
|
||||
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
padding-top: var(--spacing-lg);
|
||||
}
|
||||
</style>
|
||||
|
||||
BIN
frontend/src/assets/images/event-hero-placeholder.jpg
Normal file
BIN
frontend/src/assets/images/event-hero-placeholder.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
@@ -16,6 +16,37 @@
|
||||
--color-text-on-gradient: #ffffff;
|
||||
--color-surface: #fff5f8;
|
||||
--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);
|
||||
--color-glass-subtle: rgba(255, 255, 255, 0.05);
|
||||
--color-glass-border: rgba(255, 255, 255, 0.18);
|
||||
--color-glass-border-hover: rgba(255, 255, 255, 0.3);
|
||||
--color-glass-hover: rgba(255, 255, 255, 0.18);
|
||||
--color-glass-inner: rgba(27, 23, 48, 0.55);
|
||||
--color-glass-overlay: rgba(27, 23, 48, 0.4);
|
||||
|
||||
/* Text on gradient (opacity variants) */
|
||||
--color-text-muted: rgba(255, 255, 255, 0.5);
|
||||
--color-text-secondary: rgba(255, 255, 255, 0.7);
|
||||
--color-text-soft: rgba(255, 255, 255, 0.85);
|
||||
--color-text-bright: rgba(255, 255, 255, 0.9);
|
||||
|
||||
/* Glow border */
|
||||
--gradient-glow: conic-gradient(from 135deg, var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end), var(--color-gradient-start));
|
||||
|
||||
/* Gradient */
|
||||
--gradient-primary: linear-gradient(135deg, #f06292 0%, #ab47bc 50%, #5c6bc0 100%);
|
||||
@@ -33,7 +64,7 @@
|
||||
--radius-button: 14px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.12);
|
||||
--shadow-button: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
|
||||
/* Layout */
|
||||
@@ -60,7 +91,22 @@ html {
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--gradient-primary);
|
||||
background-color: var(--color-dark-base);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: var(--color-dark-base);
|
||||
background-image:
|
||||
radial-gradient(at 70% 20%, rgba(240, 98, 146, 0.55) 0px, transparent 50%),
|
||||
radial-gradient(at 25% 50%, rgba(171, 71, 188, 0.5) 0px, transparent 55%),
|
||||
radial-gradient(at 80% 70%, rgba(92, 107, 192, 0.55) 0px, transparent 50%),
|
||||
radial-gradient(at 35% 85%, rgba(255, 112, 67, 0.3) 0px, transparent 40%);
|
||||
filter: blur(80px);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
#app {
|
||||
@@ -82,38 +128,66 @@ body {
|
||||
/* Card-style form fields */
|
||||
.form-field {
|
||||
background: var(--color-card);
|
||||
border: none;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--spacing-md) var(--spacing-md);
|
||||
box-shadow: var(--shadow-card);
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
transition: box-shadow 0.2s ease;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-field.glass {
|
||||
color: var(--color-text-on-gradient);
|
||||
}
|
||||
|
||||
.form-field:focus {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
|
||||
border-color: var(--color-glass-border-hover);
|
||||
}
|
||||
|
||||
.form-field::placeholder {
|
||||
color: #999;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.form-field.glass::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
textarea.form-field {
|
||||
resize: vertical;
|
||||
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 {
|
||||
@@ -128,22 +202,29 @@ textarea.form-field {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: var(--color-accent);
|
||||
background: var(--color-card);
|
||||
color: var(--color-text);
|
||||
border: none;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: var(--radius-button);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-button);
|
||||
transition: opacity 0.2s ease, transform 0.1s ease;
|
||||
transition: border-color 0.2s ease, transform 0.1s ease;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary.glass {
|
||||
color: var(--color-text-on-gradient);
|
||||
border: 2px solid transparent;
|
||||
background:
|
||||
linear-gradient(var(--color-glass-inner), var(--color-glass-inner)) padding-box,
|
||||
var(--gradient-glow) border-box;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.92;
|
||||
border-color: var(--color-glass-border-hover);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
@@ -157,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;
|
||||
@@ -176,6 +257,66 @@ textarea.form-field {
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* ── Glass System ── */
|
||||
|
||||
/* Glass surface: passive containers on gradient (cards, icon boxes) */
|
||||
.glass {
|
||||
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
box-shadow: var(--shadow-card);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.glass:hover:not(input):not(textarea):not(.btn-primary) {
|
||||
background: var(--color-glass-hover);
|
||||
border-color: var(--color-glass-border-hover);
|
||||
}
|
||||
|
||||
/* Glass interactive inner: dark translucent fill for interactive elements (FAB, CTA) */
|
||||
.glass-inner {
|
||||
background: var(--color-glass-inner);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
/* Glow border: conic gradient wrapper with halo (static) */
|
||||
.glow-border {
|
||||
background: var(--gradient-glow);
|
||||
padding: 2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.glow-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border-radius: inherit;
|
||||
background: var(--gradient-glow);
|
||||
filter: blur(8px);
|
||||
opacity: 0.3;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Glow border animated variant */
|
||||
@property --glow-angle {
|
||||
syntax: '<angle>';
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
.glow-border--animated {
|
||||
background: conic-gradient(from var(--glow-angle), var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end), var(--color-gradient-start));
|
||||
animation: glow-rotate 4s linear infinite;
|
||||
}
|
||||
|
||||
.glow-border--animated::before {
|
||||
background: conic-gradient(from var(--glow-angle), var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end), var(--color-gradient-start));
|
||||
animation: glow-rotate 4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes glow-rotate {
|
||||
to { --glow-angle: 360deg; }
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
@@ -197,7 +338,7 @@ textarea.form-field {
|
||||
.sheet-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
color: var(--color-text-on-gradient);
|
||||
}
|
||||
|
||||
.rsvp-form {
|
||||
@@ -206,15 +347,16 @@ 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);
|
||||
color: var(--color-text-on-gradient);
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
.rsvp-form__field-error {
|
||||
color: #d32f2f;
|
||||
color: var(--color-danger-solid);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
padding-left: 0.25rem;
|
||||
|
||||
59
frontend/src/components/AttendeeList.vue
Normal file
59
frontend/src/components/AttendeeList.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<section class="attendee-list">
|
||||
<h3 class="attendee-list__heading">
|
||||
{{ attendees.length === 1 ? '1 Attendee' : `${attendees.length} Attendees` }}
|
||||
</h3>
|
||||
<ul v-if="attendees.length > 0" class="attendee-list__items">
|
||||
<li v-for="(name, index) in attendees" :key="index" class="attendee-list__item">
|
||||
{{ name }}
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="attendee-list__empty">No attendees yet.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
attendees: string[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.attendee-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.attendee-list__heading {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.attendee-list__items {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.attendee-list__item {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-soft);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attendee-list__empty {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
28
frontend/src/components/BackLink.vue
Normal file
28
frontend/src/components/BackLink.vue
Normal 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>
|
||||
@@ -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,13 +50,52 @@ 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>
|
||||
.sheet-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
background: var(--color-glass-overlay);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
@@ -53,7 +103,10 @@ watch(
|
||||
}
|
||||
|
||||
.sheet {
|
||||
background: var(--color-card);
|
||||
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
border-bottom: none;
|
||||
backdrop-filter: blur(24px);
|
||||
border-radius: 20px 20px 0 0;
|
||||
padding: var(--spacing-lg) var(--spacing-xl) var(--spacing-2xl);
|
||||
width: 100%;
|
||||
@@ -67,7 +120,7 @@ watch(
|
||||
.sheet__handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
background: #ccc;
|
||||
background: var(--color-glass-border-hover);
|
||||
border-radius: 2px;
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -75,7 +75,7 @@ watch(
|
||||
.confirm-dialog__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
background: var(--color-glass-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -84,9 +84,11 @@ watch(
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
background: var(--color-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(24px);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
padding: var(--spacing-xl);
|
||||
max-width: 320px;
|
||||
width: 100%;
|
||||
@@ -98,13 +100,13 @@ watch(
|
||||
.confirm-dialog__title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
color: var(--color-text-on-gradient);
|
||||
}
|
||||
|
||||
.confirm-dialog__message {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 400;
|
||||
color: #666;
|
||||
color: var(--color-text-soft);
|
||||
}
|
||||
|
||||
.confirm-dialog__actions {
|
||||
@@ -130,13 +132,14 @@ watch(
|
||||
}
|
||||
|
||||
.confirm-dialog__btn--cancel {
|
||||
background: #e8e8e8;
|
||||
color: #555;
|
||||
background: var(--color-glass);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
color: var(--color-text-on-gradient);
|
||||
}
|
||||
|
||||
.confirm-dialog__btn--confirm {
|
||||
background: #d32f2f;
|
||||
color: #fff;
|
||||
background: var(--color-danger-solid);
|
||||
color: var(--color-danger-solid-text);
|
||||
}
|
||||
|
||||
.confirm-dialog-enter-active,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<RouterLink to="/create" class="fab" aria-label="Create event">
|
||||
<RouterLink to="/create" class="fab glow-border" aria-label="Create event">
|
||||
<span class="fab__inner glass-inner">
|
||||
<span class="fab__icon" aria-hidden="true">+</span>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
@@ -16,20 +18,26 @@ import { RouterLink } from 'vue-router'
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
color: var(--color-text-on-gradient);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
text-decoration: none;
|
||||
z-index: 100;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.fab__inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.fab:active {
|
||||
@@ -41,6 +49,7 @@ import { RouterLink } from 'vue-router'
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
|
||||
.fab__icon {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 300;
|
||||
|
||||
19
frontend/src/components/DateSubheader.vue
Normal file
19
frontend/src/components/DateSubheader.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<h3 class="date-subheader">{{ label }}</h3>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.date-subheader {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-soft);
|
||||
margin: 0;
|
||||
padding: var(--spacing-xs) 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<p class="empty-state__message">No events yet.<br />Create your first one!</p>
|
||||
<RouterLink to="/create" class="btn-primary empty-state__cta">+ Create Event</RouterLink>
|
||||
<RouterLink to="/create" class="empty-state__cta glow-border glow-border--animated">
|
||||
<span class="empty-state__cta-inner glass-inner">Create Event</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -27,5 +29,34 @@ import { RouterLink } from 'vue-router'
|
||||
|
||||
.empty-state__cta {
|
||||
max-width: 280px;
|
||||
width: 100%;
|
||||
border-radius: var(--radius-button);
|
||||
text-decoration: none;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.empty-state__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;
|
||||
}
|
||||
|
||||
.empty-state__cta:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.empty-state__cta:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.empty-state__cta:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="event-card"
|
||||
class="event-card glass"
|
||||
:class="{ 'event-card--past': isPast, 'event-card--swiping': isSwiping }"
|
||||
:style="swipeStyle"
|
||||
@touchstart="onTouchStart"
|
||||
@@ -9,10 +9,10 @@
|
||||
>
|
||||
<RouterLink :to="`/events/${eventToken}`" class="event-card__link">
|
||||
<span class="event-card__title">{{ title }}</span>
|
||||
<span class="event-card__time">{{ relativeTime }}</span>
|
||||
<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,13 +34,22 @@ const props = defineProps<{
|
||||
title: string
|
||||
relativeTime: string
|
||||
isPast: boolean
|
||||
eventRole?: 'organizer' | 'attendee'
|
||||
eventRole?: 'organizer' | 'attendee' | 'watcher'
|
||||
timeDisplayMode?: 'clock' | 'relative'
|
||||
dateTime?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [eventToken: string]
|
||||
}>()
|
||||
|
||||
const displayTime = computed(() => {
|
||||
if (props.timeDisplayMode === 'clock' && props.dateTime) {
|
||||
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(new Date(props.dateTime))
|
||||
}
|
||||
return props.relativeTime
|
||||
})
|
||||
|
||||
const SWIPE_THRESHOLD = 80
|
||||
|
||||
const startX = ref(0)
|
||||
@@ -84,11 +93,10 @@ function onTouchEnd() {
|
||||
.event-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
gap: var(--spacing-sm);
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.event-card--past {
|
||||
@@ -113,7 +121,7 @@ function onTouchEnd() {
|
||||
.event-card__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
color: var(--color-text-on-gradient);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -122,7 +130,7 @@ function onTouchEnd() {
|
||||
.event-card__time {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
color: #888;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.event-card__badge {
|
||||
@@ -136,12 +144,18 @@ function onTouchEnd() {
|
||||
|
||||
.event-card__badge--organizer {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
color: var(--color-text-on-gradient);
|
||||
}
|
||||
|
||||
.event-card__badge--attendee {
|
||||
background: #e0e0e0;
|
||||
color: #555;
|
||||
background: var(--color-glass-strong);
|
||||
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 {
|
||||
@@ -154,14 +168,14 @@ function onTouchEnd() {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
color: #bbb;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.event-card__delete:hover {
|
||||
color: #d32f2f;
|
||||
color: var(--color-danger-solid);
|
||||
background: rgba(211, 47, 47, 0.08);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
<template>
|
||||
<div class="event-list" role="list" aria-label="Your events">
|
||||
<div v-for="event in sortedEvents" :key="event.eventToken" role="listitem">
|
||||
<div class="event-list">
|
||||
<section
|
||||
v-for="section in groupedSections"
|
||||
:key="section.key"
|
||||
:aria-label="section.label"
|
||||
class="event-section"
|
||||
>
|
||||
<SectionHeader :label="section.label" :emphasized="section.emphasized" />
|
||||
<div role="list">
|
||||
<template v-for="group in section.dateGroups" :key="group.dateKey">
|
||||
<DateSubheader v-if="group.showSubheader" :label="group.label" />
|
||||
<div v-for="event in group.events" :key="event.eventToken" role="listitem">
|
||||
<EventCard
|
||||
:event-token="event.eventToken"
|
||||
:title="event.title"
|
||||
:relative-time="formatRelativeTime(event.dateTime)"
|
||||
:is-past="isPast(event.dateTime)"
|
||||
:is-past="section.key === 'past'"
|
||||
:event-role="getRole(event)"
|
||||
:time-display-mode="section.key === 'past' ? 'relative' : 'clock'"
|
||||
:date-time="event.dateTime"
|
||||
@delete="requestDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
<ConfirmDialog
|
||||
:open="!!pendingDeleteToken"
|
||||
title="Remove event?"
|
||||
message="This event will be removed from your list."
|
||||
:title="deleteDialogTitle"
|
||||
:message="deleteDialogMessage"
|
||||
confirm-label="Remove"
|
||||
cancel-label="Cancel"
|
||||
@confirm="confirmDelete"
|
||||
@@ -25,23 +40,108 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useEventStorage, isValidStoredEvent } from '../composables/useEventStorage'
|
||||
import { useEventGrouping } from '../composables/useEventGrouping'
|
||||
import { formatRelativeTime } from '../composables/useRelativeTime'
|
||||
import { api } from '../api/client'
|
||||
import EventCard from './EventCard.vue'
|
||||
import SectionHeader from './SectionHeader.vue'
|
||||
import DateSubheader from './DateSubheader.vue'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import type { StoredEvent } from '../composables/useEventStorage'
|
||||
|
||||
const { getStoredEvents, 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.'
|
||||
}
|
||||
return 'This event will be removed from your list.'
|
||||
})
|
||||
|
||||
function requestDelete(eventToken: string) {
|
||||
deleteError.value = ''
|
||||
const role = getRole(getStoredEvents().find((e) => e.eventToken === eventToken)!)
|
||||
if (role === 'watcher') {
|
||||
removeEvent(eventToken)
|
||||
return
|
||||
}
|
||||
pendingDeleteToken.value = eventToken
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (pendingDeleteToken.value) {
|
||||
removeEvent(pendingDeleteToken.value)
|
||||
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/{eventToken}/rsvps/{rsvpToken}', {
|
||||
params: {
|
||||
path: {
|
||||
eventToken: eventToken,
|
||||
rsvpToken: rsvp.rsvpToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status !== 204 && response.status !== 404) {
|
||||
deleteError.value = 'Could not cancel attendance. Please try again.'
|
||||
pendingDeleteToken.value = null
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
deleteError.value = 'Could not cancel attendance. Please try again.'
|
||||
pendingDeleteToken.value = null
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
removeEvent(eventToken)
|
||||
pendingDeleteToken.value = null
|
||||
}
|
||||
|
||||
@@ -49,29 +149,26 @@ function cancelDelete() {
|
||||
pendingDeleteToken.value = null
|
||||
}
|
||||
|
||||
function isPast(dateTime: string): boolean {
|
||||
return new Date(dateTime) < new Date()
|
||||
}
|
||||
|
||||
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 sortedEvents = computed(() => {
|
||||
const groupedSections = computed(() => {
|
||||
const valid = getStoredEvents().filter(isValidStoredEvent)
|
||||
const now = new Date()
|
||||
const upcoming = valid.filter((e) => new Date(e.dateTime) >= now)
|
||||
const past = valid.filter((e) => new Date(e.dateTime) < now)
|
||||
upcoming.sort((a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime())
|
||||
past.sort((a, b) => new Date(b.dateTime).getTime() - new Date(a.dateTime).getTime())
|
||||
return [...upcoming, ...past]
|
||||
return useEventGrouping(valid)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.event-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.event-section [role="list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
|
||||
@@ -2,27 +2,90 @@
|
||||
<div class="rsvp-bar">
|
||||
<div class="rsvp-bar__inner">
|
||||
<!-- Status state: already RSVPed -->
|
||||
<div v-if="hasRsvp" class="rsvp-bar__status">
|
||||
<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>
|
||||
<Transition name="rsvp-bar-cancel">
|
||||
<button
|
||||
v-if="expanded"
|
||||
class="rsvp-bar__cancel"
|
||||
type="button"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
Cancel RSVP
|
||||
</button>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- CTA state: no RSVP yet -->
|
||||
<button v-else class="btn-primary rsvp-bar__cta" type="button" @click="$emit('open')">
|
||||
I'm attending
|
||||
<div v-else class="rsvp-bar__row">
|
||||
<div class="rsvp-bar__cta glow-border glow-border--animated">
|
||||
<button class="rsvp-bar__cta-inner glass-inner" type="button" @click="$emit('open')">
|
||||
I'm attending!
|
||||
</button>
|
||||
</div>
|
||||
<div class="rsvp-bar__bookmark glow-border glow-border--animated">
|
||||
<button
|
||||
class="rsvp-bar__bookmark-inner 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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
hasRsvp?: boolean
|
||||
bookmarked?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
open: []
|
||||
cancel: []
|
||||
bookmark: []
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
watch(() => props.hasRsvp, () => {
|
||||
expanded.value = false
|
||||
})
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.rsvp-bar__status-wrapper')) {
|
||||
expanded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(expanded, (isExpanded) => {
|
||||
if (isExpanded) {
|
||||
document.addEventListener('click', onClickOutside, { capture: true })
|
||||
} else {
|
||||
document.removeEventListener('click', onClickOutside, { capture: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -43,8 +106,44 @@ defineEmits<{
|
||||
max-width: var(--content-max-width);
|
||||
}
|
||||
|
||||
.rsvp-bar__row {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.rsvp-bar__cta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-radius: var(--radius-button);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.rsvp-bar__cta:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.rsvp-bar__cta:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.rsvp-bar__cta-inner {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: calc(var(--radius-button) - 2px);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-on-gradient);
|
||||
text-align: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rsvp-bar__status-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.rsvp-bar__status {
|
||||
@@ -52,13 +151,22 @@ defineEmits<{
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-xs);
|
||||
background: var(--color-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);
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text);
|
||||
color: var(--color-text-on-gradient);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.rsvp-bar__status:hover {
|
||||
background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%);
|
||||
}
|
||||
|
||||
.rsvp-bar__check {
|
||||
@@ -72,4 +180,81 @@ defineEmits<{
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rsvp-bar__chevron {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
transition: transform 0.2s ease;
|
||||
transform: rotate(0deg);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.rsvp-bar__chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.rsvp-bar__cancel {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border-radius: var(--radius-card);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #ef5350;
|
||||
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);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.rsvp-bar__cancel:hover {
|
||||
background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%);
|
||||
}
|
||||
|
||||
.rsvp-bar-cancel-enter-active,
|
||||
.rsvp-bar-cancel-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.rsvp-bar-cancel-enter-from,
|
||||
.rsvp-bar-cancel-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.rsvp-bar__bookmark {
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-button);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.rsvp-bar__bookmark:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.rsvp-bar__bookmark:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.rsvp-bar__bookmark-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--spacing-md);
|
||||
border-radius: calc(var(--radius-button) - 2px);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-on-gradient);
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.rsvp-bar__bookmark-inner svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
27
frontend/src/components/SectionHeader.vue
Normal file
27
frontend/src/components/SectionHeader.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<h2 class="section-header" :class="{ 'section-header--emphasized': emphasized }">
|
||||
{{ label }}
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string
|
||||
emphasized?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-header {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-on-gradient);
|
||||
margin: 0;
|
||||
padding: var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.section-header--emphasized {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
</style>
|
||||
50
frontend/src/components/__tests__/AttendeeList.spec.ts
Normal file
50
frontend/src/components/__tests__/AttendeeList.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AttendeeList from '../AttendeeList.vue'
|
||||
|
||||
describe('AttendeeList', () => {
|
||||
it('renders attendee names as list items', () => {
|
||||
const wrapper = mount(AttendeeList, {
|
||||
props: { attendees: ['Alice', 'Bob', 'Charlie'] },
|
||||
})
|
||||
|
||||
const items = wrapper.findAll('.attendee-list__item')
|
||||
expect(items).toHaveLength(3)
|
||||
expect(items[0]!.text()).toBe('Alice')
|
||||
expect(items[1]!.text()).toBe('Bob')
|
||||
expect(items[2]!.text()).toBe('Charlie')
|
||||
})
|
||||
|
||||
it('shows empty state message when no attendees', () => {
|
||||
const wrapper = mount(AttendeeList, {
|
||||
props: { attendees: [] },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.attendee-list__empty').text()).toBe('No attendees yet.')
|
||||
expect(wrapper.find('.attendee-list__items').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows plural count heading for multiple attendees', () => {
|
||||
const wrapper = mount(AttendeeList, {
|
||||
props: { attendees: ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'] },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.attendee-list__heading').text()).toBe('5 Attendees')
|
||||
})
|
||||
|
||||
it('shows singular count heading for one attendee', () => {
|
||||
const wrapper = mount(AttendeeList, {
|
||||
props: { attendees: ['Alice'] },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.attendee-list__heading').text()).toBe('1 Attendee')
|
||||
})
|
||||
|
||||
it('shows zero count heading for no attendees', () => {
|
||||
const wrapper = mount(AttendeeList, {
|
||||
props: { attendees: [] },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.attendee-list__heading').text()).toBe('0 Attendees')
|
||||
})
|
||||
})
|
||||
17
frontend/src/components/__tests__/DateSubheader.spec.ts
Normal file
17
frontend/src/components/__tests__/DateSubheader.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import DateSubheader from '../DateSubheader.vue'
|
||||
|
||||
describe('DateSubheader', () => {
|
||||
it('renders the date label as an h3', () => {
|
||||
const wrapper = mount(DateSubheader, { props: { label: 'Wed, 12 Mar' } })
|
||||
const h3 = wrapper.find('h3')
|
||||
expect(h3.exists()).toBe(true)
|
||||
expect(h3.text()).toBe('Wed, 12 Mar')
|
||||
})
|
||||
|
||||
it('applies the date-subheader class', () => {
|
||||
const wrapper = mount(DateSubheader, { props: { label: 'Fri, 14 Mar' } })
|
||||
expect(wrapper.find('.date-subheader').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
@@ -73,4 +79,28 @@ describe('EventCard', () => {
|
||||
await wrapper.find('.event-card__delete').trigger('click')
|
||||
expect(wrapper.emitted('delete')).toEqual([['abc-123']])
|
||||
})
|
||||
|
||||
it('displays clock time when timeDisplayMode is clock', () => {
|
||||
const wrapper = mountCard({
|
||||
timeDisplayMode: 'clock',
|
||||
dateTime: '2026-03-11T18:30:00',
|
||||
})
|
||||
const timeText = wrapper.find('.event-card__time').text()
|
||||
// Locale-dependent: could be "18:30" or "06:30 PM"
|
||||
expect(timeText).toMatch(/(?:18.30|6.30\s*PM)/i)
|
||||
})
|
||||
|
||||
it('displays relative time when timeDisplayMode is relative', () => {
|
||||
const wrapper = mountCard({
|
||||
relativeTime: '3 days ago',
|
||||
timeDisplayMode: 'relative',
|
||||
dateTime: '2026-03-08T10:00:00',
|
||||
})
|
||||
expect(wrapper.find('.event-card__time').text()).toBe('3 days ago')
|
||||
})
|
||||
|
||||
it('falls back to relativeTime when timeDisplayMode is not set', () => {
|
||||
const wrapper = mountCard({ relativeTime: 'in 3 days' })
|
||||
expect(wrapper.find('.event-card__time').text()).toBe('in 3 days')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
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: [
|
||||
@@ -11,12 +18,21 @@ const router = createRouter({
|
||||
],
|
||||
})
|
||||
|
||||
// Fixed "now": Wednesday, 2026-03-11 12:00
|
||||
const NOW = new Date(2026, 2, 11, 12, 0, 0)
|
||||
|
||||
const mockEvents = [
|
||||
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2025-01-01T10:00:00Z', expiryDate: '' },
|
||||
{ eventToken: 'future-1', title: 'Future Event', dateTime: '2027-06-15T10:00:00Z', expiryDate: '' },
|
||||
{ eventToken: 'future-2', title: 'Soon Event', dateTime: '2027-01-01T10:00:00Z', expiryDate: '' },
|
||||
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2026-03-01T10:00:00' },
|
||||
{ eventToken: 'later-1', title: 'Later Event', dateTime: '2027-06-15T10:00:00' },
|
||||
{ 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
|
||||
@@ -27,53 +43,264 @@ 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,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../composables/useRelativeTime', () => ({
|
||||
formatRelativeTime: (dateTime: string) => {
|
||||
if (dateTime.startsWith('2025')) return '1 year ago'
|
||||
if (dateTime.includes('03-01')) return '10 days ago'
|
||||
if (dateTime.includes('06-15')) return 'in 1 year'
|
||||
return 'in 10 months'
|
||||
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'
|
||||
},
|
||||
}))
|
||||
|
||||
function mountList() {
|
||||
return mount(EventList, {
|
||||
global: { plugins: [router] },
|
||||
global: {
|
||||
plugins: [router],
|
||||
stubs: { Teleport: true },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('EventList', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-03-08T12:00:00Z'))
|
||||
vi.setSystemTime(NOW)
|
||||
})
|
||||
|
||||
it('renders all valid events', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('renders section headers for each non-empty section', () => {
|
||||
const wrapper = mountList()
|
||||
const headers = wrapper.findAll('.section-header')
|
||||
expect(headers).toHaveLength(5)
|
||||
expect(headers[0]!.text()).toBe('Today')
|
||||
expect(headers[1]!.text()).toBe('This Week')
|
||||
expect(headers[2]!.text()).toBe('Next Week')
|
||||
expect(headers[3]!.text()).toBe('Later')
|
||||
expect(headers[4]!.text()).toBe('Past')
|
||||
})
|
||||
|
||||
it('renders events within their correct sections', () => {
|
||||
const wrapper = mountList()
|
||||
const sections = wrapper.findAll('.event-section')
|
||||
expect(sections).toHaveLength(5)
|
||||
|
||||
expect(sections[0]!.text()).toContain('Today Event')
|
||||
expect(sections[1]!.text()).toContain('This Week Event')
|
||||
expect(sections[2]!.text()).toContain('Next Week Event')
|
||||
expect(sections[3]!.text()).toContain('Later Event')
|
||||
expect(sections[4]!.text()).toContain('Past Event')
|
||||
})
|
||||
|
||||
it('renders all valid events as cards', () => {
|
||||
const wrapper = mountList()
|
||||
const cards = wrapper.findAll('.event-card')
|
||||
expect(cards).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('sorts upcoming events before past events', () => {
|
||||
const wrapper = mountList()
|
||||
const titles = wrapper.findAll('.event-card__title').map((el) => el.text())
|
||||
// Upcoming events first (sorted ascending), then past events
|
||||
expect(titles[0]).toBe('Soon Event')
|
||||
expect(titles[1]).toBe('Future Event')
|
||||
expect(titles[2]).toBe('Past Event')
|
||||
expect(cards).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('marks past events with isPast class', () => {
|
||||
const wrapper = mountList()
|
||||
const cards = wrapper.findAll('.event-card')
|
||||
expect(cards).toHaveLength(3)
|
||||
// Last card should be past
|
||||
expect(cards[2]!.classes()).toContain('event-card--past')
|
||||
// First two should not be past
|
||||
const pastSection = wrapper.findAll('.event-section')[4]!
|
||||
const pastCards = pastSection.findAll('.event-card')
|
||||
expect(pastCards).toHaveLength(1)
|
||||
expect(pastCards[0]!.classes()).toContain('event-card--past')
|
||||
})
|
||||
|
||||
it('does not mark non-past events with isPast class', () => {
|
||||
const wrapper = mountList()
|
||||
const todaySection = wrapper.findAll('.event-section')[0]!
|
||||
const cards = todaySection.findAll('.event-card')
|
||||
expect(cards[0]!.classes()).not.toContain('event-card--past')
|
||||
expect(cards[1]!.classes()).not.toContain('event-card--past')
|
||||
})
|
||||
|
||||
it('sections have aria-label attributes', () => {
|
||||
const wrapper = mountList()
|
||||
const sections = wrapper.findAll('section')
|
||||
expect(sections[0]!.attributes('aria-label')).toBe('Today')
|
||||
expect(sections[1]!.attributes('aria-label')).toBe('This Week')
|
||||
expect(sections[2]!.attributes('aria-label')).toBe('Next Week')
|
||||
expect(sections[3]!.attributes('aria-label')).toBe('Later')
|
||||
expect(sections[4]!.attributes('aria-label')).toBe('Past')
|
||||
})
|
||||
|
||||
it('does not render date subheader in "Today" section', () => {
|
||||
const wrapper = mountList()
|
||||
const todaySection = wrapper.findAll('.event-section')[0]!
|
||||
expect(todaySection.find('.date-subheader').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders date subheaders in non-today sections', () => {
|
||||
const wrapper = mountList()
|
||||
const thisWeekSection = wrapper.findAll('.event-section')[1]!
|
||||
expect(thisWeekSection.find('.date-subheader').exists()).toBe(true)
|
||||
|
||||
const nextWeekSection = wrapper.findAll('.event-section')[2]!
|
||||
expect(nextWeekSection.find('.date-subheader').exists()).toBe(true)
|
||||
|
||||
const laterSection = wrapper.findAll('.event-section')[3]!
|
||||
expect(laterSection.find('.date-subheader').exists()).toBe(true)
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ describe('RsvpBar', () => {
|
||||
it('renders CTA button when hasRsvp is false', () => {
|
||||
const wrapper = mount(RsvpBar)
|
||||
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
|
||||
expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending")
|
||||
expect(wrapper.find('.rsvp-bar__cta-inner').text()).toBe("I'm attending!")
|
||||
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('RsvpBar', () => {
|
||||
|
||||
it('emits open when CTA button is clicked', async () => {
|
||||
const wrapper = mount(RsvpBar)
|
||||
await wrapper.find('.rsvp-bar__cta').trigger('click')
|
||||
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||
expect(wrapper.emitted('open')).toHaveLength(1)
|
||||
})
|
||||
|
||||
|
||||
27
frontend/src/components/__tests__/SectionHeader.spec.ts
Normal file
27
frontend/src/components/__tests__/SectionHeader.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import SectionHeader from '../SectionHeader.vue'
|
||||
|
||||
describe('SectionHeader', () => {
|
||||
it('renders the section label as an h2', () => {
|
||||
const wrapper = mount(SectionHeader, { props: { label: 'Today' } })
|
||||
const h2 = wrapper.find('h2')
|
||||
expect(h2.exists()).toBe(true)
|
||||
expect(h2.text()).toBe('Today')
|
||||
})
|
||||
|
||||
it('does not apply emphasized class by default', () => {
|
||||
const wrapper = mount(SectionHeader, { props: { label: 'Later' } })
|
||||
expect(wrapper.find('.section-header--emphasized').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('applies emphasized class when emphasized prop is true', () => {
|
||||
const wrapper = mount(SectionHeader, { props: { label: 'Today', emphasized: true } })
|
||||
expect(wrapper.find('.section-header--emphasized').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not apply emphasized class when emphasized prop is false', () => {
|
||||
const wrapper = mount(SectionHeader, { props: { label: 'Past', emphasized: false } })
|
||||
expect(wrapper.find('.section-header--emphasized').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
157
frontend/src/components/__tests__/useEventGrouping.spec.ts
Normal file
157
frontend/src/components/__tests__/useEventGrouping.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { useEventGrouping } from '../../composables/useEventGrouping'
|
||||
import type { StoredEvent } from '../../composables/useEventStorage'
|
||||
|
||||
function makeEvent(overrides: Partial<StoredEvent> & { dateTime: string }): StoredEvent {
|
||||
return {
|
||||
eventToken: `evt-${Math.random().toString(36).slice(2, 8)}`,
|
||||
title: 'Test Event',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useEventGrouping', () => {
|
||||
// Fixed "now": Wednesday, 2026-03-11 12:00 local
|
||||
const NOW = new Date(2026, 2, 11, 12, 0, 0)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(NOW)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns empty array when no events', () => {
|
||||
const sections = useEventGrouping([], NOW)
|
||||
expect(sections).toEqual([])
|
||||
})
|
||||
|
||||
it('classifies a today event into "today" section', () => {
|
||||
const event = makeEvent({ dateTime: '2026-03-11T18:30:00' })
|
||||
const sections = useEventGrouping([event], NOW)
|
||||
expect(sections).toHaveLength(1)
|
||||
expect(sections[0]!.key).toBe('today')
|
||||
expect(sections[0]!.label).toBe('Today')
|
||||
expect(sections[0]!.dateGroups[0]!.events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('classifies events into all five sections', () => {
|
||||
const events = [
|
||||
makeEvent({ title: 'Today', dateTime: '2026-03-11T10:00:00' }),
|
||||
makeEvent({ title: 'This Week', dateTime: '2026-03-13T10:00:00' }), // Friday (same week)
|
||||
makeEvent({ title: 'Next Week', dateTime: '2026-03-16T10:00:00' }), // Monday next week
|
||||
makeEvent({ title: 'Later', dateTime: '2026-03-30T10:00:00' }), // far future
|
||||
makeEvent({ title: 'Past', dateTime: '2026-03-09T10:00:00' }), // Monday (past)
|
||||
]
|
||||
const sections = useEventGrouping(events, NOW)
|
||||
expect(sections).toHaveLength(5)
|
||||
expect(sections[0]!.key).toBe('today')
|
||||
expect(sections[1]!.key).toBe('thisWeek')
|
||||
expect(sections[2]!.key).toBe('nextWeek')
|
||||
expect(sections[3]!.key).toBe('later')
|
||||
expect(sections[4]!.key).toBe('past')
|
||||
})
|
||||
|
||||
it('omits empty sections', () => {
|
||||
const events = [
|
||||
makeEvent({ title: 'Today', dateTime: '2026-03-11T10:00:00' }),
|
||||
makeEvent({ title: 'Past', dateTime: '2026-03-01T10:00:00' }),
|
||||
]
|
||||
const sections = useEventGrouping(events, NOW)
|
||||
expect(sections).toHaveLength(2)
|
||||
expect(sections[0]!.key).toBe('today')
|
||||
expect(sections[1]!.key).toBe('past')
|
||||
})
|
||||
|
||||
it('sorts upcoming events ascending by time', () => {
|
||||
const events = [
|
||||
makeEvent({ title: 'Later', dateTime: '2026-03-11T20:00:00' }),
|
||||
makeEvent({ title: 'Earlier', dateTime: '2026-03-11T08:00:00' }),
|
||||
]
|
||||
const sections = useEventGrouping(events, NOW)
|
||||
const todayEvents = sections[0]!.dateGroups[0]!.events
|
||||
expect(todayEvents[0]!.title).toBe('Earlier')
|
||||
expect(todayEvents[1]!.title).toBe('Later')
|
||||
})
|
||||
|
||||
it('sorts past events descending by time (most recent first)', () => {
|
||||
const events = [
|
||||
makeEvent({ title: 'Older', dateTime: '2026-03-01T10:00:00' }),
|
||||
makeEvent({ title: 'Newer', dateTime: '2026-03-09T10:00:00' }),
|
||||
]
|
||||
const sections = useEventGrouping(events, NOW)
|
||||
const pastEvents = sections[0]!.dateGroups
|
||||
expect(pastEvents[0]!.events[0]!.title).toBe('Newer')
|
||||
expect(pastEvents[1]!.events[0]!.title).toBe('Older')
|
||||
})
|
||||
|
||||
it('groups events by date within a section', () => {
|
||||
const events = [
|
||||
makeEvent({ title: 'Fri AM', dateTime: '2026-03-13T09:00:00' }),
|
||||
makeEvent({ title: 'Fri PM', dateTime: '2026-03-13T18:00:00' }),
|
||||
makeEvent({ title: 'Sat', dateTime: '2026-03-14T12:00:00' }),
|
||||
]
|
||||
const sections = useEventGrouping(events, NOW)
|
||||
expect(sections[0]!.key).toBe('thisWeek')
|
||||
const dateGroups = sections[0]!.dateGroups
|
||||
expect(dateGroups).toHaveLength(2) // Friday and Saturday
|
||||
expect(dateGroups[0]!.events).toHaveLength(2) // Two Friday events
|
||||
expect(dateGroups[1]!.events).toHaveLength(1) // One Saturday event
|
||||
})
|
||||
|
||||
it('sets showSubheader=false for "today" section', () => {
|
||||
const event = makeEvent({ dateTime: '2026-03-11T18:00:00' })
|
||||
const sections = useEventGrouping([event], NOW)
|
||||
expect(sections[0]!.dateGroups[0]!.showSubheader).toBe(false)
|
||||
})
|
||||
|
||||
it('sets showSubheader=true for non-today sections', () => {
|
||||
const events = [
|
||||
makeEvent({ dateTime: '2026-03-13T10:00:00' }), // thisWeek
|
||||
makeEvent({ dateTime: '2026-03-30T10:00:00' }), // later (beyond next week)
|
||||
makeEvent({ dateTime: '2026-03-01T10:00:00' }), // past
|
||||
]
|
||||
const sections = useEventGrouping(events, NOW)
|
||||
for (const section of sections) {
|
||||
for (const group of section.dateGroups) {
|
||||
expect(group.showSubheader).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('sets emphasized=true only for "today" section', () => {
|
||||
const events = [
|
||||
makeEvent({ dateTime: '2026-03-11T18:00:00' }),
|
||||
makeEvent({ dateTime: '2026-03-30T10:00:00' }),
|
||||
]
|
||||
const sections = useEventGrouping(events, NOW)
|
||||
expect(sections[0]!.emphasized).toBe(true) // today
|
||||
expect(sections[1]!.emphasized).toBe(false) // later
|
||||
})
|
||||
|
||||
it('on Sunday, tomorrow (Monday) goes to "nextWeek" not "thisWeek"', () => {
|
||||
// Sunday 2026-03-15
|
||||
const sunday = new Date(2026, 2, 15, 12, 0, 0)
|
||||
const mondayEvent = makeEvent({ title: 'Monday', dateTime: '2026-03-16T10:00:00' })
|
||||
const sections = useEventGrouping([mondayEvent], sunday)
|
||||
expect(sections).toHaveLength(1)
|
||||
expect(sections[0]!.key).toBe('nextWeek')
|
||||
})
|
||||
|
||||
it('on Sunday, today events still appear under "today"', () => {
|
||||
const sunday = new Date(2026, 2, 15, 12, 0, 0)
|
||||
const todayEvent = makeEvent({ dateTime: '2026-03-15T18:00:00' })
|
||||
const sections = useEventGrouping([todayEvent], sunday)
|
||||
expect(sections[0]!.key).toBe('today')
|
||||
})
|
||||
|
||||
it('dateGroup labels are formatted via Intl', () => {
|
||||
const event = makeEvent({ dateTime: '2026-03-13T10:00:00' }) // Friday
|
||||
const sections = useEventGrouping([event], NOW)
|
||||
const label = sections[0]!.dateGroups[0]!.label
|
||||
// The exact format depends on locale, but should contain the day number
|
||||
expect(label).toContain('13')
|
||||
})
|
||||
})
|
||||
@@ -43,7 +43,6 @@ describe('useEventStorage', () => {
|
||||
organizerToken: 'org-456',
|
||||
title: 'Birthday',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
const events = getStoredEvents()
|
||||
@@ -61,7 +60,6 @@ describe('useEventStorage', () => {
|
||||
organizerToken: 'org-456',
|
||||
title: 'Test',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
expect(getOrganizerToken('abc-123')).toBe('org-456')
|
||||
@@ -79,14 +77,12 @@ describe('useEventStorage', () => {
|
||||
eventToken: 'event-1',
|
||||
title: 'First',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
saveCreatedEvent({
|
||||
eventToken: 'event-2',
|
||||
title: 'Second',
|
||||
dateTime: '2026-07-15T20:00:00+02:00',
|
||||
expiryDate: '2026-08-15',
|
||||
})
|
||||
|
||||
const events = getStoredEvents()
|
||||
@@ -102,14 +98,12 @@ describe('useEventStorage', () => {
|
||||
eventToken: 'abc-123',
|
||||
title: 'Old Title',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
saveCreatedEvent({
|
||||
eventToken: 'abc-123',
|
||||
title: 'New Title',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
const events = getStoredEvents()
|
||||
@@ -124,7 +118,6 @@ describe('useEventStorage', () => {
|
||||
eventToken: 'abc-123',
|
||||
title: 'Birthday',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
saveRsvp('abc-123', 'rsvp-token-1', 'Max', 'Birthday', '2026-06-15T20:00:00+02:00')
|
||||
@@ -154,7 +147,6 @@ describe('useEventStorage', () => {
|
||||
eventToken: 'abc-123',
|
||||
title: 'Test',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
expect(getRsvp('abc-123')).toBeUndefined()
|
||||
@@ -172,14 +164,12 @@ describe('useEventStorage', () => {
|
||||
eventToken: 'event-1',
|
||||
title: 'First',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
saveCreatedEvent({
|
||||
eventToken: 'event-2',
|
||||
title: 'Second',
|
||||
dateTime: '2026-07-15T20:00:00+02:00',
|
||||
expiryDate: '2026-08-15',
|
||||
})
|
||||
|
||||
removeEvent('event-1')
|
||||
@@ -196,7 +186,6 @@ describe('useEventStorage', () => {
|
||||
eventToken: 'event-1',
|
||||
title: 'First',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
removeEvent('nonexistent')
|
||||
@@ -205,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
|
||||
@@ -220,7 +274,6 @@ describe('isValidStoredEvent', () => {
|
||||
eventToken: 'abc-123',
|
||||
title: 'Birthday',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
149
frontend/src/composables/useEventGrouping.ts
Normal file
149
frontend/src/composables/useEventGrouping.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { StoredEvent } from './useEventStorage'
|
||||
|
||||
export type SectionKey = 'today' | 'thisWeek' | 'nextWeek' | 'later' | 'past'
|
||||
|
||||
export interface DateGroup {
|
||||
dateKey: string
|
||||
label: string
|
||||
events: StoredEvent[]
|
||||
showSubheader: boolean
|
||||
}
|
||||
|
||||
export interface EventSection {
|
||||
key: SectionKey
|
||||
label: string
|
||||
dateGroups: DateGroup[]
|
||||
emphasized: boolean
|
||||
}
|
||||
|
||||
const SECTION_ORDER: SectionKey[] = ['today', 'thisWeek', 'nextWeek', 'later', 'past']
|
||||
|
||||
const SECTION_LABELS: Record<SectionKey, string> = {
|
||||
today: 'Today',
|
||||
thisWeek: 'This Week',
|
||||
nextWeek: 'Next Week',
|
||||
later: 'Later',
|
||||
past: 'Past',
|
||||
}
|
||||
|
||||
function startOfDay(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
}
|
||||
|
||||
function endOfDay(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
d.setHours(23, 59, 59, 999)
|
||||
return d
|
||||
}
|
||||
|
||||
function endOfWeek(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
const dayOfWeek = d.getDay() // 0=Sun, 1=Mon, ..., 6=Sat
|
||||
// ISO week: Monday is first day. End of week = Sunday.
|
||||
// If today is Sunday (0), end of week is today.
|
||||
// Otherwise, days until Sunday = 7 - dayOfWeek
|
||||
const daysUntilSunday = dayOfWeek === 0 ? 0 : 7 - dayOfWeek
|
||||
d.setDate(d.getDate() + daysUntilSunday)
|
||||
return endOfDay(d)
|
||||
}
|
||||
|
||||
function endOfNextWeek(date: Date): Date {
|
||||
const thisWeekEnd = endOfWeek(date)
|
||||
const d = new Date(thisWeekEnd)
|
||||
d.setDate(d.getDate() + 7)
|
||||
return endOfDay(d)
|
||||
}
|
||||
|
||||
function toDateKey(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
function formatDateLabel(date: Date): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function classifyEvent(eventDate: Date, todayStart: Date, todayEnd: Date, weekEnd: Date, nextWeekEnd: Date): SectionKey {
|
||||
if (eventDate < todayStart) return 'past'
|
||||
if (eventDate <= todayEnd) return 'today'
|
||||
if (eventDate <= weekEnd) return 'thisWeek'
|
||||
if (eventDate <= nextWeekEnd) return 'nextWeek'
|
||||
return 'later'
|
||||
}
|
||||
|
||||
export function useEventGrouping(events: StoredEvent[], now: Date = new Date()): EventSection[] {
|
||||
const todayStart = startOfDay(now)
|
||||
const todayEnd = endOfDay(now)
|
||||
const weekEnd = endOfWeek(now)
|
||||
const nextWeekEnd = endOfNextWeek(now)
|
||||
|
||||
// Classify events into sections
|
||||
const buckets: Record<SectionKey, StoredEvent[]> = {
|
||||
today: [],
|
||||
thisWeek: [],
|
||||
nextWeek: [],
|
||||
later: [],
|
||||
past: [],
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
const eventDate = new Date(event.dateTime)
|
||||
const section = classifyEvent(eventDate, todayStart, todayEnd, weekEnd, nextWeekEnd)
|
||||
buckets[section].push(event)
|
||||
}
|
||||
|
||||
// Build sections
|
||||
const sections: EventSection[] = []
|
||||
|
||||
for (const key of SECTION_ORDER) {
|
||||
const sectionEvents = buckets[key]
|
||||
if (sectionEvents.length === 0) continue
|
||||
|
||||
// Sort events
|
||||
const ascending = key !== 'past'
|
||||
sectionEvents.sort((a, b) => {
|
||||
const diff = new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime()
|
||||
return ascending ? diff : -diff
|
||||
})
|
||||
|
||||
// Group by date
|
||||
const dateGroupMap = new Map<string, StoredEvent[]>()
|
||||
for (const event of sectionEvents) {
|
||||
const dateKey = toDateKey(new Date(event.dateTime))
|
||||
const group = dateGroupMap.get(dateKey)
|
||||
if (group) {
|
||||
group.push(event)
|
||||
} else {
|
||||
dateGroupMap.set(dateKey, [event])
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to DateGroup array (order preserved from sorted events)
|
||||
const dateGroups: DateGroup[] = []
|
||||
for (const [dateKey, groupEvents] of dateGroupMap) {
|
||||
dateGroups.push({
|
||||
dateKey,
|
||||
label: formatDateLabel(new Date(groupEvents[0]!.dateTime)),
|
||||
events: groupEvents,
|
||||
showSubheader: key !== 'today',
|
||||
})
|
||||
}
|
||||
|
||||
sections.push({
|
||||
key,
|
||||
label: SECTION_LABELS[key],
|
||||
dateGroups,
|
||||
emphasized: key === 'today',
|
||||
})
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
@@ -3,7 +3,6 @@ export interface StoredEvent {
|
||||
organizerToken?: string
|
||||
title: string
|
||||
dateTime: string
|
||||
expiryDate: string
|
||||
rsvpToken?: string
|
||||
rsvpName?: string
|
||||
}
|
||||
@@ -66,7 +65,7 @@ export function useEventStorage() {
|
||||
existing.rsvpToken = rsvpToken
|
||||
existing.rsvpName = rsvpName
|
||||
} else {
|
||||
events.push({ eventToken, title, dateTime, expiryDate: '', rsvpToken, rsvpName })
|
||||
events.push({ eventToken, title, dateTime, rsvpToken, rsvpName })
|
||||
}
|
||||
writeEvents(events)
|
||||
}
|
||||
@@ -79,10 +78,34 @@ export function useEventStorage() {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function removeRsvp(eventToken: string): void {
|
||||
const events = readEvents()
|
||||
const event = events.find((e) => e.eventToken === eventToken)
|
||||
if (event) {
|
||||
delete event.rsvpToken
|
||||
delete event.rsvpName
|
||||
writeEvents(events)
|
||||
}
|
||||
}
|
||||
|
||||
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, removeEvent }
|
||||
return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp, removeRsvp, saveWatch, isStored, removeEvent }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<template>
|
||||
<main class="create">
|
||||
<header class="create__header">
|
||||
<RouterLink to="/" class="create__back" aria-label="Back to home">←</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">
|
||||
@@ -12,7 +9,7 @@
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
class="form-field"
|
||||
class="form-field glass"
|
||||
required
|
||||
maxlength="200"
|
||||
placeholder="What's the event?"
|
||||
@@ -27,7 +24,7 @@
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
class="form-field"
|
||||
class="form-field glass"
|
||||
maxlength="2000"
|
||||
placeholder="Tell people more about it…"
|
||||
:aria-invalid="!!errors.description"
|
||||
@@ -42,7 +39,7 @@
|
||||
id="dateTime"
|
||||
v-model="form.dateTime"
|
||||
type="datetime-local"
|
||||
class="form-field"
|
||||
class="form-field glass"
|
||||
required
|
||||
:aria-invalid="!!errors.dateTime"
|
||||
:aria-describedby="errors.dateTime ? 'dateTime-error' : undefined"
|
||||
@@ -56,7 +53,7 @@
|
||||
id="location"
|
||||
v-model="form.location"
|
||||
type="text"
|
||||
class="form-field"
|
||||
class="form-field glass"
|
||||
maxlength="500"
|
||||
placeholder="Where is it?"
|
||||
:aria-invalid="!!errors.location"
|
||||
@@ -65,22 +62,7 @@
|
||||
<span v-if="errors.location" id="location-error" class="field-error" role="alert">{{ errors.location }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="expiryDate" class="form-label">Expiry Date *</label>
|
||||
<input
|
||||
id="expiryDate"
|
||||
v-model="form.expiryDate"
|
||||
type="date"
|
||||
class="form-field"
|
||||
required
|
||||
:min="tomorrow"
|
||||
:aria-invalid="!!errors.expiryDate"
|
||||
:aria-describedby="errors.expiryDate ? 'expiryDate-error' : undefined"
|
||||
/>
|
||||
<span v-if="errors.expiryDate" id="expiryDate-error" class="field-error" role="alert">{{ errors.expiryDate }}</span>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary" :disabled="submitting">
|
||||
<button type="submit" class="btn-primary glass" :disabled="submitting">
|
||||
{{ submitting ? 'Creating…' : 'Create Event' }}
|
||||
</button>
|
||||
|
||||
@@ -90,8 +72,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, watch } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '@/api/client'
|
||||
import { useEventStorage } from '@/composables/useEventStorage'
|
||||
|
||||
@@ -103,7 +85,6 @@ const form = reactive({
|
||||
description: '',
|
||||
dateTime: '',
|
||||
location: '',
|
||||
expiryDate: '',
|
||||
})
|
||||
|
||||
const errors = reactive({
|
||||
@@ -111,31 +92,22 @@ const errors = reactive({
|
||||
description: '',
|
||||
dateTime: '',
|
||||
location: '',
|
||||
expiryDate: '',
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
const serverError = ref('')
|
||||
|
||||
const tomorrow = computed(() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + 1)
|
||||
return d.toISOString().split('T')[0]
|
||||
})
|
||||
|
||||
function clearErrors() {
|
||||
errors.title = ''
|
||||
errors.description = ''
|
||||
errors.dateTime = ''
|
||||
errors.location = ''
|
||||
errors.expiryDate = ''
|
||||
serverError.value = ''
|
||||
}
|
||||
|
||||
// Clear individual field errors when the user types
|
||||
watch(() => form.title, () => { errors.title = ''; serverError.value = '' })
|
||||
watch(() => form.dateTime, () => { errors.dateTime = ''; serverError.value = '' })
|
||||
watch(() => form.expiryDate, () => { errors.expiryDate = ''; serverError.value = '' })
|
||||
watch(() => form.description, () => { serverError.value = '' })
|
||||
watch(() => form.location, () => { serverError.value = '' })
|
||||
|
||||
@@ -153,14 +125,6 @@ function validate(): boolean {
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!form.expiryDate) {
|
||||
errors.expiryDate = 'Expiry date is required.'
|
||||
valid = false
|
||||
} else if (form.expiryDate <= (new Date().toISOString().split('T')[0] ?? '')) {
|
||||
errors.expiryDate = 'Expiry date must be in the future.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
@@ -186,7 +150,6 @@ async function handleSubmit() {
|
||||
dateTime: dateTimeWithOffset,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
location: form.location.trim() || undefined,
|
||||
expiryDate: form.expiryDate,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -212,7 +175,6 @@ async function handleSubmit() {
|
||||
organizerToken: data.organizerToken,
|
||||
title: data.title,
|
||||
dateTime: data.dateTime,
|
||||
expiryDate: data.expiryDate,
|
||||
})
|
||||
|
||||
router.push({ name: 'event', params: { eventToken: data.eventToken } })
|
||||
@@ -229,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 {
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<template>
|
||||
<main class="detail">
|
||||
<header class="detail__header">
|
||||
<RouterLink to="/" class="detail__back" aria-label="Back to home">←</RouterLink>
|
||||
<span class="detail__brand">fete</span>
|
||||
</header>
|
||||
<!-- Hero image with overlaid header -->
|
||||
<div class="detail__hero">
|
||||
<img
|
||||
class="detail__hero-img"
|
||||
src="@/assets/images/event-hero-placeholder.jpg"
|
||||
alt=""
|
||||
/>
|
||||
<div class="detail__hero-overlay" />
|
||||
</div>
|
||||
|
||||
<div class="detail__body">
|
||||
<!-- Loading state -->
|
||||
<div v-if="state === 'loading'" class="detail__card" aria-busy="true" aria-label="Loading event details">
|
||||
<div v-if="state === 'loading'" class="detail__content" aria-busy="true" aria-label="Loading event details">
|
||||
<div class="skeleton skeleton--title" />
|
||||
<div class="skeleton skeleton--line" />
|
||||
<div class="skeleton skeleton--line skeleton--short" />
|
||||
@@ -14,52 +20,117 @@
|
||||
</div>
|
||||
|
||||
<!-- Loaded state -->
|
||||
<div v-else-if="state === 'loaded' && event" class="detail__card">
|
||||
<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__fields">
|
||||
<div class="detail__field">
|
||||
<dt class="detail__label">Date & Time</dt>
|
||||
<dd class="detail__value">{{ formattedDateTime }}</dd>
|
||||
<dl class="detail__meta">
|
||||
<div class="detail__meta-item">
|
||||
<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.description" class="detail__field">
|
||||
<dt class="detail__label">Description</dt>
|
||||
<dd class="detail__value">{{ event.description }}</dd>
|
||||
<div v-if="event.location" class="detail__meta-item">
|
||||
<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 v-if="event.location" class="detail__field">
|
||||
<dt class="detail__label">Location</dt>
|
||||
<dd class="detail__value">{{ event.location }}</dd>
|
||||
</div>
|
||||
|
||||
<div class="detail__field">
|
||||
<dt class="detail__label">Attendees</dt>
|
||||
<dd class="detail__value">{{ event.attendeeCount }}</dd>
|
||||
<div class="detail__meta-item">
|
||||
<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>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
|
||||
This event has ended.
|
||||
<AttendeeList v-if="isOrganizer && attendeeNames !== null" :attendees="attendeeNames" />
|
||||
|
||||
<div v-if="event.description" class="detail__section">
|
||||
<h2 class="detail__section-title">About</h2>
|
||||
<p class="detail__description">{{ event.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Not found state -->
|
||||
<div v-else-if="state === 'not-found'" class="detail__card detail__card--center" role="status">
|
||||
<div v-else-if="state === 'not-found'" class="detail__content detail__content--center" role="status">
|
||||
<p class="detail__message">Event not found.</p>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="state === 'error'" class="detail__card detail__card--center" role="alert">
|
||||
<div v-else-if="state === 'error'" class="detail__content detail__content--center" role="alert">
|
||||
<p class="detail__message">Something went wrong.</p>
|
||||
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
|
||||
<button class="btn-primary glass" type="button" @click="fetchEvent">Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSVP bar (only for loaded, non-expired events) -->
|
||||
<!-- Cancel event button (organizer only, not already cancelled) -->
|
||||
<div v-if="state === 'loaded' && event && isOrganizer && !event.cancelled" class="detail__cancel-event">
|
||||
<button class="detail__cancel-event-btn" type="button" @click="cancelSheetOpen = true">
|
||||
Cancel event
|
||||
</button>
|
||||
</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 (hidden when cancelled) -->
|
||||
<RsvpBar
|
||||
v-if="state === 'loaded' && event && !event.expired && !isOrganizer"
|
||||
v-if="state === 'loaded' && event && !isOrganizer && !event.cancelled"
|
||||
:has-rsvp="!!rsvpName"
|
||||
:bookmarked="eventIsStored"
|
||||
@open="sheetOpen = true"
|
||||
@cancel="confirmCancelOpen = true"
|
||||
@bookmark="handleBookmarkClick"
|
||||
/>
|
||||
|
||||
<!-- Cancel confirmation dialog -->
|
||||
<ConfirmDialog
|
||||
:open="confirmCancelOpen"
|
||||
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"
|
||||
/>
|
||||
|
||||
<!-- RSVP bottom sheet -->
|
||||
@@ -71,7 +142,7 @@
|
||||
<input
|
||||
id="rsvp-name"
|
||||
v-model.trim="nameInput"
|
||||
class="form-field"
|
||||
class="form-field glass"
|
||||
type="text"
|
||||
placeholder="e.g. Max Mustermann"
|
||||
maxlength="100"
|
||||
@@ -80,9 +151,11 @@
|
||||
/>
|
||||
<span v-if="nameError" class="rsvp-form__field-error" role="alert">{{ nameError }}</span>
|
||||
</div>
|
||||
<button class="btn-primary" type="submit" :disabled="submitting">
|
||||
<div class="rsvp-form__submit glow-border glow-border--animated">
|
||||
<button class="rsvp-form__submit-inner glass-inner" type="submit" :disabled="submitting">
|
||||
{{ submitting ? 'Sending…' : "Count me in" }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="submitError" class="rsvp-form__field-error rsvp-form__error" role="alert">{{ submitError }}</p>
|
||||
</form>
|
||||
</BottomSheet>
|
||||
@@ -91,10 +164,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api } from '@/api/client'
|
||||
import { useEventStorage } from '@/composables/useEventStorage'
|
||||
import AttendeeList from '@/components/AttendeeList.vue'
|
||||
import BottomSheet from '@/components/BottomSheet.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import RsvpBar from '@/components/RsvpBar.vue'
|
||||
import type { components } from '@/api/schema'
|
||||
|
||||
@@ -102,7 +177,7 @@ type GetEventResponse = components['schemas']['GetEventResponse']
|
||||
type State = 'loading' | 'loaded' | 'not-found' | 'error'
|
||||
|
||||
const route = useRoute()
|
||||
const { saveRsvp, getRsvp, getOrganizerToken } = useEventStorage()
|
||||
const { saveRsvp, getRsvp, removeRsvp, getOrganizerToken, saveWatch, isStored, removeEvent } = useEventStorage()
|
||||
|
||||
const state = ref<State>('loading')
|
||||
const event = ref<GetEventResponse | null>(null)
|
||||
@@ -114,7 +189,30 @@ const nameError = ref('')
|
||||
const submitError = ref('')
|
||||
const submitting = ref(false)
|
||||
const rsvpName = ref<string | undefined>(undefined)
|
||||
const confirmCancelOpen = ref(false)
|
||||
const cancelError = ref('')
|
||||
const isOrganizer = ref(false)
|
||||
const attendeeNames = ref<string[] | null>(null)
|
||||
|
||||
// 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 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 ''
|
||||
@@ -130,8 +228,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) {
|
||||
@@ -143,7 +241,13 @@ async function fetchEvent() {
|
||||
state.value = 'loaded'
|
||||
|
||||
// Check if current user is the organizer
|
||||
isOrganizer.value = !!getOrganizerToken(event.value.eventToken)
|
||||
const orgToken = getOrganizerToken(event.value.eventToken)
|
||||
isOrganizer.value = !!orgToken
|
||||
|
||||
// Fetch attendee list for organizer
|
||||
if (orgToken) {
|
||||
fetchAttendees(event.value.eventToken, orgToken)
|
||||
}
|
||||
|
||||
// Restore RSVP status from localStorage
|
||||
const stored = getRsvp(event.value.eventToken)
|
||||
@@ -172,8 +276,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 },
|
||||
})
|
||||
|
||||
@@ -203,6 +307,88 @@ async function submitRsvp() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelRsvp() {
|
||||
confirmCancelOpen.value = false
|
||||
cancelError.value = ''
|
||||
|
||||
const stored = getRsvp(route.params.eventToken as string)
|
||||
if (!stored) return
|
||||
|
||||
try {
|
||||
const { response } = await api.DELETE('/events/{eventToken}/rsvps/{rsvpToken}', {
|
||||
params: {
|
||||
path: {
|
||||
eventToken: route.params.eventToken as string,
|
||||
rsvpToken: stored.rsvpToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status === 204 || response.status === 404) {
|
||||
removeRsvp(route.params.eventToken as string)
|
||||
rsvpName.value = undefined
|
||||
if (event.value) {
|
||||
event.value.attendeeCount = Math.max(0, event.value.attendeeCount - 1)
|
||||
}
|
||||
} else {
|
||||
cancelError.value = 'Could not cancel RSVP. Please try again.'
|
||||
}
|
||||
} catch {
|
||||
cancelError.value = 'Could not cancel RSVP. Please try again.'
|
||||
}
|
||||
}
|
||||
|
||||
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/{eventToken}/attendees', {
|
||||
params: {
|
||||
path: { eventToken: eventToken },
|
||||
query: { organizerToken },
|
||||
},
|
||||
})
|
||||
|
||||
if (!error) {
|
||||
attendeeNames.value = data!.attendees.map((a) => a.name)
|
||||
}
|
||||
} catch {
|
||||
// Silently degrade — don't show attendee list
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchEvent)
|
||||
</script>
|
||||
|
||||
@@ -210,108 +396,306 @@ onMounted(fetchEvent)
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2xl);
|
||||
padding-top: var(--spacing-lg);
|
||||
/* Break out of .app-container constraints */
|
||||
width: 100dvw;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin: calc(-1 * var(--content-padding)) 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.detail__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
/* Hero image section */
|
||||
.detail__hero {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 420px;
|
||||
overflow: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail__back {
|
||||
color: var(--color-text-on-gradient);
|
||||
font-size: 1.5rem;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
.detail__hero-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
|
||||
}
|
||||
|
||||
.detail__brand {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-on-gradient);
|
||||
.detail__hero-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
var(--color-glass-overlay) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.detail__card {
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--spacing-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
.detail__body {
|
||||
flex: 1;
|
||||
padding: var(--spacing-lg) var(--content-padding);
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
|
||||
.detail__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
gap: var(--spacing-2xl);
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.detail__card--center {
|
||||
.detail__content--center {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding-top: 4rem;
|
||||
}
|
||||
|
||||
.detail__meta-icon svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.detail__title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: var(--color-text-on-gradient);
|
||||
word-break: break-word;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.detail__fields {
|
||||
/* Meta rows: icon + text */
|
||||
.detail__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.detail__field {
|
||||
.detail__meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.detail__label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
.detail__meta-icon {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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__value {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text);
|
||||
.detail__meta-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-on-gradient);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* About section */
|
||||
.detail__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.detail__section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.detail__description {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-soft);
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Expired banner */
|
||||
.detail__banner {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-card);
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail__banner--expired {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
/* Error / not-found message */
|
||||
.detail__message {
|
||||
font-size: 1rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
color: var(--color-text-on-gradient);
|
||||
}
|
||||
|
||||
/* Skeleton – shimmer on gradient */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--color-glass) 25%, var(--color-glass-hover) 50%, var(--color-glass) 75%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
/* Skeleton sizes */
|
||||
.skeleton--title {
|
||||
height: 1.6rem;
|
||||
width: 60%;
|
||||
height: 2rem;
|
||||
width: 70%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.skeleton--line {
|
||||
height: 1rem;
|
||||
width: 80%;
|
||||
width: 85%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.skeleton--short {
|
||||
width: 40%;
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
/* RSVP submit button (glow border wrapper) */
|
||||
.rsvp-form__submit {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-button);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.rsvp-form__submit:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.rsvp-form__submit:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.rsvp-form__submit-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-form__submit-inner:disabled {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Cancel event button */
|
||||
.detail__cancel-event {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: var(--spacing-md) var(--content-padding);
|
||||
padding-bottom: calc(var(--spacing-md) + env(safe-area-inset-bottom, 0px));
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.detail__cancel-event-btn {
|
||||
width: 100%;
|
||||
max-width: var(--content-max-width);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: var(--radius-button);
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-danger);
|
||||
background: var(--color-danger-bg);
|
||||
border: 1px solid var(--color-danger-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.detail__cancel-event-btn:hover {
|
||||
background: var(--color-danger-bg-hover);
|
||||
}
|
||||
|
||||
/* 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>
|
||||
|
||||
@@ -44,7 +44,6 @@ describe('EventCreateView', () => {
|
||||
expect(wrapper.find('#description').exists()).toBe(true)
|
||||
expect(wrapper.find('#dateTime').exists()).toBe(true)
|
||||
expect(wrapper.find('#location').exists()).toBe(true)
|
||||
expect(wrapper.find('#expiryDate').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has required attribute on required fields', async () => {
|
||||
@@ -58,7 +57,6 @@ describe('EventCreateView', () => {
|
||||
|
||||
expect(wrapper.find('#title').attributes('required')).toBeDefined()
|
||||
expect(wrapper.find('#dateTime').attributes('required')).toBeDefined()
|
||||
expect(wrapper.find('#expiryDate').attributes('required')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not have required attribute on optional fields', async () => {
|
||||
@@ -102,7 +100,6 @@ describe('EventCreateView', () => {
|
||||
// Fill required fields
|
||||
await wrapper.find('#title').setValue('My Event')
|
||||
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
||||
await wrapper.find('#expiryDate').setValue('2026-12-24')
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
@@ -127,7 +124,7 @@ describe('EventCreateView', () => {
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
const errorsBefore = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0)
|
||||
expect(errorsBefore.length).toBeGreaterThanOrEqual(3)
|
||||
expect(errorsBefore.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Type into title field
|
||||
await wrapper.find('#title').setValue('My Event')
|
||||
@@ -138,9 +135,6 @@ describe('EventCreateView', () => {
|
||||
|
||||
const dateTimeError = wrapper.find('#dateTime').element.closest('.form-group')!.querySelector('[role="alert"]')!
|
||||
expect(dateTimeError.textContent).not.toBe('')
|
||||
|
||||
const expiryError = wrapper.find('#expiryDate').element.closest('.form-group')!.querySelector('[role="alert"]')!
|
||||
expect(expiryError.textContent).not.toBe('')
|
||||
})
|
||||
|
||||
it('shows validation errors when submitting empty form', async () => {
|
||||
@@ -156,7 +150,7 @@ describe('EventCreateView', () => {
|
||||
|
||||
const errorElements = wrapper.findAll('[role="alert"]')
|
||||
const errorTexts = errorElements.map((el) => el.text()).filter((t) => t.length > 0)
|
||||
expect(errorTexts.length).toBeGreaterThanOrEqual(3)
|
||||
expect(errorTexts.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('submits successfully, saves to storage, and navigates to event page', async () => {
|
||||
@@ -169,6 +163,9 @@ describe('EventCreateView', () => {
|
||||
getOrganizerToken: vi.fn(),
|
||||
saveRsvp: vi.fn(),
|
||||
getRsvp: vi.fn(),
|
||||
removeRsvp: vi.fn(),
|
||||
saveWatch: vi.fn(),
|
||||
isStored: vi.fn(() => false),
|
||||
removeEvent: vi.fn(),
|
||||
})
|
||||
|
||||
@@ -179,7 +176,6 @@ describe('EventCreateView', () => {
|
||||
title: 'Birthday Party',
|
||||
dateTime: '2026-12-25T18:00:00+01:00',
|
||||
timezone: 'Europe/Berlin',
|
||||
expiryDate: '2026-12-24',
|
||||
},
|
||||
error: undefined,
|
||||
response: new Response(),
|
||||
@@ -198,7 +194,6 @@ describe('EventCreateView', () => {
|
||||
await wrapper.find('#description').setValue('Come celebrate!')
|
||||
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
||||
await wrapper.find('#location').setValue('Berlin')
|
||||
await wrapper.find('#expiryDate').setValue('2026-12-24')
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
@@ -208,7 +203,6 @@ describe('EventCreateView', () => {
|
||||
title: 'Birthday Party',
|
||||
description: 'Come celebrate!',
|
||||
location: 'Berlin',
|
||||
expiryDate: '2026-12-24',
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -217,7 +211,6 @@ describe('EventCreateView', () => {
|
||||
organizerToken: 'org-456',
|
||||
title: 'Birthday Party',
|
||||
dateTime: '2026-12-25T18:00:00+01:00',
|
||||
expiryDate: '2026-12-24',
|
||||
})
|
||||
|
||||
expect(pushSpy).toHaveBeenCalledWith({
|
||||
@@ -245,7 +238,6 @@ describe('EventCreateView', () => {
|
||||
|
||||
await wrapper.find('#title').setValue('Duplicate Event')
|
||||
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
||||
await wrapper.find('#expiryDate').setValue('2026-12-24')
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
@@ -256,6 +248,5 @@ describe('EventCreateView', () => {
|
||||
|
||||
// Other field errors should not be present
|
||||
expect(wrapper.find('#dateTime-error').exists()).toBe(false)
|
||||
expect(wrapper.find('#expiryDate-error').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
}))
|
||||
|
||||
@@ -54,7 +60,6 @@ const fullEvent = {
|
||||
timezone: 'Europe/Berlin',
|
||||
location: 'Central Park, NYC',
|
||||
attendeeCount: 12,
|
||||
expired: false,
|
||||
}
|
||||
|
||||
function mockLoadedEvent(eventOverrides = {}) {
|
||||
@@ -69,6 +74,9 @@ beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
mockGetRsvp.mockReturnValue(undefined)
|
||||
mockGetOrganizerToken.mockReturnValue(undefined)
|
||||
mockIsStored.mockReturnValue(false)
|
||||
mockSaveWatch.mockClear()
|
||||
mockRemoveEvent.mockClear()
|
||||
})
|
||||
|
||||
describe('EventDetailView', () => {
|
||||
@@ -105,7 +113,7 @@ describe('EventDetailView', () => {
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const dateField = wrapper.findAll('.detail__value')[0]!
|
||||
const dateField = wrapper.findAll('.detail__meta-text')[0]!
|
||||
expect(dateField.text()).toContain('(Europe/Berlin)')
|
||||
expect(dateField.text()).toContain('2026')
|
||||
wrapper.unmount()
|
||||
@@ -124,29 +132,6 @@ describe('EventDetailView', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// Expired state
|
||||
it('renders "event has ended" banner when expired', async () => {
|
||||
mockLoadedEvent({ expired: true })
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('This event has ended.')
|
||||
expect(wrapper.find('.detail__banner--expired').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// No expired banner when not expired
|
||||
it('does not render expired banner when event is active', async () => {
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.detail__banner--expired').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// Not found state
|
||||
it('renders "event not found" when API returns 404', async () => {
|
||||
vi.mocked(api.GET).mockResolvedValue({
|
||||
@@ -213,7 +198,7 @@ describe('EventDetailView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
|
||||
expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending")
|
||||
expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending!")
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -229,17 +214,6 @@ describe('EventDetailView', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not show RSVP bar on expired event', async () => {
|
||||
mockLoadedEvent({ expired: true })
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
||||
expect(wrapper.find('.rsvp-bar').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows RSVP status bar when localStorage has RSVP', async () => {
|
||||
mockGetRsvp.mockReturnValue({ rsvpToken: 'rsvp-1', rsvpName: 'Max' })
|
||||
mockLoadedEvent()
|
||||
@@ -262,7 +236,7 @@ describe('EventDetailView', () => {
|
||||
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
|
||||
await wrapper.find('.rsvp-bar__cta').trigger('click')
|
||||
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull()
|
||||
@@ -275,7 +249,7 @@ describe('EventDetailView', () => {
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.rsvp-bar__cta').trigger('click')
|
||||
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// Form is inside Teleport — find via document.body
|
||||
@@ -300,7 +274,7 @@ describe('EventDetailView', () => {
|
||||
await flushPromises()
|
||||
|
||||
// Open sheet
|
||||
await wrapper.find('.rsvp-bar__cta').trigger('click')
|
||||
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// Fill name via Teleported input
|
||||
@@ -315,8 +289,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' },
|
||||
})
|
||||
|
||||
@@ -339,6 +313,42 @@ describe('EventDetailView', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// Attendee list (organizer)
|
||||
it('shows attendee list for organizer', async () => {
|
||||
mockGetOrganizerToken.mockReturnValue('org-token-123')
|
||||
mockLoadedEvent()
|
||||
vi.mocked(api.GET)
|
||||
.mockResolvedValueOnce({
|
||||
data: fullEvent,
|
||||
error: undefined,
|
||||
response: new Response(null, { status: 200 }),
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
data: { attendees: [{ name: 'Alice' }, { name: 'Bob' }] },
|
||||
error: undefined,
|
||||
response: new Response(null, { status: 200 }),
|
||||
} as never)
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.attendee-list').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Alice')
|
||||
expect(wrapper.text()).toContain('Bob')
|
||||
expect(wrapper.find('.attendee-list__heading').text()).toBe('2 Attendees')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not show attendee list for visitor', async () => {
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.attendee-list').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows error when RSVP submission fails', async () => {
|
||||
mockLoadedEvent()
|
||||
vi.mocked(api.POST).mockResolvedValue({
|
||||
@@ -350,7 +360,7 @@ describe('EventDetailView', () => {
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.rsvp-bar__cta').trigger('click')
|
||||
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement
|
||||
@@ -365,4 +375,89 @@ describe('EventDetailView', () => {
|
||||
expect(document.body.textContent).toContain('Could not submit RSVP. Please try again.')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// Bookmark — T007: bookmark state is passed to RsvpBar via props
|
||||
it('passes bookmarked=false to RsvpBar when event is not in storage', async () => {
|
||||
mockIsStored.mockReturnValue(false)
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
||||
expect(rsvpBar.props('bookmarked')).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('passes bookmarked=true to RsvpBar when event is in storage', async () => {
|
||||
mockIsStored.mockReturnValue(true)
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
||||
expect(rsvpBar.props('bookmarked')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('bookmark event emitted from RsvpBar calls saveWatch', async () => {
|
||||
mockIsStored.mockReturnValue(false)
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
||||
rsvpBar.vm.$emit('bookmark')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSaveWatch).toHaveBeenCalledWith('test-token', 'Summer BBQ', '2026-03-15T20:00:00+01:00')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('bookmark event emitted from RsvpBar calls removeEvent when user is watcher', async () => {
|
||||
mockIsStored.mockReturnValue(true)
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
||||
rsvpBar.vm.$emit('bookmark')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockRemoveEvent).toHaveBeenCalledWith('test-token')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('bookmark event ignored when user is attendee', async () => {
|
||||
mockGetRsvp.mockReturnValue({ rsvpToken: 'rsvp-1', rsvpName: 'Max' })
|
||||
mockIsStored.mockReturnValue(true)
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
||||
rsvpBar.vm.$emit('bookmark')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockRemoveEvent).not.toHaveBeenCalled()
|
||||
expect(mockSaveWatch).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('passes bookmarked=true to RsvpBar after removeRsvp (event still in storage)', async () => {
|
||||
mockIsStored.mockReturnValue(true)
|
||||
mockGetRsvp.mockReturnValue(undefined)
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
||||
expect(rsvpBar.props('bookmarked')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
35
specs/010-event-list-grouping/checklists/requirements.md
Normal file
35
specs/010-event-list-grouping/checklists/requirements.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Event List Temporal Grouping
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-08
|
||||
**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`.
|
||||
- One minor note: the Assumptions section mentions `Intl` API and `localStorage` — these are context references to existing behavior, not prescriptive implementation details.
|
||||
91
specs/010-event-list-grouping/data-model.md
Normal file
91
specs/010-event-list-grouping/data-model.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Data Model: Event List Temporal Grouping
|
||||
|
||||
**Feature**: 010-event-list-grouping | **Date**: 2026-03-08
|
||||
|
||||
## Existing Entities (no changes)
|
||||
|
||||
### StoredEvent
|
||||
|
||||
**Location**: `frontend/src/composables/useEventStorage.ts`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `eventToken` | `string` | UUID v4, unique identifier |
|
||||
| `organizerToken` | `string?` | UUID v4, present if user is organizer |
|
||||
| `title` | `string` | Event title |
|
||||
| `dateTime` | `string` | ISO 8601 with UTC offset (e.g., `"2026-03-15T20:00:00+01:00"`) |
|
||||
| `expiryDate` | `string` | ISO 8601 expiry date |
|
||||
| `rsvpToken` | `string?` | Present if user has RSVP'd |
|
||||
| `rsvpName` | `string?` | Name used for RSVP |
|
||||
|
||||
**Note**: No changes to `StoredEvent`. The `dateTime` field is the sole input for all grouping and sorting logic.
|
||||
|
||||
## New Types (frontend only)
|
||||
|
||||
### SectionKey
|
||||
|
||||
```typescript
|
||||
type SectionKey = 'today' | 'thisWeek' | 'later' | 'past'
|
||||
```
|
||||
|
||||
Enum-like union type for the four temporal sections. Ordering is fixed: today → thisWeek → later → past.
|
||||
|
||||
### EventSection
|
||||
|
||||
```typescript
|
||||
interface EventSection {
|
||||
key: SectionKey
|
||||
label: string // Display label: "Today", "This Week", "Later", "Past"
|
||||
dateGroups: DateGroup[]
|
||||
emphasized: boolean // true only for 'today' section
|
||||
}
|
||||
```
|
||||
|
||||
Represents one temporal section in the grouped list. Sections with no events are omitted entirely (never constructed).
|
||||
|
||||
### DateGroup
|
||||
|
||||
```typescript
|
||||
interface DateGroup {
|
||||
dateKey: string // YYYY-MM-DD (for keying/dedup)
|
||||
label: string // Formatted via Intl.DateTimeFormat, e.g., "Wed, 12 Mar"
|
||||
events: StoredEvent[] // Events on this date, sorted by time
|
||||
showSubheader: boolean // false for "Today" section (FR-005)
|
||||
}
|
||||
```
|
||||
|
||||
Groups events within a section by their specific calendar date. The `showSubheader` flag controls whether the date subheader is rendered (always false in "Today" section per FR-005).
|
||||
|
||||
## Grouping Algorithm
|
||||
|
||||
```
|
||||
Input: StoredEvent[], now: Date
|
||||
Output: EventSection[]
|
||||
|
||||
1. Compute boundaries:
|
||||
- startOfToday = today at 00:00:00 local
|
||||
- endOfToday = today at 23:59:59.999 local
|
||||
- endOfWeek = next Sunday at 23:59:59.999 local (or today if today is Sunday)
|
||||
|
||||
2. Classify each event by dateTime:
|
||||
- dateTime < startOfToday → "past"
|
||||
- startOfToday ≤ dateTime ≤ endOfToday → "today"
|
||||
- endOfToday < dateTime ≤ endOfWeek → "thisWeek"
|
||||
- dateTime > endOfWeek → "later"
|
||||
|
||||
3. Within each section, group by calendar date (YYYY-MM-DD)
|
||||
|
||||
4. Sort:
|
||||
- today/thisWeek/later: date groups ascending, events within group ascending by time
|
||||
- past: date groups descending, events within group descending by time
|
||||
|
||||
5. Emit only non-empty sections in fixed order: today, thisWeek, later, past
|
||||
```
|
||||
|
||||
## State Transitions
|
||||
|
||||
None. Events are static data in localStorage. Temporal classification is computed on each render based on current time. No event mutation occurs.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
No new validation. Existing `isValidStoredEvent()` in `useEventStorage.ts` already validates the `dateTime` field as a parseable ISO 8601 string.
|
||||
72
specs/010-event-list-grouping/plan.md
Normal file
72
specs/010-event-list-grouping/plan.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Implementation Plan: Event List Temporal Grouping
|
||||
|
||||
**Branch**: `010-event-list-grouping` | **Date**: 2026-03-08 | **Spec**: `specs/010-event-list-grouping/spec.md`
|
||||
**Input**: Feature specification from `/specs/010-event-list-grouping/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Extend the existing flat event list with temporal section grouping (Today, This Week, Later, Past). The feature is purely client-side: the existing `EventList.vue` computed property that separates events into upcoming/past is refactored into a four-section grouping with section headers, date subheaders, and context-aware time formatting. No backend changes, no new dependencies.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.9 (frontend only)
|
||||
**Primary Dependencies**: Vue 3, Vue Router 5 (existing — no additions)
|
||||
**Storage**: localStorage via `useEventStorage.ts` composable (existing — no changes)
|
||||
**Testing**: Vitest (unit), Playwright + MSW (E2E)
|
||||
**Target Platform**: PWA, mobile-first, all modern browsers
|
||||
**Project Type**: Web application (frontend enhancement)
|
||||
**Performance Goals**: Grouping computation < 1ms for 100 events (trivial — single array pass)
|
||||
**Constraints**: Client-side only, no additional network requests, offline-capable
|
||||
**Scale/Scope**: Typically < 50 events per user in localStorage
|
||||
|
||||
## 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. Grouping uses existing `dateTime` field. No external services. |
|
||||
| II. Test-Driven Methodology | PASS | Unit tests for grouping logic + E2E tests for all user stories planned. TDD enforced. |
|
||||
| III. API-First Development | N/A | No API changes — purely frontend enhancement. |
|
||||
| IV. Simplicity & Quality | PASS | Minimal new code: one composable for grouping, template changes in EventList. No over-engineering. |
|
||||
| V. Dependency Discipline | PASS | No new dependencies. Uses browser `Intl` API and existing `Date` methods. |
|
||||
| VI. Accessibility | PASS | Section headers use semantic HTML (`<h2>`/`<h3>`), ARIA landmarks, keyboard navigable. WCAG AA contrast enforced. |
|
||||
|
||||
**Gate result: PASS** — no violations.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/010-event-list-grouping/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── spec.md # Feature specification
|
||||
└── tasks.md # Phase 2 output (via /speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── EventList.vue # MODIFY — add section grouping to template + computed
|
||||
│ │ ├── EventCard.vue # MODIFY — add time format mode prop
|
||||
│ │ ├── SectionHeader.vue # NEW — temporal section header component
|
||||
│ │ └── DateSubheader.vue # NEW — date subheader component
|
||||
│ ├── composables/
|
||||
│ │ ├── useEventGrouping.ts # NEW — grouping logic (pure function)
|
||||
│ │ ├── useRelativeTime.ts # EXISTING — no changes
|
||||
│ │ └── useEventStorage.ts # EXISTING — no changes
|
||||
│ └── components/__tests__/
|
||||
│ ├── EventList.spec.ts # MODIFY — update for grouped structure
|
||||
│ ├── EventCard.spec.ts # MODIFY — add time format tests
|
||||
│ └── useEventGrouping.spec.ts # NEW — unit tests for grouping logic
|
||||
├── e2e/
|
||||
│ └── home-events.spec.ts # MODIFY — add temporal grouping E2E tests
|
||||
```
|
||||
|
||||
**Structure Decision**: Frontend-only changes. Two new small components (SectionHeader, DateSubheader) and one new composable (useEventGrouping). Existing components modified minimally.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user