Compare commits
9 Commits
0.4.0
...
152555714f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
152555714f | ||
| aa3ea04bfc | |||
|
|
27ca8ab4b8 | ||
| 752d153cd4 | |||
| 763811fce6 | |||
| d7ed28e036 | |||
| a52d0cd1d3 | |||
| 373f3671f6 | |||
| 8f78c6cd45 |
@@ -53,6 +53,8 @@ The following skills are available and should be used for their respective purpo
|
||||
## Active Technologies
|
||||
- Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript (007-view-event)
|
||||
- PostgreSQL (JPA via Spring Data, Liquibase migrations) (007-view-event)
|
||||
- TypeScript 5.9 (frontend only) + Vue 3, Vue Router 5 (existing — no additions) (010-event-list-grouping)
|
||||
- localStorage via `useEventStorage.ts` composable (existing — no changes) (010-event-list-grouping)
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
package de.fete.adapter.in.web;
|
||||
|
||||
import de.fete.adapter.in.web.api.EventsApi;
|
||||
import de.fete.adapter.in.web.model.Attendee;
|
||||
import de.fete.adapter.in.web.model.CreateEventRequest;
|
||||
import de.fete.adapter.in.web.model.CreateEventResponse;
|
||||
import de.fete.adapter.in.web.model.CreateRsvpRequest;
|
||||
import de.fete.adapter.in.web.model.CreateRsvpResponse;
|
||||
import de.fete.adapter.in.web.model.GetAttendeesResponse;
|
||||
import de.fete.adapter.in.web.model.GetEventResponse;
|
||||
import de.fete.application.service.EventNotFoundException;
|
||||
import de.fete.application.service.InvalidTimezoneException;
|
||||
import de.fete.domain.model.CreateEventCommand;
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.model.EventToken;
|
||||
import de.fete.domain.model.OrganizerToken;
|
||||
import de.fete.domain.model.Rsvp;
|
||||
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
|
||||
import de.fete.domain.port.in.CreateEventUseCase;
|
||||
import de.fete.domain.port.in.CreateRsvpUseCase;
|
||||
import de.fete.domain.port.in.GetAttendeesUseCase;
|
||||
import de.fete.domain.port.in.GetEventUseCase;
|
||||
import java.time.Clock;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -33,6 +38,7 @@ public class EventController implements EventsApi {
|
||||
private final GetEventUseCase getEventUseCase;
|
||||
private final CreateRsvpUseCase createRsvpUseCase;
|
||||
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
|
||||
private final GetAttendeesUseCase getAttendeesUseCase;
|
||||
private final Clock clock;
|
||||
|
||||
/** Creates a new controller with the given use cases and clock. */
|
||||
@@ -41,11 +47,13 @@ public class EventController implements EventsApi {
|
||||
GetEventUseCase getEventUseCase,
|
||||
CreateRsvpUseCase createRsvpUseCase,
|
||||
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
|
||||
GetAttendeesUseCase getAttendeesUseCase,
|
||||
Clock clock) {
|
||||
this.createEventUseCase = createEventUseCase;
|
||||
this.getEventUseCase = getEventUseCase;
|
||||
this.createRsvpUseCase = createRsvpUseCase;
|
||||
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
|
||||
this.getAttendeesUseCase = getAttendeesUseCase;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@@ -97,6 +105,25 @@ public class EventController implements EventsApi {
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<GetAttendeesResponse> getAttendees(
|
||||
UUID token, UUID organizerToken) {
|
||||
var eventToken = new EventToken(token);
|
||||
var orgToken = new OrganizerToken(organizerToken);
|
||||
|
||||
List<String> names = getAttendeesUseCase
|
||||
.getAttendeeNames(eventToken, orgToken);
|
||||
|
||||
var attendees = names.stream()
|
||||
.map(name -> new Attendee().name(name))
|
||||
.toList();
|
||||
|
||||
var response = new GetAttendeesResponse();
|
||||
response.setAttendees(attendees);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<CreateRsvpResponse> createRsvp(
|
||||
UUID token, CreateRsvpRequest createRsvpRequest) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import de.fete.application.service.EventExpiredException;
|
||||
import de.fete.application.service.EventNotFoundException;
|
||||
import de.fete.application.service.ExpiryDateBeforeEventException;
|
||||
import de.fete.application.service.ExpiryDateInPastException;
|
||||
import de.fete.application.service.InvalidOrganizerTokenException;
|
||||
import de.fete.application.service.InvalidTimezoneException;
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
@@ -87,6 +88,19 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||
.body(problemDetail);
|
||||
}
|
||||
|
||||
/** Handles invalid organizer token. */
|
||||
@ExceptionHandler(InvalidOrganizerTokenException.class)
|
||||
public ResponseEntity<ProblemDetail> handleInvalidOrganizerToken(
|
||||
InvalidOrganizerTokenException ex) {
|
||||
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.FORBIDDEN, ex.getMessage());
|
||||
problemDetail.setTitle("Forbidden");
|
||||
problemDetail.setType(URI.create("urn:problem-type:invalid-organizer-token"));
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||
.body(problemDetail);
|
||||
}
|
||||
|
||||
/** Handles event not found. */
|
||||
@ExceptionHandler(EventNotFoundException.class)
|
||||
public ResponseEntity<ProblemDetail> handleEventNotFound(
|
||||
|
||||
@@ -11,4 +11,7 @@ public interface RsvpJpaRepository extends JpaRepository<RsvpJpaEntity, Long> {
|
||||
|
||||
/** Counts RSVPs for the given event. */
|
||||
long countByEventId(Long eventId);
|
||||
|
||||
/** Finds all RSVPs for the given event, ordered by ID ascending. */
|
||||
java.util.List<RsvpJpaEntity> findAllByEventIdOrderByIdAsc(Long eventId);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.fete.adapter.out.persistence;
|
||||
import de.fete.domain.model.Rsvp;
|
||||
import de.fete.domain.model.RsvpToken;
|
||||
import de.fete.domain.port.out.RsvpRepository;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/** Persistence adapter implementing the RsvpRepository outbound port. */
|
||||
@@ -28,6 +29,13 @@ public class RsvpPersistenceAdapter implements RsvpRepository {
|
||||
return jpaRepository.countByEventId(eventId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Rsvp> findByEventId(Long eventId) {
|
||||
return jpaRepository.findAllByEventIdOrderByIdAsc(eventId).stream()
|
||||
.map(this::toDomain)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private RsvpJpaEntity toEntity(Rsvp rsvp) {
|
||||
var entity = new RsvpJpaEntity();
|
||||
entity.setId(rsvp.getId());
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.fete.application.service;
|
||||
|
||||
/** Thrown when an invalid organizer token is provided. */
|
||||
public class InvalidOrganizerTokenException extends RuntimeException {
|
||||
|
||||
/** Creates a new exception for an invalid organizer token. */
|
||||
public InvalidOrganizerTokenException() {
|
||||
super("Invalid organizer token.");
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,23 @@ package de.fete.application.service;
|
||||
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.model.EventToken;
|
||||
import de.fete.domain.model.OrganizerToken;
|
||||
import de.fete.domain.model.Rsvp;
|
||||
import de.fete.domain.model.RsvpToken;
|
||||
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
|
||||
import de.fete.domain.port.in.CreateRsvpUseCase;
|
||||
import de.fete.domain.port.in.GetAttendeesUseCase;
|
||||
import de.fete.domain.port.out.EventRepository;
|
||||
import de.fete.domain.port.out.RsvpRepository;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Application service implementing RSVP creation. */
|
||||
/** Application service implementing RSVP operations. */
|
||||
@Service
|
||||
public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseCase {
|
||||
public class RsvpService
|
||||
implements CreateRsvpUseCase, CountAttendeesByEventUseCase, GetAttendeesUseCase {
|
||||
|
||||
private final EventRepository eventRepository;
|
||||
private final RsvpRepository rsvpRepository;
|
||||
@@ -53,4 +57,18 @@ public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseC
|
||||
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||
return rsvpRepository.countByEventId(event.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken) {
|
||||
Event event = eventRepository.findByEventToken(eventToken)
|
||||
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||
|
||||
if (!event.getOrganizerToken().equals(organizerToken)) {
|
||||
throw new InvalidOrganizerTokenException();
|
||||
}
|
||||
|
||||
return rsvpRepository.findByEventId(event.getId()).stream()
|
||||
.map(Rsvp::getName)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.fete.domain.port.in;
|
||||
|
||||
import de.fete.domain.model.EventToken;
|
||||
import de.fete.domain.model.OrganizerToken;
|
||||
import java.util.List;
|
||||
|
||||
/** Inbound port for retrieving attendee names of an event. */
|
||||
public interface GetAttendeesUseCase {
|
||||
|
||||
/** Returns attendee names ordered by RSVP submission time. */
|
||||
List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.fete.domain.port.out;
|
||||
|
||||
import de.fete.domain.model.Rsvp;
|
||||
import java.util.List;
|
||||
|
||||
/** Outbound port for persisting and querying RSVPs. */
|
||||
public interface RsvpRepository {
|
||||
@@ -10,4 +11,7 @@ public interface RsvpRepository {
|
||||
|
||||
/** Counts the number of RSVPs for the given event. */
|
||||
long countByEventId(Long eventId);
|
||||
|
||||
/** Finds all RSVPs for the given event, ordered by ID ascending. */
|
||||
List<Rsvp> findByEventId(Long eventId);
|
||||
}
|
||||
|
||||
@@ -83,6 +83,47 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
|
||||
/events/{token}/attendees:
|
||||
get:
|
||||
operationId: getAttendees
|
||||
summary: Get attendee list for an event (organizer only)
|
||||
tags:
|
||||
- events
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Public event token
|
||||
- name: organizerToken
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Organizer token for authorization
|
||||
responses:
|
||||
"200":
|
||||
description: Attendee list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetAttendeesResponse"
|
||||
"403":
|
||||
description: Invalid organizer token
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
"404":
|
||||
description: Event not found
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
|
||||
/events/{token}:
|
||||
get:
|
||||
operationId: getEvent
|
||||
@@ -256,6 +297,30 @@ components:
|
||||
description: Guest's display name as stored
|
||||
example: "Max Mustermann"
|
||||
|
||||
GetAttendeesResponse:
|
||||
type: object
|
||||
required:
|
||||
- attendees
|
||||
properties:
|
||||
attendees:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Attendee"
|
||||
example:
|
||||
- name: "Alice"
|
||||
- name: "Bob"
|
||||
|
||||
Attendee:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 100
|
||||
example: "Alice"
|
||||
|
||||
ProblemDetail:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -439,6 +439,68 @@ class EventControllerIntegrationTest {
|
||||
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
// --- GET /events/{token}/attendees tests ---
|
||||
|
||||
@Test
|
||||
void getAttendeesReturnsNamesForOrganizer() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Party", null, "Europe/Berlin", null,
|
||||
LocalDate.now().plusDays(30));
|
||||
seedRsvp(event, "Alice");
|
||||
seedRsvp(event, "Bob");
|
||||
|
||||
mockMvc.perform(get("/api/events/" + event.getEventToken()
|
||||
+ "/attendees?organizerToken=" + event.getOrganizerToken()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.attendees").isArray())
|
||||
.andExpect(jsonPath("$.attendees.length()").value(2))
|
||||
.andExpect(jsonPath("$.attendees[0].name").value("Alice"))
|
||||
.andExpect(jsonPath("$.attendees[1].name").value("Bob"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAttendeesReturnsEmptyListWhenNoRsvps() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Empty Party", null, "Europe/Berlin", null,
|
||||
LocalDate.now().plusDays(30));
|
||||
|
||||
mockMvc.perform(get("/api/events/" + event.getEventToken()
|
||||
+ "/attendees?organizerToken=" + event.getOrganizerToken()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.attendees").isArray())
|
||||
.andExpect(jsonPath("$.attendees.length()").value(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAttendeesReturns403ForInvalidOrganizerToken() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Secret Party", null, "Europe/Berlin", null,
|
||||
LocalDate.now().plusDays(30));
|
||||
|
||||
mockMvc.perform(get("/api/events/" + event.getEventToken()
|
||||
+ "/attendees?organizerToken=" + UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(content().contentTypeCompatibleWith(
|
||||
"application/problem+json"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAttendeesReturns404ForUnknownEvent() throws Exception {
|
||||
mockMvc.perform(get("/api/events/" + UUID.randomUUID()
|
||||
+ "/attendees?organizerToken=" + UUID.randomUUID()))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(content().contentTypeCompatibleWith(
|
||||
"application/problem+json"));
|
||||
}
|
||||
|
||||
private void seedRsvp(EventJpaEntity event, String name) {
|
||||
var rsvp = new RsvpJpaEntity();
|
||||
rsvp.setRsvpToken(UUID.randomUUID());
|
||||
rsvp.setEventId(event.getId());
|
||||
rsvp.setName(name);
|
||||
rsvpJpaRepository.save(rsvp);
|
||||
}
|
||||
|
||||
private EventJpaEntity seedEvent(
|
||||
String title, String description, String timezone,
|
||||
String location, LocalDate expiryDate) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import de.fete.domain.model.Event;
|
||||
import de.fete.domain.model.EventToken;
|
||||
import de.fete.domain.model.OrganizerToken;
|
||||
import de.fete.domain.model.Rsvp;
|
||||
import de.fete.domain.model.RsvpToken;
|
||||
import de.fete.domain.port.out.EventRepository;
|
||||
import de.fete.domain.port.out.RsvpRepository;
|
||||
import java.time.Clock;
|
||||
@@ -18,6 +19,7 @@ import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -122,6 +124,73 @@ class RsvpServiceTest {
|
||||
.isInstanceOf(EventExpiredException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAttendeeNamesReturnsNamesInOrder() {
|
||||
Event event = buildActiveEvent();
|
||||
EventToken token = event.getEventToken();
|
||||
OrganizerToken orgToken = event.getOrganizerToken();
|
||||
when(eventRepository.findByEventToken(token))
|
||||
.thenReturn(Optional.of(event));
|
||||
when(rsvpRepository.findByEventId(event.getId()))
|
||||
.thenReturn(List.of(
|
||||
buildRsvp(1L, "Alice"),
|
||||
buildRsvp(2L, "Bob"),
|
||||
buildRsvp(3L, "Charlie")));
|
||||
|
||||
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
|
||||
|
||||
assertThat(names).containsExactly("Alice", "Bob", "Charlie");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAttendeeNamesReturnsEmptyListWhenNoRsvps() {
|
||||
Event event = buildActiveEvent();
|
||||
EventToken token = event.getEventToken();
|
||||
OrganizerToken orgToken = event.getOrganizerToken();
|
||||
when(eventRepository.findByEventToken(token))
|
||||
.thenReturn(Optional.of(event));
|
||||
when(rsvpRepository.findByEventId(event.getId()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
|
||||
|
||||
assertThat(names).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAttendeeNamesThrowsWhenEventNotFound() {
|
||||
EventToken token = EventToken.generate();
|
||||
OrganizerToken orgToken = OrganizerToken.generate();
|
||||
when(eventRepository.findByEventToken(token))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> rsvpService.getAttendeeNames(token, orgToken))
|
||||
.isInstanceOf(EventNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAttendeeNamesThrowsWhenOrganizerTokenInvalid() {
|
||||
Event event = buildActiveEvent();
|
||||
EventToken token = event.getEventToken();
|
||||
OrganizerToken wrongToken = OrganizerToken.generate();
|
||||
when(eventRepository.findByEventToken(token))
|
||||
.thenReturn(Optional.of(event));
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> rsvpService.getAttendeeNames(token, wrongToken))
|
||||
.isInstanceOf(InvalidOrganizerTokenException.class);
|
||||
}
|
||||
|
||||
private Rsvp buildRsvp(Long id, String name) {
|
||||
var rsvp = new Rsvp();
|
||||
rsvp.setId(id);
|
||||
rsvp.setRsvpToken(RsvpToken.generate());
|
||||
rsvp.setEventId(1L);
|
||||
rsvp.setName(name);
|
||||
return rsvp;
|
||||
}
|
||||
|
||||
private Event buildActiveEvent() {
|
||||
var event = new Event();
|
||||
event.setId(1L);
|
||||
|
||||
@@ -191,6 +191,133 @@ test.describe('FAB: Create Event Button', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Temporal Grouping: Section Headers', () => {
|
||||
test('events are distributed under correct section headers', async ({ page }) => {
|
||||
// Use dates relative to "now" to ensure correct section assignment
|
||||
const now = new Date()
|
||||
const todayEvent: StoredEvent = {
|
||||
eventToken: 'today-1',
|
||||
title: 'Today Standup',
|
||||
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0, 0).toISOString(),
|
||||
expiryDate: '',
|
||||
}
|
||||
const laterEvent: StoredEvent = {
|
||||
eventToken: 'later-1',
|
||||
title: 'Future Conference',
|
||||
dateTime: new Date(now.getFullYear() + 1, 0, 15, 10, 0, 0).toISOString(),
|
||||
expiryDate: '',
|
||||
}
|
||||
await page.addInitScript(seedEvents([todayEvent, laterEvent, pastEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
// Verify section headers appear
|
||||
await expect(page.getByRole('heading', { name: 'Today', level: 2 })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Later', level: 2 })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Past', level: 2 })).toBeVisible()
|
||||
|
||||
// Events are in the correct sections
|
||||
const sections = page.locator('.event-section')
|
||||
const todaySection = sections.filter({ has: page.getByRole('heading', { name: 'Today', level: 2 }) })
|
||||
await expect(todaySection.getByText('Today Standup')).toBeVisible()
|
||||
|
||||
const laterSection = sections.filter({ has: page.getByRole('heading', { name: 'Later', level: 2 }) })
|
||||
await expect(laterSection.getByText('Future Conference')).toBeVisible()
|
||||
|
||||
const pastSection = sections.filter({ has: page.getByRole('heading', { name: 'Past', level: 2 }) })
|
||||
await expect(pastSection.getByText('New Year Party')).toBeVisible()
|
||||
})
|
||||
|
||||
test('empty sections are not rendered', async ({ page }) => {
|
||||
// Only a past event — no Today, This Week, or Later sections
|
||||
await page.addInitScript(seedEvents([pastEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Past', level: 2 })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Today', level: 2 })).toHaveCount(0)
|
||||
await expect(page.getByRole('heading', { name: 'This Week', level: 2 })).toHaveCount(0)
|
||||
await expect(page.getByRole('heading', { name: 'Next Week', level: 2 })).toHaveCount(0)
|
||||
await expect(page.getByRole('heading', { name: 'Later', level: 2 })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Today section header has emphasis CSS class', async ({ page }) => {
|
||||
const now = new Date()
|
||||
const todayEvent: StoredEvent = {
|
||||
eventToken: 'today-emph',
|
||||
title: 'Emphasis Test',
|
||||
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 20, 0, 0).toISOString(),
|
||||
expiryDate: '',
|
||||
}
|
||||
await page.addInitScript(seedEvents([todayEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
const todayHeader = page.getByRole('heading', { name: 'Today', level: 2 })
|
||||
await expect(todayHeader).toHaveClass(/section-header--emphasized/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Temporal Grouping: Date Subheaders', () => {
|
||||
test('no date subheader in Today section', async ({ page }) => {
|
||||
const now = new Date()
|
||||
const todayEvent: StoredEvent = {
|
||||
eventToken: 'today-sub',
|
||||
title: 'No Subheader Test',
|
||||
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 19, 0, 0).toISOString(),
|
||||
expiryDate: '',
|
||||
}
|
||||
await page.addInitScript(seedEvents([todayEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
const todaySection = page.locator('.event-section').filter({
|
||||
has: page.getByRole('heading', { name: 'Today', level: 2 }),
|
||||
})
|
||||
await expect(todaySection.locator('.date-subheader')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('date subheaders appear in Later section', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([futureEvent1, futureEvent2]))
|
||||
await page.goto('/')
|
||||
|
||||
const laterSection = page.locator('.event-section').filter({
|
||||
has: page.getByRole('heading', { name: 'Later', level: 2 }),
|
||||
})
|
||||
// Both future events are on different dates, so expect subheaders
|
||||
const subheaders = laterSection.locator('.date-subheader')
|
||||
await expect(subheaders).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('date subheaders appear in Past section', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([pastEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
const pastSection = page.locator('.event-section').filter({
|
||||
has: page.getByRole('heading', { name: 'Past', level: 2 }),
|
||||
})
|
||||
await expect(pastSection.locator('.date-subheader')).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Temporal Grouping: Time Display', () => {
|
||||
test('future event cards show clock time', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([futureEvent1]))
|
||||
await page.goto('/')
|
||||
|
||||
const timeLabel = page.locator('.event-card__time')
|
||||
const text = await timeLabel.first().textContent()
|
||||
// Should show clock time (e.g., "18:00" or "6:00 PM"), not relative time
|
||||
expect(text).toMatch(/\d{1,2}[:.]\d{2}/)
|
||||
})
|
||||
|
||||
test('past event cards show relative time', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([pastEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
const timeLabel = page.locator('.event-card__time')
|
||||
const text = await timeLabel.first().textContent()
|
||||
// Should show relative time like "X years ago" or "last year"
|
||||
expect(text).toMatch(/ago|last|yesterday/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US1: View My Events', () => {
|
||||
test('displays all stored events with title and relative time', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([futureEvent1, futureEvent2, pastEvent]))
|
||||
|
||||
99
frontend/e2e/view-attendee-list.spec.ts
Normal file
99
frontend/e2e/view-attendee-list.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { test, expect } from './msw-setup'
|
||||
|
||||
const eventToken = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
const organizerToken = 'f9e8d7c6-b5a4-3210-fedc-ba9876543210'
|
||||
|
||||
const fullEvent = {
|
||||
eventToken,
|
||||
title: 'Summer BBQ',
|
||||
description: 'Bring your own drinks!',
|
||||
dateTime: '2026-03-15T20:00:00+01:00',
|
||||
timezone: 'Europe/Berlin',
|
||||
location: 'Central Park, NYC',
|
||||
attendeeCount: 3,
|
||||
expired: false,
|
||||
}
|
||||
|
||||
const attendeesResponse = {
|
||||
attendees: [
|
||||
{ name: 'Alice' },
|
||||
{ name: 'Bob' },
|
||||
{ name: 'Charlie' },
|
||||
],
|
||||
}
|
||||
|
||||
test.describe('US-1: View attendee list as organizer', () => {
|
||||
test('organizer sees attendee names', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json(fullEvent)
|
||||
}),
|
||||
http.get('*/api/events/:token/attendees', () => {
|
||||
return HttpResponse.json(attendeesResponse)
|
||||
}),
|
||||
)
|
||||
|
||||
// Set organizer token in localStorage before navigating
|
||||
await page.goto('/')
|
||||
await page.evaluate(
|
||||
([et, ot]) => {
|
||||
localStorage.setItem(
|
||||
'fete:events',
|
||||
JSON.stringify([{ eventToken: et, organizerToken: ot, title: 'Summer BBQ', dateTime: '2026-03-15T20:00:00+01:00', expiryDate: '' }]),
|
||||
)
|
||||
},
|
||||
[eventToken, organizerToken],
|
||||
)
|
||||
|
||||
await page.goto(`/events/${eventToken}`)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||
await expect(page.getByText('3 Attendees')).toBeVisible()
|
||||
await expect(page.getByText('Alice')).toBeVisible()
|
||||
await expect(page.getByText('Bob')).toBeVisible()
|
||||
await expect(page.getByText('Charlie')).toBeVisible()
|
||||
})
|
||||
|
||||
test('visitor does not see attendee list', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json(fullEvent)
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto(`/events/${eventToken}`)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||
await expect(page.getByText('3 going')).toBeVisible()
|
||||
await expect(page.locator('.attendee-list')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('organizer sees empty state when no attendees', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json({ ...fullEvent, attendeeCount: 0 })
|
||||
}),
|
||||
http.get('*/api/events/:token/attendees', () => {
|
||||
return HttpResponse.json({ attendees: [] })
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto('/')
|
||||
await page.evaluate(
|
||||
([et, ot]) => {
|
||||
localStorage.setItem(
|
||||
'fete:events',
|
||||
JSON.stringify([{ eventToken: et, organizerToken: ot, title: 'Summer BBQ', dateTime: '2026-03-15T20:00:00+01:00', expiryDate: '' }]),
|
||||
)
|
||||
},
|
||||
[eventToken, organizerToken],
|
||||
)
|
||||
|
||||
await page.goto(`/events/${eventToken}`)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||
await expect(page.getByText('0 Attendees')).toBeVisible()
|
||||
await expect(page.getByText('No attendees yet.')).toBeVisible()
|
||||
})
|
||||
})
|
||||
114
frontend/package-lock.json
generated
114
frontend/package-lock.json
generated
@@ -2947,9 +2947,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin": {
|
||||
"version": "1.6.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.9.tgz",
|
||||
"integrity": "sha512-9WfPx1OwJ19QLCSRLkqVO7//1WcWnK3fE/3fJhKMAmDe8+9G4rB47xCNIIeCq3FdEzkIoLTfDlwDlPBaUTMhow==",
|
||||
"version": "1.6.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.10.tgz",
|
||||
"integrity": "sha512-/cOf+mTu4HBJIYHTETo8/OFCSZv3T2p+KfGnouzKfjK063cWLZp0TzvK7EU5B3eFG7ypUNtw6l+jK+SA+p1g8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3204,13 +3204,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.29",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz",
|
||||
"integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz",
|
||||
"integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@vue/shared": "3.5.29",
|
||||
"@vue/shared": "3.5.30",
|
||||
"entities": "^7.0.1",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
@@ -3229,40 +3229,40 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.29",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz",
|
||||
"integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz",
|
||||
"integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.29",
|
||||
"@vue/shared": "3.5.29"
|
||||
"@vue/compiler-core": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.29",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
|
||||
"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz",
|
||||
"integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@vue/compiler-core": "3.5.29",
|
||||
"@vue/compiler-dom": "3.5.29",
|
||||
"@vue/compiler-ssr": "3.5.29",
|
||||
"@vue/shared": "3.5.29",
|
||||
"@vue/compiler-core": "3.5.30",
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/compiler-ssr": "3.5.30",
|
||||
"@vue/shared": "3.5.30",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.8",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.5.29",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz",
|
||||
"integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz",
|
||||
"integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.29",
|
||||
"@vue/shared": "3.5.29"
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-api": {
|
||||
@@ -3362,53 +3362,53 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.29",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz",
|
||||
"integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz",
|
||||
"integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.29"
|
||||
"@vue/shared": "3.5.30"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.5.29",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz",
|
||||
"integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz",
|
||||
"integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.29",
|
||||
"@vue/shared": "3.5.29"
|
||||
"@vue/reactivity": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.5.29",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz",
|
||||
"integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz",
|
||||
"integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.29",
|
||||
"@vue/runtime-core": "3.5.29",
|
||||
"@vue/shared": "3.5.29",
|
||||
"@vue/reactivity": "3.5.30",
|
||||
"@vue/runtime-core": "3.5.30",
|
||||
"@vue/shared": "3.5.30",
|
||||
"csstype": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.5.29",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz",
|
||||
"integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz",
|
||||
"integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.29",
|
||||
"@vue/shared": "3.5.29"
|
||||
"@vue/compiler-ssr": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.5.29"
|
||||
"vue": "3.5.30"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.29",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz",
|
||||
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz",
|
||||
"integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/test-utils": {
|
||||
@@ -7319,16 +7319,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.29",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
|
||||
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
|
||||
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.29",
|
||||
"@vue/compiler-sfc": "3.5.29",
|
||||
"@vue/runtime-dom": "3.5.29",
|
||||
"@vue/server-renderer": "3.5.29",
|
||||
"@vue/shared": "3.5.29"
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/compiler-sfc": "3.5.30",
|
||||
"@vue/runtime-dom": "3.5.30",
|
||||
"@vue/server-renderer": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
|
||||
BIN
frontend/src/assets/images/event-hero-placeholder.jpg
Normal file
BIN
frontend/src/assets/images/event-hero-placeholder.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
59
frontend/src/components/AttendeeList.vue
Normal file
59
frontend/src/components/AttendeeList.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<section class="attendee-list">
|
||||
<h3 class="attendee-list__heading">
|
||||
{{ attendees.length === 1 ? '1 Attendee' : `${attendees.length} Attendees` }}
|
||||
</h3>
|
||||
<ul v-if="attendees.length > 0" class="attendee-list__items">
|
||||
<li v-for="(name, index) in attendees" :key="index" class="attendee-list__item">
|
||||
{{ name }}
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="attendee-list__empty">No attendees yet.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
attendees: string[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.attendee-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.attendee-list__heading {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.attendee-list__items {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.attendee-list__item {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attendee-list__empty {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
19
frontend/src/components/DateSubheader.vue
Normal file
19
frontend/src/components/DateSubheader.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<h3 class="date-subheader">{{ label }}</h3>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.date-subheader {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin: 0;
|
||||
padding: var(--spacing-xs) 0;
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@
|
||||
>
|
||||
<RouterLink :to="`/events/${eventToken}`" class="event-card__link">
|
||||
<span class="event-card__title">{{ title }}</span>
|
||||
<span class="event-card__time">{{ relativeTime }}</span>
|
||||
<span class="event-card__time">{{ displayTime }}</span>
|
||||
</RouterLink>
|
||||
<span v-if="eventRole" class="event-card__badge" :class="`event-card__badge--${eventRole}`">
|
||||
{{ eventRole === 'organizer' ? 'Organizer' : 'Attendee' }}
|
||||
@@ -35,12 +35,21 @@ const props = defineProps<{
|
||||
relativeTime: string
|
||||
isPast: boolean
|
||||
eventRole?: 'organizer' | 'attendee'
|
||||
timeDisplayMode?: 'clock' | 'relative'
|
||||
dateTime?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [eventToken: string]
|
||||
}>()
|
||||
|
||||
const displayTime = computed(() => {
|
||||
if (props.timeDisplayMode === 'clock' && props.dateTime) {
|
||||
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(new Date(props.dateTime))
|
||||
}
|
||||
return props.relativeTime
|
||||
})
|
||||
|
||||
const SWIPE_THRESHOLD = 80
|
||||
|
||||
const startX = ref(0)
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
<template>
|
||||
<div class="event-list" role="list" aria-label="Your events">
|
||||
<div v-for="event in sortedEvents" :key="event.eventToken" role="listitem">
|
||||
<EventCard
|
||||
:event-token="event.eventToken"
|
||||
:title="event.title"
|
||||
:relative-time="formatRelativeTime(event.dateTime)"
|
||||
:is-past="isPast(event.dateTime)"
|
||||
:event-role="getRole(event)"
|
||||
@delete="requestDelete"
|
||||
/>
|
||||
</div>
|
||||
<div class="event-list">
|
||||
<section
|
||||
v-for="section in groupedSections"
|
||||
:key="section.key"
|
||||
:aria-label="section.label"
|
||||
class="event-section"
|
||||
>
|
||||
<SectionHeader :label="section.label" :emphasized="section.emphasized" />
|
||||
<div role="list">
|
||||
<template v-for="group in section.dateGroups" :key="group.dateKey">
|
||||
<DateSubheader v-if="group.showSubheader" :label="group.label" />
|
||||
<div v-for="event in group.events" :key="event.eventToken" role="listitem">
|
||||
<EventCard
|
||||
:event-token="event.eventToken"
|
||||
:title="event.title"
|
||||
:relative-time="formatRelativeTime(event.dateTime)"
|
||||
:is-past="section.key === 'past'"
|
||||
:event-role="getRole(event)"
|
||||
:time-display-mode="section.key === 'past' ? 'relative' : 'clock'"
|
||||
:date-time="event.dateTime"
|
||||
@delete="requestDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
<ConfirmDialog
|
||||
:open="!!pendingDeleteToken"
|
||||
title="Remove event?"
|
||||
@@ -25,8 +40,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useEventStorage, isValidStoredEvent } from '../composables/useEventStorage'
|
||||
import { useEventGrouping } from '../composables/useEventGrouping'
|
||||
import { formatRelativeTime } from '../composables/useRelativeTime'
|
||||
import EventCard from './EventCard.vue'
|
||||
import SectionHeader from './SectionHeader.vue'
|
||||
import DateSubheader from './DateSubheader.vue'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import type { StoredEvent } from '../composables/useEventStorage'
|
||||
|
||||
@@ -49,29 +67,26 @@ function cancelDelete() {
|
||||
pendingDeleteToken.value = null
|
||||
}
|
||||
|
||||
function isPast(dateTime: string): boolean {
|
||||
return new Date(dateTime) < new Date()
|
||||
}
|
||||
|
||||
function getRole(event: StoredEvent): 'organizer' | 'attendee' | undefined {
|
||||
if (event.organizerToken) return 'organizer'
|
||||
if (event.rsvpToken) return 'attendee'
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sortedEvents = computed(() => {
|
||||
const groupedSections = computed(() => {
|
||||
const valid = getStoredEvents().filter(isValidStoredEvent)
|
||||
const now = new Date()
|
||||
const upcoming = valid.filter((e) => new Date(e.dateTime) >= now)
|
||||
const past = valid.filter((e) => new Date(e.dateTime) < now)
|
||||
upcoming.sort((a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime())
|
||||
past.sort((a, b) => new Date(b.dateTime).getTime() - new Date(a.dateTime).getTime())
|
||||
return [...upcoming, ...past]
|
||||
return useEventGrouping(valid)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.event-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.event-section [role="list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
|
||||
27
frontend/src/components/SectionHeader.vue
Normal file
27
frontend/src/components/SectionHeader.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<h2 class="section-header" :class="{ 'section-header--emphasized': emphasized }">
|
||||
{{ label }}
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string
|
||||
emphasized?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-header {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
padding: var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.section-header--emphasized {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
</style>
|
||||
50
frontend/src/components/__tests__/AttendeeList.spec.ts
Normal file
50
frontend/src/components/__tests__/AttendeeList.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AttendeeList from '../AttendeeList.vue'
|
||||
|
||||
describe('AttendeeList', () => {
|
||||
it('renders attendee names as list items', () => {
|
||||
const wrapper = mount(AttendeeList, {
|
||||
props: { attendees: ['Alice', 'Bob', 'Charlie'] },
|
||||
})
|
||||
|
||||
const items = wrapper.findAll('.attendee-list__item')
|
||||
expect(items).toHaveLength(3)
|
||||
expect(items[0]!.text()).toBe('Alice')
|
||||
expect(items[1]!.text()).toBe('Bob')
|
||||
expect(items[2]!.text()).toBe('Charlie')
|
||||
})
|
||||
|
||||
it('shows empty state message when no attendees', () => {
|
||||
const wrapper = mount(AttendeeList, {
|
||||
props: { attendees: [] },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.attendee-list__empty').text()).toBe('No attendees yet.')
|
||||
expect(wrapper.find('.attendee-list__items').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows plural count heading for multiple attendees', () => {
|
||||
const wrapper = mount(AttendeeList, {
|
||||
props: { attendees: ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'] },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.attendee-list__heading').text()).toBe('5 Attendees')
|
||||
})
|
||||
|
||||
it('shows singular count heading for one attendee', () => {
|
||||
const wrapper = mount(AttendeeList, {
|
||||
props: { attendees: ['Alice'] },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.attendee-list__heading').text()).toBe('1 Attendee')
|
||||
})
|
||||
|
||||
it('shows zero count heading for no attendees', () => {
|
||||
const wrapper = mount(AttendeeList, {
|
||||
props: { attendees: [] },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.attendee-list__heading').text()).toBe('0 Attendees')
|
||||
})
|
||||
})
|
||||
17
frontend/src/components/__tests__/DateSubheader.spec.ts
Normal file
17
frontend/src/components/__tests__/DateSubheader.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import DateSubheader from '../DateSubheader.vue'
|
||||
|
||||
describe('DateSubheader', () => {
|
||||
it('renders the date label as an h3', () => {
|
||||
const wrapper = mount(DateSubheader, { props: { label: 'Wed, 12 Mar' } })
|
||||
const h3 = wrapper.find('h3')
|
||||
expect(h3.exists()).toBe(true)
|
||||
expect(h3.text()).toBe('Wed, 12 Mar')
|
||||
})
|
||||
|
||||
it('applies the date-subheader class', () => {
|
||||
const wrapper = mount(DateSubheader, { props: { label: 'Fri, 14 Mar' } })
|
||||
expect(wrapper.find('.date-subheader').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -73,4 +73,28 @@ describe('EventCard', () => {
|
||||
await wrapper.find('.event-card__delete').trigger('click')
|
||||
expect(wrapper.emitted('delete')).toEqual([['abc-123']])
|
||||
})
|
||||
|
||||
it('displays clock time when timeDisplayMode is clock', () => {
|
||||
const wrapper = mountCard({
|
||||
timeDisplayMode: 'clock',
|
||||
dateTime: '2026-03-11T18:30:00',
|
||||
})
|
||||
const timeText = wrapper.find('.event-card__time').text()
|
||||
// Locale-dependent: could be "18:30" or "06:30 PM"
|
||||
expect(timeText).toMatch(/(?:18.30|6.30\s*PM)/i)
|
||||
})
|
||||
|
||||
it('displays relative time when timeDisplayMode is relative', () => {
|
||||
const wrapper = mountCard({
|
||||
relativeTime: '3 days ago',
|
||||
timeDisplayMode: 'relative',
|
||||
dateTime: '2026-03-08T10:00:00',
|
||||
})
|
||||
expect(wrapper.find('.event-card__time').text()).toBe('3 days ago')
|
||||
})
|
||||
|
||||
it('falls back to relativeTime when timeDisplayMode is not set', () => {
|
||||
const wrapper = mountCard({ relativeTime: 'in 3 days' })
|
||||
expect(wrapper.find('.event-card__time').text()).toBe('in 3 days')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import EventList from '../EventList.vue'
|
||||
@@ -11,10 +11,15 @@ const router = createRouter({
|
||||
],
|
||||
})
|
||||
|
||||
// Fixed "now": Wednesday, 2026-03-11 12:00
|
||||
const NOW = new Date(2026, 2, 11, 12, 0, 0)
|
||||
|
||||
const mockEvents = [
|
||||
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2025-01-01T10:00:00Z', expiryDate: '' },
|
||||
{ eventToken: 'future-1', title: 'Future Event', dateTime: '2027-06-15T10:00:00Z', expiryDate: '' },
|
||||
{ eventToken: 'future-2', title: 'Soon Event', dateTime: '2027-01-01T10:00:00Z', expiryDate: '' },
|
||||
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2026-03-01T10:00:00', expiryDate: '' },
|
||||
{ eventToken: 'later-1', title: 'Later Event', dateTime: '2027-06-15T10:00:00', expiryDate: '' },
|
||||
{ eventToken: 'today-1', title: 'Today Event', dateTime: '2026-03-11T18:00:00', expiryDate: '' },
|
||||
{ eventToken: 'week-1', title: 'This Week Event', dateTime: '2026-03-13T10:00:00', expiryDate: '' },
|
||||
{ eventToken: 'nextweek-1', title: 'Next Week Event', dateTime: '2026-03-16T10:00:00', expiryDate: '' },
|
||||
]
|
||||
|
||||
vi.mock('../../composables/useEventStorage', () => ({
|
||||
@@ -33,9 +38,12 @@ vi.mock('../../composables/useEventStorage', () => ({
|
||||
|
||||
vi.mock('../../composables/useRelativeTime', () => ({
|
||||
formatRelativeTime: (dateTime: string) => {
|
||||
if (dateTime.startsWith('2025')) return '1 year ago'
|
||||
if (dateTime.includes('03-01')) return '10 days ago'
|
||||
if (dateTime.includes('06-15')) return 'in 1 year'
|
||||
return 'in 10 months'
|
||||
if (dateTime.includes('03-11')) return 'in 6 hours'
|
||||
if (dateTime.includes('03-13')) return 'in 2 days'
|
||||
if (dateTime.includes('03-16')) return 'in 5 days'
|
||||
return 'sometime'
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -48,32 +56,85 @@ function mountList() {
|
||||
describe('EventList', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-03-08T12:00:00Z'))
|
||||
vi.setSystemTime(NOW)
|
||||
})
|
||||
|
||||
it('renders all valid events', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('renders section headers for each non-empty section', () => {
|
||||
const wrapper = mountList()
|
||||
const headers = wrapper.findAll('.section-header')
|
||||
expect(headers).toHaveLength(5)
|
||||
expect(headers[0]!.text()).toBe('Today')
|
||||
expect(headers[1]!.text()).toBe('This Week')
|
||||
expect(headers[2]!.text()).toBe('Next Week')
|
||||
expect(headers[3]!.text()).toBe('Later')
|
||||
expect(headers[4]!.text()).toBe('Past')
|
||||
})
|
||||
|
||||
it('renders events within their correct sections', () => {
|
||||
const wrapper = mountList()
|
||||
const sections = wrapper.findAll('.event-section')
|
||||
expect(sections).toHaveLength(5)
|
||||
|
||||
expect(sections[0]!.text()).toContain('Today Event')
|
||||
expect(sections[1]!.text()).toContain('This Week Event')
|
||||
expect(sections[2]!.text()).toContain('Next Week Event')
|
||||
expect(sections[3]!.text()).toContain('Later Event')
|
||||
expect(sections[4]!.text()).toContain('Past Event')
|
||||
})
|
||||
|
||||
it('renders all valid events as cards', () => {
|
||||
const wrapper = mountList()
|
||||
const cards = wrapper.findAll('.event-card')
|
||||
expect(cards).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('sorts upcoming events before past events', () => {
|
||||
const wrapper = mountList()
|
||||
const titles = wrapper.findAll('.event-card__title').map((el) => el.text())
|
||||
// Upcoming events first (sorted ascending), then past events
|
||||
expect(titles[0]).toBe('Soon Event')
|
||||
expect(titles[1]).toBe('Future Event')
|
||||
expect(titles[2]).toBe('Past Event')
|
||||
expect(cards).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('marks past events with isPast class', () => {
|
||||
const wrapper = mountList()
|
||||
const cards = wrapper.findAll('.event-card')
|
||||
expect(cards).toHaveLength(3)
|
||||
// Last card should be past
|
||||
expect(cards[2]!.classes()).toContain('event-card--past')
|
||||
// First two should not be past
|
||||
const pastSection = wrapper.findAll('.event-section')[4]!
|
||||
const pastCards = pastSection.findAll('.event-card')
|
||||
expect(pastCards).toHaveLength(1)
|
||||
expect(pastCards[0]!.classes()).toContain('event-card--past')
|
||||
})
|
||||
|
||||
it('does not mark non-past events with isPast class', () => {
|
||||
const wrapper = mountList()
|
||||
const todaySection = wrapper.findAll('.event-section')[0]!
|
||||
const cards = todaySection.findAll('.event-card')
|
||||
expect(cards[0]!.classes()).not.toContain('event-card--past')
|
||||
expect(cards[1]!.classes()).not.toContain('event-card--past')
|
||||
})
|
||||
|
||||
it('sections have aria-label attributes', () => {
|
||||
const wrapper = mountList()
|
||||
const sections = wrapper.findAll('section')
|
||||
expect(sections[0]!.attributes('aria-label')).toBe('Today')
|
||||
expect(sections[1]!.attributes('aria-label')).toBe('This Week')
|
||||
expect(sections[2]!.attributes('aria-label')).toBe('Next Week')
|
||||
expect(sections[3]!.attributes('aria-label')).toBe('Later')
|
||||
expect(sections[4]!.attributes('aria-label')).toBe('Past')
|
||||
})
|
||||
|
||||
it('does not render date subheader in "Today" section', () => {
|
||||
const wrapper = mountList()
|
||||
const todaySection = wrapper.findAll('.event-section')[0]!
|
||||
expect(todaySection.find('.date-subheader').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders date subheaders in non-today sections', () => {
|
||||
const wrapper = mountList()
|
||||
const thisWeekSection = wrapper.findAll('.event-section')[1]!
|
||||
expect(thisWeekSection.find('.date-subheader').exists()).toBe(true)
|
||||
|
||||
const nextWeekSection = wrapper.findAll('.event-section')[2]!
|
||||
expect(nextWeekSection.find('.date-subheader').exists()).toBe(true)
|
||||
|
||||
const laterSection = wrapper.findAll('.event-section')[3]!
|
||||
expect(laterSection.find('.date-subheader').exists()).toBe(true)
|
||||
|
||||
const pastSection = wrapper.findAll('.event-section')[4]!
|
||||
expect(pastSection.find('.date-subheader').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
27
frontend/src/components/__tests__/SectionHeader.spec.ts
Normal file
27
frontend/src/components/__tests__/SectionHeader.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import SectionHeader from '../SectionHeader.vue'
|
||||
|
||||
describe('SectionHeader', () => {
|
||||
it('renders the section label as an h2', () => {
|
||||
const wrapper = mount(SectionHeader, { props: { label: 'Today' } })
|
||||
const h2 = wrapper.find('h2')
|
||||
expect(h2.exists()).toBe(true)
|
||||
expect(h2.text()).toBe('Today')
|
||||
})
|
||||
|
||||
it('does not apply emphasized class by default', () => {
|
||||
const wrapper = mount(SectionHeader, { props: { label: 'Later' } })
|
||||
expect(wrapper.find('.section-header--emphasized').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('applies emphasized class when emphasized prop is true', () => {
|
||||
const wrapper = mount(SectionHeader, { props: { label: 'Today', emphasized: true } })
|
||||
expect(wrapper.find('.section-header--emphasized').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not apply emphasized class when emphasized prop is false', () => {
|
||||
const wrapper = mount(SectionHeader, { props: { label: 'Past', emphasized: false } })
|
||||
expect(wrapper.find('.section-header--emphasized').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
158
frontend/src/components/__tests__/useEventGrouping.spec.ts
Normal file
158
frontend/src/components/__tests__/useEventGrouping.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { useEventGrouping } from '../../composables/useEventGrouping'
|
||||
import type { StoredEvent } from '../../composables/useEventStorage'
|
||||
|
||||
function makeEvent(overrides: Partial<StoredEvent> & { dateTime: string }): StoredEvent {
|
||||
return {
|
||||
eventToken: `evt-${Math.random().toString(36).slice(2, 8)}`,
|
||||
title: 'Test Event',
|
||||
expiryDate: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useEventGrouping', () => {
|
||||
// Fixed "now": Wednesday, 2026-03-11 12:00 local
|
||||
const NOW = new Date(2026, 2, 11, 12, 0, 0)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(NOW)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns empty array when no events', () => {
|
||||
const sections = useEventGrouping([], NOW)
|
||||
expect(sections).toEqual([])
|
||||
})
|
||||
|
||||
it('classifies a today event into "today" section', () => {
|
||||
const event = makeEvent({ dateTime: '2026-03-11T18:30:00' })
|
||||
const sections = useEventGrouping([event], NOW)
|
||||
expect(sections).toHaveLength(1)
|
||||
expect(sections[0]!.key).toBe('today')
|
||||
expect(sections[0]!.label).toBe('Today')
|
||||
expect(sections[0]!.dateGroups[0]!.events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('classifies events into all five sections', () => {
|
||||
const events = [
|
||||
makeEvent({ title: 'Today', dateTime: '2026-03-11T10:00:00' }),
|
||||
makeEvent({ title: 'This Week', dateTime: '2026-03-13T10:00:00' }), // Friday (same week)
|
||||
makeEvent({ title: 'Next Week', dateTime: '2026-03-16T10:00:00' }), // Monday next week
|
||||
makeEvent({ title: 'Later', dateTime: '2026-03-30T10:00:00' }), // far future
|
||||
makeEvent({ title: 'Past', dateTime: '2026-03-09T10:00:00' }), // Monday (past)
|
||||
]
|
||||
const sections = useEventGrouping(events, NOW)
|
||||
expect(sections).toHaveLength(5)
|
||||
expect(sections[0]!.key).toBe('today')
|
||||
expect(sections[1]!.key).toBe('thisWeek')
|
||||
expect(sections[2]!.key).toBe('nextWeek')
|
||||
expect(sections[3]!.key).toBe('later')
|
||||
expect(sections[4]!.key).toBe('past')
|
||||
})
|
||||
|
||||
it('omits empty sections', () => {
|
||||
const events = [
|
||||
makeEvent({ title: 'Today', dateTime: '2026-03-11T10:00:00' }),
|
||||
makeEvent({ title: 'Past', dateTime: '2026-03-01T10:00:00' }),
|
||||
]
|
||||
const sections = useEventGrouping(events, NOW)
|
||||
expect(sections).toHaveLength(2)
|
||||
expect(sections[0]!.key).toBe('today')
|
||||
expect(sections[1]!.key).toBe('past')
|
||||
})
|
||||
|
||||
it('sorts upcoming events ascending by time', () => {
|
||||
const events = [
|
||||
makeEvent({ title: 'Later', dateTime: '2026-03-11T20:00:00' }),
|
||||
makeEvent({ title: 'Earlier', dateTime: '2026-03-11T08:00:00' }),
|
||||
]
|
||||
const sections = useEventGrouping(events, NOW)
|
||||
const todayEvents = sections[0]!.dateGroups[0]!.events
|
||||
expect(todayEvents[0]!.title).toBe('Earlier')
|
||||
expect(todayEvents[1]!.title).toBe('Later')
|
||||
})
|
||||
|
||||
it('sorts past events descending by time (most recent first)', () => {
|
||||
const events = [
|
||||
makeEvent({ title: 'Older', dateTime: '2026-03-01T10:00:00' }),
|
||||
makeEvent({ title: 'Newer', dateTime: '2026-03-09T10:00:00' }),
|
||||
]
|
||||
const sections = useEventGrouping(events, NOW)
|
||||
const pastEvents = sections[0]!.dateGroups
|
||||
expect(pastEvents[0]!.events[0]!.title).toBe('Newer')
|
||||
expect(pastEvents[1]!.events[0]!.title).toBe('Older')
|
||||
})
|
||||
|
||||
it('groups events by date within a section', () => {
|
||||
const events = [
|
||||
makeEvent({ title: 'Fri AM', dateTime: '2026-03-13T09:00:00' }),
|
||||
makeEvent({ title: 'Fri PM', dateTime: '2026-03-13T18:00:00' }),
|
||||
makeEvent({ title: 'Sat', dateTime: '2026-03-14T12:00:00' }),
|
||||
]
|
||||
const sections = useEventGrouping(events, NOW)
|
||||
expect(sections[0]!.key).toBe('thisWeek')
|
||||
const dateGroups = sections[0]!.dateGroups
|
||||
expect(dateGroups).toHaveLength(2) // Friday and Saturday
|
||||
expect(dateGroups[0]!.events).toHaveLength(2) // Two Friday events
|
||||
expect(dateGroups[1]!.events).toHaveLength(1) // One Saturday event
|
||||
})
|
||||
|
||||
it('sets showSubheader=false for "today" section', () => {
|
||||
const event = makeEvent({ dateTime: '2026-03-11T18:00:00' })
|
||||
const sections = useEventGrouping([event], NOW)
|
||||
expect(sections[0]!.dateGroups[0]!.showSubheader).toBe(false)
|
||||
})
|
||||
|
||||
it('sets showSubheader=true for non-today sections', () => {
|
||||
const events = [
|
||||
makeEvent({ dateTime: '2026-03-13T10:00:00' }), // thisWeek
|
||||
makeEvent({ dateTime: '2026-03-30T10:00:00' }), // later (beyond next week)
|
||||
makeEvent({ dateTime: '2026-03-01T10:00:00' }), // past
|
||||
]
|
||||
const sections = useEventGrouping(events, NOW)
|
||||
for (const section of sections) {
|
||||
for (const group of section.dateGroups) {
|
||||
expect(group.showSubheader).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('sets emphasized=true only for "today" section', () => {
|
||||
const events = [
|
||||
makeEvent({ dateTime: '2026-03-11T18:00:00' }),
|
||||
makeEvent({ dateTime: '2026-03-30T10:00:00' }),
|
||||
]
|
||||
const sections = useEventGrouping(events, NOW)
|
||||
expect(sections[0]!.emphasized).toBe(true) // today
|
||||
expect(sections[1]!.emphasized).toBe(false) // later
|
||||
})
|
||||
|
||||
it('on Sunday, tomorrow (Monday) goes to "nextWeek" not "thisWeek"', () => {
|
||||
// Sunday 2026-03-15
|
||||
const sunday = new Date(2026, 2, 15, 12, 0, 0)
|
||||
const mondayEvent = makeEvent({ title: 'Monday', dateTime: '2026-03-16T10:00:00' })
|
||||
const sections = useEventGrouping([mondayEvent], sunday)
|
||||
expect(sections).toHaveLength(1)
|
||||
expect(sections[0]!.key).toBe('nextWeek')
|
||||
})
|
||||
|
||||
it('on Sunday, today events still appear under "today"', () => {
|
||||
const sunday = new Date(2026, 2, 15, 12, 0, 0)
|
||||
const todayEvent = makeEvent({ dateTime: '2026-03-15T18:00:00' })
|
||||
const sections = useEventGrouping([todayEvent], sunday)
|
||||
expect(sections[0]!.key).toBe('today')
|
||||
})
|
||||
|
||||
it('dateGroup labels are formatted via Intl', () => {
|
||||
const event = makeEvent({ dateTime: '2026-03-13T10:00:00' }) // Friday
|
||||
const sections = useEventGrouping([event], NOW)
|
||||
const label = sections[0]!.dateGroups[0]!.label
|
||||
// The exact format depends on locale, but should contain the day number
|
||||
expect(label).toContain('13')
|
||||
})
|
||||
})
|
||||
149
frontend/src/composables/useEventGrouping.ts
Normal file
149
frontend/src/composables/useEventGrouping.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { StoredEvent } from './useEventStorage'
|
||||
|
||||
export type SectionKey = 'today' | 'thisWeek' | 'nextWeek' | 'later' | 'past'
|
||||
|
||||
export interface DateGroup {
|
||||
dateKey: string
|
||||
label: string
|
||||
events: StoredEvent[]
|
||||
showSubheader: boolean
|
||||
}
|
||||
|
||||
export interface EventSection {
|
||||
key: SectionKey
|
||||
label: string
|
||||
dateGroups: DateGroup[]
|
||||
emphasized: boolean
|
||||
}
|
||||
|
||||
const SECTION_ORDER: SectionKey[] = ['today', 'thisWeek', 'nextWeek', 'later', 'past']
|
||||
|
||||
const SECTION_LABELS: Record<SectionKey, string> = {
|
||||
today: 'Today',
|
||||
thisWeek: 'This Week',
|
||||
nextWeek: 'Next Week',
|
||||
later: 'Later',
|
||||
past: 'Past',
|
||||
}
|
||||
|
||||
function startOfDay(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
}
|
||||
|
||||
function endOfDay(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
d.setHours(23, 59, 59, 999)
|
||||
return d
|
||||
}
|
||||
|
||||
function endOfWeek(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
const dayOfWeek = d.getDay() // 0=Sun, 1=Mon, ..., 6=Sat
|
||||
// ISO week: Monday is first day. End of week = Sunday.
|
||||
// If today is Sunday (0), end of week is today.
|
||||
// Otherwise, days until Sunday = 7 - dayOfWeek
|
||||
const daysUntilSunday = dayOfWeek === 0 ? 0 : 7 - dayOfWeek
|
||||
d.setDate(d.getDate() + daysUntilSunday)
|
||||
return endOfDay(d)
|
||||
}
|
||||
|
||||
function endOfNextWeek(date: Date): Date {
|
||||
const thisWeekEnd = endOfWeek(date)
|
||||
const d = new Date(thisWeekEnd)
|
||||
d.setDate(d.getDate() + 7)
|
||||
return endOfDay(d)
|
||||
}
|
||||
|
||||
function toDateKey(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
function formatDateLabel(date: Date): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function classifyEvent(eventDate: Date, todayStart: Date, todayEnd: Date, weekEnd: Date, nextWeekEnd: Date): SectionKey {
|
||||
if (eventDate < todayStart) return 'past'
|
||||
if (eventDate <= todayEnd) return 'today'
|
||||
if (eventDate <= weekEnd) return 'thisWeek'
|
||||
if (eventDate <= nextWeekEnd) return 'nextWeek'
|
||||
return 'later'
|
||||
}
|
||||
|
||||
export function useEventGrouping(events: StoredEvent[], now: Date = new Date()): EventSection[] {
|
||||
const todayStart = startOfDay(now)
|
||||
const todayEnd = endOfDay(now)
|
||||
const weekEnd = endOfWeek(now)
|
||||
const nextWeekEnd = endOfNextWeek(now)
|
||||
|
||||
// Classify events into sections
|
||||
const buckets: Record<SectionKey, StoredEvent[]> = {
|
||||
today: [],
|
||||
thisWeek: [],
|
||||
nextWeek: [],
|
||||
later: [],
|
||||
past: [],
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
const eventDate = new Date(event.dateTime)
|
||||
const section = classifyEvent(eventDate, todayStart, todayEnd, weekEnd, nextWeekEnd)
|
||||
buckets[section].push(event)
|
||||
}
|
||||
|
||||
// Build sections
|
||||
const sections: EventSection[] = []
|
||||
|
||||
for (const key of SECTION_ORDER) {
|
||||
const sectionEvents = buckets[key]
|
||||
if (sectionEvents.length === 0) continue
|
||||
|
||||
// Sort events
|
||||
const ascending = key !== 'past'
|
||||
sectionEvents.sort((a, b) => {
|
||||
const diff = new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime()
|
||||
return ascending ? diff : -diff
|
||||
})
|
||||
|
||||
// Group by date
|
||||
const dateGroupMap = new Map<string, StoredEvent[]>()
|
||||
for (const event of sectionEvents) {
|
||||
const dateKey = toDateKey(new Date(event.dateTime))
|
||||
const group = dateGroupMap.get(dateKey)
|
||||
if (group) {
|
||||
group.push(event)
|
||||
} else {
|
||||
dateGroupMap.set(dateKey, [event])
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to DateGroup array (order preserved from sorted events)
|
||||
const dateGroups: DateGroup[] = []
|
||||
for (const [dateKey, groupEvents] of dateGroupMap) {
|
||||
dateGroups.push({
|
||||
dateKey,
|
||||
label: formatDateLabel(new Date(groupEvents[0]!.dateTime)),
|
||||
events: groupEvents,
|
||||
showSubheader: key !== 'today',
|
||||
})
|
||||
}
|
||||
|
||||
sections.push({
|
||||
key,
|
||||
label: SECTION_LABELS[key],
|
||||
dateGroups,
|
||||
emphasized: key === 'today',
|
||||
})
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
@@ -1,58 +1,77 @@
|
||||
<template>
|
||||
<main class="detail">
|
||||
<header class="detail__header">
|
||||
<RouterLink to="/" class="detail__back" aria-label="Back to home">←</RouterLink>
|
||||
<span class="detail__brand">fete</span>
|
||||
</header>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="state === 'loading'" class="detail__card" aria-busy="true" aria-label="Loading event details">
|
||||
<div class="skeleton skeleton--title" />
|
||||
<div class="skeleton skeleton--line" />
|
||||
<div class="skeleton skeleton--line skeleton--short" />
|
||||
<div class="skeleton skeleton--line" />
|
||||
<!-- Hero image with overlaid header -->
|
||||
<div class="detail__hero">
|
||||
<img
|
||||
class="detail__hero-img"
|
||||
src="@/assets/images/event-hero-placeholder.jpg"
|
||||
alt=""
|
||||
/>
|
||||
<div class="detail__hero-overlay" />
|
||||
<header class="detail__header">
|
||||
<RouterLink to="/" class="detail__back" aria-label="Back to home">←</RouterLink>
|
||||
<span class="detail__brand">fete</span>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<!-- Loaded state -->
|
||||
<div v-else-if="state === 'loaded' && event" class="detail__card">
|
||||
<h1 class="detail__title">{{ event.title }}</h1>
|
||||
|
||||
<dl class="detail__fields">
|
||||
<div class="detail__field">
|
||||
<dt class="detail__label">Date & Time</dt>
|
||||
<dd class="detail__value">{{ formattedDateTime }}</dd>
|
||||
</div>
|
||||
|
||||
<div v-if="event.description" class="detail__field">
|
||||
<dt class="detail__label">Description</dt>
|
||||
<dd class="detail__value">{{ event.description }}</dd>
|
||||
</div>
|
||||
|
||||
<div v-if="event.location" class="detail__field">
|
||||
<dt class="detail__label">Location</dt>
|
||||
<dd class="detail__value">{{ event.location }}</dd>
|
||||
</div>
|
||||
|
||||
<div class="detail__field">
|
||||
<dt class="detail__label">Attendees</dt>
|
||||
<dd class="detail__value">{{ event.attendeeCount }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
|
||||
This event has ended.
|
||||
<div class="detail__body">
|
||||
<!-- Loading state -->
|
||||
<div v-if="state === 'loading'" class="detail__content" aria-busy="true" aria-label="Loading event details">
|
||||
<div class="skeleton skeleton--title" />
|
||||
<div class="skeleton skeleton--line" />
|
||||
<div class="skeleton skeleton--line skeleton--short" />
|
||||
<div class="skeleton skeleton--line" />
|
||||
</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>
|
||||
<!-- Loaded state -->
|
||||
<div v-else-if="state === 'loaded' && event" class="detail__content">
|
||||
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
|
||||
This event has ended.
|
||||
</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>
|
||||
<h1 class="detail__title">{{ event.title }}</h1>
|
||||
|
||||
<dl class="detail__meta">
|
||||
<div class="detail__meta-item">
|
||||
<dt class="detail__meta-icon" aria-label="Date and time">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
</dt>
|
||||
<dd class="detail__meta-text">{{ formattedDateTime }}</dd>
|
||||
</div>
|
||||
|
||||
<div v-if="event.location" class="detail__meta-item">
|
||||
<dt class="detail__meta-icon" aria-label="Location">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
</dt>
|
||||
<dd class="detail__meta-text">{{ event.location }}</dd>
|
||||
</div>
|
||||
|
||||
<div class="detail__meta-item">
|
||||
<dt class="detail__meta-icon" aria-label="Attendees">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
</dt>
|
||||
<dd class="detail__meta-text">{{ event.attendeeCount }} going</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<AttendeeList v-if="isOrganizer && attendeeNames !== null" :attendees="attendeeNames" />
|
||||
|
||||
<div v-if="event.description" class="detail__section">
|
||||
<h2 class="detail__section-title">About</h2>
|
||||
<p class="detail__description">{{ event.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Not found state -->
|
||||
<div v-else-if="state === 'not-found'" class="detail__content detail__content--center" role="status">
|
||||
<p class="detail__message">Event not found.</p>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="state === 'error'" class="detail__content detail__content--center" role="alert">
|
||||
<p class="detail__message">Something went wrong.</p>
|
||||
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSVP bar (only for loaded, non-expired events) -->
|
||||
@@ -94,6 +113,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { api } from '@/api/client'
|
||||
import { useEventStorage } from '@/composables/useEventStorage'
|
||||
import AttendeeList from '@/components/AttendeeList.vue'
|
||||
import BottomSheet from '@/components/BottomSheet.vue'
|
||||
import RsvpBar from '@/components/RsvpBar.vue'
|
||||
import type { components } from '@/api/schema'
|
||||
@@ -115,6 +135,7 @@ const submitError = ref('')
|
||||
const submitting = ref(false)
|
||||
const rsvpName = ref<string | undefined>(undefined)
|
||||
const isOrganizer = ref(false)
|
||||
const attendeeNames = ref<string[] | null>(null)
|
||||
|
||||
const formattedDateTime = computed(() => {
|
||||
if (!event.value) return ''
|
||||
@@ -143,7 +164,13 @@ async function fetchEvent() {
|
||||
state.value = 'loaded'
|
||||
|
||||
// Check if current user is the organizer
|
||||
isOrganizer.value = !!getOrganizerToken(event.value.eventToken)
|
||||
const orgToken = getOrganizerToken(event.value.eventToken)
|
||||
isOrganizer.value = !!orgToken
|
||||
|
||||
// Fetch attendee list for organizer
|
||||
if (orgToken) {
|
||||
fetchAttendees(event.value.eventToken, orgToken)
|
||||
}
|
||||
|
||||
// Restore RSVP status from localStorage
|
||||
const stored = getRsvp(event.value.eventToken)
|
||||
@@ -203,6 +230,23 @@ async function submitRsvp() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAttendees(eventToken: string, organizerToken: string) {
|
||||
try {
|
||||
const { data, error } = await api.GET('/events/{token}/attendees', {
|
||||
params: {
|
||||
path: { token: eventToken },
|
||||
query: { organizerToken },
|
||||
},
|
||||
})
|
||||
|
||||
if (!error) {
|
||||
attendeeNames.value = data!.attendees.map((a) => a.name)
|
||||
}
|
||||
} catch {
|
||||
// Silently degrade — don't show attendee list
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchEvent)
|
||||
</script>
|
||||
|
||||
@@ -210,14 +254,53 @@ onMounted(fetchEvent)
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2xl);
|
||||
padding-top: var(--spacing-lg);
|
||||
/* Break out of .app-container constraints */
|
||||
width: 100dvw;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin: calc(-1 * var(--content-padding)) 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Hero image section */
|
||||
.detail__hero {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail__hero-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.detail__hero-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0.4) 0%,
|
||||
transparent 50%,
|
||||
var(--color-gradient-start) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.detail__header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-lg) var(--content-padding);
|
||||
padding-top: env(safe-area-inset-top, var(--spacing-lg));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.detail__back {
|
||||
@@ -233,85 +316,130 @@ onMounted(fetchEvent)
|
||||
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);
|
||||
.detail__body {
|
||||
flex: 1;
|
||||
padding: var(--spacing-lg) var(--content-padding);
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
|
||||
.detail__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
gap: var(--spacing-2xl);
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.detail__card--center {
|
||||
.detail__content--center {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding-top: 4rem;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.detail__title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: var(--color-text-on-gradient);
|
||||
word-break: break-word;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.detail__fields {
|
||||
/* Meta rows: icon + text */
|
||||
.detail__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.detail__field {
|
||||
.detail__meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.detail__label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
.detail__meta-icon {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 10px;
|
||||
color: var(--color-text-on-gradient);
|
||||
}
|
||||
|
||||
.detail__value {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text);
|
||||
.detail__meta-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-on-gradient);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* About section */
|
||||
.detail__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.detail__section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.detail__description {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Expired banner */
|
||||
.detail__banner {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-card);
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail__banner--expired {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Error / not-found message */
|
||||
.detail__message {
|
||||
font-size: 1rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
color: var(--color-text-on-gradient);
|
||||
}
|
||||
|
||||
/* Skeleton – shimmer on gradient */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.25) 50%, rgba(255, 255, 255, 0.1) 75%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
/* Skeleton sizes */
|
||||
.skeleton--title {
|
||||
height: 1.6rem;
|
||||
width: 60%;
|
||||
height: 2rem;
|
||||
width: 70%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.skeleton--line {
|
||||
height: 1rem;
|
||||
width: 80%;
|
||||
width: 85%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.skeleton--short {
|
||||
width: 40%;
|
||||
width: 45%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('EventDetailView', () => {
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const dateField = wrapper.findAll('.detail__value')[0]!
|
||||
const dateField = wrapper.findAll('.detail__meta-text')[0]!
|
||||
expect(dateField.text()).toContain('(Europe/Berlin)')
|
||||
expect(dateField.text()).toContain('2026')
|
||||
wrapper.unmount()
|
||||
@@ -339,6 +339,42 @@ describe('EventDetailView', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// Attendee list (organizer)
|
||||
it('shows attendee list for organizer', async () => {
|
||||
mockGetOrganizerToken.mockReturnValue('org-token-123')
|
||||
mockLoadedEvent()
|
||||
vi.mocked(api.GET)
|
||||
.mockResolvedValueOnce({
|
||||
data: fullEvent,
|
||||
error: undefined,
|
||||
response: new Response(null, { status: 200 }),
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
data: { attendees: [{ name: 'Alice' }, { name: 'Bob' }] },
|
||||
error: undefined,
|
||||
response: new Response(null, { status: 200 }),
|
||||
} as never)
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.attendee-list').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Alice')
|
||||
expect(wrapper.text()).toContain('Bob')
|
||||
expect(wrapper.find('.attendee-list__heading').text()).toBe('2 Attendees')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not show attendee list for visitor', async () => {
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.attendee-list').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows error when RSVP submission fails', async () => {
|
||||
mockLoadedEvent()
|
||||
vi.mocked(api.POST).mockResolvedValue({
|
||||
|
||||
35
specs/010-event-list-grouping/checklists/requirements.md
Normal file
35
specs/010-event-list-grouping/checklists/requirements.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Event List Temporal Grouping
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-08
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||
- One minor note: the Assumptions section mentions `Intl` API and `localStorage` — these are context references to existing behavior, not prescriptive implementation details.
|
||||
91
specs/010-event-list-grouping/data-model.md
Normal file
91
specs/010-event-list-grouping/data-model.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Data Model: Event List Temporal Grouping
|
||||
|
||||
**Feature**: 010-event-list-grouping | **Date**: 2026-03-08
|
||||
|
||||
## Existing Entities (no changes)
|
||||
|
||||
### StoredEvent
|
||||
|
||||
**Location**: `frontend/src/composables/useEventStorage.ts`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `eventToken` | `string` | UUID v4, unique identifier |
|
||||
| `organizerToken` | `string?` | UUID v4, present if user is organizer |
|
||||
| `title` | `string` | Event title |
|
||||
| `dateTime` | `string` | ISO 8601 with UTC offset (e.g., `"2026-03-15T20:00:00+01:00"`) |
|
||||
| `expiryDate` | `string` | ISO 8601 expiry date |
|
||||
| `rsvpToken` | `string?` | Present if user has RSVP'd |
|
||||
| `rsvpName` | `string?` | Name used for RSVP |
|
||||
|
||||
**Note**: No changes to `StoredEvent`. The `dateTime` field is the sole input for all grouping and sorting logic.
|
||||
|
||||
## New Types (frontend only)
|
||||
|
||||
### SectionKey
|
||||
|
||||
```typescript
|
||||
type SectionKey = 'today' | 'thisWeek' | 'later' | 'past'
|
||||
```
|
||||
|
||||
Enum-like union type for the four temporal sections. Ordering is fixed: today → thisWeek → later → past.
|
||||
|
||||
### EventSection
|
||||
|
||||
```typescript
|
||||
interface EventSection {
|
||||
key: SectionKey
|
||||
label: string // Display label: "Today", "This Week", "Later", "Past"
|
||||
dateGroups: DateGroup[]
|
||||
emphasized: boolean // true only for 'today' section
|
||||
}
|
||||
```
|
||||
|
||||
Represents one temporal section in the grouped list. Sections with no events are omitted entirely (never constructed).
|
||||
|
||||
### DateGroup
|
||||
|
||||
```typescript
|
||||
interface DateGroup {
|
||||
dateKey: string // YYYY-MM-DD (for keying/dedup)
|
||||
label: string // Formatted via Intl.DateTimeFormat, e.g., "Wed, 12 Mar"
|
||||
events: StoredEvent[] // Events on this date, sorted by time
|
||||
showSubheader: boolean // false for "Today" section (FR-005)
|
||||
}
|
||||
```
|
||||
|
||||
Groups events within a section by their specific calendar date. The `showSubheader` flag controls whether the date subheader is rendered (always false in "Today" section per FR-005).
|
||||
|
||||
## Grouping Algorithm
|
||||
|
||||
```
|
||||
Input: StoredEvent[], now: Date
|
||||
Output: EventSection[]
|
||||
|
||||
1. Compute boundaries:
|
||||
- startOfToday = today at 00:00:00 local
|
||||
- endOfToday = today at 23:59:59.999 local
|
||||
- endOfWeek = next Sunday at 23:59:59.999 local (or today if today is Sunday)
|
||||
|
||||
2. Classify each event by dateTime:
|
||||
- dateTime < startOfToday → "past"
|
||||
- startOfToday ≤ dateTime ≤ endOfToday → "today"
|
||||
- endOfToday < dateTime ≤ endOfWeek → "thisWeek"
|
||||
- dateTime > endOfWeek → "later"
|
||||
|
||||
3. Within each section, group by calendar date (YYYY-MM-DD)
|
||||
|
||||
4. Sort:
|
||||
- today/thisWeek/later: date groups ascending, events within group ascending by time
|
||||
- past: date groups descending, events within group descending by time
|
||||
|
||||
5. Emit only non-empty sections in fixed order: today, thisWeek, later, past
|
||||
```
|
||||
|
||||
## State Transitions
|
||||
|
||||
None. Events are static data in localStorage. Temporal classification is computed on each render based on current time. No event mutation occurs.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
No new validation. Existing `isValidStoredEvent()` in `useEventStorage.ts` already validates the `dateTime` field as a parseable ISO 8601 string.
|
||||
72
specs/010-event-list-grouping/plan.md
Normal file
72
specs/010-event-list-grouping/plan.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Implementation Plan: Event List Temporal Grouping
|
||||
|
||||
**Branch**: `010-event-list-grouping` | **Date**: 2026-03-08 | **Spec**: `specs/010-event-list-grouping/spec.md`
|
||||
**Input**: Feature specification from `/specs/010-event-list-grouping/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Extend the existing flat event list with temporal section grouping (Today, This Week, Later, Past). The feature is purely client-side: the existing `EventList.vue` computed property that separates events into upcoming/past is refactored into a four-section grouping with section headers, date subheaders, and context-aware time formatting. No backend changes, no new dependencies.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.9 (frontend only)
|
||||
**Primary Dependencies**: Vue 3, Vue Router 5 (existing — no additions)
|
||||
**Storage**: localStorage via `useEventStorage.ts` composable (existing — no changes)
|
||||
**Testing**: Vitest (unit), Playwright + MSW (E2E)
|
||||
**Target Platform**: PWA, mobile-first, all modern browsers
|
||||
**Project Type**: Web application (frontend enhancement)
|
||||
**Performance Goals**: Grouping computation < 1ms for 100 events (trivial — single array pass)
|
||||
**Constraints**: Client-side only, no additional network requests, offline-capable
|
||||
**Scale/Scope**: Typically < 50 events per user in localStorage
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Privacy by Design | PASS | No new data collection. Grouping uses existing `dateTime` field. No external services. |
|
||||
| II. Test-Driven Methodology | PASS | Unit tests for grouping logic + E2E tests for all user stories planned. TDD enforced. |
|
||||
| III. API-First Development | N/A | No API changes — purely frontend enhancement. |
|
||||
| IV. Simplicity & Quality | PASS | Minimal new code: one composable for grouping, template changes in EventList. No over-engineering. |
|
||||
| V. Dependency Discipline | PASS | No new dependencies. Uses browser `Intl` API and existing `Date` methods. |
|
||||
| VI. Accessibility | PASS | Section headers use semantic HTML (`<h2>`/`<h3>`), ARIA landmarks, keyboard navigable. WCAG AA contrast enforced. |
|
||||
|
||||
**Gate result: PASS** — no violations.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/010-event-list-grouping/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── spec.md # Feature specification
|
||||
└── tasks.md # Phase 2 output (via /speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── EventList.vue # MODIFY — add section grouping to template + computed
|
||||
│ │ ├── EventCard.vue # MODIFY — add time format mode prop
|
||||
│ │ ├── SectionHeader.vue # NEW — temporal section header component
|
||||
│ │ └── DateSubheader.vue # NEW — date subheader component
|
||||
│ ├── composables/
|
||||
│ │ ├── useEventGrouping.ts # NEW — grouping logic (pure function)
|
||||
│ │ ├── useRelativeTime.ts # EXISTING — no changes
|
||||
│ │ └── useEventStorage.ts # EXISTING — no changes
|
||||
│ └── components/__tests__/
|
||||
│ ├── EventList.spec.ts # MODIFY — update for grouped structure
|
||||
│ ├── EventCard.spec.ts # MODIFY — add time format tests
|
||||
│ └── useEventGrouping.spec.ts # NEW — unit tests for grouping logic
|
||||
├── e2e/
|
||||
│ └── home-events.spec.ts # MODIFY — add temporal grouping E2E tests
|
||||
```
|
||||
|
||||
**Structure Decision**: Frontend-only changes. Two new small components (SectionHeader, DateSubheader) and one new composable (useEventGrouping). Existing components modified minimally.
|
||||
118
specs/010-event-list-grouping/research.md
Normal file
118
specs/010-event-list-grouping/research.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Research: Event List Temporal Grouping
|
||||
|
||||
**Feature**: 010-event-list-grouping | **Date**: 2026-03-08
|
||||
|
||||
## 1. Week Boundary Calculation
|
||||
|
||||
**Decision**: Use ISO 8601 week convention (Monday = first day of week). "This Week" spans from tomorrow through Sunday of the current week.
|
||||
|
||||
**Rationale**: The spec explicitly states "ISO convention where Monday is the first day of the week" (Assumptions section). The browser's `Date.getDay()` returns 0 for Sunday, 1 for Monday — straightforward to compute end-of-week as next Sunday 23:59:59.
|
||||
|
||||
**Implementation**: Compare event date against:
|
||||
- `startOfToday` and `endOfToday` for "Today"
|
||||
- `startOfTomorrow` and `endOfSunday` for "This Week"
|
||||
- `after endOfSunday` for "Later"
|
||||
- `before startOfToday` for "Past"
|
||||
|
||||
Edge case (spec scenario 4): On Sunday, "This Week" is empty (tomorrow is already next week Monday), so events for Monday appear under "Later". This falls out naturally from the algorithm.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Using a date library (date-fns, luxon): Rejected — dependency discipline (Constitution V). Native `Date` + `Intl` is sufficient for this logic.
|
||||
- Locale-dependent week start: Rejected — spec mandates ISO convention explicitly.
|
||||
|
||||
## 2. Date Formatting for Subheaders
|
||||
|
||||
**Decision**: Use `Intl.DateTimeFormat` with `{ weekday: 'short', day: 'numeric', month: 'short' }` to produce labels like "Wed, 12 Mar".
|
||||
|
||||
**Rationale**: Consistent with existing use of `Intl.RelativeTimeFormat` in `useRelativeTime.ts`. Respects user locale for month/weekday names. No external dependency needed.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Hardcoded English day/month names: Rejected — the project already uses `Intl` APIs for locale awareness.
|
||||
- Full date format (e.g., "Wednesday, March 12, 2026"): Rejected — too long for mobile cards.
|
||||
|
||||
## 3. Time Display on Event Cards
|
||||
|
||||
**Decision**: Add a `timeDisplayMode` prop to `EventCard.vue` with two modes:
|
||||
- `'clock'`: Shows formatted time (e.g., "18:30") using `Intl.DateTimeFormat` with `{ hour: '2-digit', minute: '2-digit' }`
|
||||
- `'relative'`: Shows relative time (e.g., "3 days ago") using existing `formatRelativeTime()`
|
||||
|
||||
**Rationale**: Spec requires different time representations per section: clock time for Today/This Week/Later, relative time for Past. A prop-driven approach keeps EventCard stateless regarding section context.
|
||||
|
||||
**Alternatives considered**:
|
||||
- EventCard determining its own display mode: Rejected — card shouldn't know about sections; parent owns that context.
|
||||
- Passing a pre-formatted string: Viable but less type-safe. A mode enum is clearer.
|
||||
|
||||
## 4. Grouping Data Structure
|
||||
|
||||
**Decision**: The `useEventGrouping` composable returns an array of section objects:
|
||||
|
||||
```typescript
|
||||
interface EventSection {
|
||||
key: 'today' | 'thisWeek' | 'later' | 'past'
|
||||
label: string // "Today", "This Week", "Later", "Past"
|
||||
events: GroupedEvent[]
|
||||
}
|
||||
|
||||
interface DateGroup {
|
||||
date: string // ISO date string (YYYY-MM-DD) for keying
|
||||
label: string // Formatted date label (e.g., "Wed, 12 Mar")
|
||||
events: StoredEvent[]
|
||||
}
|
||||
|
||||
interface GroupedEvent extends StoredEvent {
|
||||
dateGroup: string // ISO date for sub-grouping
|
||||
}
|
||||
```
|
||||
|
||||
Actually, simpler: the composable returns sections, each containing date groups, each containing events.
|
||||
|
||||
```typescript
|
||||
interface EventSection {
|
||||
key: 'today' | 'thisWeek' | 'later' | 'past'
|
||||
label: string
|
||||
dateGroups: DateGroup[]
|
||||
}
|
||||
|
||||
interface DateGroup {
|
||||
dateKey: string // YYYY-MM-DD
|
||||
label: string // Formatted: "Wed, 12 Mar"
|
||||
events: StoredEvent[]
|
||||
}
|
||||
```
|
||||
|
||||
**Rationale**: Two-level grouping (section → date → events) matches the spec's hierarchy. Empty sections are simply omitted from the returned array (FR-002). The "Today" section still has one DateGroup but the template skips rendering its subheader (FR-005).
|
||||
|
||||
**Alternatives considered**:
|
||||
- Flat list with section markers: Harder to template, mixes data and presentation.
|
||||
- Map/Record structure: Arrays preserve ordering guarantees (Today → This Week → Later → Past).
|
||||
|
||||
## 5. Visual Emphasis for "Today" Section
|
||||
|
||||
**Decision**: Apply a CSS class `.section--today` to the Today section that uses:
|
||||
- Slightly larger section header (font-weight: 800, font-size: 1.1rem vs 700/1rem for others)
|
||||
- A subtle left border accent using the primary gradient pink (`#F06292`)
|
||||
|
||||
**Rationale**: Consistent with Electric Dusk design system. Subtle enough not to distract but visually distinct. The existing past-event fade (opacity: 0.6, saturate: 0.5) already handles the other end of the spectrum.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Background highlight: Could clash with card backgrounds on mobile.
|
||||
- Icon/emoji prefix: Spec doesn't mention icons; keep it typography-driven per design system.
|
||||
|
||||
## 6. Accessibility Considerations
|
||||
|
||||
**Decision**:
|
||||
- Section headers are `<h2>` elements
|
||||
- Date subheaders are `<h3>` elements
|
||||
- The event list container keeps its existing `role="list"`
|
||||
- Each section is a `<section>` element with `aria-label` matching the section label
|
||||
|
||||
**Rationale**: Constitution VI requires semantic HTML and ARIA. The heading hierarchy (h2 > h3) provides screen reader navigation landmarks. The `<section>` element with label allows assistive technology to announce section boundaries.
|
||||
|
||||
## 7. Existing Test Updates
|
||||
|
||||
**Decision**:
|
||||
- Existing `EventList.spec.ts` unit tests will be updated to account for the new grouped structure (sections instead of flat list)
|
||||
- Existing `home-events.spec.ts` E2E tests will be extended with new scenarios for temporal grouping
|
||||
- New `useEventGrouping.spec.ts` tests the pure grouping function in isolation
|
||||
|
||||
**Rationale**: TDD (Constitution II). The grouping logic is a pure function — ideal for thorough unit testing with various date combinations and edge cases.
|
||||
138
specs/010-event-list-grouping/spec.md
Normal file
138
specs/010-event-list-grouping/spec.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Feature Specification: Event List Temporal Grouping
|
||||
|
||||
**Feature Branch**: `010-event-list-grouping`
|
||||
**Created**: 2026-03-08
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Extend the event list with temporal grouping so users know if an event is today, this week, or further in the future."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Temporal Section Headers (Priority: P1)
|
||||
|
||||
As a user viewing my event list, I want events grouped under clear date-based section headers so I can instantly see what's happening today, this week, and later without reading individual dates.
|
||||
|
||||
The list displays events under these temporal sections (in order):
|
||||
|
||||
1. **Today** — events happening today
|
||||
2. **This Week** — events from tomorrow through end of current week (Sunday)
|
||||
3. **Later** — upcoming events beyond this week
|
||||
4. **Past** — events that have already occurred
|
||||
|
||||
Each section only appears if it contains at least one event. Empty sections are hidden entirely.
|
||||
|
||||
**Why this priority**: The core value of this feature — temporal orientation at a glance. Without section headers, the rest of the feature has no foundation.
|
||||
|
||||
**Independent Test**: Can be fully tested by adding events with various dates to localStorage and verifying they appear under the correct section headers.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user has events today, tomorrow, next week, and last week, **When** they view the event list, **Then** they see four sections: "Today", "This Week", "Later", and "Past" with events correctly distributed.
|
||||
2. **Given** a user has only events for today, **When** they view the event list, **Then** only the "Today" section is visible — no empty sections appear.
|
||||
3. **Given** a user has no events at all, **When** they view the event list, **Then** the empty state is shown (as currently implemented).
|
||||
4. **Given** it is Sunday and an event is scheduled for Monday, **When** the user views the list, **Then** the Monday event appears under "Later" (not "This Week"), because the current week ends on Sunday.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Date Subheaders Within Sections (Priority: P2)
|
||||
|
||||
Within each section (except "Today"), events are further grouped by their specific date with a subheader showing the formatted date (e.g., "Sat, 17 Sep"). This mirrors the inspiration layout where individual dates appear as smaller headings under the main temporal section.
|
||||
|
||||
Within the "Today" section, no date subheader is needed since all events share the same date.
|
||||
|
||||
**Why this priority**: Adds finer-grained orientation within sections — especially important when "This Week" or "Later" contain multiple events across different days.
|
||||
|
||||
**Independent Test**: Can be tested by adding multiple events on different days within the same temporal section and verifying date subheaders appear.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user has events on Wednesday and Friday of this week, **When** they view the "This Week" section, **Then** events are grouped under date subheaders like "Wed, 12 Mar" and "Fri, 14 Mar".
|
||||
2. **Given** a user has three events today, **When** they view the "Today" section, **Then** no date subheader appears — events are listed directly under the "Today" header.
|
||||
3. **Given** two events on the same future date, **When** the user views the list, **Then** both appear under a single date subheader for that day, sorted by time.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Enhanced Event Card Information (Priority: P2)
|
||||
|
||||
Each event card within the grouped list shows time information relevant to its context:
|
||||
|
||||
- **Today's events**: Show the time (e.g., "18:30") prominently, since the date is implied by the section.
|
||||
- **Future events**: Show the time (e.g., "18:30") — the date is provided by the subheader.
|
||||
- **Past events**: Continue showing relative time (e.g., "3 days ago") as currently implemented, since exact time matters less.
|
||||
|
||||
The existing role badges (Organizer/Attendee) and event title remain as-is.
|
||||
|
||||
**Why this priority**: Completes the information design — users need different time representations depending on temporal context.
|
||||
|
||||
**Independent Test**: Can be tested by checking that event cards display the correct time format based on which section they appear in.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an event today at 18:30, **When** the user views the "Today" section, **Then** the card shows "18:30" (not "in 3 hours").
|
||||
2. **Given** an event on Friday at 10:00, **When** the user views it under "This Week", **Then** the card shows "10:00".
|
||||
3. **Given** a past event from 3 days ago, **When** the user views the "Past" section, **Then** the card shows "3 days ago" as it does currently.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Today Section Visual Emphasis (Priority: P3)
|
||||
|
||||
The "Today" section header and its event cards receive subtle visual emphasis to draw the user's attention to what's happening now. This could be a slightly larger section header, bolder typography, or a subtle highlight — consistent with the Electric Dusk design system.
|
||||
|
||||
Past events continue to appear visually faded (reduced opacity/saturation) as currently implemented.
|
||||
|
||||
**Why this priority**: Nice visual polish that reinforces the temporal hierarchy, but the feature works without it.
|
||||
|
||||
**Independent Test**: Can be verified visually by checking that the "Today" section stands out compared to other sections.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** events exist for today and later, **When** the user views the list, **Then** the "Today" section is visually more prominent than other sections.
|
||||
2. **Given** only past events exist, **When** the user views the list, **Then** the "Past" section uses the existing faded treatment without any special emphasis.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the user's device clock is set incorrectly? Events may appear in the wrong section — this is acceptable, no special handling needed.
|
||||
- What happens at midnight when "today" changes? The grouping updates on next page load or navigation; real-time re-sorting is not required.
|
||||
- What happens with an event at exactly midnight (00:00)? It belongs to the day it falls on — same as any other time.
|
||||
- What happens when a section has many events (10+)? All events are shown; no pagination or truncation within sections.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST group events into temporal sections: "Today", "This Week", "Later", and "Past".
|
||||
- **FR-002**: System MUST hide sections that contain no events.
|
||||
- **FR-003**: System MUST display section headers with the temporal label (e.g., "Today", "This Week").
|
||||
- **FR-004**: System MUST display date subheaders within "This Week", "Later", and "Past" sections when events span multiple days.
|
||||
- **FR-005**: System MUST NOT display a date subheader within the "Today" section.
|
||||
- **FR-006**: System MUST sort events within each section by time ascending (earliest first) for upcoming events and by time descending (most recent first) for past events.
|
||||
- **FR-007**: System MUST display clock time (e.g., "18:30") on event cards in "Today", "This Week", and "Later" sections.
|
||||
- **FR-008**: System MUST display relative time (e.g., "3 days ago") on event cards in the "Past" section.
|
||||
- **FR-009**: System MUST visually emphasize the "Today" section compared to other sections.
|
||||
- **FR-010**: System MUST continue to fade past events visually (as currently implemented).
|
||||
- **FR-011**: System MUST preserve existing functionality: role badges, swipe-to-delete, delete confirmation, empty state.
|
||||
- **FR-012**: "This Week" MUST include events from tomorrow through the end of the current calendar week (Sunday).
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Temporal Section**: A grouping label ("Today", "This Week", "Later", "Past") that organizes events by their relationship to the current date.
|
||||
- **Date Subheader**: A formatted date label (e.g., "Sat, 17 Sep") that groups events within a temporal section by their specific date.
|
||||
- **StoredEvent**: Existing entity — no changes to its structure are required. The `dateTime` field is used for all grouping and sorting logic.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Users can identify how many events they have today within 2 seconds of viewing the list.
|
||||
- **SC-002**: Every event in the list is assigned to exactly one temporal section — no event appears in multiple sections or is missing.
|
||||
- **SC-003**: Section ordering is always consistent: Today > This Week > Later > Past.
|
||||
- **SC-004**: The feature works entirely client-side with no additional network requests beyond what currently exists.
|
||||
- **SC-005**: All existing event list functionality (delete, navigation, role badges) continues to work unchanged.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The user's locale and timezone are used for determining "today" and formatting dates/times (via the browser's `Intl` API, consistent with existing approach).
|
||||
- "Week" follows ISO convention where Monday is the first day of the week. "This Week" runs from tomorrow through Sunday.
|
||||
- The design system (Electric Dusk + Sora) applies to all new visual elements. The inspiration screenshot's color theme is explicitly NOT adopted.
|
||||
- No backend changes are needed — this is a purely frontend enhancement to the existing client-side event list.
|
||||
189
specs/010-event-list-grouping/tasks.md
Normal file
189
specs/010-event-list-grouping/tasks.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Tasks: Event List Temporal Grouping
|
||||
|
||||
**Input**: Design documents from `/specs/010-event-list-grouping/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md
|
||||
|
||||
**Tests**: Included — spec.md references TDD (Constitution II), and research.md explicitly plans unit + E2E test updates.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
**Purpose**: No new project setup needed — this is a frontend-only enhancement to an existing codebase. Phase 1 is empty.
|
||||
|
||||
*(No tasks — existing project structure is sufficient.)*
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Create the core grouping composable and its types — all user stories depend on this logic.
|
||||
|
||||
**CRITICAL**: No user story work can begin until this phase is complete.
|
||||
|
||||
- [ ] T001 Define `SectionKey`, `EventSection`, and `DateGroup` types in `frontend/src/composables/useEventGrouping.ts`
|
||||
- [ ] T002 Implement `useEventGrouping` composable with section classification, date grouping, and sorting logic in `frontend/src/composables/useEventGrouping.ts`
|
||||
- [ ] T003 Write unit tests for `useEventGrouping` covering all four sections, empty-section omission, sort order, and Sunday edge case in `frontend/src/components/__tests__/useEventGrouping.spec.ts`
|
||||
|
||||
**Checkpoint**: Grouping logic is fully tested and ready for consumption by UI components.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Temporal Section Headers (Priority: P1) MVP
|
||||
|
||||
**Goal**: Events appear grouped under "Today", "This Week", "Later", and "Past" section headers. Empty sections are hidden.
|
||||
|
||||
**Independent Test**: Add events with various dates to localStorage, verify they appear under correct section headers.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||
|
||||
- [ ] T004 [P] [US1] Write unit tests for `SectionHeader.vue` rendering section label and emphasis flag in `frontend/src/components/__tests__/SectionHeader.spec.ts`
|
||||
- [ ] T005 [P] [US1] Update `EventList.spec.ts` tests to expect grouped section structure instead of flat list in `frontend/src/components/__tests__/EventList.spec.ts`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [ ] T006 [P] [US1] Create `SectionHeader.vue` component with section label (`<h2>`) and `aria-label` in `frontend/src/components/SectionHeader.vue`
|
||||
- [ ] T007 [US1] Refactor `EventList.vue` template to use `useEventGrouping`, render `<section>` per temporal group with `SectionHeader`, and hide empty sections in `frontend/src/components/EventList.vue`
|
||||
- [ ] T008 [US1] Update E2E tests in `home-events.spec.ts` to verify section headers appear with correct events distributed across "Today", "This Week", "Later", "Past" in `frontend/e2e/home-events.spec.ts`
|
||||
|
||||
**Checkpoint**: Event list shows temporal section headers. All four acceptance scenarios pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Date Subheaders Within Sections (Priority: P2)
|
||||
|
||||
**Goal**: Within each section (except "Today"), events are further grouped by date with formatted subheaders like "Wed, 12 Mar".
|
||||
|
||||
**Independent Test**: Add multiple events on different days within one section, verify date subheaders appear.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||
|
||||
- [ ] T009 [P] [US2] Write unit tests for `DateSubheader.vue` rendering formatted date label in `frontend/src/components/__tests__/DateSubheader.spec.ts`
|
||||
- [ ] T010 [P] [US2] Add unit tests to `EventList.spec.ts` verifying date subheaders appear within sections and are absent in "Today" in `frontend/src/components/__tests__/EventList.spec.ts`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [ ] T011 [P] [US2] Create `DateSubheader.vue` component with formatted date (`<h3>`) using `Intl.DateTimeFormat` in `frontend/src/components/DateSubheader.vue`
|
||||
- [ ] T012 [US2] Update `EventList.vue` template to render `DateSubheader` within each section's date groups, skipping subheader for "Today" section (`showSubheader` flag) in `frontend/src/components/EventList.vue`
|
||||
- [ ] T013 [US2] Add E2E test scenarios for date subheaders: multiple days within a section, no subheader in "Today" in `frontend/e2e/home-events.spec.ts`
|
||||
|
||||
**Checkpoint**: Date subheaders render correctly within sections. "Today" section has no subheader.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Enhanced Event Card Time Display (Priority: P2)
|
||||
|
||||
**Goal**: Event cards show clock time ("18:30") in Today/This Week/Later sections and relative time ("3 days ago") in Past section.
|
||||
|
||||
**Independent Test**: Check event cards display the correct time format based on which section they appear in.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||
|
||||
- [ ] T014 [P] [US3] Add unit tests to `EventCard.spec.ts` for `timeDisplayMode` prop: `'clock'` renders formatted time, `'relative'` renders relative time in `frontend/src/components/__tests__/EventCard.spec.ts`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [ ] T015 [US3] Add `timeDisplayMode` prop (`'clock' | 'relative'`) to `EventCard.vue`, render clock time via `Intl.DateTimeFormat({ hour: '2-digit', minute: '2-digit' })` or existing `formatRelativeTime()` in `frontend/src/components/EventCard.vue`
|
||||
- [ ] T016 [US3] Update `EventList.vue` to pass `timeDisplayMode="clock"` for today/thisWeek/later sections and `timeDisplayMode="relative"` for past section in `frontend/src/components/EventList.vue`
|
||||
- [ ] T017 [US3] Add E2E test scenarios verifying clock time in future sections and relative time in past section in `frontend/e2e/home-events.spec.ts`
|
||||
|
||||
**Checkpoint**: Time display adapts to section context. All three acceptance scenarios pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 — Today Section Visual Emphasis (Priority: P3)
|
||||
|
||||
**Goal**: The "Today" section header is visually more prominent (bolder, slightly larger, accent border) than other sections.
|
||||
|
||||
**Independent Test**: Visual verification that "Today" stands out compared to other section headers.
|
||||
|
||||
- [ ] T018 [US4] Add `.section--today` CSS class to `SectionHeader.vue` with `font-weight: 800`, `font-size: 1.1rem`, and left border accent (`#F06292`) — triggered by `emphasized` prop in `frontend/src/components/SectionHeader.vue`
|
||||
- [ ] T019 [US4] Verify `EventList.vue` passes `emphasized: true` for the "Today" section (already set via `EventSection.emphasized` from data model) in `frontend/src/components/EventList.vue`
|
||||
- [ ] T020 [US4] Add visual E2E assertion checking that the "Today" section header has the emphasis CSS class applied in `frontend/e2e/home-events.spec.ts`
|
||||
|
||||
**Checkpoint**: "Today" section is visually distinct. Past events remain faded.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final validation and regression checks.
|
||||
|
||||
- [ ] T021 Run full unit test suite (`cd frontend && npm run test:unit`) and fix any regressions
|
||||
- [ ] T022 Run full E2E test suite and verify all existing functionality (swipe-to-delete, role badges, empty state, navigation) still works in `frontend/e2e/home-events.spec.ts`
|
||||
- [ ] T023 Verify accessibility: section headers are `<h2>`, date subheaders are `<h3>`, sections have `aria-label`, keyboard navigation works
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: Empty — no work needed
|
||||
- **Foundational (Phase 2)**: No dependencies — can start immediately
|
||||
- **US1 (Phase 3)**: Depends on Phase 2 (grouping composable)
|
||||
- **US2 (Phase 4)**: Depends on Phase 3 (needs section structure in EventList)
|
||||
- **US3 (Phase 5)**: Depends on Phase 3 (needs section context for time mode)
|
||||
- **US4 (Phase 6)**: Depends on Phase 3 (needs SectionHeader component)
|
||||
- **Polish (Phase 7)**: Depends on all user stories being complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: Can start after Foundational — no dependencies on other stories
|
||||
- **US2 (P2)**: Depends on US1 (needs section structure in template to add subheaders)
|
||||
- **US3 (P2)**: Depends on US1 (needs section context to determine time mode), independent of US2
|
||||
- **US4 (P3)**: Depends on US1 (needs SectionHeader component), independent of US2/US3
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- **Phase 2**: T001 must precede T002; T003 can run after T002
|
||||
- **Phase 3**: T004 and T005 in parallel; T006 in parallel with tests; T007 after T006
|
||||
- **Phase 4**: T009 and T010 in parallel; T011 in parallel with tests; T012 after T011
|
||||
- **Phase 5**: T014 can start as soon as US1 is done; T015 after T014; T016 after T015
|
||||
- **Phase 6**: T018 can run in parallel with Phase 5 (different files)
|
||||
- **US3 and US4** can run in parallel after US1 completes
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: After US1 Completes
|
||||
|
||||
```bash
|
||||
# These can run in parallel (different files, no dependencies):
|
||||
Task: T009 [US2] Write DateSubheader unit tests
|
||||
Task: T014 [US3] Write EventCard time mode unit tests
|
||||
Task: T018 [US4] Add .section--today CSS to SectionHeader.vue
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 2: Foundational (grouping composable + tests)
|
||||
2. Complete Phase 3: User Story 1 (section headers in EventList)
|
||||
3. **STOP and VALIDATE**: Test US1 independently
|
||||
4. Deploy/demo if ready — list is already grouped with headers
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Phase 2 → Grouping logic ready
|
||||
2. Add US1 → Section headers visible → Deploy/Demo (MVP!)
|
||||
3. Add US2 → Date subheaders within sections → Deploy/Demo
|
||||
4. Add US3 → Context-aware time display → Deploy/Demo
|
||||
5. Add US4 → Visual polish for "Today" → Deploy/Demo
|
||||
6. Each story adds value without breaking previous stories
|
||||
34
specs/011-view-attendee-list/checklists/requirements.md
Normal file
34
specs/011-view-attendee-list/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: View Attendee List
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-08
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||
136
specs/011-view-attendee-list/contracts/api.md
Normal file
136
specs/011-view-attendee-list/contracts/api.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# API Contract: View Attendee List (011)
|
||||
|
||||
**Date**: 2026-03-08
|
||||
|
||||
## New Endpoint
|
||||
|
||||
### `GET /events/{token}/attendees`
|
||||
|
||||
Retrieves the list of attendees for an event. Restricted to the event organizer.
|
||||
|
||||
**Path Parameters**:
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| token | string (UUID) | Event token |
|
||||
|
||||
**Query Parameters**:
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| organizerToken | string (UUID) | Yes | Organizer token for authorization |
|
||||
|
||||
**Responses**:
|
||||
|
||||
#### 200 OK
|
||||
|
||||
Organizer token is valid. Returns the attendee list.
|
||||
|
||||
```json
|
||||
{
|
||||
"attendees": [
|
||||
{ "name": "Alice" },
|
||||
{ "name": "Bob" },
|
||||
{ "name": "Charlie" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 200 OK (empty list)
|
||||
|
||||
No RSVPs yet.
|
||||
|
||||
```json
|
||||
{
|
||||
"attendees": []
|
||||
}
|
||||
```
|
||||
|
||||
#### 403 Forbidden
|
||||
|
||||
Organizer token is missing, invalid, or does not match the event.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "about:blank",
|
||||
"title": "Forbidden",
|
||||
"status": 403,
|
||||
"detail": "Invalid organizer token."
|
||||
}
|
||||
```
|
||||
|
||||
#### 404 Not Found
|
||||
|
||||
Event token does not exist.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "about:blank",
|
||||
"title": "Not Found",
|
||||
"status": 404,
|
||||
"detail": "Event not found."
|
||||
}
|
||||
```
|
||||
|
||||
## OpenAPI Schema Addition
|
||||
|
||||
```yaml
|
||||
/events/{token}/attendees:
|
||||
get:
|
||||
operationId: getAttendees
|
||||
summary: Get attendee list for an event (organizer only)
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- name: organizerToken
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: Attendee list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GetAttendeesResponse'
|
||||
'403':
|
||||
description: Invalid organizer token
|
||||
'404':
|
||||
description: Event not found
|
||||
|
||||
GetAttendeesResponse:
|
||||
type: object
|
||||
required:
|
||||
- attendees
|
||||
properties:
|
||||
attendees:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Attendee'
|
||||
example:
|
||||
- name: "Alice"
|
||||
- name: "Bob"
|
||||
|
||||
Attendee:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 100
|
||||
example: "Alice"
|
||||
```
|
||||
|
||||
## Existing Endpoints (unchanged)
|
||||
|
||||
- `POST /events` — no changes
|
||||
- `GET /events/{token}` — no changes (still returns `attendeeCount` publicly)
|
||||
- `POST /events/{token}/rsvps` — no changes
|
||||
72
specs/011-view-attendee-list/data-model.md
Normal file
72
specs/011-view-attendee-list/data-model.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Data Model: View Attendee List (011)
|
||||
|
||||
**Date**: 2026-03-08
|
||||
|
||||
## Entities
|
||||
|
||||
### Rsvp (existing — no schema changes)
|
||||
|
||||
The attendee list feature reads from the existing `rsvps` table. No new tables or columns are required.
|
||||
|
||||
| Field | Type | Constraints | Notes |
|
||||
|-------|------|-------------|-------|
|
||||
| id | BIGSERIAL | PK, auto-increment | Chronological order proxy |
|
||||
| rsvp_token | UUID | UNIQUE, NOT NULL | Public identifier |
|
||||
| event_id | BIGINT | FK → events.id, NOT NULL | CASCADE DELETE |
|
||||
| name | VARCHAR(100) | NOT NULL | Display name shown to organizer |
|
||||
|
||||
**Existing indexes**: `idx_rsvps_event_id` (on `event_id`), `idx_rsvps_rsvp_token` (on `rsvp_token`).
|
||||
|
||||
### Event (existing — no schema changes)
|
||||
|
||||
The `organizer_token` column on the `events` table is used for authorization. The endpoint verifies that the provided organizer token matches the event's stored token.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| organizer_token | UUID | UNIQUE, NOT NULL — used for attendee list authorization |
|
||||
|
||||
## Query Patterns
|
||||
|
||||
### Get attendees by event token
|
||||
|
||||
```sql
|
||||
SELECT r.name
|
||||
FROM rsvps r
|
||||
JOIN events e ON r.event_id = e.id
|
||||
WHERE e.event_token = :eventToken
|
||||
ORDER BY r.id ASC;
|
||||
```
|
||||
|
||||
**Performance**: Uses existing `idx_rsvps_event_id` index. Expected result set is small (spec assumes small-to-medium events, no pagination needed).
|
||||
|
||||
### Organizer token verification
|
||||
|
||||
```sql
|
||||
SELECT e.organizer_token
|
||||
FROM events e
|
||||
WHERE e.event_token = :eventToken;
|
||||
```
|
||||
|
||||
Already implemented in `EventService.getByEventToken()` — the event entity includes the organizer token. The use case compares the provided token against the stored one.
|
||||
|
||||
## Domain Model Changes
|
||||
|
||||
### New Outbound Port Method
|
||||
|
||||
```java
|
||||
// RsvpRepository (existing interface)
|
||||
List<Rsvp> findByEventId(Long eventId); // NEW
|
||||
```
|
||||
|
||||
### New Inbound Port
|
||||
|
||||
```java
|
||||
// GetAttendeesUseCase (new interface)
|
||||
List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken);
|
||||
```
|
||||
|
||||
Returns a list of attendee display names. Throws `EventNotFoundException` if event token is invalid. Throws `AccessDeniedException` (or similar) if organizer token does not match.
|
||||
|
||||
## No Migration Required
|
||||
|
||||
All required data structures already exist from changeset `003-create-rsvps-table.xml`. This feature only adds read access to existing data.
|
||||
101
specs/011-view-attendee-list/plan.md
Normal file
101
specs/011-view-attendee-list/plan.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Implementation Plan: View Attendee List
|
||||
|
||||
**Branch**: `011-view-attendee-list` | **Date**: 2026-03-08 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/011-view-attendee-list/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add an organizer-only attendee list to the event detail view. A new `GET /events/{token}/attendees?organizerToken=<uuid>` endpoint returns attendee names when the organizer token is valid (403 otherwise). The frontend conditionally renders the list below the attendee count when the viewer is identified as the organizer via localStorage.
|
||||
|
||||
## 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 + Testcontainers (backend integration), Vitest (frontend unit), Playwright + MSW (E2E)
|
||||
**Target Platform**: Self-hosted web application (PWA)
|
||||
**Project Type**: Web application (full-stack)
|
||||
**Performance Goals**: Attendee list loads within 2 seconds (SC-001)
|
||||
**Constraints**: Privacy by Design — attendee names only exposed to organizer; no PII logging
|
||||
**Scale/Scope**: Small-to-medium events; no pagination required (spec assumption)
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Privacy by Design | ✅ PASS | Attendee names only exposed via organizer token verification. Non-organizers see count only (FR-003). No analytics/tracking added. |
|
||||
| II. Test-Driven Methodology | ✅ PASS | Plan follows Research → Spec → Test → Implement. TDD enforced. E2E tests mandatory for both user stories. |
|
||||
| III. API-First Development | ✅ PASS | New endpoint defined in OpenAPI spec first. Types generated before implementation. Response schemas include `example:` fields. |
|
||||
| IV. Simplicity & Quality | ✅ PASS | Minimal new code: one endpoint, one use case, one component section. No over-engineering. |
|
||||
| V. Dependency Discipline | ✅ PASS | No new dependencies introduced. |
|
||||
| VI. Accessibility | ✅ PASS | Semantic HTML list for attendees. WCAG AA contrast. Keyboard-navigable. |
|
||||
|
||||
**Gate result**: ALL PASS — proceed to Phase 0.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/011-view-attendee-list/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output
|
||||
│ └── api.md # New endpoint contract
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
backend/
|
||||
├── src/main/java/de/fete/
|
||||
│ ├── domain/
|
||||
│ │ ├── model/ # Existing: Event, Rsvp, tokens
|
||||
│ │ └── port/
|
||||
│ │ ├── in/
|
||||
│ │ │ └── GetAttendeesUseCase.java # NEW: inbound port
|
||||
│ │ └── out/
|
||||
│ │ └── RsvpRepository.java # MODIFY: add findByEventId
|
||||
│ ├── application/service/
|
||||
│ │ └── RsvpService.java # MODIFY: implement GetAttendeesUseCase
|
||||
│ ├── adapter/
|
||||
│ │ ├── in/web/
|
||||
│ │ │ └── EventController.java # MODIFY: add attendees endpoint
|
||||
│ │ └── out/persistence/
|
||||
│ │ ├── RsvpJpaRepository.java # MODIFY: add findByEventId query
|
||||
│ │ └── RsvpPersistenceAdapter.java # MODIFY: implement findByEventId
|
||||
│ └── src/main/resources/openapi/
|
||||
│ └── api.yaml # MODIFY: add attendees endpoint + schema
|
||||
├── src/test/java/de/fete/
|
||||
│ ├── adapter/in/web/
|
||||
│ │ └── EventControllerIntegrationTest.java # MODIFY: add attendees tests
|
||||
│ └── application/service/
|
||||
│ └── RsvpServiceTest.java # MODIFY: add getAttendees tests
|
||||
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── views/
|
||||
│ │ └── EventDetailView.vue # MODIFY: add attendee list section
|
||||
│ ├── components/
|
||||
│ │ └── AttendeeList.vue # NEW: attendee list component
|
||||
│ ├── api/
|
||||
│ │ └── schema.d.ts # REGENERATED from OpenAPI
|
||||
│ └── composables/
|
||||
│ └── useEventStorage.ts # NO CHANGES (read-only usage)
|
||||
├── src/views/__tests__/
|
||||
│ └── EventDetailView.spec.ts # MODIFY: add attendee list tests
|
||||
├── src/components/__tests__/
|
||||
│ └── AttendeeList.spec.ts # NEW: unit tests
|
||||
└── e2e/
|
||||
└── view-attendee-list.spec.ts # NEW: E2E tests
|
||||
```
|
||||
|
||||
**Structure Decision**: Extends the existing web application structure. Backend follows hexagonal architecture with new inbound port + implementation. Frontend adds one new component integrated into the existing EventDetailView.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> No constitution violations — section not applicable.
|
||||
76
specs/011-view-attendee-list/quickstart.md
Normal file
76
specs/011-view-attendee-list/quickstart.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Quickstart: View Attendee List (011)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Java 25 (SDKMAN)
|
||||
- Node.js 20+ / npm
|
||||
- PostgreSQL running (or Docker for Testcontainers)
|
||||
|
||||
## Development Flow
|
||||
|
||||
### 1. Update OpenAPI spec
|
||||
|
||||
Edit `backend/src/main/resources/openapi/api.yaml` to add the `GET /events/{token}/attendees` endpoint and response schemas (see `contracts/api.md`).
|
||||
|
||||
### 2. Generate types
|
||||
|
||||
```bash
|
||||
# Backend: regenerate Spring interfaces
|
||||
cd backend && ./mvnw compile
|
||||
|
||||
# Frontend: regenerate TypeScript types
|
||||
cd frontend && npm run generate:api
|
||||
```
|
||||
|
||||
### 3. Backend implementation (TDD)
|
||||
|
||||
```bash
|
||||
# Write tests first
|
||||
cd backend && ./mvnw test
|
||||
|
||||
# Run specific test class
|
||||
cd backend && ./mvnw test -Dtest=EventControllerIntegrationTest
|
||||
cd backend && ./mvnw test -Dtest=RsvpServiceTest
|
||||
```
|
||||
|
||||
### 4. Frontend implementation (TDD)
|
||||
|
||||
```bash
|
||||
# Unit tests
|
||||
cd frontend && npm run test:unit
|
||||
|
||||
# E2E tests
|
||||
cd frontend && npx playwright test e2e/view-attendee-list.spec.ts
|
||||
```
|
||||
|
||||
### 5. Verify
|
||||
|
||||
```bash
|
||||
# Backend full verify (includes checkstyle)
|
||||
cd backend && ./mvnw verify
|
||||
|
||||
# Frontend build check
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
## Key Files to Modify
|
||||
|
||||
| Layer | File | Change |
|
||||
|-------|------|--------|
|
||||
| API Spec | `backend/src/main/resources/openapi/api.yaml` | Add endpoint + schemas |
|
||||
| Port (in) | `de.fete.domain.port.in.GetAttendeesUseCase` | New interface |
|
||||
| Port (out) | `de.fete.domain.port.out.RsvpRepository` | Add `findByEventId` |
|
||||
| Service | `de.fete.application.service.RsvpService` | Implement use case |
|
||||
| Persistence | `de.fete.adapter.out.persistence.RsvpJpaRepository` | Add query method |
|
||||
| Persistence | `de.fete.adapter.out.persistence.RsvpPersistenceAdapter` | Implement port method |
|
||||
| Controller | `de.fete.adapter.in.web.EventController` | Add endpoint handler |
|
||||
| Frontend | `src/views/EventDetailView.vue` | Integrate AttendeeList |
|
||||
| Frontend | `src/components/AttendeeList.vue` | New component |
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Backend unit test: `RsvpService.getAttendeeNames` — valid token, invalid token, no RSVPs
|
||||
- [ ] Backend integration test: `GET /events/{token}/attendees` — 200, 403, 404
|
||||
- [ ] Frontend unit test: `AttendeeList.vue` — renders names, empty state, loading
|
||||
- [ ] Frontend unit test: `EventDetailView.vue` — shows list for organizer, hides for visitor
|
||||
- [ ] E2E test: organizer sees attendee names, visitor sees count only
|
||||
68
specs/011-view-attendee-list/research.md
Normal file
68
specs/011-view-attendee-list/research.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Research: View Attendee List (011)
|
||||
|
||||
**Date**: 2026-03-08 | **Status**: Complete
|
||||
|
||||
## 1. Organizer Token Verification Pattern
|
||||
|
||||
**Decision**: Query parameter `?organizerToken=<uuid>` on the new endpoint.
|
||||
|
||||
**Rationale**: The project uses token-based access control without persistent sessions. The organizer token is stored in localStorage on the client. Passing it as a query parameter is the simplest approach that fits the existing architecture. The `GET /events/{token}` endpoint already uses path-based token lookup; adding a query parameter for the organizer token keeps the two concerns separate.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Authorization header: More RESTful, but adds complexity without benefit — no auth framework in place, and query params are simpler for this single use case.
|
||||
- Embed attendees in existing `GET /events/{token}` response: Rejected per spec clarification — separate endpoint keeps concerns clean and avoids exposing attendee data in the public response.
|
||||
|
||||
## 2. Endpoint Design
|
||||
|
||||
**Decision**: `GET /events/{token}/attendees?organizerToken=<uuid>` returns `{ attendees: [{ name: string }] }`.
|
||||
|
||||
**Rationale**:
|
||||
- Nested under `/events/{token}` — resource hierarchy is clear.
|
||||
- Returns an object with an `attendees` array (not a raw array) — allows future extension (e.g., adding metadata) without breaking the contract.
|
||||
- Each attendee object contains only `name` — minimal data exposure per Privacy by Design.
|
||||
- HTTP 403 for invalid/missing organizer token (not 401 — no authentication scheme exists).
|
||||
- HTTP 404 if the event token is invalid (consistent with existing `GET /events/{token}`).
|
||||
|
||||
**Alternatives considered**:
|
||||
- Return `{ attendees: [...], count: N }`: Rejected — count is derivable from array length, and already available on the existing event detail endpoint. Avoids redundancy.
|
||||
- Include RSVP timestamp: Rejected — spec says chronological order but doesn't require displaying timestamps. Order is implicit in array position.
|
||||
|
||||
## 3. Backend Implementation Approach
|
||||
|
||||
**Decision**: New `GetAttendeesUseCase` inbound port, implemented by `RsvpService`. New `findByEventId` method on `RsvpRepository` outbound port.
|
||||
|
||||
**Rationale**: Follows the established hexagonal architecture pattern exactly. Each use case gets its own inbound port interface. The persistence layer already has `RsvpJpaRepository` with `countByEventId`; adding `findAllByEventIdOrderByIdAsc` is a natural extension (ID order = chronological insertion order).
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add to `GetEventUseCase`: Rejected — violates single responsibility. The event detail endpoint is public; attendee retrieval is organizer-only.
|
||||
- Direct repository call in controller: Rejected — violates hexagonal architecture.
|
||||
|
||||
## 4. Frontend Integration Approach
|
||||
|
||||
**Decision**: New `AttendeeList.vue` component rendered conditionally in `EventDetailView.vue` when the viewer is the organizer. Fetches attendees via separate API call after event loads.
|
||||
|
||||
**Rationale**:
|
||||
- Separate component keeps EventDetailView manageable (it's already ~300 lines).
|
||||
- Separate API call (not bundled with event fetch) — the attendee list is organizer-only; non-organizers never trigger the request.
|
||||
- Component placed below attendee count, before RSVP form — matches spec FR-004.
|
||||
- Empty state handled within the component (FR-005).
|
||||
|
||||
**Alternatives considered**:
|
||||
- Inline in EventDetailView without separate component: Rejected — view is already complex. A dedicated component improves readability and testability.
|
||||
- Fetch attendees in the same call as event details: Not possible — separate endpoint by design.
|
||||
|
||||
## 5. Error Handling
|
||||
|
||||
**Decision**: Frontend silently degrades on 403 (does not render attendee list). No error toast or message shown.
|
||||
|
||||
**Rationale**: Per FR-007, the frontend "degrades gracefully by not rendering the list." If the organizer token is invalid (e.g., localStorage cleared on another device), the user sees the same view as a regular visitor. This is intentional — no confusing error states for edge cases that self-resolve.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Show error message on 403: Rejected — would confuse users who aren't expecting organizer features.
|
||||
- Retry with different token: Not applicable — only one token per event in localStorage.
|
||||
|
||||
## 6. Accessibility Considerations
|
||||
|
||||
**Decision**: Attendee list rendered as semantic `<ul>` with `<li>` items. Section has a heading for screen readers. Count label uses singular/plural form.
|
||||
|
||||
**Rationale**: Constitution VI requires WCAG AA compliance, semantic HTML, and keyboard navigation. A list of names is naturally a `<ul>`. The heading provides structure for screen reader navigation.
|
||||
87
specs/011-view-attendee-list/spec.md
Normal file
87
specs/011-view-attendee-list/spec.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Feature Specification: View Attendee List
|
||||
|
||||
**Feature Branch**: `011-view-attendee-list`
|
||||
**Created**: 2026-03-08
|
||||
**Status**: Draft
|
||||
**Input**: User description: "der organisator soll die Teilnehmerliste einsehen können, wenn er sich die detail view eines eigenen events anschaut"
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-03-08
|
||||
|
||||
- Q: API-Design — separater Endpoint oder bestehenden erweitern? → A: Separater Endpoint `GET /events/{token}/attendees`.
|
||||
- Q: Übermittlung des Organizer-Tokens? → A: Query-Parameter `?organizerToken=<uuid>`.
|
||||
- Q: UI-Platzierung der Attendee-Liste auf der Detail-Seite? → A: Direkt unter dem bestehenden Attendee-Count (vor dem RSVP-Formular).
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - View Attendee List as Organizer (Priority: P1)
|
||||
|
||||
As the organizer of an event, I want to see a list of all attendees (people who RSVPed) when I view my event's detail page, so that I know who is coming.
|
||||
|
||||
When the organizer opens the event detail view for an event they created, the page displays a list of attendee names directly below the existing attendee count (before the RSVP form). This list is only visible to the organizer — regular visitors only see the attendee count (existing behavior).
|
||||
|
||||
**Why this priority**: This is the core feature. Without it, organizers have no way to see who signed up for their event.
|
||||
|
||||
**Independent Test**: Can be fully tested by creating an event, submitting RSVPs from other browsers/sessions, then viewing the event detail page with the organizer token. The attendee names should be listed.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an organizer views their event with 3 RSVPs, **When** the detail page loads, **Then** the organizer sees a list showing all 3 attendee names.
|
||||
2. **Given** an organizer views their event with 0 RSVPs, **When** the detail page loads, **Then** the organizer sees an empty state message indicating no one has RSVPed yet.
|
||||
3. **Given** a regular visitor (non-organizer) views the same event, **When** the detail page loads, **Then** only the attendee count is shown — no individual names are visible.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Attendee Count Label (Priority: P2)
|
||||
|
||||
As the organizer, I want the attendee list to show the total count alongside the names, so I can quickly see how many people are attending at a glance.
|
||||
|
||||
**Why this priority**: Enhances the organizer experience but the count is already visible in the existing detail view, so this is supplementary.
|
||||
|
||||
**Independent Test**: Can be tested by verifying the attendee count displayed next to/above the list matches the number of entries in the list.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an organizer views their event with 5 RSVPs, **When** the attendee list is displayed, **Then** a heading or label shows "5 Attendees" (or equivalent) above the list.
|
||||
2. **Given** an organizer views their event with 1 RSVP, **When** the attendee list is displayed, **Then** the label uses singular form ("1 Attendee").
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the organizer token stored locally is invalid or belongs to a different event? The system treats the viewer as a regular visitor and shows the count only — no error is displayed.
|
||||
- What happens when an attendee name contains special characters or is very long? Names are displayed safely (escaped) and truncated visually if necessary.
|
||||
- What happens if a large number of attendees (e.g. 100+) have RSVPed? The list remains scrollable and performs well without pagination (events are expected to be small-to-medium scale).
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST provide a dedicated endpoint `GET /events/{token}/attendees?organizerToken=<uuid>` for organizers to retrieve the attendee list, separate from the public event detail endpoint.
|
||||
- **FR-002**: System MUST return each attendee's display name in the attendee list response.
|
||||
- **FR-003**: System MUST NOT expose individual attendee names to non-organizer visitors — only the aggregate count is shown (existing behavior preserved).
|
||||
- **FR-004**: The attendee list MUST be displayed directly below the attendee count on the event detail view (before the RSVP form) when the viewer is identified as the organizer.
|
||||
- **FR-005**: System MUST display an empty state message when no RSVPs exist for the event.
|
||||
- **FR-006**: System MUST display the total attendee count as a label alongside the attendee list.
|
||||
- **FR-007**: System MUST reject attendee list requests with an invalid or missing organizer token by returning HTTP 403 (no attendee data exposed; frontend degrades gracefully by not rendering the list).
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Attendee (RSVP)**: A person who has RSVPed to an event. The organizer sees their display name in a list; visitors see only the aggregate count.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Organizers can see the full attendee name list within 2 seconds of opening their event detail page.
|
||||
- **SC-002**: Non-organizer visitors never see individual attendee names — only the count is visible.
|
||||
- **SC-003**: The attendee list correctly reflects all RSVPs submitted for the event, with no missing or duplicate entries.
|
||||
- **SC-004**: The feature works correctly on both mobile and desktop viewports.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The organizer is identified by having a valid organizer token stored on the client. No additional login or authentication mechanism is introduced.
|
||||
- The attendee list is read-only — the organizer cannot remove or edit attendees from this view.
|
||||
- Attendee names are displayed in the order they RSVPed (chronological).
|
||||
- The existing event detail view layout is extended, not replaced, to accommodate the attendee list section.
|
||||
167
specs/011-view-attendee-list/tasks.md
Normal file
167
specs/011-view-attendee-list/tasks.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Tasks: View Attendee List
|
||||
|
||||
**Input**: Design documents from `/specs/011-view-attendee-list/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/api.md
|
||||
|
||||
**Tests**: Included — TDD enforced per constitution principle II. E2E tests mandatory per plan.
|
||||
|
||||
**Organization**: Tasks grouped by user story for independent implementation and testing.
|
||||
|
||||
## 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)
|
||||
- Exact file paths included in all descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (API Contract)
|
||||
|
||||
**Purpose**: Define the API contract in OpenAPI and generate types for both backend and frontend.
|
||||
|
||||
- [x] T001 Add `GET /events/{token}/attendees` endpoint, `GetAttendeesResponse`, and `Attendee` schemas to `backend/src/main/resources/openapi/api.yaml` per `contracts/api.md`
|
||||
- [x] T002 Regenerate backend Spring interfaces (`cd backend && ./mvnw compile`)
|
||||
- [x] T003 Regenerate frontend TypeScript types (`cd frontend && npm run generate:api`)
|
||||
|
||||
**Checkpoint**: OpenAPI spec updated, generated types available in both backend and frontend.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Backend Domain Ports)
|
||||
|
||||
**Purpose**: Create the inbound port and extend the outbound port that all backend implementation depends on.
|
||||
|
||||
- [x] T004 [P] Create `GetAttendeesUseCase` inbound port interface in `backend/src/main/java/de/fete/domain/port/in/GetAttendeesUseCase.java` with method `List<String> getAttendeeNames(UUID eventToken, UUID organizerToken)`
|
||||
- [x] T005 [P] Add `List<Rsvp> findByEventId(Long eventId)` method to `backend/src/main/java/de/fete/domain/port/out/RsvpRepository.java`
|
||||
|
||||
**Checkpoint**: Domain ports defined — service and adapter implementation can begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — View Attendee List as Organizer (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Organizer sees a list of attendee names on the event detail page. Non-organizers see only the count (existing behavior).
|
||||
|
||||
**Independent Test**: Create an event, submit RSVPs, then view the detail page with the organizer token. Attendee names should be listed.
|
||||
|
||||
### Tests for User Story 1 ⚠️
|
||||
|
||||
> **Write these tests FIRST — ensure they FAIL before implementation.**
|
||||
|
||||
- [x] T006 [P] [US1] Write unit tests for `RsvpService.getAttendeeNames` (valid token, invalid token, event not found, no RSVPs) in `backend/src/test/java/de/fete/application/service/RsvpServiceTest.java`
|
||||
- [x] T007 [P] [US1] Write integration tests for `GET /events/{token}/attendees` (200 with attendees, 200 empty, 403 invalid token, 404 event not found) in `backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java`
|
||||
- [x] T008 [P] [US1] Write unit tests for `AttendeeList.vue` (renders attendee names, empty state message, loading state) in `frontend/src/components/__tests__/AttendeeList.spec.ts`
|
||||
- [x] T009 [P] [US1] Write unit tests for organizer-conditional rendering in `EventDetailView.vue` (shows list for organizer, hides for visitor) in `frontend/src/views/__tests__/EventDetailView.spec.ts`
|
||||
- [x] T010 [P] [US1] Write E2E test: organizer sees attendee names, visitor sees count only, in `frontend/e2e/view-attendee-list.spec.ts`
|
||||
|
||||
### Backend Implementation for User Story 1
|
||||
|
||||
- [x] T011 [P] [US1] Add `findAllByEventIdOrderByIdAsc` query method to `backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaRepository.java`
|
||||
- [x] T012 [P] [US1] Implement `findByEventId` in `backend/src/main/java/de/fete/adapter/out/persistence/RsvpPersistenceAdapter.java`
|
||||
- [x] T013 [US1] Implement `GetAttendeesUseCase` in `backend/src/main/java/de/fete/application/service/RsvpService.java` — look up event by token, verify organizer token, return attendee names ordered by ID
|
||||
- [x] T014 [US1] Add `getAttendees` endpoint handler to `backend/src/main/java/de/fete/adapter/in/web/EventController.java` — map to `GetAttendeesUseCase`, return 200/403/404
|
||||
|
||||
### Frontend Implementation for User Story 1
|
||||
|
||||
- [x] T015 [US1] Create `AttendeeList.vue` component in `frontend/src/components/AttendeeList.vue` — accepts attendee names array as prop, renders semantic `<ul>/<li>` list, shows empty state message when no attendees
|
||||
- [x] T016 [US1] Integrate `AttendeeList.vue` into `frontend/src/views/EventDetailView.vue` — fetch `GET /events/{token}/attendees` with organizer token from localStorage, render below attendee count (before RSVP form), silently degrade on 403
|
||||
|
||||
**Checkpoint**: User Story 1 fully functional. Organizer sees attendee names; visitor sees count only. All tests pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Attendee Count Label (Priority: P2)
|
||||
|
||||
**Goal**: The attendee list section shows a count label ("5 Attendees" / "1 Attendee") alongside the names.
|
||||
|
||||
**Independent Test**: Verify the count label above the list matches the number of entries, and uses singular/plural correctly.
|
||||
|
||||
### Tests for User Story 2 ⚠️
|
||||
|
||||
> **Write these tests FIRST — ensure they FAIL before implementation.**
|
||||
|
||||
- [x] T017 [P] [US2] Write unit tests for count label in `AttendeeList.vue` (plural "5 Attendees", singular "1 Attendee", zero "0 Attendees") in `frontend/src/components/__tests__/AttendeeList.spec.ts`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T018 [US2] Add count heading to `AttendeeList.vue` in `frontend/src/components/AttendeeList.vue` — render `<h3>` with singular/plural label based on attendee array length
|
||||
|
||||
**Checkpoint**: User Story 2 complete. Count label renders correctly with singular/plural form. All tests pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Verification across both stories, accessibility, edge cases.
|
||||
|
||||
- [x] T019 Run full backend verification (`cd backend && ./mvnw verify`) — checkstyle, all tests green
|
||||
- [x] T020 Run full frontend build and tests (`cd frontend && npm run build && npm run test:unit`)
|
||||
- [x] T021 Run E2E tests (`cd frontend && npx playwright test e2e/view-attendee-list.spec.ts`)
|
||||
- [x] T022 Verify WCAG AA contrast and semantic HTML (attendee list uses `<ul>/<li>`, section has heading for screen readers)
|
||||
- [x] T023 Verify edge cases: long names truncated visually, special characters escaped, large attendee list scrollable
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies — start immediately
|
||||
- **Foundational (Phase 2)**: Depends on T002 (generated backend interfaces)
|
||||
- **User Story 1 (Phase 3)**: Depends on Phase 2 completion
|
||||
- **User Story 2 (Phase 4)**: Depends on T015 (AttendeeList component exists)
|
||||
- **Polish (Phase 5)**: Depends on Phase 3 + Phase 4 completion
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: Can start after Phase 2 — no dependencies on other stories
|
||||
- **User Story 2 (P2)**: Depends on US1's `AttendeeList.vue` component (T015) existing
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests MUST be written and FAIL before implementation
|
||||
- Ports/models before services
|
||||
- Services before endpoints/controllers
|
||||
- Backend before frontend (API must exist for frontend integration)
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T004 + T005 can run in parallel (different files)
|
||||
- T006 + T007 + T008 + T009 + T010 can all run in parallel (different test files)
|
||||
- T011 + T012 can run in parallel (different persistence files)
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch all tests in parallel (TDD — write first):
|
||||
Task: T006 "Unit tests for RsvpService.getAttendeeNames"
|
||||
Task: T007 "Integration tests for GET /events/{token}/attendees"
|
||||
Task: T008 "Unit tests for AttendeeList.vue"
|
||||
Task: T009 "Unit tests for EventDetailView.vue organizer rendering"
|
||||
Task: T010 "E2E test for attendee list"
|
||||
|
||||
# Launch persistence layer in parallel:
|
||||
Task: T011 "Add findAllByEventIdOrderByIdAsc to RsvpJpaRepository"
|
||||
Task: T012 "Implement findByEventId in RsvpPersistenceAdapter"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup (OpenAPI + type generation)
|
||||
2. Complete Phase 2: Foundational (domain ports)
|
||||
3. Complete Phase 3: User Story 1 (backend + frontend)
|
||||
4. **STOP and VALIDATE**: Test independently — organizer sees names, visitor sees count only
|
||||
5. Deploy/demo if ready
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Setup + Foundational → API contract and ports ready
|
||||
2. Add User Story 1 → Test independently → Deploy (MVP!)
|
||||
3. Add User Story 2 → Test independently → Deploy (count label enhancement)
|
||||
4. Polish phase → Full verification, accessibility, edge cases
|
||||
Reference in New Issue
Block a user