25 Commits

Author SHA1 Message Date
5f50ea991b Merge pull request 'Add public event detail page (007-view-event)' (#14) from 007-view-event into master
All checks were successful
CI / backend-test (push) Successful in 55s
CI / frontend-test (push) Successful in 21s
CI / frontend-e2e (push) Successful in 52s
CI / build-and-publish (push) Successful in 1m1s
Reviewed-on: #14
2026-03-06 22:57:04 +01:00
fd9175925e Use vue-tsc --build in frontend hook to match CI
All checks were successful
CI / backend-test (push) Successful in 57s
CI / frontend-test (push) Successful in 21s
CI / frontend-e2e (push) Successful in 52s
CI / build-and-publish (push) Has been skipped
The hook used --noEmit which is less strict than CI's --build,
causing type errors to slip through.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:54:41 +01:00
63108f4eb5 Fix TypeScript type errors in frontend test files
- Add missing timezone field to CreateEventResponse mock
- Fix createTestRouter signature to accept optional token parameter
- Add non-null assertion for dateField element access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:53:36 +01:00
cd71110514 Add test-results/ to gitignore
Some checks failed
CI / backend-test (push) Successful in 1m1s
CI / frontend-test (push) Failing after 17s
CI / frontend-e2e (push) Successful in 52s
CI / build-and-publish (push) Has been skipped
Playwright test artifacts should not be tracked.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:34:51 +01:00
76b48d8b61 Add EventDetailView with loading, expired, not-found, and error states
New view fetches event via openapi-fetch, formats date/time with
Intl.DateTimeFormat. Skeleton shimmer during loading (CSS-only).
Create form now sends auto-detected timezone.
Unit tests for all five view states, E2E tests with MSW mocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:34:51 +01:00
e5d0dd5f8f Implement GET /events/{token} backend with timezone support
Domain: add timezone field to Event and CreateEventCommand.
Ports: new GetEventUseCase inbound port.
Service: implement getByEventToken, validate IANA timezone on create.
Controller: map to GetEventResponse, compute expired flag via Clock.
Persistence: timezone column in JPA entity and mapping.
Tests: integration tests use DTOs + ObjectMapper instead of inline JSON,
GET tests seed DB directly via JpaRepository for isolation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:34:51 +01:00
e77e479e2a Add GET /events/{token} endpoint and timezone field to OpenAPI spec
OpenAPI: new GetEventResponse schema, timezone on Create request/response.
Liquibase: add timezone VARCHAR(64) NOT NULL DEFAULT 'UTC' column.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:34:51 +01:00
80d79c3596 Add design artifacts for view event feature (007)
Spec, research, data model, API contract, implementation plan, and
task breakdown for the public event detail page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:34:51 +01:00
7efe932621 Merge pull request 'Update actions/upload-artifact action to v7' (#13) from renovate/major-github-artifact-actions into master
All checks were successful
CI / backend-test (push) Successful in 56s
CI / frontend-test (push) Successful in 21s
CI / frontend-e2e (push) Successful in 50s
CI / build-and-publish (push) Has been skipped
Reviewed-on: #13
2026-03-06 22:29:15 +01:00
a56a26b1f0 Merge pull request 'Update actions/setup-node action to v6' (#12) from renovate/actions-setup-node-6.x into master
All checks were successful
CI / backend-test (push) Successful in 55s
CI / frontend-test (push) Successful in 22s
CI / frontend-e2e (push) Successful in 50s
CI / build-and-publish (push) Has been skipped
Reviewed-on: #12
2026-03-06 21:27:18 +01:00
906ba99b75 Merge pull request 'Update actions/setup-java action to v5' (#11) from renovate/actions-setup-java-5.x into master
Some checks failed
CI / backend-test (push) Has been cancelled
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
Reviewed-on: #11
2026-03-06 21:25:03 +01:00
da08752642 Merge pull request 'Update actions/checkout action to v6' (#10) from renovate/actions-checkout-6.x into master
Some checks failed
CI / backend-test (push) Has been cancelled
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
Reviewed-on: #10
2026-03-06 21:22:58 +01:00
014b3b0171 Merge pull request 'Update oxlint monorepo to ~1.51.0' (#6) from renovate/oxlint-monorepo into master
Some checks failed
CI / backend-test (push) Has been cancelled
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
Reviewed-on: #6
2026-03-06 21:20:53 +01:00
33aff5bff5 Merge pull request 'Update dependency @vue/tsconfig to ^0.9.0' (#5) from renovate/vue-tsconfig-0.x into master
Some checks failed
CI / backend-test (push) Has been cancelled
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
Reviewed-on: #5
2026-03-06 21:18:46 +01:00
6de0769d70 Merge pull request 'Update eclipse-temurin Docker tag' (#3) from renovate/eclipse-temurin-25.x into master
Some checks failed
CI / backend-test (push) Has been cancelled
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
Reviewed-on: #3
2026-03-06 21:16:41 +01:00
6a16255984 Merge pull request 'Update dependency org.codehaus.mojo:build-helper-maven-plugin to v3.6.1' (#2) from renovate/org.codehaus.mojo-build-helper-maven-plugin-3.x into master
Some checks failed
CI / backend-test (push) Has been cancelled
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
Reviewed-on: #2
2026-03-06 21:14:34 +01:00
2ce3ce0d05 Merge pull request 'Update dependency maven to v3.9.13' (#8) from renovate/maven-3.x into master
Some checks failed
CI / backend-test (push) Has been cancelled
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
Reviewed-on: #8
2026-03-06 21:12:25 +01:00
Renovate Bot
ca651d4c05 Update actions/upload-artifact action to v7
All checks were successful
CI / backend-test (push) Successful in 56s
CI / frontend-test (push) Successful in 21s
CI / frontend-e2e (push) Successful in 50s
CI / build-and-publish (push) Has been skipped
2026-03-06 20:07:53 +00:00
Renovate Bot
1e065bef18 Update actions/setup-node action to v6
All checks were successful
CI / backend-test (push) Successful in 1m1s
CI / frontend-test (push) Successful in 21s
CI / frontend-e2e (push) Successful in 53s
CI / build-and-publish (push) Has been skipped
2026-03-06 20:07:51 +00:00
Renovate Bot
6e655597d7 Update actions/setup-java action to v5
All checks were successful
CI / backend-test (push) Successful in 53s
CI / frontend-test (push) Successful in 20s
CI / frontend-e2e (push) Successful in 49s
CI / build-and-publish (push) Has been skipped
2026-03-06 20:07:48 +00:00
Renovate Bot
e10b88ee5f Update actions/checkout action to v6
All checks were successful
CI / backend-test (push) Successful in 54s
CI / frontend-test (push) Successful in 21s
CI / frontend-e2e (push) Successful in 49s
CI / build-and-publish (push) Has been skipped
2026-03-06 20:07:46 +00:00
Renovate Bot
465fc2178f Update oxlint monorepo to ~1.51.0
All checks were successful
CI / backend-test (push) Successful in 55s
CI / frontend-test (push) Successful in 21s
CI / frontend-e2e (push) Successful in 49s
CI / build-and-publish (push) Has been skipped
2026-03-06 20:07:38 +00:00
Renovate Bot
9e48debca7 Update dependency @vue/tsconfig to ^0.9.0
All checks were successful
CI / backend-test (push) Successful in 54s
CI / frontend-test (push) Successful in 21s
CI / frontend-e2e (push) Successful in 50s
CI / build-and-publish (push) Has been skipped
2026-03-06 20:07:33 +00:00
Renovate Bot
fc344d3ca0 Update eclipse-temurin Docker tag
All checks were successful
CI / backend-test (push) Successful in 55s
CI / frontend-test (push) Successful in 21s
CI / frontend-e2e (push) Successful in 50s
CI / build-and-publish (push) Has been skipped
2026-03-06 20:07:25 +00:00
Renovate Bot
e04a86399c Update dependency org.codehaus.mojo:build-helper-maven-plugin to v3.6.1
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 21s
CI / frontend-e2e (push) Successful in 51s
CI / build-and-publish (push) Has been skipped
2026-03-06 20:07:22 +00:00
41 changed files with 1793 additions and 217 deletions

View File

@@ -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"

View File

@@ -7,10 +7,10 @@ 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@v4
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 25
@@ -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
View File

@@ -14,6 +14,9 @@ Thumbs.db
.agent-tests/
.ralph/*/iteration-*.jsonl
# Test results (Playwright artifacts)
test-results/
# Java/Maven
*.class
*.jar

View File

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

View File

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

View File

@@ -49,3 +49,10 @@ The following skills are available and should be used for their respective purpo
- The loop runner is `ralph.sh`. Each run lives in its own directory under `.ralph/`.
- Run directories contain: `instructions.md` (prompt), `chief-wiggum.md` (directives), `answers.md` (human answers), `questions.md` (Ralph's questions), `progress.txt` (iteration log), `meta.md` (metadata), `run.log` (execution log).
- Project specifications (user stories, setup tasks, personas, etc.) live in `specs/` (feature dirs) and `.specify/memory/` (cross-cutting docs).
## Active Technologies
- 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;

View File

@@ -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());

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;

View File

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

View File

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

View File

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

View File

@@ -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:

View File

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

View File

@@ -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());

View File

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

View File

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

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

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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;

View File

@@ -17,7 +17,7 @@ const router = createRouter({
{
path: '/events/:token',
name: 'event',
component: () => import('../views/EventStubView.vue'),
component: () => import('../views/EventDetailView.vue'),
},
],
})

View File

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

View File

@@ -0,0 +1,214 @@
<template>
<main class="detail">
<header class="detail__header">
<RouterLink to="/" class="detail__back" aria-label="Back to home">&larr;</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 &amp; 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>

View File

@@ -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,

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

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

View 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 | 1200 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 | 1100 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` |

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

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

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

View File

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

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