Compare commits
25 Commits
6e655597d7
...
0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f50ea991b | |||
| fd9175925e | |||
| 63108f4eb5 | |||
| cd71110514 | |||
| 76b48d8b61 | |||
| e5d0dd5f8f | |||
| e77e479e2a | |||
| 80d79c3596 | |||
| 7efe932621 | |||
| a56a26b1f0 | |||
| 906ba99b75 | |||
| da08752642 | |||
| 014b3b0171 | |||
| 33aff5bff5 | |||
| 6de0769d70 | |||
| 6a16255984 | |||
| 2ce3ce0d05 | |||
|
|
ca651d4c05 | ||
|
|
1e065bef18 | ||
|
|
e10b88ee5f | ||
|
|
465fc2178f | ||
|
|
9e48debca7 | ||
|
|
fc344d3ca0 | ||
|
|
e04a86399c | ||
|
|
0069747e68 |
@@ -16,7 +16,7 @@ cd "$CLAUDE_PROJECT_DIR/frontend"
|
||||
ERRORS=""
|
||||
|
||||
# Type-check
|
||||
if OUTPUT=$(npx vue-tsc --noEmit 2>&1); then
|
||||
if OUTPUT=$(npm run type-check 2>&1); then
|
||||
:
|
||||
else
|
||||
ERRORS+="Type-check failed:\n$OUTPUT\n\n"
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
backend-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@v5
|
||||
@@ -21,10 +21,10 @@ jobs:
|
||||
frontend-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node 24
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
@@ -49,10 +49,10 @@ jobs:
|
||||
frontend-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node 24
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
run: cd frontend && npm run test:e2e
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
if: startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, '.')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Parse SemVer tag
|
||||
id: semver
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,6 +14,9 @@ Thumbs.db
|
||||
.agent-tests/
|
||||
.ralph/*/iteration-*.jsonl
|
||||
|
||||
# Test results (Playwright artifacts)
|
||||
test-results/
|
||||
|
||||
# Java/Maven
|
||||
*.class
|
||||
*.jar
|
||||
|
||||
@@ -107,8 +107,10 @@ Accessibility is a baseline requirement, not an afterthought.
|
||||
rationale. Never rewrite or delete the original decision.
|
||||
- The visual design system in `.specify/memory/design-system.md` is authoritative. All
|
||||
frontend implementation MUST follow it.
|
||||
- Research reports go to `docs/agents/research/`, implementation plans to
|
||||
`docs/agents/plan/`.
|
||||
- Feature specs, research, and plans live in `specs/NNN-feature-name/`
|
||||
(spec-kit format). Cross-cutting research goes to
|
||||
`.specify/memory/research/`, cross-cutting plans to
|
||||
`.specify/memory/plans/`.
|
||||
- Conversation and brainstorming in German; code, comments, commits, and
|
||||
documentation in English.
|
||||
- Documentation lives in the README. No wiki, no elaborate docs site.
|
||||
|
||||
@@ -33,6 +33,7 @@ Person erstellt via App eine Veranstaltung und schickt seine Freunden irgendwie
|
||||
* Updaten der Veranstaltung
|
||||
* Einsicht angemeldete Gäste, kann bei Bedarf Einträge entfernen
|
||||
* Featureideen:
|
||||
* Link-Previews (OpenGraph Meta-Tags): Generische OG-Tags mit App-Branding (z.B. "fete — Du wurdest eingeladen") damit geteilte Links in WhatsApp/Signal/Telegram hübsch aussehen. Keine Event-Daten an Crawler aus Privacy-Gründen. → Eigene User Story.
|
||||
* Kalender-Integration: .ics-Download + optional webcal:// für Live-Updates bei Änderungen
|
||||
* Änderungen zum ursprünglichen Inhalt (z.b. geändertes datum/ort) werden iwi hervorgehoben
|
||||
* Veranstalter kann Updatenachrichten im Event posten, pro Device wird via LocalStorage gemerkt was man schon gesehen hat (Badge/Hervorhebung für neue Updates)
|
||||
|
||||
@@ -49,3 +49,10 @@ The following skills are available and should be used for their respective purpo
|
||||
- The loop runner is `ralph.sh`. Each run lives in its own directory under `.ralph/`.
|
||||
- Run directories contain: `instructions.md` (prompt), `chief-wiggum.md` (directives), `answers.md` (human answers), `questions.md` (Ralph's questions), `progress.txt` (iteration log), `meta.md` (metadata), `run.log` (execution log).
|
||||
- Project specifications (user stories, setup tasks, personas, etc.) live in `specs/` (feature dirs) and `.specify/memory/` (cross-cutting docs).
|
||||
|
||||
## Active Technologies
|
||||
- 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)
|
||||
|
||||
## 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
|
||||
|
||||
@@ -10,14 +10,14 @@ COPY backend/src/main/resources/openapi/api.yaml \
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build backend with frontend assets baked in
|
||||
FROM eclipse-temurin:25-jdk-alpine AS backend-build
|
||||
FROM eclipse-temurin:25.0.2_10-jdk-alpine AS backend-build
|
||||
WORKDIR /app/backend
|
||||
COPY backend/ ./
|
||||
COPY --from=frontend-build /app/frontend/dist src/main/resources/static/
|
||||
RUN ./mvnw -B -DskipTests -Dcheckstyle.skip -Dspotbugs.skip package
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM eclipse-temurin:25-jre-alpine
|
||||
FROM eclipse-temurin:25.0.2_10-jre-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=backend-build /app/backend/target/*.jar app.jar
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -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.12/apache-maven-3.9.12-bin.zip
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.13/apache-maven-3.9.13-bin.zip
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>build-helper-maven-plugin</artifactId>
|
||||
<version>3.6.0</version>
|
||||
<version>3.6.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>add-openapi-sources</id>
|
||||
|
||||
@@ -3,9 +3,18 @@ package de.fete.adapter.in.web;
|
||||
import de.fete.adapter.in.web.api.EventsApi;
|
||||
import de.fete.adapter.in.web.model.CreateEventRequest;
|
||||
import de.fete.adapter.in.web.model.CreateEventResponse;
|
||||
import de.fete.adapter.in.web.model.GetEventResponse;
|
||||
import de.fete.application.service.EventNotFoundException;
|
||||
import de.fete.application.service.InvalidTimezoneException;
|
||||
import de.fete.domain.model.CreateEventCommand;
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.port.in.CreateEventUseCase;
|
||||
import de.fete.domain.port.in.GetEventUseCase;
|
||||
import java.time.Clock;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.UUID;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@@ -15,19 +24,29 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class EventController implements EventsApi {
|
||||
|
||||
private final CreateEventUseCase createEventUseCase;
|
||||
private final GetEventUseCase getEventUseCase;
|
||||
private final Clock clock;
|
||||
|
||||
/** Creates a new controller with the given use case. */
|
||||
public EventController(CreateEventUseCase createEventUseCase) {
|
||||
/** Creates a new controller with the given use cases and clock. */
|
||||
public EventController(
|
||||
CreateEventUseCase createEventUseCase,
|
||||
GetEventUseCase getEventUseCase,
|
||||
Clock clock) {
|
||||
this.createEventUseCase = createEventUseCase;
|
||||
this.getEventUseCase = getEventUseCase;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<CreateEventResponse> createEvent(
|
||||
CreateEventRequest request) {
|
||||
ZoneId zoneId = parseTimezone(request.getTimezone());
|
||||
|
||||
var command = new CreateEventCommand(
|
||||
request.getTitle(),
|
||||
request.getDescription(),
|
||||
request.getDateTime(),
|
||||
zoneId,
|
||||
request.getLocation(),
|
||||
request.getExpiryDate()
|
||||
);
|
||||
@@ -39,8 +58,36 @@ public class EventController implements EventsApi {
|
||||
response.setOrganizerToken(event.getOrganizerToken());
|
||||
response.setTitle(event.getTitle());
|
||||
response.setDateTime(event.getDateTime());
|
||||
response.setTimezone(event.getTimezone().getId());
|
||||
response.setExpiryDate(event.getExpiryDate());
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<GetEventResponse> getEvent(UUID token) {
|
||||
Event event = getEventUseCase.getByEventToken(token)
|
||||
.orElseThrow(() -> new EventNotFoundException(token));
|
||||
|
||||
var response = new GetEventResponse();
|
||||
response.setEventToken(event.getEventToken());
|
||||
response.setTitle(event.getTitle());
|
||||
response.setDescription(event.getDescription());
|
||||
response.setDateTime(event.getDateTime());
|
||||
response.setTimezone(event.getTimezone().getId());
|
||||
response.setLocation(event.getLocation());
|
||||
response.setAttendeeCount(0);
|
||||
response.setExpired(
|
||||
event.getExpiryDate().isBefore(LocalDate.now(clock)));
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
private static ZoneId parseTimezone(String timezone) {
|
||||
try {
|
||||
return ZoneId.of(timezone);
|
||||
} catch (DateTimeException e) {
|
||||
throw new InvalidTimezoneException(timezone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package de.fete.adapter.in.web;
|
||||
|
||||
import de.fete.application.service.EventNotFoundException;
|
||||
import de.fete.application.service.ExpiryDateInPastException;
|
||||
import de.fete.application.service.InvalidTimezoneException;
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -57,6 +59,32 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||
.body(problemDetail);
|
||||
}
|
||||
|
||||
/** Handles event not found. */
|
||||
@ExceptionHandler(EventNotFoundException.class)
|
||||
public ResponseEntity<ProblemDetail> handleEventNotFound(
|
||||
EventNotFoundException ex) {
|
||||
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.NOT_FOUND, ex.getMessage());
|
||||
problemDetail.setTitle("Event Not Found");
|
||||
problemDetail.setType(URI.create("urn:problem-type:event-not-found"));
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||
.body(problemDetail);
|
||||
}
|
||||
|
||||
/** Handles invalid timezone. */
|
||||
@ExceptionHandler(InvalidTimezoneException.class)
|
||||
public ResponseEntity<ProblemDetail> handleInvalidTimezone(
|
||||
InvalidTimezoneException ex) {
|
||||
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||
problemDetail.setTitle("Invalid Timezone");
|
||||
problemDetail.setType(URI.create("urn:problem-type:invalid-timezone"));
|
||||
return ResponseEntity.badRequest()
|
||||
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||
.body(problemDetail);
|
||||
}
|
||||
|
||||
/** Catches all unhandled exceptions. */
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ProblemDetail> handleAll(Exception ex) {
|
||||
|
||||
@@ -34,6 +34,9 @@ public class EventJpaEntity {
|
||||
@Column(name = "date_time", nullable = false)
|
||||
private OffsetDateTime dateTime;
|
||||
|
||||
@Column(nullable = false, length = 64)
|
||||
private String timezone;
|
||||
|
||||
@Column(length = 500)
|
||||
private String location;
|
||||
|
||||
@@ -103,6 +106,16 @@ public class EventJpaEntity {
|
||||
this.dateTime = dateTime;
|
||||
}
|
||||
|
||||
/** Returns the IANA timezone name. */
|
||||
public String getTimezone() {
|
||||
return timezone;
|
||||
}
|
||||
|
||||
/** Sets the IANA timezone name. */
|
||||
public void setTimezone(String timezone) {
|
||||
this.timezone = timezone;
|
||||
}
|
||||
|
||||
/** Returns the event location. */
|
||||
public String getLocation() {
|
||||
return location;
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.fete.adapter.out.persistence;
|
||||
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.port.out.EventRepository;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Repository;
|
||||
@@ -37,6 +38,7 @@ public class EventPersistenceAdapter implements EventRepository {
|
||||
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());
|
||||
@@ -51,6 +53,7 @@ public class EventPersistenceAdapter implements EventRepository {
|
||||
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());
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.fete.application.service;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** Thrown when an event cannot be found by its token. */
|
||||
public class EventNotFoundException extends RuntimeException {
|
||||
|
||||
/** Creates a new exception for the given event token. */
|
||||
public EventNotFoundException(UUID eventToken) {
|
||||
super("Event not found: " + eventToken);
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,18 @@ package de.fete.application.service;
|
||||
import de.fete.domain.model.CreateEventCommand;
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.port.in.CreateEventUseCase;
|
||||
import de.fete.domain.port.in.GetEventUseCase;
|
||||
import de.fete.domain.port.out.EventRepository;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Application service implementing event creation. */
|
||||
/** Application service implementing event creation and retrieval. */
|
||||
@Service
|
||||
public class EventService implements CreateEventUseCase {
|
||||
public class EventService implements CreateEventUseCase, GetEventUseCase {
|
||||
|
||||
private final EventRepository eventRepository;
|
||||
private final Clock clock;
|
||||
@@ -35,10 +37,16 @@ public class EventService implements CreateEventUseCase {
|
||||
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));
|
||||
|
||||
return eventRepository.save(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Event> getByEventToken(UUID eventToken) {
|
||||
return eventRepository.findByEventToken(eventToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.fete.application.service;
|
||||
|
||||
/** Thrown when an invalid IANA timezone ID is provided. */
|
||||
public class InvalidTimezoneException extends RuntimeException {
|
||||
|
||||
/** Creates a new exception for the given invalid timezone. */
|
||||
public InvalidTimezoneException(String timezone) {
|
||||
super("Invalid IANA timezone: " + timezone);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@ package de.fete.domain.model;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/** Command carrying the data needed to create an event. */
|
||||
public record CreateEventCommand(
|
||||
String title,
|
||||
String description,
|
||||
OffsetDateTime dateTime,
|
||||
ZoneId timezone,
|
||||
String location,
|
||||
LocalDate expiryDate
|
||||
) {}
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.fete.domain.model;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Domain entity representing an event. */
|
||||
@@ -13,6 +14,7 @@ public class Event {
|
||||
private String title;
|
||||
private String description;
|
||||
private OffsetDateTime dateTime;
|
||||
private ZoneId timezone;
|
||||
private String location;
|
||||
private LocalDate expiryDate;
|
||||
private OffsetDateTime createdAt;
|
||||
@@ -77,6 +79,16 @@ public class Event {
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.fete.domain.port.in;
|
||||
|
||||
import de.fete.domain.model.Event;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Inbound port for retrieving a public event by its token. */
|
||||
public interface GetEventUseCase {
|
||||
|
||||
/** Finds an event by its public event token. */
|
||||
Optional<Event> getByEventToken(UUID eventToken);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?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="002-add-timezone-column" author="fete">
|
||||
<addColumn tableName="events">
|
||||
<column name="timezone" type="varchar(64)" defaultValue="UTC">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -7,5 +7,6 @@
|
||||
|
||||
<include file="db/changelog/000-baseline.xml"/>
|
||||
<include file="db/changelog/001-create-events-table.xml"/>
|
||||
<include file="db/changelog/002-add-timezone-column.xml"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
||||
@@ -37,6 +37,34 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ValidationProblemDetail"
|
||||
|
||||
/events/{token}:
|
||||
get:
|
||||
operationId: getEvent
|
||||
summary: Get public event details by token
|
||||
tags:
|
||||
- events
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Public event token
|
||||
responses:
|
||||
"200":
|
||||
description: Event found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetEventResponse"
|
||||
"404":
|
||||
description: Event not found
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
|
||||
components:
|
||||
schemas:
|
||||
CreateEventRequest:
|
||||
@@ -44,6 +72,7 @@ components:
|
||||
required:
|
||||
- title
|
||||
- dateTime
|
||||
- timezone
|
||||
- expiryDate
|
||||
properties:
|
||||
title:
|
||||
@@ -58,6 +87,10 @@ components:
|
||||
format: date-time
|
||||
description: Event date and time with UTC offset (ISO 8601)
|
||||
example: "2026-03-15T20:00:00+01:00"
|
||||
timezone:
|
||||
type: string
|
||||
description: IANA timezone of the organizer
|
||||
example: "Europe/Berlin"
|
||||
location:
|
||||
type: string
|
||||
maxLength: 500
|
||||
@@ -74,6 +107,7 @@ components:
|
||||
- organizerToken
|
||||
- title
|
||||
- dateTime
|
||||
- timezone
|
||||
- expiryDate
|
||||
properties:
|
||||
eventToken:
|
||||
@@ -93,11 +127,61 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2026-03-15T20:00:00+01:00"
|
||||
timezone:
|
||||
type: string
|
||||
description: IANA timezone of the organizer
|
||||
example: "Europe/Berlin"
|
||||
expiryDate:
|
||||
type: string
|
||||
format: date
|
||||
example: "2026-06-15"
|
||||
|
||||
GetEventResponse:
|
||||
type: object
|
||||
required:
|
||||
- eventToken
|
||||
- title
|
||||
- dateTime
|
||||
- timezone
|
||||
- attendeeCount
|
||||
- expired
|
||||
properties:
|
||||
eventToken:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Public event token
|
||||
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||
title:
|
||||
type: string
|
||||
description: Event title
|
||||
example: "Summer BBQ"
|
||||
description:
|
||||
type: string
|
||||
description: Event description (absent if not set)
|
||||
example: "Bring your own drinks!"
|
||||
dateTime:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Event date/time with organizer's UTC offset
|
||||
example: "2026-03-15T20:00:00+01:00"
|
||||
timezone:
|
||||
type: string
|
||||
description: IANA timezone name of the organizer
|
||||
example: "Europe/Berlin"
|
||||
location:
|
||||
type: string
|
||||
description: Event location (absent if not set)
|
||||
example: "Central Park, NYC"
|
||||
attendeeCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Number of confirmed attendees (attending=true)
|
||||
example: 12
|
||||
expired:
|
||||
type: boolean
|
||||
description: Whether the event's expiry date has passed
|
||||
example: false
|
||||
|
||||
ProblemDetail:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
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.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.fete.TestcontainersConfig;
|
||||
import de.fete.adapter.in.web.model.CreateEventRequest;
|
||||
import de.fete.adapter.in.web.model.CreateEventResponse;
|
||||
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;
|
||||
@@ -23,63 +33,89 @@ class EventControllerIntegrationTest {
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private EventJpaRepository jpaRepository;
|
||||
|
||||
// --- Create Event tests ---
|
||||
|
||||
@Test
|
||||
void createEventWithValidBody() throws Exception {
|
||||
String body =
|
||||
"""
|
||||
{
|
||||
"title": "Birthday Party",
|
||||
"description": "Come celebrate!",
|
||||
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||
"location": "Berlin",
|
||||
"expiryDate": "%s"
|
||||
}
|
||||
""".formatted(LocalDate.now().plusDays(30));
|
||||
var request = new CreateEventRequest()
|
||||
.title("Birthday Party")
|
||||
.description("Come celebrate!")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.location("Berlin")
|
||||
.expiryDate(LocalDate.now().plusDays(30));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
var result = mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.eventToken").isNotEmpty())
|
||||
.andExpect(jsonPath("$.organizerToken").isNotEmpty())
|
||||
.andExpect(jsonPath("$.title").value("Birthday Party"))
|
||||
.andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
|
||||
.andExpect(jsonPath("$.dateTime").isNotEmpty())
|
||||
.andExpect(jsonPath("$.expiryDate").isNotEmpty());
|
||||
.andExpect(jsonPath("$.expiryDate").isNotEmpty())
|
||||
.andReturn();
|
||||
|
||||
var response = objectMapper.readValue(
|
||||
result.getResponse().getContentAsString(), CreateEventResponse.class);
|
||||
|
||||
EventJpaEntity persisted = jpaRepository
|
||||
.findByEventToken(response.getEventToken()).orElseThrow();
|
||||
assertThat(persisted.getTitle()).isEqualTo("Birthday Party");
|
||||
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.getDateTime().toInstant())
|
||||
.isEqualTo(request.getDateTime().toInstant());
|
||||
assertThat(persisted.getOrganizerToken()).isNotNull();
|
||||
assertThat(persisted.getCreatedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventWithOptionalFieldsNull() throws Exception {
|
||||
String body =
|
||||
"""
|
||||
{
|
||||
"title": "Minimal Event",
|
||||
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||
"expiryDate": "%s"
|
||||
}
|
||||
""".formatted(LocalDate.now().plusDays(30));
|
||||
var request = new CreateEventRequest()
|
||||
.title("Minimal Event")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("UTC")
|
||||
.expiryDate(LocalDate.now().plusDays(30));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
var result = mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.eventToken").isNotEmpty())
|
||||
.andExpect(jsonPath("$.organizerToken").isNotEmpty())
|
||||
.andExpect(jsonPath("$.title").value("Minimal Event"));
|
||||
.andExpect(jsonPath("$.title").value("Minimal Event"))
|
||||
.andReturn();
|
||||
|
||||
var response = objectMapper.readValue(
|
||||
result.getResponse().getContentAsString(), CreateEventResponse.class);
|
||||
|
||||
EventJpaEntity persisted = jpaRepository
|
||||
.findByEventToken(response.getEventToken()).orElseThrow();
|
||||
assertThat(persisted.getTitle()).isEqualTo("Minimal Event");
|
||||
assertThat(persisted.getDescription()).isNull();
|
||||
assertThat(persisted.getLocation()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventMissingTitleReturns400() throws Exception {
|
||||
String body =
|
||||
"""
|
||||
{
|
||||
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||
"expiryDate": "%s"
|
||||
}
|
||||
""".formatted(LocalDate.now().plusDays(30));
|
||||
var request = new CreateEventRequest()
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.now().plusDays(30));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.title").value("Validation Failed"))
|
||||
@@ -88,17 +124,14 @@ class EventControllerIntegrationTest {
|
||||
|
||||
@Test
|
||||
void createEventMissingDateTimeReturns400() throws Exception {
|
||||
String body =
|
||||
"""
|
||||
{
|
||||
"title": "No Date",
|
||||
"expiryDate": "%s"
|
||||
}
|
||||
""".formatted(LocalDate.now().plusDays(30));
|
||||
var request = new CreateEventRequest()
|
||||
.title("No Date")
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.now().plusDays(30));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||
@@ -106,17 +139,14 @@ class EventControllerIntegrationTest {
|
||||
|
||||
@Test
|
||||
void createEventMissingExpiryDateReturns400() throws Exception {
|
||||
String body =
|
||||
"""
|
||||
{
|
||||
"title": "No Expiry",
|
||||
"dateTime": "2026-06-15T20:00:00+02:00"
|
||||
}
|
||||
""";
|
||||
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")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||
@@ -124,18 +154,15 @@ class EventControllerIntegrationTest {
|
||||
|
||||
@Test
|
||||
void createEventExpiryDateInPastReturns400() throws Exception {
|
||||
String body =
|
||||
"""
|
||||
{
|
||||
"title": "Past Expiry",
|
||||
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||
"expiryDate": "2025-01-01"
|
||||
}
|
||||
""";
|
||||
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(body))
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
||||
@@ -143,18 +170,15 @@ class EventControllerIntegrationTest {
|
||||
|
||||
@Test
|
||||
void createEventExpiryDateTodayReturns400() throws Exception {
|
||||
String body =
|
||||
"""
|
||||
{
|
||||
"title": "Today Expiry",
|
||||
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||
"expiryDate": "%s"
|
||||
}
|
||||
""".formatted(LocalDate.now());
|
||||
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(body))
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
||||
@@ -162,19 +186,101 @@ class EventControllerIntegrationTest {
|
||||
|
||||
@Test
|
||||
void errorResponseContentTypeIsProblemJson() throws Exception {
|
||||
String body =
|
||||
"""
|
||||
{
|
||||
"title": "",
|
||||
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||
"expiryDate": "%s"
|
||||
}
|
||||
""".formatted(LocalDate.now().plusDays(30));
|
||||
var request = new CreateEventRequest()
|
||||
.title("")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.now().plusDays(30));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventWithInvalidTimezoneReturns400() throws Exception {
|
||||
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.now().plusDays(30));
|
||||
|
||||
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:invalid-timezone"));
|
||||
}
|
||||
|
||||
// --- GET /events/{token} tests ---
|
||||
|
||||
@Test
|
||||
void getEventReturnsFullResponse() throws Exception {
|
||||
EventJpaEntity entity = seedEvent(
|
||||
"Summer BBQ", "Bring drinks!", "Europe/Berlin",
|
||||
"Central Park", LocalDate.now().plusDays(30));
|
||||
|
||||
mockMvc.perform(get("/api/events/" + entity.getEventToken()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.eventToken").value(entity.getEventToken().toString()))
|
||||
.andExpect(jsonPath("$.title").value("Summer BBQ"))
|
||||
.andExpect(jsonPath("$.description").value("Bring drinks!"))
|
||||
.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());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getEventWithOptionalFieldsAbsent() throws Exception {
|
||||
EventJpaEntity entity = seedEvent(
|
||||
"Minimal", null, "UTC", null, LocalDate.now().plusDays(30));
|
||||
|
||||
mockMvc.perform(get("/api/events/" + entity.getEventToken()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.title").value("Minimal"))
|
||||
.andExpect(jsonPath("$.description").doesNotExist())
|
||||
.andExpect(jsonPath("$.location").doesNotExist())
|
||||
.andExpect(jsonPath("$.attendeeCount").value(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getEventNotFoundReturns404() throws Exception {
|
||||
mockMvc.perform(get("/api/events/" + UUID.randomUUID()))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.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));
|
||||
}
|
||||
|
||||
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 jpaRepository.save(entity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import de.fete.domain.model.Event;
|
||||
import de.fete.domain.port.out.EventRepository;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -65,6 +66,7 @@ class EventPersistenceAdapterTest {
|
||||
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);
|
||||
@@ -77,6 +79,7 @@ class EventPersistenceAdapterTest {
|
||||
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());
|
||||
@@ -89,6 +92,7 @@ class EventPersistenceAdapterTest {
|
||||
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());
|
||||
|
||||
@@ -16,6 +16,8 @@ import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -50,6 +52,7 @@ class EventServiceTest {
|
||||
"Birthday Party",
|
||||
"Come celebrate!",
|
||||
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
|
||||
ZoneId.of("Europe/Berlin"),
|
||||
"Berlin",
|
||||
LocalDate.of(2026, 7, 15)
|
||||
);
|
||||
@@ -58,28 +61,13 @@ class EventServiceTest {
|
||||
|
||||
assertThat(result.getTitle()).isEqualTo("Birthday Party");
|
||||
assertThat(result.getDescription()).isEqualTo("Come celebrate!");
|
||||
assertThat(result.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin"));
|
||||
assertThat(result.getLocation()).isEqualTo("Berlin");
|
||||
assertThat(result.getEventToken()).isNotNull();
|
||||
assertThat(result.getOrganizerToken()).isNotNull();
|
||||
assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.now(FIXED_CLOCK));
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventTokenAndOrganizerTokenAreDifferent() {
|
||||
when(eventRepository.save(any(Event.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
||||
LocalDate.now(FIXED_CLOCK).plusDays(30)
|
||||
);
|
||||
|
||||
Event result = eventService.createEvent(command);
|
||||
|
||||
assertThat(result.getEventToken()).isNotEqualTo(result.getOrganizerToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void repositorySaveCalledExactlyOnce() {
|
||||
when(eventRepository.save(any(Event.class)))
|
||||
@@ -87,7 +75,7 @@ class EventServiceTest {
|
||||
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null,
|
||||
LocalDate.now(FIXED_CLOCK).plusDays(30)
|
||||
);
|
||||
|
||||
@@ -102,7 +90,7 @@ class EventServiceTest {
|
||||
void expiryDateTodayThrowsException() {
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null,
|
||||
LocalDate.now(FIXED_CLOCK)
|
||||
);
|
||||
|
||||
@@ -114,7 +102,7 @@ class EventServiceTest {
|
||||
void expiryDateInPastThrowsException() {
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null,
|
||||
LocalDate.now(FIXED_CLOCK).minusDays(5)
|
||||
);
|
||||
|
||||
@@ -129,7 +117,7 @@ class EventServiceTest {
|
||||
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null,
|
||||
LocalDate.now(FIXED_CLOCK).plusDays(1)
|
||||
);
|
||||
|
||||
@@ -137,4 +125,51 @@ class EventServiceTest {
|
||||
|
||||
assertThat(result.getExpiryDate()).isEqualTo(LocalDate.of(2026, 3, 6));
|
||||
}
|
||||
|
||||
// --- GetEventUseCase tests (T004) ---
|
||||
|
||||
@Test
|
||||
void getByEventTokenReturnsEvent() {
|
||||
UUID token = UUID.randomUUID();
|
||||
var event = new Event();
|
||||
event.setEventToken(token);
|
||||
event.setTitle("Found Event");
|
||||
when(eventRepository.findByEventToken(token))
|
||||
.thenReturn(Optional.of(event));
|
||||
|
||||
Optional<Event> result = eventService.getByEventToken(token);
|
||||
|
||||
assertThat(result).isPresent();
|
||||
assertThat(result.get().getTitle()).isEqualTo("Found Event");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getByEventTokenReturnsEmptyForUnknownToken() {
|
||||
UUID token = UUID.randomUUID();
|
||||
when(eventRepository.findByEventToken(token))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
Optional<Event> result = eventService.getByEventToken(token);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// --- Timezone validation tests (T006) ---
|
||||
|
||||
@Test
|
||||
void createEventWithValidTimezoneSucceeds() {
|
||||
when(eventRepository.save(any(Event.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1),
|
||||
ZoneId.of("America/New_York"), null,
|
||||
LocalDate.now(FIXED_CLOCK).plusDays(30)
|
||||
);
|
||||
|
||||
Event result = eventService.createEvent(command);
|
||||
|
||||
assertThat(result.getTimezone()).isEqualTo(ZoneId.of("America/New_York"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ test.describe('US-1: Create an event', () => {
|
||||
await expect(page.getByText('Expiry date is required.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('creates an event and redirects to stub page', async ({ page }) => {
|
||||
test('creates an event and redirects to event detail page', async ({ page }) => {
|
||||
await page.goto('/create')
|
||||
|
||||
await page.getByLabel(/title/i).fill('Summer BBQ')
|
||||
@@ -24,7 +24,6 @@ test.describe('US-1: Create an event', () => {
|
||||
await page.getByRole('button', { name: /create event/i }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/events\/.+/)
|
||||
await expect(page.getByText('Event created!')).toBeVisible()
|
||||
})
|
||||
|
||||
test('stores event data in localStorage after creation', async ({ page }) => {
|
||||
|
||||
127
frontend/e2e/event-view.spec.ts
Normal file
127
frontend/e2e/event-view.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { test, expect } from './msw-setup'
|
||||
|
||||
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,
|
||||
expired: false,
|
||||
}
|
||||
|
||||
test.describe('US-1: View event details', () => {
|
||||
test('displays all event fields for a valid event', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json(fullEvent)
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
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')).toBeVisible()
|
||||
await expect(page.getByText('Europe/Berlin')).toBeVisible()
|
||||
await expect(page.getByText('2026')).toBeVisible()
|
||||
})
|
||||
|
||||
test('does not load external resources', async ({ page, network }) => {
|
||||
const externalRequests: string[] = []
|
||||
page.on('request', (req) => {
|
||||
const url = new URL(req.url())
|
||||
if (!['localhost', '127.0.0.1'].includes(url.hostname)) {
|
||||
externalRequests.push(req.url())
|
||||
}
|
||||
})
|
||||
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json(fullEvent)
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||
|
||||
expect(externalRequests).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
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(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json(
|
||||
{ type: 'urn:problem-type:event-not-found', title: 'Event Not Found', status: 404, detail: 'Event not found.' },
|
||||
{ status: 404, headers: { 'Content-Type': 'application/problem+json' } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto('/events/00000000-0000-0000-0000-000000000000')
|
||||
|
||||
await expect(page.getByText('Event not found.')).toBeVisible()
|
||||
// No event data visible
|
||||
await expect(page.locator('.detail__title')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Server error', () => {
|
||||
test('shows error message and retry button on 500', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json(
|
||||
{ type: 'about:blank', title: 'Internal Server Error', status: 500, detail: 'An unexpected error occurred.' },
|
||||
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
await expect(page.getByText('Something went wrong.')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('retry button re-fetches the event', async ({ page, network }) => {
|
||||
let callCount = 0
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return HttpResponse.json(
|
||||
{ type: 'about:blank', title: 'Error', status: 500 },
|
||||
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } },
|
||||
)
|
||||
}
|
||||
return HttpResponse.json(fullEvent)
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
await expect(page.getByText('Something went wrong.')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Retry' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
178
frontend/package-lock.json
generated
178
frontend/package-lock.json
generated
@@ -23,17 +23,17 @@
|
||||
"@vitest/eslint-plugin": "^1.6.9",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.50.0",
|
||||
"eslint-plugin-oxlint": "~1.51.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.50.0",
|
||||
"oxlint": "~1.51.0",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
@@ -1727,9 +1727,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oxlint/binding-android-arm-eabi": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.50.0.tgz",
|
||||
"integrity": "sha512-G7MRGk/6NCe+L8ntonRdZP7IkBfEpiZ/he3buLK6JkLgMHgJShXZ+BeOwADmspXez7U7F7L1Anf4xLSkLHiGTg==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.51.0.tgz",
|
||||
"integrity": "sha512-jJYIqbx4sX+suIxWstc4P7SzhEwb4ArWA2KVrmEuu9vH2i0qM6QIHz/ehmbGE4/2fZbpuMuBzTl7UkfNoqiSgw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1744,9 +1744,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-android-arm64": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.50.0.tgz",
|
||||
"integrity": "sha512-GeSuMoJWCVpovJi/e3xDSNgjeR8WEZ6MCXL6EtPiCIM2NTzv7LbflARINTXTJy2oFBYyvdf/l2PwHzYo6EdXvg==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.51.0.tgz",
|
||||
"integrity": "sha512-GtXyBCcH4ti98YdiMNCrpBNGitx87EjEWxevnyhcBK12k/Vu4EzSB45rzSC4fGFUD6sQgeaxItRCEEWeVwPafw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1761,9 +1761,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-darwin-arm64": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.50.0.tgz",
|
||||
"integrity": "sha512-w3SY5YtxGnxCHPJ8Twl3KmS9oja1gERYk3AMoZ7Hv8P43ZtB6HVfs02TxvarxfL214Tm3uzvc2vn+DhtUNeKnw==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.51.0.tgz",
|
||||
"integrity": "sha512-3QJbeYaMHn6Bh2XeBXuITSsbnIctyTjvHf5nRjKYrT9pPeErNIpp5VDEeAXC0CZSwSVTsc8WOSDwgrAI24JolQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1778,9 +1778,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-darwin-x64": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.50.0.tgz",
|
||||
"integrity": "sha512-hNfogDqy7tvmllXKBSlHo6k5x7dhTUVOHbMSE15CCAcXzmqf5883aPvBYPOq9AE7DpDUQUZ1kVE22YbiGW+tuw==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.51.0.tgz",
|
||||
"integrity": "sha512-NzErhMaTEN1cY0E8C5APy74lw5VwsNfJfVPBMWPVQLqAbO0k4FFLjvHURvkUL+Y18Wu+8Vs1kbqPh2hjXYA4pg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1795,9 +1795,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-freebsd-x64": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.50.0.tgz",
|
||||
"integrity": "sha512-ykZevOWEyu0nsxolA911ucxpEv0ahw8jfEeGWOwwb/VPoE4xoexuTOAiPNlWZNJqANlJl7yp8OyzCtXTUAxotw==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.51.0.tgz",
|
||||
"integrity": "sha512-msAIh3vPAoKoHlOE/oe6Q5C/n9umypv/k81lED82ibrJotn+3YG2Qp1kiR8o/Dg5iOEU97c6tl0utxcyFenpFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1812,9 +1812,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.50.0.tgz",
|
||||
"integrity": "sha512-hif3iDk7vo5GGJ4OLCCZAf2vjnU9FztGw4L0MbQL0M2iY9LKFtDMMiQAHmkF0PQGQMVbTYtPdXCLKVgdkiqWXQ==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.51.0.tgz",
|
||||
"integrity": "sha512-CqQPcvqYyMe9ZBot2stjGogEzk1z8gGAngIX7srSzrzexmXixwVxBdFZyxTVM0CjGfDeV+Ru0w25/WNjlMM2Hw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1829,9 +1829,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.50.0.tgz",
|
||||
"integrity": "sha512-dVp9iSssiGAnTNey2Ruf6xUaQhdnvcFOJyRWd/mu5o2jVbFK15E5fbWGeFRfmuobu5QXuROtFga44+7DOS3PLg==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.51.0.tgz",
|
||||
"integrity": "sha512-dstrlYQgZMnyOssxSbolGCge/sDbko12N/35RBNuqLpoPbft2aeBidBAb0dvQlyBd9RJ6u8D4o4Eh8Un6iTgyQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1846,9 +1846,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm64-gnu": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.50.0.tgz",
|
||||
"integrity": "sha512-1cT7yz2HA910CKA9NkH1ZJo50vTtmND2fkoW1oyiSb0j6WvNtJ0Wx2zoySfXWc/c+7HFoqRK5AbEoL41LOn9oA==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.51.0.tgz",
|
||||
"integrity": "sha512-QEjUpXO7d35rP1/raLGGbAsBLLGZIzV3ZbeSjqWlD3oRnxpRIZ6iL4o51XQHkconn3uKssc+1VKdtHJ81BBhDA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1863,9 +1863,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm64-musl": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.50.0.tgz",
|
||||
"integrity": "sha512-++B3k/HEPFVlj89cOz8kWfQccMZB/aWL9AhsW7jPIkG++63Mpwb2cE9XOEsd0PATbIan78k2Gky+09uWM1d/gQ==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.51.0.tgz",
|
||||
"integrity": "sha512-YSJua5irtG4DoMAjUapDTPhkQLHhBIY0G9JqlZS6/SZPzqDkPku/1GdWs0D6h/wyx0Iz31lNCfIaWKBQhzP0wQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1880,9 +1880,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.50.0.tgz",
|
||||
"integrity": "sha512-Z9b/KpFMkx66w3gVBqjIC1AJBTZAGoI9+U+K5L4QM0CB/G0JSNC1es9b3Y0Vcrlvtdn8A+IQTkYjd/Q0uCSaZw==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.51.0.tgz",
|
||||
"integrity": "sha512-7L4Wj2IEUNDETKssB9IDYt16T6WlF+X2jgC/hBq3diGHda9vJLpAgb09+D3quFq7TdkFtI7hwz/jmuQmQFPc1Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1897,9 +1897,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.50.0.tgz",
|
||||
"integrity": "sha512-jvmuIw8wRSohsQlFNIST5uUwkEtEJmOQYr33bf/K2FrFPXHhM4KqGekI3ShYJemFS/gARVacQFgBzzJKCAyJjg==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.51.0.tgz",
|
||||
"integrity": "sha512-cBUHqtOXy76G41lOB401qpFoKx1xq17qYkhWrLSM7eEjiHM9sOtYqpr6ZdqCnN9s6ZpzudX4EkeHOFH2E9q0vA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1914,9 +1914,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-riscv64-musl": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.50.0.tgz",
|
||||
"integrity": "sha512-x+UrN47oYNh90nmAAyql8eQaaRpHbDPu5guasDg10+OpszUQ3/1+1J6zFMmV4xfIEgTcUXG/oI5fxJhF4eWCNA==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.51.0.tgz",
|
||||
"integrity": "sha512-WKbg8CysgZcHfZX0ixQFBRSBvFZUHa3SBnEjHY2FVYt2nbNJEjzTxA3ZR5wMU0NOCNKIAFUFvAh5/XJKPRJuJg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1931,9 +1931,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-s390x-gnu": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.50.0.tgz",
|
||||
"integrity": "sha512-i/JLi2ljLUIVfekMj4ISmdt+Hn11wzYUdRRrkVUYsCWw7zAy5xV7X9iA+KMyM156LTFympa7s3oKBjuCLoTAUQ==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.51.0.tgz",
|
||||
"integrity": "sha512-N1QRUvJTxqXNSu35YOufdjsAVmKVx5bkrggOWAhTWBc3J4qjcBwr1IfyLh/6YCg8sYRSR1GraldS9jUgJL/U4A==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1948,9 +1948,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-x64-gnu": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.50.0.tgz",
|
||||
"integrity": "sha512-/C7brhn6c6UUPccgSPCcpLQXcp+xKIW/3sji/5VZ8/OItL3tQ2U7KalHz887UxxSQeEOmd1kY6lrpuwFnmNqOA==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.51.0.tgz",
|
||||
"integrity": "sha512-e0Mz0DizsCoqNIjeOg6OUKe8JKJWZ5zZlwsd05Bmr51Jo3AOL4UJnPvwKumr4BBtBrDZkCmOLhCvDGm95nJM2g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1965,9 +1965,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-x64-musl": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.50.0.tgz",
|
||||
"integrity": "sha512-oDR1f+bGOYU8LfgtEW8XtotWGB63ghtcxk5Jm6IDTCk++rTA/IRMsjOid2iMd+1bW+nP9Mdsmcdc7VbPD3+iyQ==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.51.0.tgz",
|
||||
"integrity": "sha512-wD8HGTWhYBKXvRDvoBVB1y+fEYV01samhWQSy1Zkxq2vpezvMnjaFKRuiP6tBNITLGuffbNDEXOwcAhJ3gI5Ug==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1982,9 +1982,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-openharmony-arm64": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.50.0.tgz",
|
||||
"integrity": "sha512-4CmRGPp5UpvXyu4jjP9Tey/SrXDQLRvZXm4pb4vdZBxAzbFZkCyh0KyRy4txld/kZKTJlW4TO8N1JKrNEk+mWw==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.51.0.tgz",
|
||||
"integrity": "sha512-5NSwQ2hDEJ0GPXqikjWtwzgAQCsS7P9aLMNenjjKa+gknN3lTCwwwERsT6lKXSirfU3jLjexA2XQvQALh5h27w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1999,9 +1999,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-arm64-msvc": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.50.0.tgz",
|
||||
"integrity": "sha512-Fq0M6vsGcFsSfeuWAACDhd5KJrO85ckbEfe1EGuBj+KPyJz7KeWte2fSFrFGmNKNXyhEMyx4tbgxiWRujBM2KQ==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.51.0.tgz",
|
||||
"integrity": "sha512-JEZyah1M0RHMw8d+jjSSJmSmO8sABA1J1RtrHYujGPeCkYg1NeH0TGuClpe2h5QtioRTaF57y/TZfn/2IFV6fA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2016,9 +2016,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-ia32-msvc": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.50.0.tgz",
|
||||
"integrity": "sha512-qTdWR9KwY/vxJGhHVIZG2eBOhidOQvOwzDxnX+jhW/zIVacal1nAhR8GLkiywW8BIFDkQKXo/zOfT+/DY+ns/w==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.51.0.tgz",
|
||||
"integrity": "sha512-q3cEoKH6kwjz/WRyHwSf0nlD2F5Qw536kCXvmlSu+kaShzgrA0ojmh45CA81qL+7udfCaZL2SdKCZlLiGBVFlg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -2033,9 +2033,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-x64-msvc": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.50.0.tgz",
|
||||
"integrity": "sha512-682t7npLC4G2Ca+iNlI9fhAKTcFPYYXJjwoa88H4q+u5HHHlsnL/gHULapX3iqp+A8FIJbgdylL5KMYo2LaluQ==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.51.0.tgz",
|
||||
"integrity": "sha512-Q14+fOGb9T28nWF/0EUsYqERiRA7cl1oy4TJrGmLaqhm+aO2cV+JttboHI3CbdeMCAyDI1+NoSlrM7Melhp/cw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3423,9 +3423,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/tsconfig": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.8.1.tgz",
|
||||
"integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==",
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.9.0.tgz",
|
||||
"integrity": "sha512-RP+v9Cpbsk1ZVXltCHHkYBr7+624x6gcijJXVjIcsYk7JXqvIpRtMwU2ARLvWDhmy9ffdFYxhsfJnPztADBohQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
@@ -4376,9 +4376,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-oxlint": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-oxlint/-/eslint-plugin-oxlint-1.50.0.tgz",
|
||||
"integrity": "sha512-QAxeFeUHuekmLkuRLdzHH8Z0JvC7482OaQ3jlUMdEd0gcS6m+MYHei3Favoew9DdvTQT7yHxrm7BL0iXoenb6w==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-oxlint/-/eslint-plugin-oxlint-1.51.0.tgz",
|
||||
"integrity": "sha512-lct8LD1AxfHF1PcsuK6mFYals+zX0mx/WP2G4i16h0iR8jpT3xCfGTmTNwXiImcevzGIiJ/VDBgQ7t0B9z2Jeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5776,9 +5776,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oxlint": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.50.0.tgz",
|
||||
"integrity": "sha512-iSJ4IZEICBma8cZX7kxIIz9PzsYLF2FaLAYN6RKu7VwRVKdu7RIgpP99bTZaGl//Yao7fsaGZLSEo5xBrI5ReQ==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.51.0.tgz",
|
||||
"integrity": "sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -5791,28 +5791,28 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oxlint/binding-android-arm-eabi": "1.50.0",
|
||||
"@oxlint/binding-android-arm64": "1.50.0",
|
||||
"@oxlint/binding-darwin-arm64": "1.50.0",
|
||||
"@oxlint/binding-darwin-x64": "1.50.0",
|
||||
"@oxlint/binding-freebsd-x64": "1.50.0",
|
||||
"@oxlint/binding-linux-arm-gnueabihf": "1.50.0",
|
||||
"@oxlint/binding-linux-arm-musleabihf": "1.50.0",
|
||||
"@oxlint/binding-linux-arm64-gnu": "1.50.0",
|
||||
"@oxlint/binding-linux-arm64-musl": "1.50.0",
|
||||
"@oxlint/binding-linux-ppc64-gnu": "1.50.0",
|
||||
"@oxlint/binding-linux-riscv64-gnu": "1.50.0",
|
||||
"@oxlint/binding-linux-riscv64-musl": "1.50.0",
|
||||
"@oxlint/binding-linux-s390x-gnu": "1.50.0",
|
||||
"@oxlint/binding-linux-x64-gnu": "1.50.0",
|
||||
"@oxlint/binding-linux-x64-musl": "1.50.0",
|
||||
"@oxlint/binding-openharmony-arm64": "1.50.0",
|
||||
"@oxlint/binding-win32-arm64-msvc": "1.50.0",
|
||||
"@oxlint/binding-win32-ia32-msvc": "1.50.0",
|
||||
"@oxlint/binding-win32-x64-msvc": "1.50.0"
|
||||
"@oxlint/binding-android-arm-eabi": "1.51.0",
|
||||
"@oxlint/binding-android-arm64": "1.51.0",
|
||||
"@oxlint/binding-darwin-arm64": "1.51.0",
|
||||
"@oxlint/binding-darwin-x64": "1.51.0",
|
||||
"@oxlint/binding-freebsd-x64": "1.51.0",
|
||||
"@oxlint/binding-linux-arm-gnueabihf": "1.51.0",
|
||||
"@oxlint/binding-linux-arm-musleabihf": "1.51.0",
|
||||
"@oxlint/binding-linux-arm64-gnu": "1.51.0",
|
||||
"@oxlint/binding-linux-arm64-musl": "1.51.0",
|
||||
"@oxlint/binding-linux-ppc64-gnu": "1.51.0",
|
||||
"@oxlint/binding-linux-riscv64-gnu": "1.51.0",
|
||||
"@oxlint/binding-linux-riscv64-musl": "1.51.0",
|
||||
"@oxlint/binding-linux-s390x-gnu": "1.51.0",
|
||||
"@oxlint/binding-linux-x64-gnu": "1.51.0",
|
||||
"@oxlint/binding-linux-x64-musl": "1.51.0",
|
||||
"@oxlint/binding-openharmony-arm64": "1.51.0",
|
||||
"@oxlint/binding-win32-arm64-msvc": "1.51.0",
|
||||
"@oxlint/binding-win32-ia32-msvc": "1.51.0",
|
||||
"@oxlint/binding-win32-x64-msvc": "1.51.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"oxlint-tsgolint": ">=0.14.1"
|
||||
"oxlint-tsgolint": ">=0.15.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"oxlint-tsgolint": {
|
||||
|
||||
@@ -35,17 +35,17 @@
|
||||
"@vitest/eslint-plugin": "^1.6.9",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.50.0",
|
||||
"eslint-plugin-oxlint": "~1.51.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.50.0",
|
||||
"oxlint": "~1.51.0",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
|
||||
@@ -163,6 +163,19 @@ textarea.form-field {
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Skeleton shimmer loading state */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--color-card) 25%, #e0e0e0 50%, var(--color-card) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
|
||||
@@ -17,7 +17,7 @@ const router = createRouter({
|
||||
{
|
||||
path: '/events/:token',
|
||||
name: 'event',
|
||||
component: () => import('../views/EventStubView.vue'),
|
||||
component: () => import('../views/EventDetailView.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -184,6 +184,7 @@ async function handleSubmit() {
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
dateTime: dateTimeWithOffset,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
location: form.location.trim() || undefined,
|
||||
expiryDate: form.expiryDate,
|
||||
},
|
||||
|
||||
214
frontend/src/views/EventDetailView.vue
Normal file
214
frontend/src/views/EventDetailView.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<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>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="state === 'loading'" class="detail__card" 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" />
|
||||
<div class="skeleton skeleton--line" />
|
||||
</div>
|
||||
|
||||
<!-- Loaded state -->
|
||||
<div v-else-if="state === 'loaded' && event" class="detail__card">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div v-if="event.description" class="detail__field">
|
||||
<dt class="detail__label">Description</dt>
|
||||
<dd class="detail__value">{{ event.description }}</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>
|
||||
</dl>
|
||||
|
||||
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
|
||||
This event has ended.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Not found state -->
|
||||
<div v-else-if="state === 'not-found'" class="detail__card detail__card--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">
|
||||
<p class="detail__message">Something went wrong.</p>
|
||||
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { api } from '@/api/client'
|
||||
import type { components } from '@/api/schema'
|
||||
|
||||
type GetEventResponse = components['schemas']['GetEventResponse']
|
||||
type State = 'loading' | 'loaded' | 'not-found' | 'error'
|
||||
|
||||
const route = useRoute()
|
||||
const state = ref<State>('loading')
|
||||
const event = ref<GetEventResponse | null>(null)
|
||||
|
||||
const formattedDateTime = computed(() => {
|
||||
if (!event.value) return ''
|
||||
const formatted = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(event.value.dateTime))
|
||||
return `${formatted} (${event.value.timezone})`
|
||||
})
|
||||
|
||||
async function fetchEvent() {
|
||||
state.value = 'loading'
|
||||
event.value = null
|
||||
|
||||
try {
|
||||
const { data, error, response } = await api.GET('/events/{token}', {
|
||||
params: { path: { token: route.params.token as string } },
|
||||
})
|
||||
|
||||
if (error) {
|
||||
state.value = response.status === 404 ? 'not-found' : 'error'
|
||||
return
|
||||
}
|
||||
|
||||
event.value = data!
|
||||
state.value = 'loaded'
|
||||
} catch {
|
||||
state.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchEvent)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2xl);
|
||||
padding-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.detail__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.detail__back {
|
||||
color: var(--color-text-on-gradient);
|
||||
font-size: 1.5rem;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.detail__brand {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-on-gradient);
|
||||
}
|
||||
|
||||
.detail__card {
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--spacing-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.detail__card--center {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail__title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail__fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.detail__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.detail__label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.detail__value {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail__banner {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-card);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail__banner--expired {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.detail__message {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Skeleton sizes */
|
||||
.skeleton--title {
|
||||
height: 1.6rem;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.skeleton--line {
|
||||
height: 1rem;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.skeleton--short {
|
||||
width: 40%;
|
||||
}
|
||||
</style>
|
||||
@@ -173,6 +173,7 @@ describe('EventCreateView', () => {
|
||||
organizerToken: 'org-456',
|
||||
title: 'Birthday Party',
|
||||
dateTime: '2026-12-25T18:00:00+01:00',
|
||||
timezone: 'Europe/Berlin',
|
||||
expiryDate: '2026-12-24',
|
||||
},
|
||||
error: undefined,
|
||||
|
||||
198
frontend/src/views/__tests__/EventDetailView.spec.ts
Normal file
198
frontend/src/views/__tests__/EventDetailView.spec.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import EventDetailView from '../EventDetailView.vue'
|
||||
import { api } from '@/api/client'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
api: {
|
||||
GET: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function createTestRouter(_token?: string) {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: { template: '<div />' } },
|
||||
{ path: '/events/:token', name: 'event', component: EventDetailView },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function mountWithToken(token = 'test-token') {
|
||||
const router = createTestRouter(token)
|
||||
await router.push(`/events/${token}`)
|
||||
await router.isReady()
|
||||
return mount(EventDetailView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
}
|
||||
|
||||
const fullEvent = {
|
||||
eventToken: 'abc-123',
|
||||
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,
|
||||
expired: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('EventDetailView', () => {
|
||||
// T014: Loading state
|
||||
it('renders skeleton shimmer placeholders while loading', async () => {
|
||||
vi.mocked(api.GET).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
|
||||
expect(wrapper.findAll('.skeleton').length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
// T013: Loaded state — all fields
|
||||
it('renders all event fields when loaded', async () => {
|
||||
vi.mocked(api.GET).mockResolvedValue({
|
||||
data: fullEvent,
|
||||
error: undefined,
|
||||
response: new Response(null, { status: 200 }),
|
||||
} as never)
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.detail__title').text()).toBe('Summer BBQ')
|
||||
expect(wrapper.text()).toContain('Bring your own drinks!')
|
||||
expect(wrapper.text()).toContain('Central Park, NYC')
|
||||
expect(wrapper.text()).toContain('12')
|
||||
expect(wrapper.text()).toContain('Europe/Berlin')
|
||||
})
|
||||
|
||||
// T013: Loaded state — locale-formatted date/time
|
||||
it('formats date/time with Intl.DateTimeFormat and timezone', async () => {
|
||||
vi.mocked(api.GET).mockResolvedValue({
|
||||
data: fullEvent,
|
||||
error: undefined,
|
||||
response: new Response(null, { status: 200 }),
|
||||
} as never)
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const dateField = wrapper.findAll('.detail__value')[0]!
|
||||
expect(dateField.text()).toContain('(Europe/Berlin)')
|
||||
// The formatted date part is locale-dependent but should contain the year
|
||||
expect(dateField.text()).toContain('2026')
|
||||
})
|
||||
|
||||
// T013: Loaded state — optional fields absent
|
||||
it('does not render description and location when absent', async () => {
|
||||
vi.mocked(api.GET).mockResolvedValue({
|
||||
data: {
|
||||
...fullEvent,
|
||||
description: undefined,
|
||||
location: undefined,
|
||||
attendeeCount: 0,
|
||||
},
|
||||
error: undefined,
|
||||
response: new Response(null, { status: 200 }),
|
||||
} as never)
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Description')
|
||||
expect(wrapper.text()).not.toContain('Location')
|
||||
expect(wrapper.text()).toContain('0')
|
||||
})
|
||||
|
||||
// T020 (US2): Expired state
|
||||
it('renders "event has ended" banner when expired', async () => {
|
||||
vi.mocked(api.GET).mockResolvedValue({
|
||||
data: { ...fullEvent, expired: true },
|
||||
error: undefined,
|
||||
response: new Response(null, { status: 200 }),
|
||||
} as never)
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('This event has ended.')
|
||||
expect(wrapper.find('.detail__banner--expired').exists()).toBe(true)
|
||||
})
|
||||
|
||||
// T020 (US2): No expired banner when not expired
|
||||
it('does not render expired banner when event is active', async () => {
|
||||
vi.mocked(api.GET).mockResolvedValue({
|
||||
data: fullEvent,
|
||||
error: undefined,
|
||||
response: new Response(null, { status: 200 }),
|
||||
} as never)
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.detail__banner--expired').exists()).toBe(false)
|
||||
})
|
||||
|
||||
// T023 (US4): Not found state
|
||||
it('renders "event not found" when API returns 404', async () => {
|
||||
vi.mocked(api.GET).mockResolvedValue({
|
||||
data: undefined,
|
||||
error: { type: 'about:blank', title: 'Not Found', status: 404 },
|
||||
response: new Response(null, { status: 404 }),
|
||||
} as never)
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Event not found.')
|
||||
// No event data in DOM
|
||||
expect(wrapper.find('.detail__title').exists()).toBe(false)
|
||||
})
|
||||
|
||||
// T027: Server error + retry
|
||||
it('renders error state with retry button on server error', async () => {
|
||||
vi.mocked(api.GET).mockResolvedValue({
|
||||
data: undefined,
|
||||
error: { type: 'about:blank', title: 'Internal Server Error', status: 500 },
|
||||
response: new Response(null, { status: 500 }),
|
||||
} as never)
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Something went wrong.')
|
||||
expect(wrapper.find('button').text()).toBe('Retry')
|
||||
})
|
||||
|
||||
// T027: Retry button re-fetches
|
||||
it('retry button triggers a new fetch', async () => {
|
||||
vi.mocked(api.GET)
|
||||
.mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
error: { type: 'about:blank', title: 'Error', status: 500 },
|
||||
response: new Response(null, { status: 500 }),
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
data: fullEvent,
|
||||
error: undefined,
|
||||
response: new Response(null, { status: 200 }),
|
||||
} as never)
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Something went wrong.')
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.detail__title').text()).toBe('Summer BBQ')
|
||||
})
|
||||
})
|
||||
94
specs/007-view-event/contracts/get-event.yaml
Normal file
94
specs/007-view-event/contracts/get-event.yaml
Normal file
@@ -0,0 +1,94 @@
|
||||
# OpenAPI contract addition for GET /events/{token}
|
||||
# To be merged into backend/src/main/resources/openapi/api.yaml
|
||||
|
||||
paths:
|
||||
/events/{token}:
|
||||
get:
|
||||
operationId: getEvent
|
||||
summary: Get public event details by token
|
||||
tags:
|
||||
- events
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Public event token
|
||||
responses:
|
||||
"200":
|
||||
description: Event found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetEventResponse"
|
||||
"404":
|
||||
description: Event not found
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
|
||||
components:
|
||||
schemas:
|
||||
GetEventResponse:
|
||||
type: object
|
||||
required:
|
||||
- eventToken
|
||||
- title
|
||||
- dateTime
|
||||
- timezone
|
||||
- attendeeCount
|
||||
- expired
|
||||
properties:
|
||||
eventToken:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Public event token
|
||||
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||
title:
|
||||
type: string
|
||||
description: Event title
|
||||
example: "Summer BBQ"
|
||||
description:
|
||||
type: string
|
||||
description: Event description (absent if not set)
|
||||
example: "Bring your own drinks!"
|
||||
dateTime:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Event date/time with organizer's UTC offset
|
||||
example: "2026-03-15T20:00:00+01:00"
|
||||
timezone:
|
||||
type: string
|
||||
description: IANA timezone name of the organizer
|
||||
example: "Europe/Berlin"
|
||||
location:
|
||||
type: string
|
||||
description: Event location (absent if not set)
|
||||
example: "Central Park, NYC"
|
||||
attendeeCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Number of confirmed attendees (attending=true)
|
||||
example: 12
|
||||
expired:
|
||||
type: boolean
|
||||
description: Whether the event's expiry date has passed
|
||||
example: false
|
||||
|
||||
# Modification to existing CreateEventRequest — add timezone field
|
||||
# CreateEventRequest (additions):
|
||||
# timezone:
|
||||
# type: string
|
||||
# description: IANA timezone of the organizer
|
||||
# example: "Europe/Berlin"
|
||||
# (make required)
|
||||
|
||||
# Modification to existing CreateEventResponse — add timezone field
|
||||
# CreateEventResponse (additions):
|
||||
# timezone:
|
||||
# type: string
|
||||
# description: IANA timezone of the organizer
|
||||
# example: "Europe/Berlin"
|
||||
56
specs/007-view-event/data-model.md
Normal file
56
specs/007-view-event/data-model.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Data Model: View Event Landing Page (007)
|
||||
|
||||
**Date**: 2026-03-06
|
||||
|
||||
## Entities
|
||||
|
||||
### Event (modified — adds `timezone` field)
|
||||
|
||||
| Field | Type | Required | Constraints | Notes |
|
||||
|-----------------|------------------|----------|--------------------------|----------------------------------|
|
||||
| id | Long | yes | BIGSERIAL, PK | Internal only, never exposed |
|
||||
| eventToken | UUID | yes | UNIQUE, NOT NULL | Public identifier in URLs |
|
||||
| organizerToken | UUID | yes | UNIQUE, NOT NULL | Secret, never in public API |
|
||||
| title | String | yes | 1–200 chars | |
|
||||
| description | String | no | max 2000 chars | |
|
||||
| dateTime | OffsetDateTime | yes | | Organizer's original offset |
|
||||
| timezone | String | yes | IANA zone ID, max 64 | **NEW** — e.g. "Europe/Berlin" |
|
||||
| location | String | no | max 500 chars | |
|
||||
| expiryDate | LocalDate | yes | Must be future at create | Auto-deletion trigger |
|
||||
| createdAt | OffsetDateTime | yes | Server-generated | |
|
||||
|
||||
**Validation rules**:
|
||||
- `timezone` must be a valid IANA zone ID (`ZoneId.getAvailableZoneIds()`).
|
||||
- `expiryDate` must be in the future at creation time (existing rule).
|
||||
|
||||
**State transitions**:
|
||||
- Active → Expired: when `expiryDate < today` (computed, not stored).
|
||||
- Active → Cancelled: future (US-18), adds `cancelledAt` + `cancellationMessage`.
|
||||
|
||||
### RSVP (future — not created in this feature)
|
||||
|
||||
Documented here for context only. Created when the RSVP feature (US-8+) is implemented.
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
|------------|---------|----------|------------------------------|
|
||||
| id | Long | yes | BIGSERIAL, PK |
|
||||
| eventId | Long | yes | FK → events.id |
|
||||
| guestName | String | yes | 1–100 chars |
|
||||
| attending | Boolean | yes | true = attending |
|
||||
| createdAt | OffsetDateTime | yes | Server-generated |
|
||||
|
||||
## Relationships
|
||||
|
||||
```
|
||||
Event 1 ←── * RSVP (future)
|
||||
```
|
||||
|
||||
## Type Mapping (full stack)
|
||||
|
||||
| Concept | Java | PostgreSQL | OpenAPI | TypeScript |
|
||||
|--------------|-------------------|---------------|---------------------|------------|
|
||||
| Event time | `OffsetDateTime` | `timestamptz` | `string` `date-time`| `string` |
|
||||
| Timezone | `String` | `varchar(64)` | `string` | `string` |
|
||||
| Expiry date | `LocalDate` | `date` | `string` `date` | `string` |
|
||||
| Token | `UUID` | `uuid` | `string` `uuid` | `string` |
|
||||
| Count | `int` | `integer` | `integer` | `number` |
|
||||
89
specs/007-view-event/plan.md
Normal file
89
specs/007-view-event/plan.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Implementation Plan: View Event Landing Page
|
||||
|
||||
**Branch**: `007-view-event` | **Date**: 2026-03-06 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/007-view-event/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add a public event detail page at `/events/:token` that displays event information (title, date/time with IANA timezone, description, location, attendee count) without requiring authentication. The page handles four states: loaded, expired ("event has ended"), not found (404), and server error (retry button). Loading uses skeleton-shimmer placeholders. Backend adds `GET /events/{token}` endpoint and a `timezone` field to the Event model (cross-cutting change to US-1).
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Java 25 (backend), TypeScript 5.9 (frontend)
|
||||
**Primary Dependencies**: Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
|
||||
**Storage**: PostgreSQL (JPA via Spring Data, Liquibase migrations)
|
||||
**Testing**: JUnit (backend), Vitest (frontend unit), Playwright + MSW (frontend E2E)
|
||||
**Target Platform**: Self-hosted web application (Docker)
|
||||
**Project Type**: Web service + SPA
|
||||
**Performance Goals**: N/A (single-user scale, self-hosted)
|
||||
**Constraints**: No external resources (CDNs, fonts, tracking), WCAG AA, privacy-first
|
||||
**Scale/Scope**: Single new view + one new API endpoint + one cross-cutting model change
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Privacy by Design | PASS | No PII exposed. Only attendee count shown (not names). No external resources. No tracking. |
|
||||
| II. Test-Driven Methodology | PASS | TDD enforced: backend unit tests, frontend unit tests, E2E tests per spec. |
|
||||
| III. API-First Development | PASS | OpenAPI spec updated first. Types generated. Response schemas include `example:` fields. |
|
||||
| IV. Simplicity & Quality | PASS | Minimal changes: one GET endpoint, one new view, one model field. `attendeeCount` returns 0 (no RSVP stub). Cancelled state deferred. |
|
||||
| V. Dependency Discipline | PASS | No new dependencies. Skeleton shimmer is CSS-only. |
|
||||
| VI. Accessibility | PASS | Semantic HTML, ARIA attributes, keyboard navigable, WCAG AA contrast via design system. |
|
||||
|
||||
**Post-Phase-1 re-check**: All gates still pass. The `timezone` field addition is a justified cross-cutting change documented in research.md R-1.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/007-view-event/
|
||||
├── plan.md # This file
|
||||
├── spec.md # Feature specification
|
||||
├── research.md # Phase 0: research decisions
|
||||
├── data-model.md # Phase 1: entity definitions
|
||||
├── quickstart.md # Phase 1: implementation overview
|
||||
├── contracts/
|
||||
│ └── get-event.yaml # Phase 1: GET endpoint contract
|
||||
└── tasks.md # Phase 2: implementation tasks (via /speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
backend/
|
||||
├── src/main/java/de/fete/
|
||||
│ ├── domain/
|
||||
│ │ ├── model/Event.java # Add timezone field
|
||||
│ │ └── port/in/GetEventUseCase.java # NEW: inbound port
|
||||
│ ├── application/service/EventService.java # Implement GetEventUseCase
|
||||
│ ├── adapter/
|
||||
│ │ ├── in/web/EventController.java # Implement getEvent()
|
||||
│ │ └── out/persistence/
|
||||
│ │ ├── EventJpaEntity.java # Add timezone column
|
||||
│ │ └── EventPersistenceAdapter.java # Map timezone field
|
||||
│ └── config/
|
||||
├── src/main/resources/
|
||||
│ ├── openapi/api.yaml # Add GET endpoint + timezone
|
||||
│ └── db/changelog/ # Liquibase: add timezone column
|
||||
└── src/test/java/de/fete/ # Unit + integration tests
|
||||
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── api/schema.d.ts # Regenerated from OpenAPI
|
||||
│ ├── views/EventDetailView.vue # NEW: event detail page
|
||||
│ ├── views/EventCreateView.vue # Add timezone to create request
|
||||
│ ├── router/index.ts # Point /events/:token to EventDetailView
|
||||
│ └── assets/main.css # Skeleton shimmer styles
|
||||
├── e2e/
|
||||
│ └── event-view.spec.ts # NEW: E2E tests for view event
|
||||
└── src/__tests__/ # Unit tests for EventDetailView
|
||||
```
|
||||
|
||||
**Structure Decision**: Existing web application structure (backend + frontend). No new packages or modules — extends existing hexagonal architecture with one new inbound port and one new frontend view.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations. No entries needed.
|
||||
39
specs/007-view-event/quickstart.md
Normal file
39
specs/007-view-event/quickstart.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Quickstart: View Event Landing Page (007)
|
||||
|
||||
## What this feature does
|
||||
|
||||
Adds a public event detail page at `/events/:token`. Guests open a shared link and see:
|
||||
- Event title, date/time (with IANA timezone), description, location
|
||||
- Count of confirmed attendees (no names)
|
||||
- "Event has ended" state for expired events
|
||||
- "Event not found" for invalid tokens
|
||||
- Skeleton shimmer while loading
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- US-1 (Create Event) is implemented — Event entity, JPA persistence, POST endpoint exist.
|
||||
- No RSVP model yet — attendee count returns 0 until RSVP feature is built.
|
||||
|
||||
## Key changes
|
||||
|
||||
### Backend
|
||||
|
||||
1. **OpenAPI**: Add `GET /events/{token}` endpoint + `GetEventResponse` schema. Add `timezone` field to `CreateEventRequest`, `CreateEventResponse`, and `GetEventResponse`.
|
||||
2. **Domain**: Add `timezone` (String) to `Event.java`.
|
||||
3. **Persistence**: Add `timezone` column to `EventJpaEntity`, Liquibase migration.
|
||||
4. **Use case**: New `GetEventUseCase` (inbound port) + implementation in `EventService`.
|
||||
5. **Controller**: `EventController` implements `getEvent()` — maps to `GetEventResponse`, computes `expired` and `attendeeCount`.
|
||||
|
||||
### Frontend
|
||||
|
||||
1. **API types**: Regenerate `schema.d.ts` from updated OpenAPI spec.
|
||||
2. **EventDetailView.vue**: New view component — fetches event by token, renders detail card.
|
||||
3. **Router**: Replace `EventStubView` import at `/events/:token` with `EventDetailView`.
|
||||
4. **States**: Loading (skeleton shimmer), loaded, expired, not-found, server-error (retry button).
|
||||
5. **Create form**: Send `timezone` field (auto-detected via `Intl.DateTimeFormat`).
|
||||
|
||||
### Testing
|
||||
|
||||
- Backend: Unit tests for `GetEventUseCase`, controller tests for GET endpoint (200, 404).
|
||||
- Frontend: Unit tests for EventDetailView (all states).
|
||||
- E2E: Playwright tests with MSW mocks for all states (loaded, expired, not-found, error).
|
||||
100
specs/007-view-event/research.md
Normal file
100
specs/007-view-event/research.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Research: View Event Landing Page (007)
|
||||
|
||||
**Date**: 2026-03-06 | **Status**: Complete
|
||||
|
||||
## R-1: Timezone Field (Cross-Cutting)
|
||||
|
||||
**Decision**: Add `timezone` String field (IANA zone ID) to Event entity, JPA entity, and OpenAPI schemas (both Create and Get).
|
||||
|
||||
**Rationale**: The spec requires displaying the IANA timezone name (e.g. "Europe/Berlin") alongside the event time. `OffsetDateTime` preserves the offset (e.g. `+01:00`) but loses the IANA zone name. Since Europe/Berlin and Africa/Lagos both use `+01:00`, the zone name must be stored separately.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Store `ZonedDateTime` instead of `OffsetDateTime` — rejected because `OffsetDateTime` is already the established type in the stack (see `datetime-best-practices.md`), and `ZonedDateTime` serialization is non-standard in JSON/OpenAPI.
|
||||
- Derive timezone from offset — rejected because offset-to-zone mapping is ambiguous.
|
||||
|
||||
**Impact on US-1 (Create Event)**:
|
||||
- `CreateEventRequest` gains a required `timezone` field (string, IANA zone ID).
|
||||
- `CreateEventResponse` gains a `timezone` field.
|
||||
- Frontend auto-detects via `Intl.DateTimeFormat().resolvedOptions().timeZone`.
|
||||
- Backend validates against `java.time.ZoneId.getAvailableZoneIds()`.
|
||||
- JPA: new `VARCHAR(64)` column `timezone` on `events` table.
|
||||
- Liquibase changeset: add `timezone` column. Existing events without timezone get `UTC` as default (pre-launch, destructive migration acceptable).
|
||||
|
||||
## R-2: GET Endpoint Design
|
||||
|
||||
**Decision**: `GET /api/events/{token}` returns public event data. Uses the existing hexagonal architecture pattern.
|
||||
|
||||
**Rationale**: Follows the established pattern from `POST /events`. The event token is the public identifier — no auth required.
|
||||
|
||||
**Flow**:
|
||||
1. `EventController` implements generated `EventsApi.getEvent()`.
|
||||
2. New inbound port: `GetEventUseCase` with `getByEventToken(UUID): Optional<Event>`.
|
||||
3. `EventService` implements the use case, delegates to `EventRepository.findByEventToken()` (already exists).
|
||||
4. Controller maps domain `Event` to `GetEventResponse` DTO.
|
||||
5. 404 returns `ProblemDetail` (RFC 9457) — no event data leaked.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Separate `/event/{token}` path (singular) — rejected because OpenAPI groups by resource; `/events/{token}` is RESTful convention.
|
||||
- Note: Frontend route is `/event/:token` (spec clarification), but API path is `/api/events/{token}`. These are independent.
|
||||
|
||||
## R-3: Attendee Count Without RSVP Model
|
||||
|
||||
**Decision**: Include `attendeeCount` (integer) in the `GetEventResponse`. Return `0` until the RSVP feature (US-8+) is implemented.
|
||||
|
||||
**Rationale**: FR-001 requires attendee count display. The API contract should be stable from the start — consumers should not need to change when RSVP is added later. Returning `0` is correct (no RSVPs exist yet).
|
||||
|
||||
**Future hook**: When RSVP is implemented, `EventService` or a dedicated query will `COUNT(*) WHERE event_id = ? AND status = 'ATTENDING'`.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Omit `attendeeCount` until RSVP exists — rejected because it would require API consumers to handle the field's absence, then handle its presence later. Breaking change.
|
||||
- Add a stub RSVP table now — rejected (YAGNI, violates Principle IV).
|
||||
|
||||
## R-4: Expired Event Detection
|
||||
|
||||
**Decision**: Server-side. The `GetEventResponse` includes a boolean `expired` field, computed by comparing `expiryDate` with the server's current date.
|
||||
|
||||
**Rationale**: Server is the source of truth for time. Client clocks may be wrong. The frontend uses this flag to toggle the "event has ended" state.
|
||||
|
||||
**Computation**: `event.getExpiryDate().isBefore(LocalDate.now(clock))` — uses the injected `Clock` bean (already exists for testability in `EventService`).
|
||||
|
||||
**Alternatives considered**:
|
||||
- Client-side comparison — rejected because client clock may differ from server, leading to inconsistent behavior.
|
||||
- Separate endpoint for status — rejected (over-engineering).
|
||||
|
||||
## R-5: URL Pattern
|
||||
|
||||
**Decision**: Frontend route stays at `/events/:token` (plural). API path is `/api/events/{token}`. Both use the plural RESTful convention consistently.
|
||||
|
||||
**Rationale**: `/events/:token` is the standard REST resource pattern (collection + identifier). The existing router already uses this path. Consistency between frontend route and API resource name reduces cognitive overhead.
|
||||
|
||||
**Impact**: No route change needed — the existing `/events/:token` route in the router is correct.
|
||||
|
||||
## R-6: Skeleton Shimmer Loading State
|
||||
|
||||
**Decision**: CSS-only shimmer animation using a gradient sweep. No additional dependencies.
|
||||
|
||||
**Rationale**: The spec requires skeleton-shimmer placeholders during API loading. A CSS-only approach is lightweight and matches the dependency discipline principle.
|
||||
|
||||
**Implementation pattern**:
|
||||
```css
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--color-card) 25%, #e0e0e0 50%, var(--color-card) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
```
|
||||
|
||||
Skeleton blocks match the approximate shape/size of the real content fields (title, date, location, etc.).
|
||||
|
||||
## R-7: Cancelled Event State (Deferred)
|
||||
|
||||
**Decision**: The `GetEventResponse` does NOT include cancellation fields yet. US-3 (view cancelled event) is explicitly deferred until US-18 (cancel event) is implemented.
|
||||
|
||||
**Rationale**: Spec says "[Deferred until US-18 is implemented]". Adding unused fields violates Principle IV (KISS).
|
||||
|
||||
**Future hook**: When US-18 lands, add `cancelled: boolean` and `cancellationMessage: string` to the response schema.
|
||||
@@ -9,18 +9,18 @@
|
||||
|
||||
### User Story 1 - View event details as guest (Priority: P1)
|
||||
|
||||
A guest receives a shared event link, opens it, and sees all relevant event information: title, description (if provided), date and time, location (if provided), and the list of confirmed attendees with a count.
|
||||
A guest receives a shared event link, opens it, and sees all relevant event information: title, description (if provided), date and time, location (if provided), and the count of confirmed attendees.
|
||||
|
||||
**Why this priority**: Core value of the feature — without this, no other part of the event page is meaningful.
|
||||
|
||||
**Independent Test**: Can be fully tested by navigating to a valid event URL and verifying all event fields are displayed correctly, including attendee list and count.
|
||||
**Independent Test**: Can be fully tested by navigating to a valid event URL and verifying all event fields are displayed correctly, including attendee count.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a valid event link, **When** a guest opens the URL, **Then** the page displays the event title, date and time, and attendee count.
|
||||
2. **Given** a valid event link for an event with optional fields set, **When** a guest opens the URL, **Then** the description and location are also displayed.
|
||||
3. **Given** a valid event link for an event with optional fields absent, **When** a guest opens the URL, **Then** only the required fields are shown — no placeholder text for missing optional fields.
|
||||
4. **Given** a valid event with RSVPs, **When** a guest opens the event page, **Then** the names of all confirmed attendees ("attending") are listed and a total count is shown.
|
||||
4. **Given** a valid event with RSVPs, **When** a guest opens the event page, **Then** only the total count of confirmed attendees is shown — individual names are NOT displayed to guests (names are only visible to the organizer via the organizer view).
|
||||
5. **Given** an event page, **When** it is rendered, **Then** no external resources (CDNs, fonts, tracking scripts) are loaded — all assets are served from the app's own domain.
|
||||
6. **Given** a guest with no account, **When** they open the event URL, **Then** the page loads without any login, account, or access code required.
|
||||
|
||||
@@ -70,9 +70,9 @@ A guest navigates to an event URL that no longer resolves — the event was dele
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the event has no attendees yet? — Attendee list is empty; count shows 0.
|
||||
- What happens when the event has no attendees yet? — Count shows 0.
|
||||
- What happens when the event has been cancelled after US-18 is implemented? — Renders cancelled state with optional message; RSVP hidden. [Deferred]
|
||||
- What happens when the server is temporarily unavailable? — [NEEDS EXPANSION]
|
||||
- What happens when the server is temporarily unavailable? — The page displays a generic, friendly error message with a manual "Retry" button. No automatic retry.
|
||||
- How does the page behave when JavaScript is disabled? — Per Q-3 resolution: the app is a SPA; JavaScript-dependent rendering is acceptable.
|
||||
|
||||
## Requirements
|
||||
@@ -81,7 +81,7 @@ A guest navigates to an event URL that no longer resolves — the event was dele
|
||||
|
||||
- **FR-001**: The event page MUST display: title, date and time, and attendee count for any valid event.
|
||||
- **FR-002**: The event page MUST display description and location when those optional fields are set on the event.
|
||||
- **FR-003**: The event page MUST list the names of all confirmed attendees (those who RSVPed "attending").
|
||||
- **FR-003**: The public event page MUST display only the count of confirmed attendees. Individual attendee names MUST NOT be shown to guests — names are only visible to the organizer (organizer view, separate user story).
|
||||
- **FR-004**: If the event's expiry date has passed, the page MUST render a clear "this event has ended" state and MUST NOT show any RSVP actions.
|
||||
- **FR-005**: If the event has been cancelled (US-18), the page MUST display a "cancelled" state with the cancellation message (if provided) and MUST NOT show any RSVP actions. [Deferred until US-18 is implemented]
|
||||
- **FR-006**: If the event token does not match any event on the server, the page MUST display a clear "event not found" message — no partial data or error traces.
|
||||
@@ -90,15 +90,29 @@ A guest navigates to an event URL that no longer resolves — the event was dele
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Event**: Has a public event token (UUID in URL), title, optional description, date/time, optional location, expiry date, and optionally a cancelled state with message.
|
||||
- **RSVP**: Has a guest name and attending status; confirmed attendees (status = attending) are listed on the public event page.
|
||||
- **Event**: Has a public event token (UUID in URL), title, optional description, date/time (OffsetDateTime — displayed in the organizer's original timezone, no conversion to viewer timezone), IANA timezone name (e.g. `Europe/Berlin`, stored as separate field — required for human-readable timezone display), optional location, expiry date (LocalDate), and optionally a cancelled state with message. See `.specify/memory/research/datetime-best-practices.md` for full stack type mapping.
|
||||
- **Note**: The IANA timezone requires a new `timezone` field on the Event entity and API schema. This impacts US-1 (Create Event) — the frontend must send the organizer's IANA zone ID alongside the OffsetDateTime.
|
||||
- **RSVP**: Has a guest name and binary attending status (attending / not attending — no "maybe"). Only the count of confirmed attendees (status = attending) is exposed on the public event page. Individual names are visible only in the organizer view, sorted alphabetically by name.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: A guest who opens a valid event URL can see all set event fields (title, date/time, and any optional fields) without logging in.
|
||||
- **SC-002**: The attendee list and count reflect all current server-side RSVPs with attending status.
|
||||
- **SC-002**: The attendee count reflects all current server-side RSVPs with attending status. No individual names are exposed on the public event page.
|
||||
- **SC-003**: An expired event URL renders the "ended" state — RSVP controls are absent from the DOM, not merely hidden via CSS.
|
||||
- **SC-004**: An unknown event token URL renders a "not found" message — no event data, no server error details.
|
||||
- **SC-005**: No network requests to external domains are made when loading the event page.
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-03-06
|
||||
|
||||
- Q: What should the event page display when the server is temporarily unavailable? → A: Generic friendly error state with a manual "Retry" button; no automatic retry.
|
||||
- Q: How should date/time be displayed regarding timezones? → A: Organizer timezone preserved — display the time exactly as entered by the organizer (OffsetDateTime), no conversion to viewer's local timezone. The IANA timezone name (e.g. "Europe/Berlin") MUST be displayed alongside the time. Requires a new `timezone` field on Event entity/API (impacts US-1).
|
||||
- Q: What is the URL pattern for event pages? → A: `/events/:token` (e.g. `/events/a1b2c3d4-...`). Plural, matching the RESTful API resource name.
|
||||
- Q: Should guest names be visible to other guests on the public event page? → A: No. Only the attendee count is shown to guests. Individual names are exclusively visible to the organizer, sorted alphabetically.
|
||||
- Q: How should the loading state look while the API call is in progress? → A: Skeleton-shimmer (placeholder blocks in field shape that shimmer until data arrives).
|
||||
- Q: Should the event page include OpenGraph meta tags for link previews? → A: Out of scope for US-007. Separate user story — generic app-branding OG-tags only, no event data exposed to crawlers. Noted in `.specify/memory/ideen.md`.
|
||||
- Q: Should date/time formatting adapt to the viewer's browser locale? → A: Yes, browser-locale-based via `Intl.DateTimeFormat` (e.g. DE: "15. März 2026, 20:00" / EN: "March 15, 2026, 8:00 PM").
|
||||
- Q: Is RSVP status binary or are there more states (e.g. "maybe")? → A: Binary — attending or not attending. No "maybe" status. Count reflects only confirmed attendees.
|
||||
|
||||
225
specs/007-view-event/tasks.md
Normal file
225
specs/007-view-event/tasks.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Tasks: View Event Landing Page
|
||||
|
||||
**Input**: Design documents from `/specs/007-view-event/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/get-event.yaml
|
||||
|
||||
**Tests**: Included — constitution enforces Test-Driven Methodology (Principle II).
|
||||
|
||||
**Organization**: Tasks grouped by user story. US3 (cancelled event) is deferred until US-18.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US4)
|
||||
- Exact file paths included in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Cross-Cutting Schema Changes)
|
||||
|
||||
**Purpose**: OpenAPI contract update, database migration, and type generation — prerequisites for all backend and frontend work.
|
||||
|
||||
- [x] T001 Update OpenAPI spec: add `GET /events/{token}` endpoint, `GetEventResponse` schema, and `timezone` field to `CreateEventRequest`/`CreateEventResponse` in `backend/src/main/resources/openapi/api.yaml`
|
||||
- [x] T002 [P] Add Liquibase changeset: `timezone VARCHAR(64) NOT NULL DEFAULT 'UTC'` column on `events` table in `backend/src/main/resources/db/changelog/`
|
||||
- [x] T003 Regenerate frontend TypeScript types from updated OpenAPI spec in `frontend/src/api/schema.d.ts`
|
||||
|
||||
**Checkpoint**: OpenAPI contract finalized, DB schema ready, frontend types available.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Backend — Blocks All User Stories)
|
||||
|
||||
**Purpose**: Domain model update, new GET use case, controller endpoint, and backend tests. All user stories depend on this.
|
||||
|
||||
**CRITICAL**: No frontend user story work can begin until this phase is complete.
|
||||
|
||||
### Backend Tests (TDD — write first, verify they fail)
|
||||
|
||||
- [x] T004 [P] Backend unit tests for `GetEventUseCase`: test getByEventToken returns event, returns empty for unknown token, computes expired flag — in `backend/src/test/java/de/fete/`
|
||||
- [x] T005 [P] Backend controller tests for `GET /events/{token}`: test 200 with full response, 200 with optional fields absent, 404 with ProblemDetail — in `backend/src/test/java/de/fete/`
|
||||
- [x] T006 [P] Backend tests for timezone in Create Event flow: request validation (valid/invalid IANA zone), persistence round-trip — in `backend/src/test/java/de/fete/`
|
||||
|
||||
### Backend Implementation
|
||||
|
||||
- [x] T007 Add `timezone` field (String) to domain model in `backend/src/main/java/de/fete/domain/model/Event.java`
|
||||
- [x] T008 [P] Add `timezone` column to JPA entity and update persistence mapping in `backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java` and `EventPersistenceAdapter.java`
|
||||
- [x] T009 [P] Update Create Event flow to accept and validate `timezone` (must be valid IANA zone ID via `ZoneId.getAvailableZoneIds()`) in `backend/src/main/java/de/fete/application/service/EventService.java` and `EventController.java`
|
||||
- [x] T010 Create `GetEventUseCase` inbound port with `getByEventToken(UUID): Optional<Event>` in `backend/src/main/java/de/fete/domain/port/in/GetEventUseCase.java`
|
||||
- [x] T011 Implement `GetEventUseCase` in `backend/src/main/java/de/fete/application/service/EventService.java` — delegates to existing `findByEventToken()` repository method
|
||||
- [x] T012 Implement `getEvent()` in `backend/src/main/java/de/fete/adapter/in/web/EventController.java` — maps domain Event to GetEventResponse, computes `expired` (expiryDate vs server clock) and `attendeeCount` (hardcoded 0)
|
||||
|
||||
**Checkpoint**: Backend complete — `GET /api/events/{token}` returns 200 or 404. All backend tests pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — View Event Details as Guest (Priority: P1) MVP
|
||||
|
||||
**Goal**: A guest opens a shared event link and sees all event information: title, date/time with IANA timezone, description, location, attendee count. Loading shows skeleton shimmer.
|
||||
|
||||
**Independent Test**: Navigate to a valid event URL, verify all fields display correctly with locale-formatted date/time.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T013 [P] [US1] Unit tests for EventDetailView loaded state: renders title, date/time (locale-formatted via `Intl.DateTimeFormat`), timezone, description, location, attendee count — in `frontend/src/__tests__/EventDetailView.spec.ts`
|
||||
- [x] T014 [P] [US1] Unit test for EventDetailView loading state: renders skeleton shimmer placeholders — in `frontend/src/__tests__/EventDetailView.spec.ts`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T015 [P] [US1] Add skeleton shimmer CSS (CSS-only gradient animation, no dependencies) in `frontend/src/assets/main.css`
|
||||
- [x] T016 [US1] Create `EventDetailView.vue` with loading (skeleton shimmer) and loaded states — fetches event via `openapi-fetch` GET `/events/{token}`, formats date/time with `Intl.DateTimeFormat` using browser locale — in `frontend/src/views/EventDetailView.vue`
|
||||
- [x] T017 [US1] Update router to use `EventDetailView` for `/events/:token` route in `frontend/src/router/index.ts`
|
||||
- [x] T018 [P] [US1] Update `EventCreateView.vue` to send `timezone` field (auto-detected via `Intl.DateTimeFormat().resolvedOptions().timeZone`) in `frontend/src/views/EventCreateView.vue`
|
||||
- [x] T019 [US1] E2E test for loaded event: navigate to valid event URL, verify all fields displayed, verify no external resource requests — in `frontend/e2e/event-view.spec.ts`
|
||||
|
||||
**Checkpoint**: US1 complete — guest can view event details. Skeleton shimmer during loading. Date/time locale-formatted with timezone label.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — View Expired Event (Priority: P2)
|
||||
|
||||
**Goal**: A guest opens a link to an expired event. The page shows event details plus a clear "event has ended" indicator. No RSVP actions shown.
|
||||
|
||||
**Independent Test**: Create an event with past expiry date, navigate to its URL, verify "event has ended" state renders and no RSVP controls are present.
|
||||
|
||||
**Dependencies**: Requires Phase 3 (EventDetailView exists).
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T020 [P] [US2] Unit test for EventDetailView expired state: renders "event has ended" indicator, RSVP controls absent from DOM — in `frontend/src/__tests__/EventDetailView.spec.ts`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T021 [US2] Add expired state rendering to `EventDetailView.vue`: show event details + "event has ended" banner when `expired === true`, no RSVP actions in DOM — in `frontend/src/views/EventDetailView.vue`
|
||||
- [x] T022 [US2] E2E test for expired event: MSW returns event with `expired: true`, verify banner and absent RSVP controls — in `frontend/e2e/event-view.spec.ts`
|
||||
|
||||
**Checkpoint**: US2 complete — expired events clearly show "ended" state.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 4 — Event Not Found (Priority: P2)
|
||||
|
||||
**Goal**: A guest navigates to an invalid event URL. The page shows a clear "event not found" message — no partial data, no error traces.
|
||||
|
||||
**Independent Test**: Navigate to a URL with an unknown event token, verify "event not found" message renders.
|
||||
|
||||
**Dependencies**: Requires Phase 3 (EventDetailView exists). No dependency on US2.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [x] T023 [P] [US4] Unit test for EventDetailView not-found state: renders "event not found" message, no event data in DOM — in `frontend/src/__tests__/EventDetailView.spec.ts`
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [x] T024 [US4] Add not-found state rendering to `EventDetailView.vue`: show "event not found" message when API returns 404 — in `frontend/src/views/EventDetailView.vue`
|
||||
- [x] T025 [US4] E2E test for event not found: MSW returns 404 ProblemDetail, verify message and no event data — in `frontend/e2e/event-view.spec.ts`
|
||||
|
||||
**Checkpoint**: US4 complete — invalid tokens show friendly not-found message.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Server error edge case, final validation, and cleanup.
|
||||
|
||||
- [x] T026 Add server error state with manual retry button to `EventDetailView.vue`: friendly error message + "Retry" button that re-fetches — in `frontend/src/views/EventDetailView.vue`
|
||||
- [x] T027 [P] Unit test for server error + retry state in `frontend/src/__tests__/EventDetailView.spec.ts`
|
||||
- [x] T028 [P] E2E test for server error: MSW returns 500, verify error message and retry button functionality — in `frontend/e2e/event-view.spec.ts`
|
||||
- [x] T029 Run quickstart.md validation: verify all key changes listed in quickstart.md are implemented
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies — start immediately
|
||||
- **Foundational (Phase 2)**: Depends on T001 (OpenAPI spec) and T002 (migration) from Setup
|
||||
- **US1 (Phase 3)**: Depends on Phase 2 completion (backend endpoint must exist)
|
||||
- **US2 (Phase 4)**: Depends on Phase 3 (EventDetailView exists) — can parallelize with US4
|
||||
- **US4 (Phase 5)**: Depends on Phase 3 (EventDetailView exists) — can parallelize with US2
|
||||
- **Polish (Phase 6)**: Depends on Phase 3 minimum; ideally after US2 + US4
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
```
|
||||
Phase 1 (Setup) ──► Phase 2 (Backend) ──► Phase 3 (US1/MVP)
|
||||
│
|
||||
┌────┴────┐
|
||||
▼ ▼
|
||||
Phase 4 Phase 5
|
||||
(US2) (US4)
|
||||
└────┬────┘
|
||||
▼
|
||||
Phase 6 (Polish)
|
||||
```
|
||||
|
||||
- **US1 (P1)**: Requires Phase 2 — no dependency on other stories
|
||||
- **US2 (P2)**: Requires US1 (same component) — no dependency on US4
|
||||
- **US4 (P2)**: Requires US1 (same component) — no dependency on US2
|
||||
- **US3 (P2)**: DEFERRED until US-18 (cancel event) is implemented
|
||||
|
||||
### Within Each Phase
|
||||
|
||||
- Tests MUST be written and FAIL before implementation (TDD)
|
||||
- Models/ports before services
|
||||
- Services before controllers
|
||||
- Backend before frontend (for the same endpoint)
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
**Phase 1**: T002 (migration) can run in parallel with T001 (OpenAPI update)
|
||||
**Phase 2**: T004, T005, T006 (tests) can run in parallel. T008, T009 can run in parallel after T007.
|
||||
**Phase 3**: T013, T014 (unit tests) and T015 (CSS) can run in parallel. T018 (create form timezone) is independent.
|
||||
**Phase 4 + 5**: US2 and US4 can be implemented in parallel (different UI states, same file but non-conflicting sections).
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: Phase 2 (Backend)
|
||||
|
||||
```bash
|
||||
# Write all backend tests in parallel (TDD):
|
||||
Task T004: "Unit tests for GetEventUseCase"
|
||||
Task T005: "Controller tests for GET /events/{token}"
|
||||
Task T006: "Tests for timezone in Create Event flow"
|
||||
|
||||
# Then implement in parallel where possible:
|
||||
Task T008: "Add timezone to JPA entity + persistence" # parallel
|
||||
Task T009: "Update Create Event flow for timezone" # parallel
|
||||
# T010-T012 are sequential (port → service → controller)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup (OpenAPI + migration + types)
|
||||
2. Complete Phase 2: Backend (domain + use case + controller + tests)
|
||||
3. Complete Phase 3: US1 (EventDetailView + router + tests)
|
||||
4. **STOP and VALIDATE**: Guest can view event details via shared link
|
||||
5. Deploy/demo if ready
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Setup + Backend → Backend ready, API testable via curl
|
||||
2. Add US1 → Guest can view events (MVP!)
|
||||
3. Add US2 → Expired events show "ended" state
|
||||
4. Add US4 → Invalid tokens show "not found"
|
||||
5. Polish → Server error handling, final validation
|
||||
|
||||
### Deferred Work
|
||||
|
||||
- **US3 (Cancelled event)**: Blocked on US-18. No tasks generated. Will require adding `cancelled` + `cancellationMessage` to GetEventResponse and a new UI state.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies on incomplete tasks
|
||||
- [Story] label maps task to specific user story for traceability
|
||||
- `attendeeCount` returns 0 until RSVP feature (US-8+) is implemented (R-3)
|
||||
- `expired` is computed server-side using injected Clock bean (R-4)
|
||||
- Frontend route: `/events/:token` — API path: `/api/events/{token}` (R-5)
|
||||
- Skeleton shimmer is CSS-only, no additional dependencies (R-6)
|
||||
- Date/time formatted via `Intl.DateTimeFormat` with browser locale (spec clarification Q7)
|
||||
Reference in New Issue
Block a user