Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 752d153cd4 | |||
| 763811fce6 | |||
| d7ed28e036 | |||
| a52d0cd1d3 | |||
| 373f3671f6 | |||
| 8f78c6cd45 | |||
| fe291e36e4 | |||
| e56998b17c | |||
| 1b3eafa8d1 |
@@ -82,3 +82,113 @@ Die folgenden Punkte wurden in Diskussionen bereits geklärt und sind verbindlic
|
|||||||
* Frontend: Vue 3 (mit Vite als Bundler, TypeScript, Vue Router)
|
* Frontend: Vue 3 (mit Vite als Bundler, TypeScript, Vue Router)
|
||||||
* Architekturentscheidungen die NOCH NICHT getroffen wurden (hier darf nichts eigenmächtig entschieden werden!):
|
* Architekturentscheidungen die NOCH NICHT getroffen wurden (hier darf nichts eigenmächtig entschieden werden!):
|
||||||
* (derzeit keine offenen Architekturentscheidungen)
|
* (derzeit keine offenen Architekturentscheidungen)
|
||||||
|
|
||||||
|
## Nicht umgesetzte Feature-Ideen (ehemals Specs 009–026)
|
||||||
|
|
||||||
|
### 009 – Gästeliste
|
||||||
|
Organisator sieht alle RSVPs (Name, Status) und kann einzelne Einträge löschen.
|
||||||
|
* Nur mit gültigem Organizer-Token sichtbar
|
||||||
|
* Gäste ohne Token sehen keine Gästeliste
|
||||||
|
* Löschung serverseitig validiert
|
||||||
|
|
||||||
|
### 010 – Event bearbeiten
|
||||||
|
Organisator kann Titel, Beschreibung, Datum, Ort und Ablaufdatum ändern.
|
||||||
|
* Formular vorausgefüllt mit aktuellen Werten
|
||||||
|
* Ablaufdatum muss in der Zukunft liegen
|
||||||
|
* Ohne Organizer-Token kein Edit-UI sichtbar
|
||||||
|
|
||||||
|
### 011 – Event merken/bookmarken
|
||||||
|
Gäste können Events lokal merken, ohne RSVP abzugeben — rein clientseitig via localStorage.
|
||||||
|
* Kein Serverkontakt nötig
|
||||||
|
* Unabhängig vom RSVP-Status
|
||||||
|
* Auch bei abgelaufenen Events möglich
|
||||||
|
|
||||||
|
### 012 – Lokale Event-Übersicht
|
||||||
|
Startseite (`/`) zeigt alle getrackten Events (erstellt, zugesagt, gemerkt) aus localStorage.
|
||||||
|
* Zeigt Titel, Datum, Beziehungstyp (Organisator/Gast/Gemerkt)
|
||||||
|
* Vergangene Events als "beendet" markiert
|
||||||
|
* Einträge können entfernt werden
|
||||||
|
|
||||||
|
### 013 – Kalender-Export
|
||||||
|
.ics-Download (RFC 5545) mit Event-Details, optional webcal:// für Live-Updates.
|
||||||
|
* Stabile UID aus Event-Token (Re-Import aktualisiert statt dupliziert)
|
||||||
|
* Bei Absage: STATUS:CANCELLED im .ics
|
||||||
|
* Kein externer Kalenderservice kontaktiert
|
||||||
|
|
||||||
|
### 014 – Änderungen hervorheben
|
||||||
|
Geänderte Felder werden visuell hervorgehoben, wenn der Gast seit der letzten Änderung nicht mehr auf der Seite war.
|
||||||
|
* Server trackt `last_edited_at` + geänderte Feldnamen
|
||||||
|
* Client speichert `last_seen_at` in localStorage
|
||||||
|
* Privacy-freundlich: kein serverseitiges Read-Tracking
|
||||||
|
|
||||||
|
### 015 – Organisator-Updates
|
||||||
|
Organisator kann Textnachrichten im Event posten (Pinnwand-Stil).
|
||||||
|
* Chronologisch sortiert, löschbar durch Organisator
|
||||||
|
* Nach Ablauf kein Posting mehr möglich
|
||||||
|
* Ohne Organizer-Token kein Compose-UI
|
||||||
|
|
||||||
|
### 016 – Gast-Benachrichtigungen
|
||||||
|
Badge/Indikator bei ungelesenen Organisator-Updates, rein clientseitig via localStorage.
|
||||||
|
* Eigener Timestamp `updates_last_seen_at` (getrennt von Feld-Änderungen)
|
||||||
|
* Kein Indikator beim ersten Besuch
|
||||||
|
* Kein serverseitiges Tracking (Privacy)
|
||||||
|
|
||||||
|
### 017 – QR-Code
|
||||||
|
Event-Seite zeigt QR-Code mit der öffentlichen Event-URL.
|
||||||
|
* Serverseitig generiert (kein externer QR-Service)
|
||||||
|
* Download als SVG oder hochauflösendes PNG
|
||||||
|
* Auch bei abgelaufenen Events verfügbar
|
||||||
|
|
||||||
|
### 018 – Datenlöschung
|
||||||
|
Automatische Löschung aller Event-Daten nach Ablaufdatum (Privacy-Garantie).
|
||||||
|
* Scheduled Job oder Lazy Cleanup bei Zugriff
|
||||||
|
* Löscht Event, RSVPs, Updates, Bilder, Metadaten
|
||||||
|
* Idempotent, kein PII im Log
|
||||||
|
|
||||||
|
### 019 – Instanz-Limit
|
||||||
|
`MAX_ACTIVE_EVENTS` als Env-Variable begrenzt aktive Events für Self-Hoster.
|
||||||
|
* Nur nicht-abgelaufene Events zählen
|
||||||
|
* Unset/leer = unbegrenzt
|
||||||
|
* Serverseitige Durchsetzung bei Event-Erstellung
|
||||||
|
|
||||||
|
### 020 – PWA
|
||||||
|
Web App Manifest + Service Worker für Installierbarkeit und Offline-Caching.
|
||||||
|
* Standalone-Modus ohne Browser-Chrome
|
||||||
|
* Icon + Name auf Home-Screen
|
||||||
|
* Alle Assets selbstgehostet
|
||||||
|
|
||||||
|
### 021 – Farbthemen
|
||||||
|
Organisator wählt bei Erstellung ein vordefiniertes Farbthema für die Event-Seite.
|
||||||
|
* Nur auf der Gast-Seite angewendet (nicht global)
|
||||||
|
* Änderbar beim Bearbeiten
|
||||||
|
* Unabhängig von Dark/Light Mode
|
||||||
|
|
||||||
|
### 022 – Headerbild
|
||||||
|
Organisator sucht Headerbild über integrierte Unsplash-Suche.
|
||||||
|
* Serverseitig geproxied (Client kontaktiert nie Unsplash)
|
||||||
|
* Bild lokal gespeichert + Unsplash-Attribution
|
||||||
|
* Feature deaktiviert wenn kein API-Key konfiguriert
|
||||||
|
|
||||||
|
### 023 – Dark Mode
|
||||||
|
App erkennt `prefers-color-scheme` und bietet manuellen Toggle.
|
||||||
|
* Manuelle Auswahl in localStorage gespeichert
|
||||||
|
* Gilt für globales App-Chrome, nicht Event-Farbthemen
|
||||||
|
* Beide Modi WCAG AA konform
|
||||||
|
|
||||||
|
### 024 – Event absagen
|
||||||
|
Organisator kann Event absagen (mit optionaler Nachricht, Einweg-Transition).
|
||||||
|
* RSVPs werden nach Absage abgelehnt
|
||||||
|
* Absage-Nachricht nachträglich editierbar
|
||||||
|
* Kann nicht rückgängig gemacht werden
|
||||||
|
|
||||||
|
### 025 – Event löschen
|
||||||
|
Organisator löscht Event permanent und unwiderruflich.
|
||||||
|
* Entfernt alle zugehörigen Daten sofort
|
||||||
|
* localStorage-Eintrag wird entfernt, Redirect zu `/`
|
||||||
|
* Funktioniert in jedem Event-Status
|
||||||
|
|
||||||
|
### 026 – 404-Seite
|
||||||
|
Catch-all Route für ungültige Pfade mit "Seite nicht gefunden" und Link zur Startseite.
|
||||||
|
* Folgt dem Design System (Electric Dusk + Sora)
|
||||||
|
* WCAG AA konform
|
||||||
|
* Verhindert leere Seiten bei Fehlnavigation
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ The following skills are available and should be used for their respective purpo
|
|||||||
## Active Technologies
|
## 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)
|
- 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)
|
- 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
|
## 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
|
- 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;
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
import de.fete.adapter.in.web.api.EventsApi;
|
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.CreateEventRequest;
|
||||||
import de.fete.adapter.in.web.model.CreateEventResponse;
|
import de.fete.adapter.in.web.model.CreateEventResponse;
|
||||||
import de.fete.adapter.in.web.model.CreateRsvpRequest;
|
import de.fete.adapter.in.web.model.CreateRsvpRequest;
|
||||||
import de.fete.adapter.in.web.model.CreateRsvpResponse;
|
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.adapter.in.web.model.GetEventResponse;
|
||||||
import de.fete.application.service.EventNotFoundException;
|
import de.fete.application.service.EventNotFoundException;
|
||||||
import de.fete.application.service.InvalidTimezoneException;
|
import de.fete.application.service.InvalidTimezoneException;
|
||||||
import de.fete.domain.model.CreateEventCommand;
|
import de.fete.domain.model.CreateEventCommand;
|
||||||
import de.fete.domain.model.Event;
|
import de.fete.domain.model.Event;
|
||||||
import de.fete.domain.model.EventToken;
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.OrganizerToken;
|
||||||
import de.fete.domain.model.Rsvp;
|
import de.fete.domain.model.Rsvp;
|
||||||
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
|
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
|
||||||
import de.fete.domain.port.in.CreateEventUseCase;
|
import de.fete.domain.port.in.CreateEventUseCase;
|
||||||
import de.fete.domain.port.in.CreateRsvpUseCase;
|
import de.fete.domain.port.in.CreateRsvpUseCase;
|
||||||
|
import de.fete.domain.port.in.GetAttendeesUseCase;
|
||||||
import de.fete.domain.port.in.GetEventUseCase;
|
import de.fete.domain.port.in.GetEventUseCase;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.DateTimeException;
|
import java.time.DateTimeException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -33,6 +38,7 @@ public class EventController implements EventsApi {
|
|||||||
private final GetEventUseCase getEventUseCase;
|
private final GetEventUseCase getEventUseCase;
|
||||||
private final CreateRsvpUseCase createRsvpUseCase;
|
private final CreateRsvpUseCase createRsvpUseCase;
|
||||||
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
|
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
|
||||||
|
private final GetAttendeesUseCase getAttendeesUseCase;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
/** Creates a new controller with the given use cases and clock. */
|
/** Creates a new controller with the given use cases and clock. */
|
||||||
@@ -41,11 +47,13 @@ public class EventController implements EventsApi {
|
|||||||
GetEventUseCase getEventUseCase,
|
GetEventUseCase getEventUseCase,
|
||||||
CreateRsvpUseCase createRsvpUseCase,
|
CreateRsvpUseCase createRsvpUseCase,
|
||||||
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
|
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
|
||||||
|
GetAttendeesUseCase getAttendeesUseCase,
|
||||||
Clock clock) {
|
Clock clock) {
|
||||||
this.createEventUseCase = createEventUseCase;
|
this.createEventUseCase = createEventUseCase;
|
||||||
this.getEventUseCase = getEventUseCase;
|
this.getEventUseCase = getEventUseCase;
|
||||||
this.createRsvpUseCase = createRsvpUseCase;
|
this.createRsvpUseCase = createRsvpUseCase;
|
||||||
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
|
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
|
||||||
|
this.getAttendeesUseCase = getAttendeesUseCase;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +105,25 @@ public class EventController implements EventsApi {
|
|||||||
return ResponseEntity.ok(response);
|
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
|
@Override
|
||||||
public ResponseEntity<CreateRsvpResponse> createRsvp(
|
public ResponseEntity<CreateRsvpResponse> createRsvp(
|
||||||
UUID token, CreateRsvpRequest createRsvpRequest) {
|
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.EventNotFoundException;
|
||||||
import de.fete.application.service.ExpiryDateBeforeEventException;
|
import de.fete.application.service.ExpiryDateBeforeEventException;
|
||||||
import de.fete.application.service.ExpiryDateInPastException;
|
import de.fete.application.service.ExpiryDateInPastException;
|
||||||
|
import de.fete.application.service.InvalidOrganizerTokenException;
|
||||||
import de.fete.application.service.InvalidTimezoneException;
|
import de.fete.application.service.InvalidTimezoneException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -87,6 +88,19 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
|||||||
.body(problemDetail);
|
.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. */
|
/** Handles event not found. */
|
||||||
@ExceptionHandler(EventNotFoundException.class)
|
@ExceptionHandler(EventNotFoundException.class)
|
||||||
public ResponseEntity<ProblemDetail> handleEventNotFound(
|
public ResponseEntity<ProblemDetail> handleEventNotFound(
|
||||||
|
|||||||
@@ -11,4 +11,7 @@ public interface RsvpJpaRepository extends JpaRepository<RsvpJpaEntity, Long> {
|
|||||||
|
|
||||||
/** Counts RSVPs for the given event. */
|
/** Counts RSVPs for the given event. */
|
||||||
long countByEventId(Long eventId);
|
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.Rsvp;
|
||||||
import de.fete.domain.model.RsvpToken;
|
import de.fete.domain.model.RsvpToken;
|
||||||
import de.fete.domain.port.out.RsvpRepository;
|
import de.fete.domain.port.out.RsvpRepository;
|
||||||
|
import java.util.List;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
/** Persistence adapter implementing the RsvpRepository outbound port. */
|
/** Persistence adapter implementing the RsvpRepository outbound port. */
|
||||||
@@ -28,6 +29,13 @@ public class RsvpPersistenceAdapter implements RsvpRepository {
|
|||||||
return jpaRepository.countByEventId(eventId);
|
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) {
|
private RsvpJpaEntity toEntity(Rsvp rsvp) {
|
||||||
var entity = new RsvpJpaEntity();
|
var entity = new RsvpJpaEntity();
|
||||||
entity.setId(rsvp.getId());
|
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.Event;
|
||||||
import de.fete.domain.model.EventToken;
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.OrganizerToken;
|
||||||
import de.fete.domain.model.Rsvp;
|
import de.fete.domain.model.Rsvp;
|
||||||
import de.fete.domain.model.RsvpToken;
|
import de.fete.domain.model.RsvpToken;
|
||||||
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
|
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
|
||||||
import de.fete.domain.port.in.CreateRsvpUseCase;
|
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.EventRepository;
|
||||||
import de.fete.domain.port.out.RsvpRepository;
|
import de.fete.domain.port.out.RsvpRepository;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
/** Application service implementing RSVP creation. */
|
/** Application service implementing RSVP operations. */
|
||||||
@Service
|
@Service
|
||||||
public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseCase {
|
public class RsvpService
|
||||||
|
implements CreateRsvpUseCase, CountAttendeesByEventUseCase, GetAttendeesUseCase {
|
||||||
|
|
||||||
private final EventRepository eventRepository;
|
private final EventRepository eventRepository;
|
||||||
private final RsvpRepository rsvpRepository;
|
private final RsvpRepository rsvpRepository;
|
||||||
@@ -53,4 +57,18 @@ public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseC
|
|||||||
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||||
return rsvpRepository.countByEventId(event.getId());
|
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;
|
package de.fete.domain.port.out;
|
||||||
|
|
||||||
import de.fete.domain.model.Rsvp;
|
import de.fete.domain.model.Rsvp;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/** Outbound port for persisting and querying RSVPs. */
|
/** Outbound port for persisting and querying RSVPs. */
|
||||||
public interface RsvpRepository {
|
public interface RsvpRepository {
|
||||||
@@ -10,4 +11,7 @@ public interface RsvpRepository {
|
|||||||
|
|
||||||
/** Counts the number of RSVPs for the given event. */
|
/** Counts the number of RSVPs for the given event. */
|
||||||
long countByEventId(Long eventId);
|
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:
|
schema:
|
||||||
$ref: "#/components/schemas/ProblemDetail"
|
$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}:
|
/events/{token}:
|
||||||
get:
|
get:
|
||||||
operationId: getEvent
|
operationId: getEvent
|
||||||
@@ -256,6 +297,30 @@ components:
|
|||||||
description: Guest's display name as stored
|
description: Guest's display name as stored
|
||||||
example: "Max Mustermann"
|
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:
|
ProblemDetail:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -439,6 +439,68 @@ class EventControllerIntegrationTest {
|
|||||||
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
|
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(
|
private EventJpaEntity seedEvent(
|
||||||
String title, String description, String timezone,
|
String title, String description, String timezone,
|
||||||
String location, LocalDate expiryDate) {
|
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.EventToken;
|
||||||
import de.fete.domain.model.OrganizerToken;
|
import de.fete.domain.model.OrganizerToken;
|
||||||
import de.fete.domain.model.Rsvp;
|
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.EventRepository;
|
||||||
import de.fete.domain.port.out.RsvpRepository;
|
import de.fete.domain.port.out.RsvpRepository;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
@@ -18,6 +19,7 @@ import java.time.LocalDate;
|
|||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -122,6 +124,73 @@ class RsvpServiceTest {
|
|||||||
.isInstanceOf(EventExpiredException.class);
|
.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() {
|
private Event buildActiveEvent() {
|
||||||
var event = new Event();
|
var event = new Event();
|
||||||
event.setId(1L);
|
event.setId(1L);
|
||||||
|
|||||||
377
frontend/e2e/home-events.spec.ts
Normal file
377
frontend/e2e/home-events.spec.ts
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import { test, expect } from './msw-setup'
|
||||||
|
import type { StoredEvent } from '../src/composables/useEventStorage'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'fete:events'
|
||||||
|
|
||||||
|
const futureEvent1: StoredEvent = {
|
||||||
|
eventToken: 'future-aaa',
|
||||||
|
title: 'Summer BBQ',
|
||||||
|
dateTime: '2027-06-15T18:00:00Z',
|
||||||
|
expiryDate: '2027-06-16T00:00:00Z',
|
||||||
|
organizerToken: 'org-token-1',
|
||||||
|
}
|
||||||
|
|
||||||
|
const futureEvent2: StoredEvent = {
|
||||||
|
eventToken: 'future-bbb',
|
||||||
|
title: 'Team Meeting',
|
||||||
|
dateTime: '2027-01-10T09:00:00Z',
|
||||||
|
expiryDate: '2027-01-11T00:00:00Z',
|
||||||
|
rsvpToken: 'rsvp-token-1',
|
||||||
|
rsvpName: 'Alice',
|
||||||
|
}
|
||||||
|
|
||||||
|
const pastEvent: StoredEvent = {
|
||||||
|
eventToken: 'past-ccc',
|
||||||
|
title: 'New Year Party',
|
||||||
|
dateTime: '2025-01-01T00:00:00Z',
|
||||||
|
expiryDate: '2025-01-02T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedEvents(events: StoredEvent[]): string {
|
||||||
|
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US2: Empty State', () => {
|
||||||
|
test('shows empty state when no events are stored', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await expect(page.getByText('No events yet')).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: /Create Event/ })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('empty state links to create page', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const link = page.getByRole('link', { name: /Create Event/ })
|
||||||
|
await expect(link).toHaveAttribute('href', '/create')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('empty state is hidden when events exist', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await expect(page.getByText('No events yet')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US4: Past Events Appear Faded', () => {
|
||||||
|
test('past events have the faded modifier class', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1, pastEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const cards = page.locator('.event-card')
|
||||||
|
await expect(cards).toHaveCount(2)
|
||||||
|
|
||||||
|
// Future event should NOT have past class
|
||||||
|
const futureCard = cards.filter({ hasText: 'Summer BBQ' })
|
||||||
|
await expect(futureCard).not.toHaveClass(/event-card--past/)
|
||||||
|
|
||||||
|
// Past event should have past class
|
||||||
|
const pastCard = cards.filter({ hasText: 'New Year Party' })
|
||||||
|
await expect(pastCard).toHaveClass(/event-card--past/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('past events remain clickable', async ({ page, network }) => {
|
||||||
|
await page.addInitScript(seedEvents([pastEvent]))
|
||||||
|
|
||||||
|
const { http, HttpResponse } = await import('msw')
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
eventToken: pastEvent.eventToken,
|
||||||
|
title: pastEvent.title,
|
||||||
|
dateTime: pastEvent.dateTime,
|
||||||
|
description: '',
|
||||||
|
location: '',
|
||||||
|
timezone: 'UTC',
|
||||||
|
attendeeCount: 0,
|
||||||
|
expired: true,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByText('New Year Party').click()
|
||||||
|
await expect(page).toHaveURL(`/events/${pastEvent.eventToken}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US3: Remove Event from List', () => {
|
||||||
|
test('delete icon triggers confirmation dialog, confirm removes event', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1, futureEvent2]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
// Both events visible
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
await expect(page.getByText('Team Meeting')).toBeVisible()
|
||||||
|
|
||||||
|
// Click delete on Summer BBQ
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
|
||||||
|
// Confirmation dialog appears
|
||||||
|
await expect(page.getByText('Remove event?')).toBeVisible()
|
||||||
|
|
||||||
|
// Confirm removal
|
||||||
|
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||||
|
|
||||||
|
// Event is gone, other remains
|
||||||
|
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||||
|
await expect(page.getByText('Team Meeting')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cancel keeps the event in the list', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await expect(page.getByText('Remove event?')).toBeVisible()
|
||||||
|
|
||||||
|
// Cancel
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||||
|
|
||||||
|
// Dialog gone, event still there
|
||||||
|
await expect(page.getByText('Remove event?')).not.toBeVisible()
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US5: Visual Distinction for Event Roles', () => {
|
||||||
|
test('shows organizer badge for events with organizerToken', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const card = page.locator('.event-card').filter({ hasText: 'Summer BBQ' })
|
||||||
|
const badge = card.locator('.event-card__badge')
|
||||||
|
await expect(badge).toBeVisible()
|
||||||
|
await expect(badge).toHaveText('Organizer')
|
||||||
|
await expect(badge).toHaveClass(/event-card__badge--organizer/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows attendee badge for events with rsvpToken only', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent2]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const card = page.locator('.event-card').filter({ hasText: 'Team Meeting' })
|
||||||
|
const badge = card.locator('.event-card__badge')
|
||||||
|
await expect(badge).toBeVisible()
|
||||||
|
await expect(badge).toHaveText('Attendee')
|
||||||
|
await expect(badge).toHaveClass(/event-card__badge--attendee/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows no badge for events without organizerToken or rsvpToken', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([pastEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const card = page.locator('.event-card').filter({ hasText: 'New Year Party' })
|
||||||
|
await expect(card.locator('.event-card__badge')).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('FAB: Create Event Button', () => {
|
||||||
|
test('FAB is visible when events exist', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const fab = page.getByRole('link', { name: 'Create event' })
|
||||||
|
await expect(fab).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('FAB navigates to create page', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const fab = page.getByRole('link', { name: 'Create event' })
|
||||||
|
await expect(fab).toHaveAttribute('href', '/create')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('FAB is not visible on empty state (empty state has its own CTA)', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await expect(page.locator('.fab')).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
await expect(page.getByText('Team Meeting')).toBeVisible()
|
||||||
|
await expect(page.getByText('New Year Party')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('events are sorted: upcoming ascending, then past', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1, futureEvent2, pastEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const titles = page.locator('.event-card__title')
|
||||||
|
await expect(titles).toHaveCount(3)
|
||||||
|
// Team Meeting (Jan 2027) before Summer BBQ (Jun 2027), then past event
|
||||||
|
await expect(titles.nth(0)).toHaveText('Team Meeting')
|
||||||
|
await expect(titles.nth(1)).toHaveText('Summer BBQ')
|
||||||
|
await expect(titles.nth(2)).toHaveText('New Year Party')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking an event navigates to its detail page', async ({ page, network }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
|
||||||
|
// Mock the event detail API so navigation doesn't fail
|
||||||
|
const { http, HttpResponse } = await import('msw')
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
eventToken: futureEvent1.eventToken,
|
||||||
|
title: futureEvent1.title,
|
||||||
|
dateTime: futureEvent1.dateTime,
|
||||||
|
description: '',
|
||||||
|
location: '',
|
||||||
|
timezone: 'UTC',
|
||||||
|
attendeeCount: 0,
|
||||||
|
expired: false,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByText('Summer BBQ').click()
|
||||||
|
await expect(page).toHaveURL(`/events/${futureEvent1.eventToken}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('each event shows a relative time label', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
// The relative time element should exist and contain text (exact value depends on current time)
|
||||||
|
const timeLabel = page.locator('.event-card__time')
|
||||||
|
await expect(timeLabel).toHaveCount(1)
|
||||||
|
await expect(timeLabel.first()).not.toBeEmpty()
|
||||||
|
})
|
||||||
|
})
|
||||||
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
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>
|
||||||
151
frontend/src/components/ConfirmDialog.vue
Normal file
151
frontend/src/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="confirm-dialog">
|
||||||
|
<div v-if="open" class="confirm-dialog__overlay" @click.self="$emit('cancel')">
|
||||||
|
<div
|
||||||
|
class="confirm-dialog"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="title"
|
||||||
|
@keydown.escape="$emit('cancel')"
|
||||||
|
>
|
||||||
|
<p class="confirm-dialog__title">{{ title }}</p>
|
||||||
|
<p class="confirm-dialog__message">{{ message }}</p>
|
||||||
|
<div class="confirm-dialog__actions">
|
||||||
|
<button
|
||||||
|
ref="cancelBtn"
|
||||||
|
class="confirm-dialog__btn confirm-dialog__btn--cancel"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('cancel')"
|
||||||
|
>
|
||||||
|
{{ cancelLabel }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="confirm-dialog__btn confirm-dialog__btn--confirm"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('confirm')"
|
||||||
|
>
|
||||||
|
{{ confirmLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
open: boolean
|
||||||
|
title?: string
|
||||||
|
message?: string
|
||||||
|
confirmLabel?: string
|
||||||
|
cancelLabel?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: 'Are you sure?',
|
||||||
|
message: '',
|
||||||
|
confirmLabel: 'Remove',
|
||||||
|
cancelLabel: 'Cancel',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
confirm: []
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const cancelBtn = ref<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
async (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
await nextTick()
|
||||||
|
cancelBtn.value?.focus()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.confirm-dialog__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog {
|
||||||
|
background: var(--color-card);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
max-width: 320px;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__message {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-button);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__btn--cancel {
|
||||||
|
background: #e8e8e8;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__btn--confirm {
|
||||||
|
background: #d32f2f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-enter-active,
|
||||||
|
.confirm-dialog-leave-active {
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-enter-from,
|
||||||
|
.confirm-dialog-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
49
frontend/src/components/CreateEventFab.vue
Normal file
49
frontend/src/components/CreateEventFab.vue
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<RouterLink to="/create" class="fab" aria-label="Create event">
|
||||||
|
<span class="fab__icon" aria-hidden="true">+</span>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(1.2rem + env(safe-area-inset-bottom));
|
||||||
|
right: 1.2rem;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
text-decoration: none;
|
||||||
|
z-index: 100;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:hover {
|
||||||
|
transform: scale(1.08);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:focus-visible {
|
||||||
|
outline: 2px solid #fff;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab__icon {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
</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>
|
||||||
31
frontend/src/components/EmptyState.vue
Normal file
31
frontend/src/components/EmptyState.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div class="empty-state">
|
||||||
|
<p class="empty-state__message">No events yet.<br />Create your first one!</p>
|
||||||
|
<RouterLink to="/create" class="btn-primary empty-state__cta">+ Create Event</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__message {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
opacity: 0.9;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__cta {
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
181
frontend/src/components/EventCard.vue
Normal file
181
frontend/src/components/EventCard.vue
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="event-card"
|
||||||
|
:class="{ 'event-card--past': isPast, 'event-card--swiping': isSwiping }"
|
||||||
|
:style="swipeStyle"
|
||||||
|
@touchstart="onTouchStart"
|
||||||
|
@touchmove="onTouchMove"
|
||||||
|
@touchend="onTouchEnd"
|
||||||
|
>
|
||||||
|
<RouterLink :to="`/events/${eventToken}`" class="event-card__link">
|
||||||
|
<span class="event-card__title">{{ title }}</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' }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="event-card__delete"
|
||||||
|
type="button"
|
||||||
|
:aria-label="`Remove ${title}`"
|
||||||
|
@click.stop="$emit('delete', eventToken)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
eventToken: string
|
||||||
|
title: string
|
||||||
|
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)
|
||||||
|
const deltaX = ref(0)
|
||||||
|
const isSwiping = ref(false)
|
||||||
|
|
||||||
|
const swipeStyle = computed(() => {
|
||||||
|
if (deltaX.value === 0) return {}
|
||||||
|
return { transform: `translateX(${deltaX.value}px)` }
|
||||||
|
})
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
const touch = e.touches[0]
|
||||||
|
if (!touch) return
|
||||||
|
startX.value = touch.clientX
|
||||||
|
deltaX.value = 0
|
||||||
|
isSwiping.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
const touch = e.touches[0]
|
||||||
|
if (!touch) return
|
||||||
|
const diff = touch.clientX - startX.value
|
||||||
|
// Only allow leftward swipe
|
||||||
|
if (diff < 0) {
|
||||||
|
deltaX.value = diff
|
||||||
|
isSwiping.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd() {
|
||||||
|
if (deltaX.value < -SWIPE_THRESHOLD) {
|
||||||
|
emit('delete', props.eventToken)
|
||||||
|
}
|
||||||
|
deltaX.value = 0
|
||||||
|
isSwiping.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.event-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-card);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card--past {
|
||||||
|
opacity: 0.6;
|
||||||
|
filter: saturate(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card:not(.event-card--swiping) {
|
||||||
|
transition: opacity 0.2s ease, filter 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__link {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__time {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__badge--organizer {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__badge--attendee {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__delete {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #bbb;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: color 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__delete:hover {
|
||||||
|
color: #d32f2f;
|
||||||
|
background: rgba(211, 47, 47, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__delete:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
94
frontend/src/components/EventList.vue
Normal file
94
frontend/src/components/EventList.vue
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<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?"
|
||||||
|
message="This event will be removed from your list."
|
||||||
|
confirm-label="Remove"
|
||||||
|
cancel-label="Cancel"
|
||||||
|
@confirm="confirmDelete"
|
||||||
|
@cancel="cancelDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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'
|
||||||
|
|
||||||
|
const { getStoredEvents, removeEvent } = useEventStorage()
|
||||||
|
|
||||||
|
const pendingDeleteToken = ref<string | null>(null)
|
||||||
|
|
||||||
|
function requestDelete(eventToken: string) {
|
||||||
|
pendingDeleteToken.value = eventToken
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
if (pendingDeleteToken.value) {
|
||||||
|
removeEvent(pendingDeleteToken.value)
|
||||||
|
}
|
||||||
|
pendingDeleteToken.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDelete() {
|
||||||
|
pendingDeleteToken.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRole(event: StoredEvent): 'organizer' | 'attendee' | undefined {
|
||||||
|
if (event.organizerToken) return 'organizer'
|
||||||
|
if (event.rsvpToken) return 'attendee'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedSections = computed(() => {
|
||||||
|
const valid = getStoredEvents().filter(isValidStoredEvent)
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
27
frontend/src/components/SectionHeader.vue
Normal file
27
frontend/src/components/SectionHeader.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<h2 class="section-header" :class="{ 'section-header--emphasized': emphasized }">
|
||||||
|
{{ label }}
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
emphasized?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.section-header {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
111
frontend/src/components/__tests__/ConfirmDialog.spec.ts
Normal file
111
frontend/src/components/__tests__/ConfirmDialog.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { mount, VueWrapper } from '@vue/test-utils'
|
||||||
|
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||||
|
|
||||||
|
let wrapper: VueWrapper
|
||||||
|
|
||||||
|
function mountDialog(props: Record<string, unknown> = {}) {
|
||||||
|
wrapper = mount(ConfirmDialog, {
|
||||||
|
props: {
|
||||||
|
open: true,
|
||||||
|
...props,
|
||||||
|
},
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
function dialog() {
|
||||||
|
return document.body.querySelector('.confirm-dialog')
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlay() {
|
||||||
|
return document.body.querySelector('.confirm-dialog__overlay')
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ConfirmDialog', () => {
|
||||||
|
it('renders when open is true', () => {
|
||||||
|
mountDialog()
|
||||||
|
expect(dialog()).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render when open is false', () => {
|
||||||
|
mountDialog({ open: false })
|
||||||
|
expect(dialog()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays default title', () => {
|
||||||
|
mountDialog()
|
||||||
|
expect(dialog()!.querySelector('.confirm-dialog__title')!.textContent).toBe('Are you sure?')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays custom title and message', () => {
|
||||||
|
mountDialog({
|
||||||
|
title: 'Remove event?',
|
||||||
|
message: 'This cannot be undone.',
|
||||||
|
})
|
||||||
|
expect(dialog()!.querySelector('.confirm-dialog__title')!.textContent).toBe('Remove event?')
|
||||||
|
expect(dialog()!.querySelector('.confirm-dialog__message')!.textContent).toBe('This cannot be undone.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays custom button labels', () => {
|
||||||
|
mountDialog({
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
cancelLabel: 'Keep',
|
||||||
|
})
|
||||||
|
const buttons = dialog()!.querySelectorAll('.confirm-dialog__btn')
|
||||||
|
expect(buttons[0]!.textContent!.trim()).toBe('Keep')
|
||||||
|
expect(buttons[1]!.textContent!.trim()).toBe('Delete')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits confirm when confirm button is clicked', async () => {
|
||||||
|
mountDialog()
|
||||||
|
const btn = dialog()!.querySelector('.confirm-dialog__btn--confirm') as HTMLElement
|
||||||
|
btn.click()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.emitted('confirm')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits cancel when cancel button is clicked', async () => {
|
||||||
|
mountDialog()
|
||||||
|
const btn = dialog()!.querySelector('.confirm-dialog__btn--cancel') as HTMLElement
|
||||||
|
btn.click()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits cancel when overlay is clicked', async () => {
|
||||||
|
mountDialog()
|
||||||
|
const el = overlay() as HTMLElement
|
||||||
|
el.click()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits cancel when Escape key is pressed', async () => {
|
||||||
|
mountDialog()
|
||||||
|
const el = dialog() as HTMLElement
|
||||||
|
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('focuses cancel button when opened', async () => {
|
||||||
|
mountDialog({ open: false })
|
||||||
|
await wrapper.setProps({ open: true })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
const cancelBtn = dialog()!.querySelector('.confirm-dialog__btn--cancel')
|
||||||
|
expect(document.activeElement).toBe(cancelBtn)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has alertdialog role and aria-modal', () => {
|
||||||
|
mountDialog()
|
||||||
|
const el = dialog() as HTMLElement
|
||||||
|
expect(el.getAttribute('role')).toBe('alertdialog')
|
||||||
|
expect(el.getAttribute('aria-modal')).toBe('true')
|
||||||
|
})
|
||||||
|
})
|
||||||
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
35
frontend/src/components/__tests__/EmptyState.spec.ts
Normal file
35
frontend/src/components/__tests__/EmptyState.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import EmptyState from '../EmptyState.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', component: { template: '<div />' } },
|
||||||
|
{ path: '/create', name: 'create', component: { template: '<div />' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
function mountEmptyState() {
|
||||||
|
return mount(EmptyState, {
|
||||||
|
global: {
|
||||||
|
plugins: [router],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EmptyState', () => {
|
||||||
|
it('renders an inviting message', () => {
|
||||||
|
const wrapper = mountEmptyState()
|
||||||
|
expect(wrapper.text()).toContain('No events yet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a Create Event link', () => {
|
||||||
|
const wrapper = mountEmptyState()
|
||||||
|
const link = wrapper.find('a')
|
||||||
|
expect(link.exists()).toBe(true)
|
||||||
|
expect(link.text()).toContain('Create Event')
|
||||||
|
expect(link.attributes('href')).toBe('/create')
|
||||||
|
})
|
||||||
|
})
|
||||||
100
frontend/src/components/__tests__/EventCard.spec.ts
Normal file
100
frontend/src/components/__tests__/EventCard.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import EventCard from '../EventCard.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', component: { template: '<div />' } },
|
||||||
|
{ path: '/events/:eventToken', name: 'event', component: { template: '<div />' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
function mountCard(props: Record<string, unknown> = {}) {
|
||||||
|
return mount(EventCard, {
|
||||||
|
props: {
|
||||||
|
eventToken: 'abc-123',
|
||||||
|
title: 'Birthday Party',
|
||||||
|
relativeTime: 'in 3 days',
|
||||||
|
isPast: false,
|
||||||
|
...props,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
plugins: [router],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EventCard', () => {
|
||||||
|
it('renders the event title', () => {
|
||||||
|
const wrapper = mountCard()
|
||||||
|
expect(wrapper.text()).toContain('Birthday Party')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders relative time', () => {
|
||||||
|
const wrapper = mountCard({ relativeTime: 'yesterday' })
|
||||||
|
expect(wrapper.text()).toContain('yesterday')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links to the event detail page', () => {
|
||||||
|
const wrapper = mountCard({ eventToken: 'xyz-789' })
|
||||||
|
const link = wrapper.find('a')
|
||||||
|
expect(link.attributes('href')).toBe('/events/xyz-789')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies past modifier class when isPast is true', () => {
|
||||||
|
const wrapper = mountCard({ isPast: true })
|
||||||
|
expect(wrapper.find('.event-card--past').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not apply past modifier class when isPast is false', () => {
|
||||||
|
const wrapper = mountCard({ isPast: false })
|
||||||
|
expect(wrapper.find('.event-card--past').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders organizer badge when eventRole is organizer', () => {
|
||||||
|
const wrapper = mountCard({ eventRole: 'organizer' })
|
||||||
|
expect(wrapper.text()).toContain('Organizer')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders attendee badge when eventRole is attendee', () => {
|
||||||
|
const wrapper = mountCard({ eventRole: 'attendee' })
|
||||||
|
expect(wrapper.text()).toContain('Attendee')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders no badge when eventRole is undefined', () => {
|
||||||
|
const wrapper = mountCard({ eventRole: undefined })
|
||||||
|
expect(wrapper.find('.event-card__badge').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits delete event with eventToken when delete button is clicked', async () => {
|
||||||
|
const wrapper = mountCard({ eventToken: 'abc-123' })
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
140
frontend/src/components/__tests__/EventList.spec.ts
Normal file
140
frontend/src/components/__tests__/EventList.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', component: { template: '<div />' } },
|
||||||
|
{ path: '/events/:eventToken', name: 'event', component: { template: '<div />' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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: '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', () => ({
|
||||||
|
isValidStoredEvent: (e: unknown) => {
|
||||||
|
if (typeof e !== 'object' || e === null) return false
|
||||||
|
const obj = e as Record<string, unknown>
|
||||||
|
return typeof obj.eventToken === 'string' && obj.eventToken.length > 0
|
||||||
|
&& typeof obj.title === 'string' && obj.title.length > 0
|
||||||
|
&& typeof obj.dateTime === 'string' && obj.dateTime.length > 0
|
||||||
|
},
|
||||||
|
useEventStorage: () => ({
|
||||||
|
getStoredEvents: () => mockEvents,
|
||||||
|
removeEvent: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../composables/useRelativeTime', () => ({
|
||||||
|
formatRelativeTime: (dateTime: string) => {
|
||||||
|
if (dateTime.includes('03-01')) return '10 days ago'
|
||||||
|
if (dateTime.includes('06-15')) return 'in 1 year'
|
||||||
|
if (dateTime.includes('03-11')) return 'in 6 hours'
|
||||||
|
if (dateTime.includes('03-13')) return 'in 2 days'
|
||||||
|
if (dateTime.includes('03-16')) return 'in 5 days'
|
||||||
|
return 'sometime'
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
function mountList() {
|
||||||
|
return mount(EventList, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EventList', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(NOW)
|
||||||
|
})
|
||||||
|
|
||||||
|
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(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks past events with isPast class', () => {
|
||||||
|
const wrapper = mountList()
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -164,4 +164,120 @@ describe('useEventStorage', () => {
|
|||||||
const { getRsvp } = useEventStorage()
|
const { getRsvp } = useEventStorage()
|
||||||
expect(getRsvp('unknown')).toBeUndefined()
|
expect(getRsvp('unknown')).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('removes an event by token', () => {
|
||||||
|
const { saveCreatedEvent, getStoredEvents, removeEvent } = useEventStorage()
|
||||||
|
|
||||||
|
saveCreatedEvent({
|
||||||
|
eventToken: 'event-1',
|
||||||
|
title: 'First',
|
||||||
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
|
expiryDate: '2026-07-15',
|
||||||
|
})
|
||||||
|
|
||||||
|
saveCreatedEvent({
|
||||||
|
eventToken: 'event-2',
|
||||||
|
title: 'Second',
|
||||||
|
dateTime: '2026-07-15T20:00:00+02:00',
|
||||||
|
expiryDate: '2026-08-15',
|
||||||
|
})
|
||||||
|
|
||||||
|
removeEvent('event-1')
|
||||||
|
|
||||||
|
const events = getStoredEvents()
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
expect(events[0]!.eventToken).toBe('event-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removeEvent does nothing for unknown token', () => {
|
||||||
|
const { saveCreatedEvent, getStoredEvents, removeEvent } = useEventStorage()
|
||||||
|
|
||||||
|
saveCreatedEvent({
|
||||||
|
eventToken: 'event-1',
|
||||||
|
title: 'First',
|
||||||
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
|
expiryDate: '2026-07-15',
|
||||||
|
})
|
||||||
|
|
||||||
|
removeEvent('nonexistent')
|
||||||
|
|
||||||
|
expect(getStoredEvents()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isValidStoredEvent', () => {
|
||||||
|
// Import directly since it's an exported function
|
||||||
|
let isValidStoredEvent: (e: unknown) => boolean
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const mod = await import('../useEventStorage')
|
||||||
|
isValidStoredEvent = mod.isValidStoredEvent
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true for a valid event', () => {
|
||||||
|
expect(
|
||||||
|
isValidStoredEvent({
|
||||||
|
eventToken: 'abc-123',
|
||||||
|
title: 'Birthday',
|
||||||
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
|
expiryDate: '2026-07-15',
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for null', () => {
|
||||||
|
expect(isValidStoredEvent(null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for non-object', () => {
|
||||||
|
expect(isValidStoredEvent('string')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when eventToken is missing', () => {
|
||||||
|
expect(
|
||||||
|
isValidStoredEvent({
|
||||||
|
title: 'Birthday',
|
||||||
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when eventToken is empty', () => {
|
||||||
|
expect(
|
||||||
|
isValidStoredEvent({
|
||||||
|
eventToken: '',
|
||||||
|
title: 'Birthday',
|
||||||
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when title is missing', () => {
|
||||||
|
expect(
|
||||||
|
isValidStoredEvent({
|
||||||
|
eventToken: 'abc-123',
|
||||||
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when dateTime is invalid', () => {
|
||||||
|
expect(
|
||||||
|
isValidStoredEvent({
|
||||||
|
eventToken: 'abc-123',
|
||||||
|
title: 'Birthday',
|
||||||
|
dateTime: 'not-a-date',
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when dateTime is empty', () => {
|
||||||
|
expect(
|
||||||
|
isValidStoredEvent({
|
||||||
|
eventToken: 'abc-123',
|
||||||
|
title: 'Birthday',
|
||||||
|
dateTime: '',
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
72
frontend/src/composables/__tests__/useRelativeTime.spec.ts
Normal file
72
frontend/src/composables/__tests__/useRelativeTime.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { formatRelativeTime } from '../useRelativeTime'
|
||||||
|
|
||||||
|
describe('formatRelativeTime', () => {
|
||||||
|
const now = new Date('2026-06-15T12:00:00Z')
|
||||||
|
|
||||||
|
it('formats seconds ago', () => {
|
||||||
|
const result = formatRelativeTime('2026-06-15T11:59:30Z', now)
|
||||||
|
expect(result).toMatch(/30 seconds ago/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats minutes ago', () => {
|
||||||
|
const result = formatRelativeTime('2026-06-15T11:55:00Z', now)
|
||||||
|
expect(result).toMatch(/5 minutes ago/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats hours ago', () => {
|
||||||
|
const result = formatRelativeTime('2026-06-15T09:00:00Z', now)
|
||||||
|
expect(result).toMatch(/3 hours ago/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats days ago', () => {
|
||||||
|
const result = formatRelativeTime('2026-06-13T12:00:00Z', now)
|
||||||
|
expect(result).toMatch(/2 days ago/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats weeks ago', () => {
|
||||||
|
const result = formatRelativeTime('2026-06-01T12:00:00Z', now)
|
||||||
|
expect(result).toMatch(/2 weeks ago/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats months ago', () => {
|
||||||
|
const result = formatRelativeTime('2026-03-15T12:00:00Z', now)
|
||||||
|
expect(result).toMatch(/3 months ago/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats years ago', () => {
|
||||||
|
const result = formatRelativeTime('2024-06-15T12:00:00Z', now)
|
||||||
|
expect(result).toMatch(/2 years ago/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats future seconds', () => {
|
||||||
|
const result = formatRelativeTime('2026-06-15T12:00:30Z', now)
|
||||||
|
expect(result).toMatch(/in 30 seconds/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats future days', () => {
|
||||||
|
const result = formatRelativeTime('2026-06-18T12:00:00Z', now)
|
||||||
|
expect(result).toMatch(/in 3 days/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats future months', () => {
|
||||||
|
const result = formatRelativeTime('2026-09-15T12:00:00Z', now)
|
||||||
|
expect(result).toMatch(/in 3 months/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats "now" for zero difference', () => {
|
||||||
|
const result = formatRelativeTime('2026-06-15T12:00:00Z', now)
|
||||||
|
// Intl.RelativeTimeFormat with numeric: 'auto' returns "now" for 0 seconds
|
||||||
|
expect(result).toMatch(/now/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats yesterday', () => {
|
||||||
|
const result = formatRelativeTime('2026-06-14T12:00:00Z', now)
|
||||||
|
expect(result).toMatch(/yesterday|1 day ago/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats tomorrow', () => {
|
||||||
|
const result = formatRelativeTime('2026-06-16T12:00:00Z', now)
|
||||||
|
expect(result).toMatch(/tomorrow|in 1 day/)
|
||||||
|
})
|
||||||
|
})
|
||||||
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
|
||||||
|
}
|
||||||
@@ -8,8 +8,26 @@ export interface StoredEvent {
|
|||||||
rsvpName?: string
|
rsvpName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const STORAGE_KEY = 'fete:events'
|
const STORAGE_KEY = 'fete:events'
|
||||||
|
|
||||||
|
const version = ref(0)
|
||||||
|
|
||||||
|
export function isValidStoredEvent(e: unknown): e is StoredEvent {
|
||||||
|
if (typeof e !== 'object' || e === null) return false
|
||||||
|
const obj = e as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
typeof obj.eventToken === 'string' &&
|
||||||
|
obj.eventToken.length > 0 &&
|
||||||
|
typeof obj.title === 'string' &&
|
||||||
|
obj.title.length > 0 &&
|
||||||
|
typeof obj.dateTime === 'string' &&
|
||||||
|
obj.dateTime.length > 0 &&
|
||||||
|
!isNaN(new Date(obj.dateTime).getTime())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function readEvents(): StoredEvent[] {
|
function readEvents(): StoredEvent[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
@@ -21,6 +39,7 @@ function readEvents(): StoredEvent[] {
|
|||||||
|
|
||||||
function writeEvents(events: StoredEvent[]): void {
|
function writeEvents(events: StoredEvent[]): void {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(events))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(events))
|
||||||
|
version.value++
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEventStorage() {
|
export function useEventStorage() {
|
||||||
@@ -31,6 +50,7 @@ export function useEventStorage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getStoredEvents(): StoredEvent[] {
|
function getStoredEvents(): StoredEvent[] {
|
||||||
|
void version.value
|
||||||
return readEvents()
|
return readEvents()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,5 +79,10 @@ export function useEventStorage() {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp }
|
function removeEvent(eventToken: string): void {
|
||||||
|
const events = readEvents().filter((e) => e.eventToken !== eventToken)
|
||||||
|
writeEvents(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp, removeEvent }
|
||||||
}
|
}
|
||||||
|
|||||||
23
frontend/src/composables/useRelativeTime.ts
Normal file
23
frontend/src/composables/useRelativeTime.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||||
|
['year', 365 * 24 * 60 * 60],
|
||||||
|
['month', 30 * 24 * 60 * 60],
|
||||||
|
['week', 7 * 24 * 60 * 60],
|
||||||
|
['day', 24 * 60 * 60],
|
||||||
|
['hour', 60 * 60],
|
||||||
|
['minute', 60],
|
||||||
|
['second', 1],
|
||||||
|
]
|
||||||
|
|
||||||
|
export function formatRelativeTime(dateTime: string, now: Date = new Date()): string {
|
||||||
|
const target = new Date(dateTime)
|
||||||
|
const diffSeconds = Math.round((target.getTime() - now.getTime()) / 1000)
|
||||||
|
|
||||||
|
for (const [unit, secondsInUnit] of UNITS) {
|
||||||
|
if (Math.abs(diffSeconds) >= secondsInUnit) {
|
||||||
|
const value = Math.round(diffSeconds / secondsInUnit)
|
||||||
|
return new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }).format(value, unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }).format(0, 'second')
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ const router = createRouter({
|
|||||||
component: () => import('../views/EventCreateView.vue'),
|
component: () => import('../views/EventCreateView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/events/:token',
|
path: '/events/:eventToken',
|
||||||
name: 'event',
|
name: 'event',
|
||||||
component: () => import('../views/EventDetailView.vue'),
|
component: () => import('../views/EventDetailView.vue'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ async function handleSubmit() {
|
|||||||
expiryDate: data.expiryDate,
|
expiryDate: data.expiryDate,
|
||||||
})
|
})
|
||||||
|
|
||||||
router.push({ name: 'event', params: { token: data.eventToken } })
|
router.push({ name: 'event', params: { eventToken: data.eventToken } })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="detail">
|
<main class="detail">
|
||||||
|
<!-- 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">
|
<header class="detail__header">
|
||||||
<RouterLink to="/" class="detail__back" aria-label="Back to home">←</RouterLink>
|
<RouterLink to="/" class="detail__back" aria-label="Back to home">←</RouterLink>
|
||||||
<span class="detail__brand">fete</span>
|
<span class="detail__brand">fete</span>
|
||||||
</header>
|
</header>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail__body">
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<div v-if="state === 'loading'" class="detail__card" aria-busy="true" aria-label="Loading event details">
|
<div v-if="state === 'loading'" class="detail__content" aria-busy="true" aria-label="Loading event details">
|
||||||
<div class="skeleton skeleton--title" />
|
<div class="skeleton skeleton--title" />
|
||||||
<div class="skeleton skeleton--line" />
|
<div class="skeleton skeleton--line" />
|
||||||
<div class="skeleton skeleton--line skeleton--short" />
|
<div class="skeleton skeleton--line skeleton--short" />
|
||||||
@@ -14,46 +24,55 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loaded state -->
|
<!-- Loaded state -->
|
||||||
<div v-else-if="state === 'loaded' && event" class="detail__card">
|
<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>
|
||||||
|
|
||||||
<h1 class="detail__title">{{ event.title }}</h1>
|
<h1 class="detail__title">{{ event.title }}</h1>
|
||||||
|
|
||||||
<dl class="detail__fields">
|
<dl class="detail__meta">
|
||||||
<div class="detail__field">
|
<div class="detail__meta-item">
|
||||||
<dt class="detail__label">Date & Time</dt>
|
<dt class="detail__meta-icon" aria-label="Date and time">
|
||||||
<dd class="detail__value">{{ formattedDateTime }}</dd>
|
<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>
|
||||||
|
|
||||||
<div v-if="event.description" class="detail__field">
|
<div v-if="event.location" class="detail__meta-item">
|
||||||
<dt class="detail__label">Description</dt>
|
<dt class="detail__meta-icon" aria-label="Location">
|
||||||
<dd class="detail__value">{{ event.description }}</dd>
|
<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>
|
||||||
|
|
||||||
<div v-if="event.location" class="detail__field">
|
<div class="detail__meta-item">
|
||||||
<dt class="detail__label">Location</dt>
|
<dt class="detail__meta-icon" aria-label="Attendees">
|
||||||
<dd class="detail__value">{{ event.location }}</dd>
|
<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>
|
||||||
</div>
|
</dt>
|
||||||
|
<dd class="detail__meta-text">{{ event.attendeeCount }} going</dd>
|
||||||
<div class="detail__field">
|
|
||||||
<dt class="detail__label">Attendees</dt>
|
|
||||||
<dd class="detail__value">{{ event.attendeeCount }}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
|
<AttendeeList v-if="isOrganizer && attendeeNames !== null" :attendees="attendeeNames" />
|
||||||
This event has ended.
|
|
||||||
|
<div v-if="event.description" class="detail__section">
|
||||||
|
<h2 class="detail__section-title">About</h2>
|
||||||
|
<p class="detail__description">{{ event.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Not found state -->
|
<!-- Not found state -->
|
||||||
<div v-else-if="state === 'not-found'" class="detail__card detail__card--center" role="status">
|
<div v-else-if="state === 'not-found'" class="detail__content detail__content--center" role="status">
|
||||||
<p class="detail__message">Event not found.</p>
|
<p class="detail__message">Event not found.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error state -->
|
<!-- Error state -->
|
||||||
<div v-else-if="state === 'error'" class="detail__card detail__card--center" role="alert">
|
<div v-else-if="state === 'error'" class="detail__content detail__content--center" role="alert">
|
||||||
<p class="detail__message">Something went wrong.</p>
|
<p class="detail__message">Something went wrong.</p>
|
||||||
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
|
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- RSVP bar (only for loaded, non-expired events) -->
|
<!-- RSVP bar (only for loaded, non-expired events) -->
|
||||||
<RsvpBar
|
<RsvpBar
|
||||||
@@ -94,6 +113,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
import { api } from '@/api/client'
|
import { api } from '@/api/client'
|
||||||
import { useEventStorage } from '@/composables/useEventStorage'
|
import { useEventStorage } from '@/composables/useEventStorage'
|
||||||
|
import AttendeeList from '@/components/AttendeeList.vue'
|
||||||
import BottomSheet from '@/components/BottomSheet.vue'
|
import BottomSheet from '@/components/BottomSheet.vue'
|
||||||
import RsvpBar from '@/components/RsvpBar.vue'
|
import RsvpBar from '@/components/RsvpBar.vue'
|
||||||
import type { components } from '@/api/schema'
|
import type { components } from '@/api/schema'
|
||||||
@@ -115,6 +135,7 @@ const submitError = ref('')
|
|||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const rsvpName = ref<string | undefined>(undefined)
|
const rsvpName = ref<string | undefined>(undefined)
|
||||||
const isOrganizer = ref(false)
|
const isOrganizer = ref(false)
|
||||||
|
const attendeeNames = ref<string[] | null>(null)
|
||||||
|
|
||||||
const formattedDateTime = computed(() => {
|
const formattedDateTime = computed(() => {
|
||||||
if (!event.value) return ''
|
if (!event.value) return ''
|
||||||
@@ -131,7 +152,7 @@ async function fetchEvent() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error, response } = await api.GET('/events/{token}', {
|
const { data, error, response } = await api.GET('/events/{token}', {
|
||||||
params: { path: { token: route.params.token as string } },
|
params: { path: { token: route.params.eventToken as string } },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -143,7 +164,13 @@ async function fetchEvent() {
|
|||||||
state.value = 'loaded'
|
state.value = 'loaded'
|
||||||
|
|
||||||
// Check if current user is the organizer
|
// 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
|
// Restore RSVP status from localStorage
|
||||||
const stored = getRsvp(event.value.eventToken)
|
const stored = getRsvp(event.value.eventToken)
|
||||||
@@ -173,7 +200,7 @@ async function submitRsvp() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await api.POST('/events/{token}/rsvps', {
|
const { data, error } = await api.POST('/events/{token}/rsvps', {
|
||||||
params: { path: { token: route.params.token as string } },
|
params: { path: { token: route.params.eventToken as string } },
|
||||||
body: { name: nameInput.value },
|
body: { name: nameInput.value },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -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)
|
onMounted(fetchEvent)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -210,14 +254,53 @@ onMounted(fetchEvent)
|
|||||||
.detail {
|
.detail {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-2xl);
|
/* Break out of .app-container constraints */
|
||||||
padding-top: var(--spacing-lg);
|
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 {
|
.detail__header {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-sm);
|
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 {
|
.detail__back {
|
||||||
@@ -233,85 +316,130 @@ onMounted(fetchEvent)
|
|||||||
color: var(--color-text-on-gradient);
|
color: var(--color-text-on-gradient);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail__card {
|
.detail__body {
|
||||||
background: var(--color-card);
|
flex: 1;
|
||||||
border-radius: var(--radius-card);
|
padding: var(--spacing-lg) var(--content-padding);
|
||||||
padding: var(--spacing-xl);
|
padding-bottom: 6rem;
|
||||||
box-shadow: var(--shadow-card);
|
}
|
||||||
|
|
||||||
|
.detail__content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding-top: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Title */
|
||||||
.detail__title {
|
.detail__title {
|
||||||
font-size: 1.4rem;
|
font-size: 2rem;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
color: var(--color-text);
|
color: var(--color-text-on-gradient);
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail__fields {
|
/* Meta rows: icon + text */
|
||||||
|
.detail__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail__field {
|
.detail__meta-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 0.15rem;
|
gap: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail__label {
|
.detail__meta-icon {
|
||||||
font-size: 0.8rem;
|
flex-shrink: 0;
|
||||||
font-weight: 700;
|
width: 36px;
|
||||||
color: #888;
|
height: 36px;
|
||||||
text-transform: uppercase;
|
display: flex;
|
||||||
letter-spacing: 0.04em;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail__value {
|
.detail__meta-text {
|
||||||
font-size: 0.95rem;
|
font-size: 0.9rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text-on-gradient);
|
||||||
word-break: break-word;
|
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 {
|
.detail__banner {
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
border-radius: var(--radius-card);
|
border-radius: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail__banner--expired {
|
.detail__banner--expired {
|
||||||
background: #fff3e0;
|
background: rgba(255, 255, 255, 0.12);
|
||||||
color: #e65100;
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Error / not-found message */
|
||||||
.detail__message {
|
.detail__message {
|
||||||
font-size: 1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
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 {
|
.skeleton--title {
|
||||||
height: 1.6rem;
|
height: 2rem;
|
||||||
width: 60%;
|
width: 70%;
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton--line {
|
.skeleton--line {
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
width: 80%;
|
width: 85%;
|
||||||
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton--short {
|
.skeleton--short {
|
||||||
width: 40%;
|
width: 45%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const route = useRoute()
|
|||||||
const copyState = ref<'idle' | 'copied' | 'failed'>('idle')
|
const copyState = ref<'idle' | 'copied' | 'failed'>('idle')
|
||||||
|
|
||||||
const eventUrl = computed(() => {
|
const eventUrl = computed(() => {
|
||||||
return window.location.origin + '/events/' + route.params.token
|
return window.location.origin + '/events/' + route.params.eventToken
|
||||||
})
|
})
|
||||||
|
|
||||||
const copyLabel = computed(() => {
|
const copyLabel = computed(() => {
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="home">
|
<main class="home">
|
||||||
<h1 class="home__title">fete</h1>
|
<h1 class="home__title">fete</h1>
|
||||||
<p class="home__subtitle">No events yet.<br />Create your first one!</p>
|
<template v-if="events.length > 0">
|
||||||
<RouterLink to="/create" class="btn-primary home__cta">+ Create Event</RouterLink>
|
<EventList />
|
||||||
|
<CreateEventFab />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<EmptyState />
|
||||||
|
</template>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink } from 'vue-router'
|
import { computed } from 'vue'
|
||||||
|
import { useEventStorage, isValidStoredEvent } from '../composables/useEventStorage'
|
||||||
|
import EventList from '../components/EventList.vue'
|
||||||
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
|
import CreateEventFab from '../components/CreateEventFab.vue'
|
||||||
|
|
||||||
|
const { getStoredEvents } = useEventStorage()
|
||||||
|
|
||||||
|
const events = computed(() => getStoredEvents().filter(isValidStoredEvent))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -15,27 +28,15 @@ import { RouterLink } from 'vue-router'
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--spacing-lg);
|
gap: var(--spacing-lg);
|
||||||
text-align: center;
|
padding-top: var(--spacing-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home__title {
|
.home__title {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--color-text-on-gradient);
|
color: var(--color-text-on-gradient);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home__subtitle {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--color-text-on-gradient);
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home__cta {
|
|
||||||
margin-top: var(--spacing-md);
|
|
||||||
max-width: 280px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function createTestRouter() {
|
|||||||
routes: [
|
routes: [
|
||||||
{ path: '/', name: 'home', component: { template: '<div />' } },
|
{ path: '/', name: 'home', component: { template: '<div />' } },
|
||||||
{ path: '/create', name: 'create-event', component: EventCreateView },
|
{ path: '/create', name: 'create-event', component: EventCreateView },
|
||||||
{ path: '/events/:token', name: 'event', component: { template: '<div />' } },
|
{ path: '/events/:eventToken', name: 'event', component: { template: '<div />' } },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -169,6 +169,7 @@ describe('EventCreateView', () => {
|
|||||||
getOrganizerToken: vi.fn(),
|
getOrganizerToken: vi.fn(),
|
||||||
saveRsvp: vi.fn(),
|
saveRsvp: vi.fn(),
|
||||||
getRsvp: vi.fn(),
|
getRsvp: vi.fn(),
|
||||||
|
removeEvent: vi.fn(),
|
||||||
})
|
})
|
||||||
|
|
||||||
vi.mocked(api.POST).mockResolvedValueOnce({
|
vi.mocked(api.POST).mockResolvedValueOnce({
|
||||||
@@ -221,7 +222,7 @@ describe('EventCreateView', () => {
|
|||||||
|
|
||||||
expect(pushSpy).toHaveBeenCalledWith({
|
expect(pushSpy).toHaveBeenCalledWith({
|
||||||
name: 'event',
|
name: 'event',
|
||||||
params: { token: 'abc-123' },
|
params: { eventToken: 'abc-123' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ vi.mock('@/composables/useEventStorage', () => ({
|
|||||||
getOrganizerToken: mockGetOrganizerToken,
|
getOrganizerToken: mockGetOrganizerToken,
|
||||||
saveRsvp: mockSaveRsvp,
|
saveRsvp: mockSaveRsvp,
|
||||||
getRsvp: mockGetRsvp,
|
getRsvp: mockGetRsvp,
|
||||||
|
removeEvent: vi.fn(),
|
||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ function createTestRouter(_token?: string) {
|
|||||||
history: createMemoryHistory(),
|
history: createMemoryHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', name: 'home', component: { template: '<div />' } },
|
{ path: '/', name: 'home', component: { template: '<div />' } },
|
||||||
{ path: '/events/:token', name: 'event', component: EventDetailView },
|
{ path: '/events/:eventToken', name: 'event', component: EventDetailView },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -104,7 +105,7 @@ describe('EventDetailView', () => {
|
|||||||
const wrapper = await mountWithToken()
|
const wrapper = await mountWithToken()
|
||||||
await flushPromises()
|
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('(Europe/Berlin)')
|
||||||
expect(dateField.text()).toContain('2026')
|
expect(dateField.text()).toContain('2026')
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
@@ -338,6 +339,42 @@ describe('EventDetailView', () => {
|
|||||||
wrapper.unmount()
|
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 () => {
|
it('shows error when RSVP submission fails', async () => {
|
||||||
mockLoadedEvent()
|
mockLoadedEvent()
|
||||||
vi.mocked(api.POST).mockResolvedValue({
|
vi.mocked(api.POST).mockResolvedValue({
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function createTestRouter() {
|
|||||||
history: createMemoryHistory(),
|
history: createMemoryHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', name: 'home', component: { template: '<div />' } },
|
{ path: '/', name: 'home', component: { template: '<div />' } },
|
||||||
{ path: '/events/:token', name: 'event', component: EventStubView },
|
{ path: '/events/:eventToken', name: 'event', component: EventStubView },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
# Feature Specification: Manage Guest List as Organizer
|
|
||||||
|
|
||||||
**Feature**: `009-guest-list`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - View and manage RSVPs (Priority: P1)
|
|
||||||
|
|
||||||
As an event organizer, I want to view all RSVPs for my event and remove individual entries if needed, so that I have an accurate overview of attendance and can moderate erroneous or spam entries.
|
|
||||||
|
|
||||||
The organizer view is accessible from the event page when a valid organizer token for that event is present in localStorage. When no organizer token is present, no organizer-specific UI is shown. The organizer can see each RSVP entry with name and attending status, and can permanently delete any entry.
|
|
||||||
|
|
||||||
**Why this priority**: Core organizer capability — without it, the organizer has no way to manage erroneous or spam RSVP entries. The public attendee list is only trustworthy if the organizer can moderate it.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by creating an event (obtaining an organizer token), submitting several RSVPs via the RSVP form, then opening the organizer view to verify the list is displayed and deletion works.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an organizer token for an event is present in localStorage, **When** the organizer opens the event page, **Then** an organizer view link or section is visible that is not shown to guests without the token.
|
|
||||||
2. **Given** the organizer view is open, **When** the page loads, **Then** all RSVPs for the event are listed, each showing the entry's name and attending status.
|
|
||||||
3. **Given** the organizer view is open with at least one RSVP entry, **When** the organizer deletes an entry, **Then** the entry is permanently removed from the server and immediately disappears from the attendee list on the public event page.
|
|
||||||
4. **Given** a visitor without an organizer token in localStorage opens the event page, **When** the page renders, **Then** no organizer-specific UI (link, button, or organizer view) is visible.
|
|
||||||
5. **Given** the organizer view is open, **When** the organizer attempts to access it via a guessable URL without the organizer token in localStorage, **Then** organizer access is denied — it requires the organizer token established during event creation.
|
|
||||||
6. **Given** the organizer has a valid organizer token in localStorage, **When** the organizer accesses the organizer view, **Then** no additional authentication step beyond the localStorage token is required.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens when the organizer token is present in localStorage but the event no longer exists on the server?
|
|
||||||
- How does the system handle a deletion request when the organizer token is invalid or has been cleared from localStorage mid-session?
|
|
||||||
- What if all RSVPs are deleted — does the organizer view show an empty state?
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: System MUST display the organizer view (guest list management) only when a valid organizer token for that event is present in localStorage.
|
|
||||||
- **FR-002**: System MUST hide all organizer-specific UI (links, buttons, organizer view) from visitors who do not have the organizer token in localStorage.
|
|
||||||
- **FR-003**: Organizer view MUST list all RSVPs for the event, showing each entry's name and attending status.
|
|
||||||
- **FR-004**: Organizer MUST be able to permanently delete any individual RSVP entry from the organizer view.
|
|
||||||
- **FR-005**: System MUST reflect RSVP deletions immediately on the public event page — the attendee list must update without delay.
|
|
||||||
- **FR-006**: Organizer view MUST NOT be accessible via a guessable URL — access requires the organizer token stored in localStorage during event creation (US-1).
|
|
||||||
- **FR-007**: System MUST NOT require any additional authentication step beyond the presence of the organizer token in localStorage.
|
|
||||||
- **FR-008**: Server MUST reject RSVP deletion requests that do not include a valid organizer token.
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **RSVP**: An entry submitted by a guest (US-3). Attributes: event association, guest name, attending status. The organizer can delete individual entries.
|
|
||||||
- **Organizer token**: A secret UUID stored in localStorage on the device where the event was created (US-1). Grants organizer access to the guest list management view.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: An organizer can view the complete guest list for their event from the event page without navigating away to a separate URL.
|
|
||||||
- **SC-002**: An organizer can delete any RSVP entry and the deletion is reflected on the public event page within the same page interaction (no reload required).
|
|
||||||
- **SC-003**: A visitor without the organizer token in localStorage sees no organizer UI at all — zero organizer-specific elements rendered.
|
|
||||||
- **SC-004**: The organizer view is not reachable by guessing or constructing a URL — it requires the in-memory/localStorage token to render.
|
|
||||||
- **SC-005**: No additional login, account, or authentication beyond the organizer token is required to manage the guest list.
|
|
||||||
35
specs/009-list-events/checklists/requirements.md
Normal file
35
specs/009-list-events/checklists/requirements.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Event List on Home Page
|
||||||
|
|
||||||
|
**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 validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||||
|
- Assumptions section documents that no backend changes are needed — this is a frontend-only feature using existing localStorage data.
|
||||||
99
specs/009-list-events/data-model.md
Normal file
99
specs/009-list-events/data-model.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Data Model: Event List on Home Page
|
||||||
|
|
||||||
|
**Feature**: 009-list-events | **Date**: 2026-03-08
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### StoredEvent (existing — no changes)
|
||||||
|
|
||||||
|
The `StoredEvent` interface in `frontend/src/composables/useEventStorage.ts` already contains all fields needed for the event list feature.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface StoredEvent {
|
||||||
|
eventToken: string // Required — UUID, used for navigation
|
||||||
|
organizerToken?: string // Present if user created this event
|
||||||
|
title: string // Required — displayed on card
|
||||||
|
dateTime: string // Required — ISO 8601, used for sorting + relative time
|
||||||
|
expiryDate: string // Stored but not displayed in list view
|
||||||
|
rsvpToken?: string // Present if user RSVP'd to this event
|
||||||
|
rsvpName?: string // User's name at RSVP time
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation Rules
|
||||||
|
|
||||||
|
An event entry is considered **valid** for display if all of:
|
||||||
|
- `eventToken` is a non-empty string
|
||||||
|
- `title` is a non-empty string
|
||||||
|
- `dateTime` is a non-empty string that parses to a valid `Date`
|
||||||
|
|
||||||
|
Invalid entries are silently excluded from the list (FR-010).
|
||||||
|
|
||||||
|
### Derived Properties (computed at render time)
|
||||||
|
|
||||||
|
| Property | Derivation |
|
||||||
|
|----------|-----------|
|
||||||
|
| `isPast` | `new Date(dateTime) < new Date()` |
|
||||||
|
| `isOrganizer` | `organizerToken !== undefined` |
|
||||||
|
| `isAttendee` | `rsvpToken !== undefined && organizerToken === undefined` |
|
||||||
|
| `relativeTime` | `Intl.RelativeTimeFormat` applied to `dateTime` vs now |
|
||||||
|
| `detailRoute` | `/events/${eventToken}` |
|
||||||
|
|
||||||
|
### Sorting Order
|
||||||
|
|
||||||
|
1. **Upcoming events** (`dateTime >= now`): ascending by `dateTime` (soonest first)
|
||||||
|
2. **Past events** (`dateTime < now`): descending by `dateTime` (most recently passed first)
|
||||||
|
|
||||||
|
### Composable Extension
|
||||||
|
|
||||||
|
The `useEventStorage` composable needs one new function:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function removeEvent(eventToken: string): void {
|
||||||
|
const events = readEvents().filter((e) => e.eventToken !== eventToken)
|
||||||
|
writeEvents(events)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returned alongside existing functions from `useEventStorage()`.
|
||||||
|
|
||||||
|
## State Transitions
|
||||||
|
|
||||||
|
```
|
||||||
|
localStorage read
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Parse JSON ──(error)──► empty array
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Validate entries ──(invalid)──► silently excluded
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Split: upcoming / past
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Sort each group
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Concatenate ──► rendered list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove Event Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User taps delete icon / swipes left
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ConfirmDialog opens
|
||||||
|
│
|
||||||
|
┌────┴────┐
|
||||||
|
│ Cancel │ Confirm
|
||||||
|
│ │ │
|
||||||
|
│ ▼ ▼
|
||||||
|
│ removeEvent(token)
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ Event removed from localStorage
|
||||||
|
│ List re-renders (event disappears)
|
||||||
|
└────────────────────────────────┘
|
||||||
|
```
|
||||||
86
specs/009-list-events/plan.md
Normal file
86
specs/009-list-events/plan.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Implementation Plan: Event List on Home Page
|
||||||
|
|
||||||
|
**Branch**: `009-list-events` | **Date**: 2026-03-08 | **Spec**: `specs/009-list-events/spec.md`
|
||||||
|
**Input**: Feature specification from `/specs/009-list-events/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Transform the home page from a static empty-state placeholder into a dynamic event list that shows all events stored in the browser's localStorage. Each event card displays title, relative time, and role indicator (organizer/attendee). Events are sorted chronologically (upcoming first), past events appear faded, and users can remove events via delete icon or swipe gesture. A FAB provides persistent access to event creation.
|
||||||
|
|
||||||
|
This is a **frontend-only** feature — no backend or API changes required. The existing `useEventStorage` composable already provides all necessary data access.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5.9, Vue 3.5
|
||||||
|
**Primary Dependencies**: Vue 3, Vue Router 5, Vite
|
||||||
|
**Storage**: Browser localStorage via `useEventStorage` composable
|
||||||
|
**Testing**: Vitest (unit), Playwright + MSW (E2E)
|
||||||
|
**Target Platform**: Mobile-first PWA (centered 480px column on desktop)
|
||||||
|
**Project Type**: Web application (frontend-only changes)
|
||||||
|
**Performance Goals**: Event list renders within 1 second (SC-001) — trivial given localStorage read
|
||||||
|
**Constraints**: No external dependencies, no tracking, WCAG AA, keyboard navigable
|
||||||
|
**Scale/Scope**: Typically <50 events in localStorage; no pagination needed
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Privacy by Design | ✅ PASS | Purely client-side. No data leaves the browser. No analytics. |
|
||||||
|
| II. Test-Driven Methodology | ✅ PASS | Unit tests for composable, E2E for each user story. TDD enforced. |
|
||||||
|
| III. API-First Development | ✅ N/A | No API changes — this feature reads only from localStorage. |
|
||||||
|
| IV. Simplicity & Quality | ✅ PASS | Minimal approach: extend existing composable + new components. No over-engineering. |
|
||||||
|
| V. Dependency Discipline | ✅ PASS | No new dependencies. Swipe gesture implemented with native Touch API. Relative time via built-in `Intl.RelativeTimeFormat`. |
|
||||||
|
| VI. Accessibility | ✅ PASS | Semantic list markup, ARIA labels, keyboard navigation, WCAG AA contrast on faded past events. |
|
||||||
|
|
||||||
|
**Gate result: PASS** — no violations.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/009-list-events/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── spec.md # Feature specification
|
||||||
|
├── research.md # Phase 0 output
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks command)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── composables/
|
||||||
|
│ │ ├── useEventStorage.ts # MODIFY: add removeEvent()
|
||||||
|
│ │ ├── useRelativeTime.ts # NEW: Intl.RelativeTimeFormat wrapper
|
||||||
|
│ │ └── __tests__/
|
||||||
|
│ │ ├── useEventStorage.spec.ts # MODIFY: add removeEvent tests
|
||||||
|
│ │ └── useRelativeTime.spec.ts # NEW: relative time formatting tests
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── EventCard.vue # NEW: individual event list item
|
||||||
|
│ │ ├── EventList.vue # NEW: sorted event list container
|
||||||
|
│ │ ├── EmptyState.vue # NEW: extracted empty state
|
||||||
|
│ │ ├── CreateEventFab.vue # NEW: floating action button
|
||||||
|
│ │ ├── ConfirmDialog.vue # NEW: reusable confirmation prompt
|
||||||
|
│ │ └── __tests__/
|
||||||
|
│ │ ├── EventCard.spec.ts # NEW
|
||||||
|
│ │ ├── EventList.spec.ts # NEW
|
||||||
|
│ │ ├── EmptyState.spec.ts # NEW
|
||||||
|
│ │ └── ConfirmDialog.spec.ts # NEW
|
||||||
|
│ ├── views/
|
||||||
|
│ │ └── HomeView.vue # MODIFY: compose list/empty/fab
|
||||||
|
│ └── assets/
|
||||||
|
│ └── main.css # MODIFY: add event card, faded, fab styles
|
||||||
|
└── e2e/
|
||||||
|
└── home-events.spec.ts # NEW: E2E tests for all user stories
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Frontend-only changes. New components in `components/`, composable extensions in `composables/`, styles in existing `main.css`. No backend changes.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations — this section is intentionally empty.
|
||||||
110
specs/009-list-events/research.md
Normal file
110
specs/009-list-events/research.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Research: Event List on Home Page
|
||||||
|
|
||||||
|
**Feature**: 009-list-events | **Date**: 2026-03-08
|
||||||
|
|
||||||
|
## Research Questions
|
||||||
|
|
||||||
|
### 1. Relative Time Formatting with `Intl.RelativeTimeFormat`
|
||||||
|
|
||||||
|
**Decision**: Use the built-in `Intl.RelativeTimeFormat` API directly — no library needed.
|
||||||
|
|
||||||
|
**Rationale**: The API is supported in all modern browsers (97%+ coverage). It handles locale-aware output natively (e.g., "in 3 days", "vor 2 Tagen" for German). The spec requires exactly this (FR-002).
|
||||||
|
|
||||||
|
**Implementation approach**: Create a `useRelativeTime` composable that:
|
||||||
|
1. Takes a date string (ISO 8601) and computes the difference from `now`
|
||||||
|
2. Selects the appropriate unit (seconds → minutes → hours → days → weeks → months → years)
|
||||||
|
3. Returns a formatted string via `Intl.RelativeTimeFormat(navigator.language, { numeric: 'auto' })`
|
||||||
|
4. Exposes a reactive `label` that updates (optional — can be static since the list re-reads on mount)
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- `date-fns/formatDistance`: Would add a dependency for something the platform already does. Rejected per Principle V.
|
||||||
|
- `dayjs/relativeTime`: Same reasoning — unnecessary dependency.
|
||||||
|
|
||||||
|
### 2. Swipe-to-Delete Gesture (FR-006b)
|
||||||
|
|
||||||
|
**Decision**: Implement with native Touch API (`touchstart`, `touchmove`, `touchend`) — no gesture library.
|
||||||
|
|
||||||
|
**Rationale**: The gesture is simple (horizontal swipe on a single element). A library like Hammer.js or @vueuse/gesture would be overkill for one swipe direction on one component type. Per Principle V, dependencies must provide substantial value.
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
1. Track `touchstart` X position on the event card
|
||||||
|
2. On `touchmove`, calculate delta-X; if leftward and exceeds threshold (~80px), reveal delete action
|
||||||
|
3. On `touchend`, either snap back or trigger confirmation
|
||||||
|
4. CSS `transform: translateX()` with `transition` for smooth animation
|
||||||
|
5. Desktop users use the visible delete icon (no swipe needed)
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- `@vueuse/gesture`: Wraps Hammer.js, adds ~15KB. Rejected — too heavy for one gesture.
|
||||||
|
- CSS `scroll-snap` trick: Clever but brittle and poor accessibility. Rejected.
|
||||||
|
|
||||||
|
### 3. Past Event Visual Fading (FR-009)
|
||||||
|
|
||||||
|
**Decision**: Use CSS `opacity` reduction + `filter: saturate()` for faded appearance.
|
||||||
|
|
||||||
|
**Rationale**: The spec says "subtle reduction in contrast and saturation" — not a blunt grey-out. Combining `opacity: 0.6` with `filter: saturate(0.5)` achieves this while keeping text readable. Must verify WCAG AA contrast on the faded state.
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- Add a `.event-card--past` modifier class
|
||||||
|
- Apply `opacity: 0.55; filter: saturate(0.4)` (tune exact values for WCAG AA)
|
||||||
|
- Keep `pointer-events: auto` and normal hover/focus styles so the card remains interactive
|
||||||
|
- The card still navigates to the event detail page on click
|
||||||
|
|
||||||
|
**Contrast verification**: The card text (`#1C1C1E` on `#FFFFFF`) has a contrast ratio of ~17:1. At `opacity: 0.55`, effective contrast drops to ~9:1, which still passes WCAG AA (4.5:1 minimum). Safe.
|
||||||
|
|
||||||
|
### 4. Confirmation Dialog (FR-007)
|
||||||
|
|
||||||
|
**Decision**: Custom modal component (reusing the existing `BottomSheet.vue` pattern) rather than `window.confirm()`.
|
||||||
|
|
||||||
|
**Rationale**: `window.confirm()` is blocking, non-stylable, and inconsistent across browsers. A custom dialog matches the app's design system and provides a better UX. The existing `BottomSheet.vue` already handles teleportation, focus trapping, and Escape-key dismissal — the confirm dialog can reuse this or follow the same pattern.
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- Create a `ConfirmDialog.vue` component
|
||||||
|
- Props: `open`, `title`, `message`, `confirmLabel`, `cancelLabel`
|
||||||
|
- Emits: `confirm`, `cancel`
|
||||||
|
- Uses the same teleport-to-body pattern as `BottomSheet.vue`
|
||||||
|
- Focus trapping and keyboard navigation (Tab, Escape, Enter)
|
||||||
|
|
||||||
|
### 5. localStorage Validation (FR-010)
|
||||||
|
|
||||||
|
**Decision**: Validate entries during read — filter out invalid events silently.
|
||||||
|
|
||||||
|
**Rationale**: The spec says "silently excluded from the list." The `readEvents()` function already handles parse errors with a try/catch. We need to add field-level validation: an event is valid only if it has `eventToken`, `title`, and `dateTime` (all non-empty strings).
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- Add a `isValidStoredEvent(e: unknown): e is StoredEvent` type guard
|
||||||
|
- Apply it in `getStoredEvents()` as a filter
|
||||||
|
- Invalid entries remain in localStorage (no destructive cleanup) but are not displayed
|
||||||
|
|
||||||
|
### 6. FAB Placement (FR-011)
|
||||||
|
|
||||||
|
**Decision**: Fixed-position button at bottom-right with safe-area padding.
|
||||||
|
|
||||||
|
**Rationale**: Standard Material Design pattern for primary actions. The existing `RsvpBar.vue` already uses `padding-bottom: env(safe-area-inset-bottom)` for mobile notch avoidance — reuse the same approach.
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- `position: fixed; bottom: calc(1.2rem + env(safe-area-inset-bottom)); right: 1.2rem`
|
||||||
|
- Circular button with `+` icon, accent color background
|
||||||
|
- `z-index` above content, shadow for elevation
|
||||||
|
- Navigates to `/create` on click
|
||||||
|
|
||||||
|
### 7. Event Sorting (FR-004)
|
||||||
|
|
||||||
|
**Decision**: Sort in-memory after reading from localStorage.
|
||||||
|
|
||||||
|
**Rationale**: The list is small (<100 events typically). Sorting on every render is negligible. Sort by `dateTime` ascending (nearest upcoming first), then past events after.
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- Split events into `upcoming` (dateTime >= now) and `past` (dateTime < now)
|
||||||
|
- Sort upcoming ascending (soonest first), past descending (most recent past first)
|
||||||
|
- Concatenate: `[...upcoming, ...past]`
|
||||||
|
|
||||||
|
### 8. Role Distinction (FR-008 / US-5)
|
||||||
|
|
||||||
|
**Decision**: Small badge/label on the event card indicating "Organizer" or "Attendee."
|
||||||
|
|
||||||
|
**Rationale**: The data is already available — `organizerToken` present means organizer, `rsvpToken` present (without `organizerToken`) means attendee. A subtle text badge is sufficient; no need for icons or colors.
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- If `organizerToken` is set → show "Organizer" badge (accent-colored)
|
||||||
|
- If `rsvpToken` is set (no `organizerToken`) → show "Attendee" badge (muted)
|
||||||
|
- If neither → show no badge (edge case: event stored but no role — could happen with manual localStorage manipulation)
|
||||||
145
specs/009-list-events/spec.md
Normal file
145
specs/009-list-events/spec.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Feature Specification: Event List on Home Page
|
||||||
|
|
||||||
|
**Feature Branch**: `009-list-events`
|
||||||
|
**Created**: 2026-03-08
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "man kann auf der hauptseite eine liste an events sehen, sofern sie im localstorage gespeichert sind"
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - View My Events (Priority: P1)
|
||||||
|
|
||||||
|
As a returning user, I want to see a list of events I have previously created or interacted with (RSVP'd to) on the home page, so I can quickly navigate back to them without needing to remember or bookmark individual event links.
|
||||||
|
|
||||||
|
The home page displays all events stored in the browser's local storage. Each event entry shows the event title and date/time. Tapping an event navigates to its detail page.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core value of the feature — without the list, the home page remains a dead end for returning users.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by creating an event (or simulating localStorage entries), returning to the home page, and verifying all stored events appear in a list with correct titles and dates.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the user has 3 events stored in localStorage, **When** they visit the home page, **Then** all 3 events are displayed in a list showing title and date/time for each.
|
||||||
|
2. **Given** the user has events stored in localStorage, **When** they tap on an event in the list, **Then** they are navigated to the event detail page (`/events/:eventToken`).
|
||||||
|
3. **Given** the user has events stored in localStorage, **When** they visit the home page, **Then** events are sorted by date/time (nearest upcoming event first, past events last).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Empty State (Priority: P2)
|
||||||
|
|
||||||
|
As a new user with no stored events, I see an inviting empty state on the home page that encourages me to create my first event or explains how to get started.
|
||||||
|
|
||||||
|
**Why this priority**: First-time users need clear guidance. The empty state is the first impression for new users.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by clearing localStorage and visiting the home page — the empty state message and "Create Event" call-to-action should be visible.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** no events are stored in localStorage, **When** the user visits the home page, **Then** an empty state message is displayed (e.g., "No events yet") with a prominent "Create Event" button.
|
||||||
|
2. **Given** the user has at least one event stored, **When** they visit the home page, **Then** the empty state message is not shown — the event list is displayed instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Remove Event from List (Priority: P3)
|
||||||
|
|
||||||
|
As a user, I want to remove an event from my personal list so I can keep my home page tidy and only show events I still care about.
|
||||||
|
|
||||||
|
**Why this priority**: Housekeeping capability. Without removal, the list grows indefinitely and becomes cluttered over time.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by having multiple events in localStorage, removing one from the list, and verifying it disappears from the home page while the others remain.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the user has events in their list, **When** they tap the delete icon on an event card, **Then** a confirmation prompt appears asking if they are sure.
|
||||||
|
1b. **Given** the user has events in their list, **When** they swipe an event card to the left, **Then** a confirmation prompt appears asking if they are sure.
|
||||||
|
2. **Given** the confirmation prompt is shown, **When** the user confirms removal, **Then** the event is removed from localStorage and disappears from the list immediately.
|
||||||
|
3. **Given** the confirmation prompt is shown, **When** the user cancels, **Then** the event remains in the list unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 4 - Past Events Appear Faded (Priority: P2)
|
||||||
|
|
||||||
|
As a user, I want events whose date/time has passed to appear visually faded or muted in the list, so I can immediately focus on upcoming events without past events cluttering my attention.
|
||||||
|
|
||||||
|
The fading should feel modern and polished — not a blunt grey-out, but a subtle reduction in contrast and saturation that makes past events recede visually while remaining readable and tappable.
|
||||||
|
|
||||||
|
**Why this priority**: Without this, past and upcoming events look identical, making the list harder to scan. This is essential for usability once a user has accumulated several events.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by having both future and past events in localStorage and verifying that past events display with reduced visual prominence while remaining interactive.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the user has a past event (dateTime before now) in localStorage, **When** they view the home page, **Then** the event appears with reduced visual prominence (muted colors, lower contrast) compared to upcoming events.
|
||||||
|
2. **Given** the user has a past event in the list, **When** they tap on it, **Then** it still navigates to the event detail page — it remains fully interactive.
|
||||||
|
3. **Given** the user has both past and upcoming events, **When** they view the home page, **Then** upcoming events appear first (full visual prominence), followed by past events (faded), creating a clear visual hierarchy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 5 - Visual Distinction for Event Roles (Priority: P3)
|
||||||
|
|
||||||
|
As a user, I want to see at a glance whether I am the organizer of an event or just an attendee, so I can quickly identify my responsibilities.
|
||||||
|
|
||||||
|
**Why this priority**: Nice-to-have clarity. The data is already available in localStorage (presence of `organizerToken`), so surfacing it improves usability at low effort.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by having both created events (with organizerToken) and RSVP'd events (with rsvpToken) in localStorage, and verifying they display different visual indicators.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the user has a created event (organizerToken present) in localStorage, **When** they view the home page, **Then** the event shows a visual indicator marking them as the organizer (e.g., a badge or label).
|
||||||
|
2. **Given** the user has an event with an RSVP (rsvpToken present, no organizerToken) in localStorage, **When** they view the home page, **Then** the event shows a visual indicator marking them as an attendee.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when localStorage data is corrupted or contains invalid entries? Events with missing required fields (eventToken, title, dateTime) are silently excluded from the list.
|
||||||
|
- What happens when localStorage is unavailable (e.g., private browsing with storage disabled)? The empty state is shown with the "Create Event" button — the app remains functional.
|
||||||
|
- What happens when an event's date/time has passed? The event remains in the list but appears visually faded.
|
||||||
|
- What happens when the user has a very large number of stored events (e.g., 50+)? The list scrolls naturally. No pagination is needed at this scale since localStorage entries are lightweight.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST display a list of all events stored in the browser's local storage on the home page.
|
||||||
|
- **FR-002**: Each event entry MUST show the event title and the event date/time displayed as a relative time label (e.g., "in 3 days", "yesterday") using `Intl.RelativeTimeFormat`.
|
||||||
|
- **FR-003**: Each event entry MUST be tappable/clickable and navigate to the event detail page (`/events/:eventToken`).
|
||||||
|
- **FR-004**: Events MUST be sorted by date/time with nearest upcoming events first and past events last.
|
||||||
|
- **FR-005**: System MUST display an empty state with a "Create Event" call-to-action when no events are stored.
|
||||||
|
- **FR-006a**: Users MUST be able to remove individual events from their local list via a visible delete icon on each event card (primary mechanism, implemented first).
|
||||||
|
- **FR-006b**: Users MUST be able to remove individual events via swipe-to-delete gesture (secondary mechanism, implemented separately after FR-006a).
|
||||||
|
- **FR-007**: System MUST show a confirmation prompt before removing an event from the list.
|
||||||
|
- **FR-008**: System MUST visually distinguish events where the user is the organizer from events where the user is an attendee.
|
||||||
|
- **FR-009**: System MUST display past events (dateTime before current time) with reduced visual prominence — muted colors and lower contrast — while keeping them readable and interactive.
|
||||||
|
- **FR-010**: System MUST gracefully handle corrupted or incomplete localStorage entries by excluding invalid events from the list.
|
||||||
|
- **FR-011**: The "Create Event" button MUST remain accessible on the home page even when events are listed, implemented as a Floating Action Button (FAB) fixed at the bottom-right corner.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Stored Event**: A locally persisted reference to an event the user has interacted with. Contains: event token (unique identifier for navigation), title, date/time, expiry date, and optionally an organizer token (if created by this user) or RSVP token and name (if the user RSVP'd).
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Users can see all their stored events on the home page within 1 second of page load.
|
||||||
|
- **SC-002**: Users can navigate from the home page to any event detail page in a single tap/click.
|
||||||
|
- **SC-003**: Users can remove an unwanted event from their list in under 3 seconds (including confirmation).
|
||||||
|
- **SC-004**: New users (no stored events) see a clear call-to-action to create their first event.
|
||||||
|
- **SC-005**: Users can distinguish their role (organizer vs. attendee) for each event at a glance without opening the event.
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-03-08
|
||||||
|
|
||||||
|
- Q: How does the user trigger event removal? → A: Two mechanisms — visible delete icon on each event card (primary, implemented first) and swipe-to-delete gesture (secondary, implemented separately after).
|
||||||
|
- Q: Placement of "Create Event" button when events exist? → A: Floating Action Button (FAB) fixed at bottom-right corner.
|
||||||
|
- Q: Date/time display format in event list? → A: Relative time labels ("in 3 days", "yesterday") via Intl.RelativeTimeFormat.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The existing `useEventStorage` composable and `StoredEvent` interface provide all necessary data for the event list (no backend API calls needed for listing).
|
||||||
|
- The event list is purely client-side — there is no server-side "my events" endpoint. Privacy is preserved because events are only known to the user's browser.
|
||||||
|
- The event list uses `Intl.RelativeTimeFormat` for relative time labels (FR-002), while the event detail view uses `Intl.DateTimeFormat` for absolute date/time display. Both use the browser's locale (`navigator.language`).
|
||||||
|
- The "Create Event" flow (spec 006) already saves events to localStorage, so no changes to event creation are needed.
|
||||||
|
- The RSVP flow (spec 008) already saves RSVP data to localStorage, so no changes to RSVP are needed.
|
||||||
215
specs/009-list-events/tasks.md
Normal file
215
specs/009-list-events/tasks.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# Tasks: Event List on Home Page
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/009-list-events/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, research.md, data-model.md
|
||||||
|
|
||||||
|
**Tests**: Unit tests (Vitest) and E2E tests (Playwright) are included per constitution (Principle II: Test-Driven Methodology).
|
||||||
|
|
||||||
|
**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 (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Composable extensions and utility functions shared across all user stories
|
||||||
|
|
||||||
|
- [x] T000 Rename router param `:token` to `:eventToken` in `frontend/src/router/index.ts` and update all references in `EventDetailView.vue`, `EventStubView.vue`, and their test files (consistency with `StoredEvent.eventToken` field name)
|
||||||
|
- [x] T001 Add `isValidStoredEvent` type guard and validation filter to `frontend/src/composables/useEventStorage.ts` (FR-010)
|
||||||
|
- [x] T002 Add `removeEvent(eventToken: string)` function to `frontend/src/composables/useEventStorage.ts` (needed by US3)
|
||||||
|
- [x] T003 [P] Create `useRelativeTime` composable in `frontend/src/composables/useRelativeTime.ts` (Intl.RelativeTimeFormat wrapper, FR-002)
|
||||||
|
- [x] T004 [P] Add unit tests for `isValidStoredEvent` and `removeEvent` in `frontend/src/composables/__tests__/useEventStorage.spec.ts`
|
||||||
|
- [x] T005 [P] Create unit tests for `useRelativeTime` in `frontend/src/composables/__tests__/useRelativeTime.spec.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: Composable layer complete — all shared logic tested and available for components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Story 1 — View My Events (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Home page shows all stored events in a sorted list with title and relative time. Tapping navigates to event detail.
|
||||||
|
|
||||||
|
**Independent Test**: Simulate localStorage entries, visit home page, verify all events appear sorted with correct titles and relative times. Tap an event and verify navigation to `/events/:eventToken`.
|
||||||
|
|
||||||
|
### Unit Tests for User Story 1
|
||||||
|
|
||||||
|
- [x] T006 [P] [US1] Create unit tests for EventCard component in `frontend/src/components/__tests__/EventCard.spec.ts` — include test cases for `isPast` prop (faded styling) and role badge rendering (organizer vs. attendee)
|
||||||
|
- [x] T007 [P] [US1] Create unit tests for EventList component in `frontend/src/components/__tests__/EventList.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T008 [P] [US1] Create `EventCard.vue` component in `frontend/src/components/EventCard.vue` — displays title, relative time, role badge; emits click for navigation
|
||||||
|
- [x] T009 [US1] Create `EventList.vue` component in `frontend/src/components/EventList.vue` — reads events from composable, validates, sorts (upcoming asc, past desc), renders EventCard list
|
||||||
|
- [x] T010 [US1] Refactor `HomeView.vue` in `frontend/src/views/HomeView.vue` — integrate EventList, conditionally show list when events exist
|
||||||
|
- [x] T011 [US1] Add event card and list styles to `frontend/src/assets/main.css`
|
||||||
|
|
||||||
|
### E2E Tests for User Story 1
|
||||||
|
|
||||||
|
- [x] T012 [US1] Create E2E test file `frontend/e2e/home-events.spec.ts` — tests: events displayed with title and relative time, sorted correctly, click navigates to detail page
|
||||||
|
|
||||||
|
**Checkpoint**: MVP complete — returning users see their events and can navigate to details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 2 — Empty State (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: New users with no stored events see an inviting empty state with a "Create Event" call-to-action.
|
||||||
|
|
||||||
|
**Independent Test**: Clear localStorage, visit home page, verify empty state message and "Create Event" button are visible.
|
||||||
|
|
||||||
|
### Unit Tests for User Story 2
|
||||||
|
|
||||||
|
- [x] T013 [P] [US2] Create unit tests for EmptyState component in `frontend/src/components/__tests__/EmptyState.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T014 [US2] Create `EmptyState.vue` component in `frontend/src/components/EmptyState.vue` — shows message and "Create Event" RouterLink
|
||||||
|
- [x] T015 [US2] Update `HomeView.vue` in `frontend/src/views/HomeView.vue` — show EmptyState when no valid events, show EventList otherwise
|
||||||
|
- [x] T016 [US2] Add empty state styles to `frontend/src/assets/main.css`
|
||||||
|
|
||||||
|
### E2E Tests for User Story 2
|
||||||
|
|
||||||
|
- [x] T017 [US2] Add E2E tests to `frontend/e2e/home-events.spec.ts` — tests: empty state shown when no events, hidden when events exist
|
||||||
|
|
||||||
|
**Checkpoint**: Home page handles both new and returning users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 4 — Past Events Appear Faded (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Events whose date/time has passed appear with reduced visual prominence (muted colors, lower contrast) while remaining interactive.
|
||||||
|
|
||||||
|
**Independent Test**: Have both future and past events in localStorage, verify past events display faded while remaining clickable.
|
||||||
|
|
||||||
|
### Implementation for User Story 4
|
||||||
|
|
||||||
|
- [x] T018 [US4] Add `.event-card--past` modifier class with `opacity: 0.6; filter: saturate(0.5)` to `frontend/src/components/EventCard.vue` or `frontend/src/assets/main.css`
|
||||||
|
- [x] T019 [US4] Pass `isPast` computed property to EventCard in `EventList.vue` and apply modifier class in `frontend/src/components/EventCard.vue`
|
||||||
|
|
||||||
|
### E2E Tests for User Story 4
|
||||||
|
|
||||||
|
- [x] T020 [US4] Add E2E tests to `frontend/e2e/home-events.spec.ts` — tests: past events have faded class, upcoming events do not, past events remain clickable
|
||||||
|
|
||||||
|
**Checkpoint**: Visual hierarchy distinguishes upcoming from past events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 — Remove Event from List (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Users can remove events from their local list via delete icon (and later swipe) with confirmation.
|
||||||
|
|
||||||
|
**Independent Test**: Have multiple events, remove one via delete icon, verify it disappears while others remain.
|
||||||
|
|
||||||
|
### Unit Tests for User Story 3
|
||||||
|
|
||||||
|
- [x] T021 [P] [US3] Create unit tests for ConfirmDialog component in `frontend/src/components/__tests__/ConfirmDialog.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T022 [US3] Create `ConfirmDialog.vue` component in `frontend/src/components/ConfirmDialog.vue` — teleport-to-body modal with confirm/cancel, focus trapping, Escape key
|
||||||
|
- [x] T023 [US3] Add delete icon button to `EventCard.vue` in `frontend/src/components/EventCard.vue` — emits `delete` event with eventToken (FR-006a)
|
||||||
|
- [x] T024 [US3] Wire delete flow in `EventList.vue` in `frontend/src/components/EventList.vue` — listen for delete event, show ConfirmDialog, call `removeEvent()` on confirm
|
||||||
|
- [x] T025 [US3] Add delete icon and confirm dialog styles to `frontend/src/assets/main.css`
|
||||||
|
|
||||||
|
### E2E Tests for User Story 3
|
||||||
|
|
||||||
|
- [x] T026 [US3] Add E2E tests to `frontend/e2e/home-events.spec.ts` — tests: delete icon visible, confirmation dialog appears, confirm removes event, cancel keeps event
|
||||||
|
|
||||||
|
**Checkpoint**: Users can manage their event list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: User Story 5 — Visual Distinction for Event Roles (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Events show a badge indicating whether the user is the organizer or an attendee.
|
||||||
|
|
||||||
|
**Independent Test**: Have events with organizerToken and rsvpToken in localStorage, verify different badges displayed.
|
||||||
|
|
||||||
|
### Implementation for User Story 5
|
||||||
|
|
||||||
|
- [x] T027 [US5] Add role badge (Organizer/Attendee) to `EventCard.vue` in `frontend/src/components/EventCard.vue` — derive from organizerToken/rsvpToken presence
|
||||||
|
- [x] T028 [US5] Add role badge styles to `frontend/src/assets/main.css`
|
||||||
|
|
||||||
|
### E2E Tests for User Story 5
|
||||||
|
|
||||||
|
- [x] T029 [US5] Add E2E tests to `frontend/e2e/home-events.spec.ts` — tests: organizer badge shown for events with organizerToken, attendee badge for events with rsvpToken only
|
||||||
|
|
||||||
|
**Checkpoint**: Role distinction visible at a glance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: FAB, swipe gesture, accessibility, and final polish
|
||||||
|
|
||||||
|
- [x] T030 Create `CreateEventFab.vue` in `frontend/src/components/CreateEventFab.vue` — fixed FAB at bottom-right, navigates to `/create` (FR-011)
|
||||||
|
- [x] T031 Add FAB to `HomeView.vue` in `frontend/src/views/HomeView.vue` — visible when events exist (empty state has its own CTA)
|
||||||
|
- [x] T032 Add FAB styles to `frontend/src/assets/main.css`
|
||||||
|
- [x] T033 Implement swipe-to-delete gesture on EventCard in `frontend/src/components/EventCard.vue` — native Touch API (FR-006b)
|
||||||
|
- [x] T034 Accessibility review: verify ARIA labels, keyboard navigation (Tab/Enter/Escape), focus trapping in ConfirmDialog, WCAG AA contrast on faded cards
|
||||||
|
- [x] T035 Add E2E tests for FAB to `frontend/e2e/home-events.spec.ts` — tests: FAB visible when events exist, navigates to create page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Phase 1 (Setup)**: No dependencies — start immediately
|
||||||
|
- **Phase 2 (US1)**: Depends on T001, T003 (validation + relative time composable)
|
||||||
|
- **Phase 3 (US2)**: Depends on T001 (validation); can run in parallel with US1
|
||||||
|
- **Phase 4 (US4)**: Depends on Phase 2 completion (EventCard must exist)
|
||||||
|
- **Phase 5 (US3)**: Depends on Phase 2 completion (EventList must exist) + T002 (removeEvent)
|
||||||
|
- **Phase 6 (US5)**: Depends on Phase 2 completion (EventCard must exist)
|
||||||
|
- **Phase 7 (Polish)**: Depends on Phases 2–6 completion
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1 (P1)**: Depends only on Phase 1 — no other story dependencies
|
||||||
|
- **US2 (P2)**: Depends only on Phase 1 — independent of US1 but shares HomeView
|
||||||
|
- **US4 (P2)**: Depends on US1 (extends EventCard with past styling)
|
||||||
|
- **US3 (P3)**: Depends on US1 (extends EventList with delete flow)
|
||||||
|
- **US5 (P3)**: Depends on US1 (extends EventCard with role badge)
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- T003 + T004 + T005 can all run in parallel (different files)
|
||||||
|
- T006 + T007 can run in parallel (different test files)
|
||||||
|
- T008 can run in parallel with T006/T007 (component vs test files)
|
||||||
|
- US4, US5 can start in parallel once US1 is done (both extend EventCard independently)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup composables
|
||||||
|
2. Complete Phase 2: US1 — EventCard, EventList, HomeView refactor
|
||||||
|
3. **STOP and VALIDATE**: Test the event list end-to-end
|
||||||
|
4. Deploy/demo if ready
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Phase 1 → Composable layer ready
|
||||||
|
2. Phase 2 (US1) → Event list works → MVP!
|
||||||
|
3. Phase 3 (US2) → Empty state for new users
|
||||||
|
4. Phase 4 (US4) → Past events faded
|
||||||
|
5. Phase 5 (US3) → Remove events from list
|
||||||
|
6. Phase 6 (US5) → Role badges
|
||||||
|
7. Phase 7 → FAB, swipe, accessibility polish
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- [P] tasks = different files, no dependencies
|
||||||
|
- [Story] label maps task to specific user story for traceability
|
||||||
|
- This is a **frontend-only** feature — no backend changes needed
|
||||||
|
- All data comes from existing `useEventStorage` composable (localStorage)
|
||||||
|
- E2E tests consolidated in single file `home-events.spec.ts` with separate `describe` blocks per story
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# Feature Specification: Edit Event Details as Organizer
|
|
||||||
|
|
||||||
**Feature**: `010-edit-event`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - Edit event details (Priority: P1)
|
|
||||||
|
|
||||||
The event organizer wants to update the details of an event they created — title, description, date/time, location, or expiry date — so that guests always see accurate and up-to-date information if something changes.
|
|
||||||
|
|
||||||
**Why this priority**: Core organizer capability. Edits are expected for any real-world event (date changes, venue updates). Without this, the organizer has no recourse when details change after creation.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by creating an event (US-1), navigating to the edit form, submitting changed values, and verifying the event page reflects the updates.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an organizer on the event page with a valid organizer token in localStorage, **When** they navigate to the edit form, **Then** the form is pre-filled with the current event values (title, description, date/time, location, expiry date).
|
|
||||||
2. **Given** the edit form is pre-filled, **When** the organizer modifies one or more fields and submits, **Then** the changes are persisted server-side and the organizer is returned to the event page which reflects the updated details.
|
|
||||||
3. **Given** the organizer is on the event page without an organizer token in localStorage, **When** they attempt to access the edit UI, **Then** no edit option is shown and the server rejects any update request.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Expiry date future-date validation (Priority: P2)
|
|
||||||
|
|
||||||
When editing, the organizer must set the expiry date to a future date. Setting it to today or a past date is rejected, and the organizer is directed to the delete feature (US-19) if they want to remove the event immediately.
|
|
||||||
|
|
||||||
**Why this priority**: Enforces the invariant that an event's expiry date is always in the future. Prevents the expiry date field from being misused as an implicit deletion mechanism, keeping the model clean.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by submitting the edit form with a past or current date in the expiry date field and verifying the rejection response and validation message.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the edit form is open, **When** the organizer sets the expiry date to today or a past date and submits, **Then** the submission is rejected with a clear validation message directing the organizer to use the delete feature (US-19) instead.
|
|
||||||
2. **Given** the edit form is open, **When** the organizer sets the expiry date to a date in the future and submits, **Then** the change is accepted and persisted.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Organizer token authentication (Priority: P2)
|
|
||||||
|
|
||||||
If the organizer token is absent or invalid, neither the edit UI is shown nor the edit request is accepted server-side.
|
|
||||||
|
|
||||||
**Why this priority**: Security constraint — the organizer token is the sole authentication mechanism. Both client and server must enforce it independently.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by attempting an edit request with a missing or wrong organizer token and verifying a 403/401 response and no UI exposure.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a visitor without an organizer token in localStorage for the event, **When** they view the event page, **Then** no edit option or link is shown.
|
|
||||||
2. **Given** an edit request is submitted with an absent or invalid organizer token, **When** the server processes the request, **Then** it rejects the request and the event data is unchanged.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens when the organizer submits the edit form with no changes? The server accepts the submission (idempotent update) and the organizer is returned to the event page.
|
|
||||||
- What happens if the title field is left empty? Submission is rejected with a validation message — title is required.
|
|
||||||
- What happens if the event has expired before the organizer submits the edit form? The server rejects the edit — editing an expired event is not permitted.
|
|
||||||
- How does the system handle concurrent edits (e.g. organizer edits from two devices simultaneously)? [NEEDS EXPANSION — last-write-wins is the simplest strategy]
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: System MUST allow the organizer to edit: title (required), description (optional), date and time (required), location (optional), expiry date (required).
|
|
||||||
- **FR-002**: System MUST pre-fill the edit form with the current stored values for all editable fields.
|
|
||||||
- **FR-003**: System MUST reject an edit submission where the expiry date is today or in the past, and MUST return a validation message directing the organizer to use the delete feature (US-19).
|
|
||||||
- **FR-004**: System MUST persist all submitted changes server-side upon successful validation.
|
|
||||||
- **FR-005**: System MUST redirect the organizer to the event page after a successful edit, with the updated details visible.
|
|
||||||
- **FR-006**: System MUST NOT expose the edit UI to any visitor who does not have a valid organizer token for the event in localStorage.
|
|
||||||
- **FR-007**: System MUST reject any edit request where the organizer token is absent or does not match the event's stored organizer token.
|
|
||||||
- **FR-008**: No account or additional authentication step beyond the organizer token is required to edit an event.
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **Event**: Mutable entity with fields: title, description, date/time, location, expiry date. Identified externally by its event token. Updated via the organizer token.
|
|
||||||
- **Organizer token**: Secret UUID stored in localStorage on the device where the event was created. Required to authenticate all organizer operations including editing.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: An organizer can update any editable field and see the change reflected on the public event page without a page reload after redirect.
|
|
||||||
- **SC-002**: Any attempt to set the expiry date to a non-future date is rejected with a user-visible validation message before the server persists the change.
|
|
||||||
- **SC-003**: No edit operation is possible — client or server — without a valid organizer token.
|
|
||||||
- **SC-004**: The edit form is never shown to a visitor who does not hold the organizer token for the event.
|
|
||||||
- **SC-005**: Visual highlighting of changed fields for guests is deferred to US-9 (Highlight changed event details); this story covers only the server-side persistence and organizer UX of editing.
|
|
||||||
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
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# Feature Specification: Bookmark an Event
|
|
||||||
|
|
||||||
**Feature**: `011-bookmark-event`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
> **Note on directory naming**: The migration task list labeled this directory `us-06-calendar-export`, but US-6 in userstories.md is "Bookmark an event". Calendar export is US-8. The directory was created with the correct name reflecting the actual story content.
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - Bookmark an event without RSVP (Priority: P1)
|
|
||||||
|
|
||||||
A guest who has opened an event page wants to save it for later without committing to attendance. They activate a "Remember" / "Follow" action on the event page. The event token, title, and date are stored in localStorage — no server request is made. The bookmark persists across browser sessions on the same device. The guest can unfollow by activating the action again.
|
|
||||||
|
|
||||||
**Why this priority**: Core bookmarking capability — without this, the entire feature has no value.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by visiting an event page, activating the bookmark action, closing the browser, and reopening to verify persistence. Delivers value by enabling the local event overview (US-7) to display bookmarked events.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a guest has opened an event page, **When** they activate the "Remember" / "Follow" action, **Then** the event token, title, and date are stored in localStorage and no server request is made.
|
|
||||||
2. **Given** a guest has bookmarked an event, **When** they close and reopen the browser, **Then** the bookmark is still present in localStorage.
|
|
||||||
3. **Given** a guest has bookmarked an event, **When** they activate the bookmark action again, **Then** the bookmark is removed from localStorage ("unfollow") without any server contact.
|
|
||||||
4. **Given** a guest has bookmarked an event, **When** the event page is loaded again, **Then** the bookmark action reflects the current bookmarked state.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Bookmark is independent of RSVP state (Priority: P2)
|
|
||||||
|
|
||||||
A guest who has already RSVPed to an event on their device can still explicitly bookmark or un-bookmark the event. The bookmark state is tracked separately from RSVP state.
|
|
||||||
|
|
||||||
**Why this priority**: Important for correctness but not the primary use case. The primary scenario is the undecided guest who wants to remember without committing.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by RSVPing to an event and then toggling the bookmark action — both states persist independently in localStorage.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a guest has RSVPed "attending" on this device, **When** they activate the bookmark action, **Then** the bookmark is stored independently and the RSVP state is unaffected.
|
|
||||||
2. **Given** a guest has bookmarked an event and RSVPed, **When** they remove the bookmark, **Then** the RSVP state is unaffected.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Bookmark available on expired events (Priority: P2)
|
|
||||||
|
|
||||||
A guest who is viewing an event that has passed its expiry date can still bookmark it, so it remains visible in their local event overview (US-7).
|
|
||||||
|
|
||||||
**Why this priority**: Edge case that ensures continuity of the local overview even for past events.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by visiting an expired event page and verifying the bookmark action is present and functional.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an event has passed its expiry date, **When** a guest views the event page, **Then** the bookmark action is still shown and functional.
|
|
||||||
2. **Given** a guest bookmarks an expired event, **When** they view their local event overview (US-7), **Then** the event appears in the list marked as ended.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens when the event title or date changes after bookmarking? Locally cached title and date may become stale if the organizer edits the event — this is an accepted trade-off. Cached values are refreshed when the guest next visits the event page.
|
|
||||||
- How does the app handle localStorage being unavailable (e.g. private browsing in some browsers)? [NEEDS EXPANSION]
|
|
||||||
- What happens if the guest bookmarks the same event from multiple devices? Each device maintains its own independent bookmark — no server-side sync.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: The event page MUST display a "Remember" / "Follow" action accessible to any visitor holding the event link.
|
|
||||||
- **FR-002**: Activating the bookmark action MUST store the event token, event title, and event date in localStorage with no server request.
|
|
||||||
- **FR-003**: The bookmark MUST persist across browser sessions on the same device.
|
|
||||||
- **FR-004**: A second activation of the bookmark action MUST remove the bookmark ("unfollow") with no server contact.
|
|
||||||
- **FR-005**: The bookmark state MUST be independent of the RSVP state — both can coexist for the same event on the same device.
|
|
||||||
- **FR-006**: The bookmark action MUST remain available on event pages where the event has expired.
|
|
||||||
- **FR-007**: No personal data, IP address, or identifier MUST be transmitted to the server when bookmarking or un-bookmarking.
|
|
||||||
- **FR-008**: The bookmark action MUST reflect the current state (bookmarked / not bookmarked) when the event page loads.
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **Bookmark record** (localStorage): Stored per event token. Contains: event token, event title, event date. Indicates the guest has explicitly bookmarked this event without RSVPing. Independent of the RSVP record.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: A guest can bookmark an event and find it in their local overview (US-7) without any server contact at any point in the bookmark flow.
|
|
||||||
- **SC-002**: Bookmarking and un-bookmarking produce no network requests (verifiable via browser DevTools).
|
|
||||||
- **SC-003**: A bookmark persists after browser restart on the same device.
|
|
||||||
- **SC-004**: The bookmark state is correctly reflected on the event page across multiple sessions.
|
|
||||||
- **SC-005**: Guests with existing RSVPs on this device can independently toggle the bookmark without affecting their RSVP state.
|
|
||||||
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
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
# Feature Specification: Local Event Overview List
|
|
||||||
|
|
||||||
**Feature**: `012-local-event-overview`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - View tracked events on the root page (Priority: P1)
|
|
||||||
|
|
||||||
A user who has created, bookmarked, or RSVPed to events on this device opens the root page (`/`) and sees a list of all those events, each with the event title, date, and their relationship to the event (organizer / attending / not attending / bookmarked only). Each entry is a link to the event page. The list is rendered entirely from localStorage — no server request is required. If no events are tracked locally, an empty state is shown.
|
|
||||||
|
|
||||||
**Why this priority**: This is the core feature — the reason the overview exists. Without this story, no other scenario is meaningful.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by seeding localStorage with event entries (simulating US-1/US-3/US-6 data) and loading the root page. Delivers value by allowing users to navigate back to any tracked event without the original link.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the user has created an event from this device (organizer token in localStorage), **When** they navigate to `/`, **Then** the event appears in the list with title, date, and relationship "organizer"
|
|
||||||
2. **Given** the user has RSVPed "attending" to an event from this device, **When** they navigate to `/`, **Then** the event appears in the list with title, date, and relationship "attending"
|
|
||||||
3. **Given** the user has RSVPed "not attending" to an event from this device, **When** they navigate to `/`, **Then** the event appears in the list with title, date, and relationship "not attending"
|
|
||||||
4. **Given** the user has bookmarked an event from this device (US-6), **When** they navigate to `/`, **Then** the event appears in the list with title, date, and relationship "bookmarked only"
|
|
||||||
5. **Given** no events are tracked in localStorage, **When** the user navigates to `/`, **Then** an empty state message is shown — not an error
|
|
||||||
6. **Given** the list is rendered, **When** the user clicks an entry, **Then** they are navigated directly to the event page for that entry
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Visually distinguish past events (Priority: P2)
|
|
||||||
|
|
||||||
Events whose date has passed are still shown in the list but rendered with a visual distinction (e.g. marked as "ended"), so the user can differentiate between upcoming and past events at a glance.
|
|
||||||
|
|
||||||
**Why this priority**: Useful for UX clarity but the overview is still functional without this distinction. Past events in the list are still navigable.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by placing a past-dated event entry in localStorage and loading the root page. The entry should appear visually distinct from current events.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an event in localStorage has a date in the past, **When** the user views the overview, **Then** the entry is visually distinguished (e.g. marked "ended") compared to upcoming events
|
|
||||||
2. **Given** an event in localStorage has a date in the future, **When** the user views the overview, **Then** the entry is rendered without any "ended" indicator
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Remove an entry from the local list (Priority: P2)
|
|
||||||
|
|
||||||
The user can remove individual entries from the local overview. The behavior depends on the entry type: for bookmarked-only events the bookmark is removed; for RSVPed events the local record is removed (server-side RSVP unaffected); for organizer-created events the organizer token and event data are removed from localStorage, with a confirmation warning that organizer access on this device will be lost.
|
|
||||||
|
|
||||||
**Why this priority**: Important for list hygiene but not required for the core navigation use case.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by placing entries of each type in localStorage and verifying removal behavior from the overview UI.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a bookmarked-only event is in the local list, **When** the user removes that entry, **Then** the bookmark is removed from localStorage and the entry disappears from the list
|
|
||||||
2. **Given** an RSVPed event is in the local list, **When** the user removes that entry, **Then** the local RSVP record is removed from localStorage; the server-side RSVP is unaffected
|
|
||||||
3. **Given** an organizer-created event is in the local list, **When** the user initiates removal, **Then** a confirmation warning is shown explaining that organizer access on this device will be revoked
|
|
||||||
4. **Given** the organizer confirms removal, **Then** the organizer token and event metadata are removed from localStorage and the entry disappears from the list
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 4 - Handle a deleted event when navigating from the overview (Priority: P2)
|
|
||||||
|
|
||||||
If the user clicks an entry in the local overview and the server responds that the event no longer exists (deleted per US-12 automatic cleanup or US-19 organizer deletion), the app displays an "event no longer exists" message and offers to remove the entry from the local list.
|
|
||||||
|
|
||||||
**Why this priority**: Edge case that improves consistency and prevents stale entries from accumulating, but not core to the overview's primary purpose.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by navigating to an event whose token does not exist on the server. The app should display a "no longer exists" message and offer removal.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an entry exists in the local overview, **When** the user navigates to that event and the server returns "event not found", **Then** the app displays an "event no longer exists" message and offers to remove the entry from the local list
|
|
||||||
2. **Given** the user confirms removal of the stale entry, **Then** the entry is removed from localStorage and the user is returned to the overview
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens if localStorage is unavailable or disabled in the browser? The overview cannot render — [NEEDS EXPANSION: define fallback message or behavior]
|
|
||||||
- What happens if the same event appears under multiple localStorage keys (e.g. both RSVPed and organizer)? [NEEDS EXPANSION: define de-duplication or priority rule for relationship label]
|
|
||||||
- What happens if an event's locally cached title or date is stale (organizer edited via US-5)? Stale values are displayed until the user next visits the event page — this is an accepted trade-off per the story notes.
|
|
||||||
- What happens when the user has a very large number of tracked events? [NEEDS EXPANSION: pagination or truncation strategy]
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: The root page (`/`) MUST display the local event overview list below a project header/branding section
|
|
||||||
- **FR-002**: The list MUST include any event for which an organizer token, RSVP record, or bookmark exists in localStorage for this device
|
|
||||||
- **FR-003**: Each list entry MUST show at minimum: event title, event date, and the user's relationship to the event (organizer / attending / not attending / bookmarked only)
|
|
||||||
- **FR-004**: Each list entry MUST be a link that navigates directly to the corresponding event page
|
|
||||||
- **FR-005**: The list MUST be populated entirely from localStorage — no server request is made to render the overview
|
|
||||||
- **FR-006**: Events whose date has passed MUST be visually distinguished (e.g. marked "ended") from upcoming events
|
|
||||||
- **FR-007**: An individual entry MUST be removable from the list; removal behavior depends on entry type (bookmark removal, local RSVP record removal, or organizer token removal)
|
|
||||||
- **FR-008**: Removing an organizer-created event entry MUST require a confirmation warning explaining that organizer access on this device will be revoked
|
|
||||||
- **FR-009**: No personal data or event data MUST be transmitted to the server when viewing or interacting with the overview
|
|
||||||
- **FR-010**: If no events are tracked locally, an empty state MUST be shown — not an error or blank screen
|
|
||||||
- **FR-011**: When navigating from the overview to an event that the server reports as deleted, the app MUST display an "event no longer exists" message and offer to remove the stale entry from the local list
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **LocalEventEntry**: A localStorage-stored record representing one tracked event. Contains at minimum: event token, event title, event date, and relationship type (organizer / attending / not attending / bookmarked). May also contain: organizer token (for organizer entries), RSVP choice and name (for RSVP entries).
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: A user with events tracked in localStorage can navigate to `/` and see all tracked events without any server request being made
|
|
||||||
- **SC-002**: Each event entry links correctly to its event page
|
|
||||||
- **SC-003**: Past events are visually distinguishable from upcoming events in the list
|
|
||||||
- **SC-004**: An entry can be removed from the list, and the corresponding localStorage key is cleaned up correctly for each entry type
|
|
||||||
- **SC-005**: The empty state is shown when no events are tracked in localStorage — no blank page or error state
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# Feature Specification: Add Event to Calendar
|
|
||||||
|
|
||||||
**Feature**: `013-calendar-export`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - Download .ics file (Priority: P1)
|
|
||||||
|
|
||||||
A guest who wants to remember the event opens the event page and downloads an iCalendar file to import into their personal calendar application. The file contains all relevant event details: title, description, start date/time, location, and the public event URL. The UID in the file is derived from the event token so that re-downloading the file and re-importing it updates the existing calendar entry rather than creating a duplicate.
|
|
||||||
|
|
||||||
**Why this priority**: Core calendar integration — the most common and universally supported way to add an event to a calendar across all platforms.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by visiting a valid event page, clicking the `.ics` download link, and verifying the downloaded file is a valid iCalendar (RFC 5545) document with the correct event fields and a stable UID.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a valid event page, **When** the guest clicks the `.ics` download link, **Then** a standards-compliant iCalendar file is downloaded containing the event title, description (if present), start date and time, location (if present), the public event URL, and a unique UID derived from the event token.
|
|
||||||
2. **Given** the guest downloads the `.ics` file twice for the same event, **When** they import both files into a calendar application, **Then** the calendar application updates the existing entry rather than creating a duplicate (due to the stable UID).
|
|
||||||
3. **Given** a guest holding only the event link (no RSVP, no login), **When** they access the `.ics` download link, **Then** the file is served without requiring any authentication or personal data.
|
|
||||||
4. **Given** an event whose expiry date has passed, **When** the guest accesses the `.ics` download link, **Then** the file is still served (the guest can still obtain the calendar record of a past event).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Subscribe via webcal:// link (Priority: P2)
|
|
||||||
|
|
||||||
A guest subscribes to the event via a `webcal://` link so that their calendar application automatically picks up any changes — such as a rescheduled date or updated location — when the organizer edits the event via US-5.
|
|
||||||
|
|
||||||
**Why this priority**: Adds live-update value on top of the static `.ics` download. Requires the `.ics` download (P1) to already work. Most useful in conjunction with US-5 (Edit event details).
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by opening the `webcal://` URL in a calendar application and verifying it subscribes to the feed and shows the correct event details.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a valid event page, **When** the guest clicks the `webcal://` subscription link, **Then** their calendar application subscribes to the feed and displays the event.
|
|
||||||
2. **Given** a subscribed `webcal://` feed and an organizer who edits the event date via US-5, **When** the calendar application syncs the feed, **Then** the calendar entry is updated to reflect the new date.
|
|
||||||
3. **Given** a `webcal://` endpoint, **When** it is accessed, **Then** it serves identical iCalendar content as the `.ics` download, using the same event token in the URL.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Cancelled event reflected in calendar (Priority: P3)
|
|
||||||
|
|
||||||
When an event is cancelled (US-18), the `.ics` file and `webcal://` feed include `STATUS:CANCELLED` so that subscribed calendar applications reflect the cancellation automatically on their next sync.
|
|
||||||
|
|
||||||
**Why this priority**: Quality-of-life enhancement dependent on US-18 (cancel event). Deferred until US-18 is implemented.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by cancelling an event and verifying the calendar feed includes `STATUS:CANCELLED`.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an event that has been cancelled (US-18), **When** a calendar application syncs the `webcal://` feed, **Then** the calendar entry is updated to show the cancelled status.
|
|
||||||
2. **Given** an event that has been cancelled (US-18), **When** the guest downloads the `.ics` file, **Then** it includes `STATUS:CANCELLED`.
|
|
||||||
|
|
||||||
> **Note**: Deferred until US-18 is implemented.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens when the event has no description or location? The `.ics` file must omit those optional fields rather than including blank values.
|
|
||||||
- What happens if the server has no public URL configured? The event URL included in the `.ics` file must always be the correct public event URL.
|
|
||||||
- What happens if the event's date or timezone changes after a guest already imported the `.ics` file? The static import will be stale; the `webcal://` subscription will auto-update on next sync.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: System MUST expose a server-side endpoint that generates and serves a standards-compliant iCalendar (RFC 5545) `.ics` file for any event identified by its event token.
|
|
||||||
- **FR-002**: The `.ics` file MUST include: event title, description (if present), start date and time, location (if present), the public event URL, and a unique UID derived from the event token.
|
|
||||||
- **FR-003**: The UID in the `.ics` file MUST be stable across regenerations (same event token always produces the same UID) so that calendar applications update existing entries on re-import rather than creating duplicates.
|
|
||||||
- **FR-004**: The `.ics` file MUST be generated server-side; no external calendar or QR code service is called.
|
|
||||||
- **FR-005**: System MUST expose a `webcal://` subscription endpoint that serves identical iCalendar content as the `.ics` download, using the same event token in the URL.
|
|
||||||
- **FR-006**: Both the `.ics` download link and the `webcal://` subscription link MUST be accessible to any visitor holding the event link — no RSVP, login, or personal data required.
|
|
||||||
- **FR-007**: No personal data, name, or IP address MUST be logged when either link is accessed.
|
|
||||||
- **FR-008**: Both links MUST remain available and functional after the event's expiry date has passed.
|
|
||||||
- **FR-009**: When an event is cancelled (US-18), the `.ics` file and `webcal://` feed MUST include `STATUS:CANCELLED`. [Deferred until US-18 is implemented]
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **CalendarFeed**: A virtual resource derived from the Event entity. Identified by the event token. Serialized to iCalendar (RFC 5545) format on demand. Has no independent storage — always generated from current event state.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: Downloading the `.ics` file and importing it into a standard calendar application (Google Calendar, Apple Calendar, Outlook) results in the event appearing with correct title, date/time, and location.
|
|
||||||
- **SC-002**: Re-importing the `.ics` file after an event edit updates the existing calendar entry rather than creating a duplicate.
|
|
||||||
- **SC-003**: Subscribing via `webcal://` and triggering a calendar sync after an event edit (US-5) reflects the updated details in the calendar application.
|
|
||||||
- **SC-004**: Both endpoints are accessible without authentication and without transmitting any personal data.
|
|
||||||
- **SC-005**: No external service is contacted during `.ics` generation or `webcal://` feed serving.
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
# Feature Specification: Highlight Changed Event Details
|
|
||||||
|
|
||||||
**Feature**: `014-highlight-changes`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
> **NOTE on directory name**: The migration task list specified `us-09-reminders` for this directory, but US-9 in userstories.md is "Highlight changed event details". The directory has been created as `014-highlight-changes` to match the actual story content. This is consistent with corrections made in iterations 16 (us-06-bookmark-event) and 18 (us-08-calendar-export).
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - Guest sees highlight for recently changed fields (Priority: P1)
|
|
||||||
|
|
||||||
A guest opens an event page that the organizer has edited since the guest's last visit. Changed fields (e.g. new date, new location) are visually highlighted so the guest immediately notices what is different. After the page loads, the highlight is cleared for the next visit.
|
|
||||||
|
|
||||||
**Why this priority**: Core value of the feature — guests must notice important updates like a rescheduled date or changed location without having to read the entire page again.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by creating an event, visiting it (establishing `last_seen_at`), editing it as organizer, then revisiting as guest — changed fields appear highlighted; unmodified fields do not.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a guest has previously visited an event page (establishing `last_seen_at` in localStorage), **When** the organizer saves an edit that changes one or more fields, **Then** on the guest's next visit those changed fields are visually highlighted with a "recently changed" indicator.
|
|
||||||
2. **Given** an event with an edit, **When** a guest opens the event page and the `last_edited_at` timestamp is newer than the stored `last_seen_at`, **Then** only the fields changed in the most recent edit are highlighted; unmodified fields are not highlighted.
|
|
||||||
3. **Given** a guest who has seen the latest edit, **When** they visit the event page again without any new edits having occurred, **Then** no highlights are shown.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - No highlight on first visit (Priority: P2)
|
|
||||||
|
|
||||||
A guest opens an event page for the first time (no `last_seen_at` in localStorage). Even if the organizer has made edits since creation, no "recently changed" highlights are shown — the event is new to the guest, so labelling fields as changed would be misleading.
|
|
||||||
|
|
||||||
**Why this priority**: Correctness requirement. Showing highlights on first visit would be confusing because the guest has no reference point for what "changed" means.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by clearing localStorage and opening an edited event page — no highlight indicators should appear.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** no `last_seen_at` value in localStorage for a given event token, **When** a guest opens the event page, **Then** no field highlights are shown regardless of whether the organizer has made edits.
|
|
||||||
2. **Given** a first visit with no `last_seen_at`, **When** the event page is rendered, **Then** `last_seen_at` is written to localStorage with the current `last_edited_at` value, so the next visit will correctly compare against it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Highlight clears after viewing (Priority: P2)
|
|
||||||
|
|
||||||
After the guest views the highlighted changes, the highlight is cleared on the next visit. Subsequent visits to the same event page (without new edits) show no highlights.
|
|
||||||
|
|
||||||
**Why this priority**: Without this, the highlight would become permanent noise rather than a meaningful "new change" signal.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by visiting an event page with a change (seeing highlights), then visiting again — highlights should be gone.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a guest views an event page with highlighted fields, **When** the page is rendered, **Then** `last_seen_at` in localStorage is updated to match the current `last_edited_at`.
|
|
||||||
2. **Given** `last_seen_at` was updated on the previous visit, **When** the guest visits the event page again (with no new edits), **Then** no highlights are shown.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 4 - Only most recent edit is tracked (Priority: P3)
|
|
||||||
|
|
||||||
If the organizer makes multiple successive edits, only the fields changed in the most recent edit are highlighted. Intermediate changes between visits are not accumulated.
|
|
||||||
|
|
||||||
**Why this priority**: Simplicity constraint — tracking the full change history is overengineered for this scope. Guests see what changed last, not everything that ever changed.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by making two successive edits to different fields, then visiting as a guest — only fields from the second edit are highlighted.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an organizer edits the event twice (first changing title, then changing location), **When** a guest visits the page after both edits, **Then** only the location is highlighted (changed in the most recent edit); title is not highlighted (changed in an earlier edit).
|
|
||||||
2. **Given** an event with no edits since creation, **When** any guest visits the event page, **Then** no highlights are shown.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What if the organizer edits the event while the guest has the page open? The highlight logic runs on page load; open-page state is stale and will be corrected on the next visit.
|
|
||||||
- What if localStorage is unavailable (e.g. private browsing)? No `last_seen_at` can be stored, so the guest is treated as a first-time visitor and no highlights are shown. This is safe and graceful.
|
|
||||||
- What if `last_edited_at` is null (event has never been edited)? No highlights are shown. The field-change metadata is only populated on the first actual edit.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: System MUST record which fields changed (title, description, date/time, location) and store a `last_edited_at` timestamp server-side whenever the organizer saves an edit (US-5).
|
|
||||||
- **FR-002**: System MUST include `last_edited_at` and the set of changed field names in the event page API response.
|
|
||||||
- **FR-003**: Client MUST store a `last_seen_at` value per event token in localStorage, set to the event's `last_edited_at` on each page render.
|
|
||||||
- **FR-004**: Client MUST compare the event's `last_edited_at` against the stored `last_seen_at` on page load to determine whether highlights should be shown.
|
|
||||||
- **FR-005**: Client MUST display a "recently changed" visual indicator next to each field that appears in the server's changed-fields set, only when `last_edited_at` is newer than `last_seen_at`.
|
|
||||||
- **FR-006**: Client MUST NOT show any highlights when no `last_seen_at` is present in localStorage for the event (first visit).
|
|
||||||
- **FR-007**: Client MUST NOT show any highlights when `last_edited_at` is null or equal to `last_seen_at`.
|
|
||||||
- **FR-008**: Client MUST update `last_seen_at` in localStorage after rendering the event page, regardless of whether highlights were shown.
|
|
||||||
- **FR-009**: System MUST NOT transmit any visit data or `last_seen_at` value to the server — the read-state tracking is entirely client-side.
|
|
||||||
- **FR-010**: System MUST track only the most recent edit's changed fields; intermediate changes between visits are not accumulated.
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **EditMetadata** (server-side): Records `last_edited_at` timestamp and the set of changed field names for an event. Associated with the event record. Populated on first edit; overwritten on each subsequent edit.
|
|
||||||
- **last_seen_at** (client-side, localStorage): Per-event-token timestamp. Records when the guest last viewed the event page. Used to determine whether highlights should be shown. Never transmitted to the server.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: A guest who has visited an event page before an edit correctly sees highlight indicators on the changed fields when revisiting after the edit.
|
|
||||||
- **SC-002**: A guest who visits an event page for the first time sees no highlight indicators, even if edits have been made.
|
|
||||||
- **SC-003**: Highlights disappear on the guest's next visit after they have viewed the highlighted changes.
|
|
||||||
- **SC-004**: No server request beyond the normal event page load is required to determine whether highlights should be shown.
|
|
||||||
- **SC-005**: No visit data or read-state information is transmitted to the server — privacy is fully preserved.
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# Feature Specification: Post Update Messages as Organizer
|
|
||||||
|
|
||||||
**Feature**: `015-organizer-updates`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - Post and manage update messages (Priority: P1)
|
|
||||||
|
|
||||||
As an event organizer, I want to post short update messages on the event page and manage them, so that guests are informed of announcements or notes without requiring a separate communication channel.
|
|
||||||
|
|
||||||
**Why this priority**: The ability to post and display update messages is the core capability of this feature. Without it, nothing else in this story is testable.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by creating an event, posting an update message via the organizer view, and verifying the message appears on the public event page.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a valid organizer token is present in localStorage, **When** the organizer submits a plain-text update message, **Then** the message is stored server-side with a timestamp and appears on the public event page in reverse chronological order.
|
|
||||||
2. **Given** multiple update messages have been posted, **When** a guest opens the event page, **Then** all messages are displayed newest-first, each with a human-readable timestamp.
|
|
||||||
3. **Given** a valid organizer token is present in localStorage, **When** the organizer deletes a previously posted update message, **Then** the message is permanently removed and no longer appears on the public event page.
|
|
||||||
4. **Given** no organizer token is present in localStorage, **When** a visitor views the event page, **Then** no compose or delete UI is shown and the server rejects any attempt to post or delete update messages.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Block posting after event expiry (Priority: P2)
|
|
||||||
|
|
||||||
As an event organizer, I want to be prevented from posting update messages after the event's expiry date has passed, so that the system remains consistent with the event lifecycle.
|
|
||||||
|
|
||||||
**Why this priority**: Expiry enforcement is a consistency constraint on top of the core posting capability.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by attempting to post an update message via the API after an event's expiry date and verifying a rejection response is returned.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an event has passed its expiry date, **When** the organizer attempts to submit an update message, **Then** the server rejects the request and the message is not stored.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens if the organizer submits an empty or whitespace-only update message?
|
|
||||||
- What is the maximum length of an update message? [NEEDS EXPANSION]
|
|
||||||
- How many update messages can an event accumulate? [NEEDS EXPANSION]
|
|
||||||
- Cancelled events (US-18): posting update messages is not blocked by cancellation, only by expiry — the organizer may want to post post-cancellation communication (e.g. a rescheduling notice or explanation).
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: System MUST allow the organizer to compose and submit a plain-text update message from the organizer view when a valid organizer token is present in localStorage.
|
|
||||||
- **FR-002**: System MUST store each submitted update message server-side, associated with the event, with a server-assigned timestamp at the time of posting.
|
|
||||||
- **FR-003**: System MUST display all update messages for an event on the public event page in reverse chronological order (newest first), each with a human-readable timestamp.
|
|
||||||
- **FR-004**: System MUST reject any attempt to post an update message after the event's expiry date has passed.
|
|
||||||
- **FR-005**: System MUST allow the organizer to permanently delete any previously posted update message from the organizer view.
|
|
||||||
- **FR-006**: System MUST immediately remove a deleted update message from the public event page upon deletion.
|
|
||||||
- **FR-007**: System MUST reject any attempt to post or delete update messages when the organizer token is absent or invalid.
|
|
||||||
- **FR-008**: System MUST NOT show the compose or delete UI to visitors who do not have a valid organizer token in localStorage.
|
|
||||||
- **FR-009**: System MUST NOT log personal data or IP addresses when update messages are fetched or posted.
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **UpdateMessage**: A plain-text message associated with an event. Key attributes: event reference, message body (plain text), created_at timestamp. Owned by the event; deleted when the event is deleted (US-12, US-19) or manually removed by the organizer.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: A posted update message appears on the public event page without requiring a page reload beyond the normal navigation.
|
|
||||||
- **SC-002**: Deleting an update message removes it from the public event page immediately upon deletion confirmation.
|
|
||||||
- **SC-003**: An attempt to post an update message without a valid organizer token returns a 4xx error response from the server.
|
|
||||||
- **SC-004**: An attempt to post an update message after the event's expiry date returns a 4xx error response from the server.
|
|
||||||
- **SC-005**: No IP addresses or personal data appear in server logs when update messages are fetched or posted.
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# Feature Specification: New-Update Indicator for Guests
|
|
||||||
|
|
||||||
**Feature**: `016-guest-notifications`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - Unread update indicator (Priority: P1)
|
|
||||||
|
|
||||||
A guest opens the event page and sees a visual indicator (badge or highlighted section) drawing attention to update messages that were posted since their last visit. The read state is tracked entirely in localStorage — no server involvement.
|
|
||||||
|
|
||||||
**Why this priority**: Core purpose of this feature. Without this, guests miss new organizer announcements unless they manually read through all messages.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by opening an event page after new update messages have been posted, verifying that a badge or visual highlight appears on the update messages section.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a guest has previously visited the event page and `updates_last_seen_at` is stored in localStorage, **When** they return and the event has updates newer than `updates_last_seen_at`, **Then** a visual indicator is shown drawing attention to the unread messages.
|
|
||||||
2. **Given** the event page is rendered with unread updates shown, **When** the page finishes loading, **Then** `updates_last_seen_at` in localStorage is updated to the timestamp of the most recent update, so the indicator does not appear on the next visit.
|
|
||||||
3. **Given** a guest opens the event page and all updates are older than the stored `updates_last_seen_at`, **When** the page loads, **Then** no "new update" indicator is shown.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - First visit: no indicator (Priority: P2)
|
|
||||||
|
|
||||||
A guest who has never visited the event page before (no `updates_last_seen_at` in localStorage) sees the update messages without any "new" badge or indicator.
|
|
||||||
|
|
||||||
**Why this priority**: A first-time visitor has not established a baseline; labeling existing updates as "new" would be misleading since they have never seen the event before.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by opening an event page on a device with no prior localStorage state for that event token, verifying that no unread indicator is shown even if update messages are present.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a guest opens an event page for the first time (no `updates_last_seen_at` in localStorage for this event token), **When** the page loads and update messages are present, **Then** no "new update" indicator is shown and `updates_last_seen_at` is initialized to the most recent update timestamp.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - No server read-tracking (Priority: P1)
|
|
||||||
|
|
||||||
No server request is made to record that a guest viewed the updates. The read state is purely client-side.
|
|
||||||
|
|
||||||
**Why this priority**: Fundamental privacy requirement — tracking which guests have read which updates would be a form of user surveillance, violating the project's privacy statutes.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by inspecting network traffic when a guest opens the event page, verifying that no "mark as read" or analytics request is sent.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a guest opens the event page with unread updates, **When** the page loads and `updates_last_seen_at` is updated in localStorage, **Then** no additional server request is made to record the read event.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens when all update messages are deleted (US-10a) and a guest reopens the page? The stored `updates_last_seen_at` should remain in localStorage; no indicator is shown since there are no updates to compare against.
|
|
||||||
- What happens if localStorage is unavailable (private browsing, storage quota exceeded)? The indicator is not shown (degrades gracefully); no error is displayed to the user.
|
|
||||||
- The `updates_last_seen_at` key is separate from the `last_seen_at` key used in US-9 (field-change highlights). The two mechanisms operate independently.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: System MUST display a visual indicator (badge or highlighted section) on the event page when the guest has unread update messages, determined by comparing the newest update's timestamp against `updates_last_seen_at` stored in localStorage.
|
|
||||||
- **FR-002**: System MUST store the `updates_last_seen_at` timestamp in localStorage per event token after each page render, so the indicator clears on subsequent visits.
|
|
||||||
- **FR-003**: System MUST NOT show a "new update" indicator on a guest's first visit to an event page (when no `updates_last_seen_at` exists in localStorage for that event token).
|
|
||||||
- **FR-004**: System MUST initialize `updates_last_seen_at` in localStorage on first visit, set to the timestamp of the most recent update (or a sentinel value if no updates exist), to prevent spurious indicators on subsequent visits.
|
|
||||||
- **FR-005**: System MUST NOT transmit any data to the server when a guest views or is marked as having read update messages — read tracking is purely client-side.
|
|
||||||
- **FR-006**: System MUST use a localStorage key distinct from the `last_seen_at` key used in US-9 to avoid conflicts between the two read-state mechanisms.
|
|
||||||
- **FR-007**: System MUST degrade gracefully if localStorage is unavailable: no indicator is shown, and no error is surfaced to the user.
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **UpdateReadState** (client-side only): Stored in localStorage, keyed by event token. Contains `updates_last_seen_at` (timestamp of the most recent update at last visit). Never transmitted to the server.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: A guest who has not visited an event page since a new update was posted sees a visual indicator on their next visit, without any server request being made to track readership.
|
|
||||||
- **SC-002**: After the event page is rendered, the same guest sees no indicator on their next visit (indicator clears after viewing).
|
|
||||||
- **SC-003**: A first-time visitor to an event page with existing updates sees no "new" indicator.
|
|
||||||
- **SC-004**: No network request is sent to the server when the read state transitions from unread to read.
|
|
||||||
- **SC-005**: The read-state mechanism is independent of US-9's field-change highlight mechanism — toggling one does not affect the other.
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# Feature Specification: Generate a QR Code for an Event
|
|
||||||
|
|
||||||
**Feature**: `017-qr-code`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - Display and Download QR Code (Priority: P1)
|
|
||||||
|
|
||||||
Any visitor who holds the event link can view a QR code on the event page that encodes the public event URL. The QR code is generated server-side — no external service is contacted — and can be downloaded as a print-ready file (SVG or high-resolution PNG). This makes it easy to print the code on posters or flyers.
|
|
||||||
|
|
||||||
**Why this priority**: This is the core deliverable of US-11. Without a downloadable, server-generated QR code, the feature has no value. All other criteria are conditions of this baseline.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by loading any event page and verifying that a QR code is displayed, that a download link produces a valid SVG or PNG file whose content encodes the correct event URL, and that no external network request was made to generate it.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a valid event exists, **When** a visitor opens the event page, **Then** a QR code encoding the public event URL is displayed on the page.
|
|
||||||
2. **Given** a QR code is displayed, **When** the visitor clicks the download link, **Then** a file (SVG or high-resolution PNG) is downloaded directly from the app's backend without client-side generation.
|
|
||||||
3. **Given** the downloaded file, **When** it is scanned with a QR reader, **Then** it resolves to the correct public event URL.
|
|
||||||
4. **Given** the QR code endpoint is accessed, **When** the server generates the code, **Then** no request is made to any external QR code service.
|
|
||||||
5. **Given** a visitor who has not RSVPed or logged in, **When** they access the event page, **Then** the QR code and download link are still available — no organizer token or RSVP required.
|
|
||||||
6. **Given** the event has expired, **When** a visitor opens the event page, **Then** the QR code and download link remain available and functional.
|
|
||||||
7. **Given** the QR code download is requested, **When** the server handles the request, **Then** no personal data, IP address, or identifier is transmitted to any third party.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens when the event does not exist? The server returns "event not found" — the QR code endpoint must behave consistently and not leak data.
|
|
||||||
- How does the download link behave when the event URL is long? The QR code must be generated at sufficient error-correction level to remain scannable even for longer URLs.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: The event page MUST display a QR code that encodes the public event URL.
|
|
||||||
- **FR-002**: The QR code MUST be generated entirely server-side — no external QR code service or third-party API may be contacted.
|
|
||||||
- **FR-003**: The QR code MUST be downloadable as a file suitable for printing (SVG or high-resolution PNG).
|
|
||||||
- **FR-004**: The QR code download MUST be served from a direct backend endpoint — the actual file download MUST NOT require client-side generation.
|
|
||||||
- **FR-005**: The QR code MUST be accessible to any visitor holding the event link; no organizer token or RSVP is required.
|
|
||||||
- **FR-006**: No personal data, IP address, or identifier MUST be transmitted to any third party when the QR code is generated or downloaded.
|
|
||||||
- **FR-007**: The QR code MUST remain available and downloadable after the event has expired.
|
|
||||||
- **FR-008**: The QR code endpoint MUST return a consistent "event not found" response if the event does not exist — no partial data or error traces may be exposed.
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **QRCode**: Virtual — no independent storage. Generated on demand from the event token and the public event URL. Not persisted.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: A visitor can view a QR code on any event page without performing any additional action (no login, no RSVP).
|
|
||||||
- **SC-002**: The downloaded file scans correctly to the event URL in at least two independent QR reader applications.
|
|
||||||
- **SC-003**: No outbound network request to an external service is made during QR code generation (verifiable via network inspection).
|
|
||||||
- **SC-004**: The QR code endpoint returns a valid file for both active and expired events.
|
|
||||||
- **SC-005**: The download link works without JavaScript (direct server endpoint).
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# Feature Specification: Automatic Data Deletion After Expiry Date
|
|
||||||
|
|
||||||
**Feature**: `018-data-deletion`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - Automatic cleanup of expired event data (Priority: P1)
|
|
||||||
|
|
||||||
As a guest, I want all event data — including my RSVP and any other stored personal information — to be automatically and permanently deleted after the event's expiry date, so that I can trust that data I submitted is not retained on the server longer than necessary.
|
|
||||||
|
|
||||||
**Why this priority**: This is a privacy guarantee, not merely a housekeeping task. The mandatory expiry date in US-1 is only meaningful if the server actually enforces deletion. Without this story, the expiry date is a lie.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by creating an event with a near-future expiry date, submitting an RSVP, waiting for expiry, and verifying that the event's public URL returns "event not found" and no data remains accessible.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an event whose expiry date has passed, **When** the cleanup process runs, **Then** the event record and all associated data (RSVPs, update messages, field-change metadata, header images, cancellation state) are permanently deleted from the server.
|
|
||||||
2. **Given** an event that has been deleted by the cleanup process, **When** a guest navigates to the event's public URL, **Then** the server returns a clear "event not found" response with no partial data or error traces.
|
|
||||||
3. **Given** a deletion event, **When** the cleanup process deletes data, **Then** no log entry records the names, RSVPs, or any personal data of the deleted event's guests — the deletion is silent from a logging perspective.
|
|
||||||
4. **Given** the cleanup process, **When** it runs, **Then** it runs automatically without manual operator intervention (e.g. via a scheduled job or on-request lazy cleanup triggered by access attempts).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Expiry date extension delays deletion (Priority: P2)
|
|
||||||
|
|
||||||
As an event organizer, I want to be able to extend the expiry date of my event (via US-5) and have the deletion be deferred accordingly, so that I can keep my event data available for longer when needed.
|
|
||||||
|
|
||||||
**Why this priority**: Ensures US-5 (edit expiry date) and US-12 (deletion) work together correctly — the cleanup must always use the current stored expiry date, not the original one.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by creating an event, extending its expiry date before expiry passes, and verifying that the event data is not deleted until the new expiry date.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an event whose expiry date was extended via US-5 before the original expiry passed, **When** the cleanup process runs after the original date but before the new date, **Then** the event data is not deleted.
|
|
||||||
2. **Given** an event whose expiry date was extended, **When** the new expiry date passes and the cleanup process runs, **Then** the event and all associated data are deleted.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Cleanup does not trigger early (Priority: P2)
|
|
||||||
|
|
||||||
As a guest, I want my event data to be retained until the expiry date has fully passed, so that I can access the event page right up until expiry without unexpected data loss.
|
|
||||||
|
|
||||||
**Why this priority**: Ensures correctness — data must not be deleted prematurely.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by verifying that an event with an expiry date set to tomorrow remains fully accessible today.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an event whose expiry date is today but has not yet passed, **When** a guest accesses the event page, **Then** the event data is still served normally.
|
|
||||||
2. **Given** an event whose expiry date passed yesterday, **When** the cleanup process runs, **Then** the event and all associated data are deleted.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens if the cleanup process fails mid-run (e.g. server crash)? The next run must safely re-attempt deletion without corrupting partial state.
|
|
||||||
- What happens if multiple cleanup runs overlap? The process must be idempotent — deleting an already-deleted event must not cause errors.
|
|
||||||
- LocalStorage entries on guests' devices are unaffected by server-side deletion — this is intentional. The app handles the "event not found" response gracefully (US-2, US-7).
|
|
||||||
- If a stored header image file is missing on disk at deletion time (e.g. corrupted storage), the cleanup must still complete and delete the database record.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: The server MUST run a periodic cleanup process that automatically deletes all data associated with events whose expiry date has passed.
|
|
||||||
- **FR-002**: The cleanup MUST delete the event record along with all associated data: RSVPs, update messages (US-10a), field-change metadata (US-9), stored header images (US-16) [deferred until US-16 is implemented], and cancellation state (US-18 if applicable).
|
|
||||||
- **FR-003**: After deletion, the event's public URL MUST return a clear "event not found" response — no partial data is ever served.
|
|
||||||
- **FR-004**: The cleanup process MUST run automatically without manual operator intervention (e.g. a scheduled job, Spring `@Scheduled`, or on-request lazy cleanup triggered by access attempts).
|
|
||||||
- **FR-005**: The cleanup MUST NOT log the names, RSVPs, or any personal data of deleted events — deletion is silent from a logging perspective.
|
|
||||||
- **FR-006**: The cleanup MUST always use the current stored expiry date when determining whether an event is eligible for deletion — extending the expiry date via US-5 before expiry passes delays deletion accordingly.
|
|
||||||
- **FR-007**: The cleanup MUST NOT be triggered early — data is retained until the expiry date has passed, not before.
|
|
||||||
- **FR-008**: The cleanup process MUST be idempotent — re-running it against already-deleted events must not cause errors.
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **Event (expiry_date)**: The `expiry_date` field on the Event entity determines when the event becomes eligible for deletion. It is updated by US-5 (edit event details).
|
|
||||||
- **Cleanup Job**: A background process (not a user-facing entity) responsible for identifying and deleting expired events and all their associated data.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: An event with a passed expiry date returns "event not found" from the server within one cleanup cycle of the expiry.
|
|
||||||
- **SC-002**: All associated data (RSVPs, update messages, metadata, images) is deleted atomically with the event record — no orphaned records remain after a successful cleanup run.
|
|
||||||
- **SC-003**: No PII (names, RSVP choices) appears in server logs during or after the deletion process.
|
|
||||||
- **SC-004**: Extending an event's expiry date via US-5 correctly defers deletion — verified by querying the database after the original expiry would have triggered cleanup.
|
|
||||||
- **SC-005**: The cleanup process completes successfully even if a stored header image file is missing on disk (resilient to partial storage failures).
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# Feature Specification: Limit Active Events Per Instance
|
|
||||||
|
|
||||||
**Feature**: `019-instance-limit`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md (US-13)
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - Enforce Configured Event Cap on Creation (Priority: P1)
|
|
||||||
|
|
||||||
As a self-hoster, I want to configure a maximum number of simultaneously active events via a server environment variable, so that I can prevent storage exhaustion and limit potential abuse on my instance without modifying code.
|
|
||||||
|
|
||||||
**Why this priority**: The event cap is the primary deliverable of this story — without it, there is no feature. All other scenarios are edge cases of this core enforcement behavior.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by configuring `MAX_ACTIVE_EVENTS=1`, creating one event, then attempting to create a second — the second creation should be rejected with a clear error.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the server is configured with `MAX_ACTIVE_EVENTS=3` and 3 non-expired events exist, **When** a user submits the event creation form, **Then** the server rejects the request with a clear error indicating the instance is at capacity, and the frontend surfaces this error on the creation form — not as a silent failure.
|
|
||||||
2. **Given** the server is configured with `MAX_ACTIVE_EVENTS=3` and 2 non-expired events exist, **When** a user submits the event creation form, **Then** the request succeeds and the new event is created normally.
|
|
||||||
3. **Given** the server is configured with `MAX_ACTIVE_EVENTS=3` and 3 non-expired events exist, but 1 is past its expiry date (awaiting cleanup), **When** a user submits the event creation form, **Then** the request succeeds — expired events do not count toward the limit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - No Limit When Variable Is Unset (Priority: P2)
|
|
||||||
|
|
||||||
As a self-hoster running a personal or trusted-group instance, I want no event limit applied by default, so that I do not need to configure anything to run the app normally.
|
|
||||||
|
|
||||||
**Why this priority**: The default behavior (unlimited) must be safe and require no configuration. Self-hosters who do not need a cap should not have to think about this setting.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by starting the server without `MAX_ACTIVE_EVENTS` set and verifying that multiple events can be created without rejection.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the server has no `MAX_ACTIVE_EVENTS` environment variable set, **When** any number of events are created, **Then** no capacity error is returned — event creation is unlimited.
|
|
||||||
2. **Given** the server has `MAX_ACTIVE_EVENTS` set to an empty string, **When** events are created, **Then** no capacity error is returned — an empty value is treated the same as unset.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Cap Is Enforced Server-Side Only (Priority: P2)
|
|
||||||
|
|
||||||
As a self-hoster, I want the event cap to be enforced exclusively on the server, so that it cannot be bypassed by a modified or malicious client.
|
|
||||||
|
|
||||||
**Why this priority**: Client-side enforcement alone would be trivially bypassable. The server is the authoritative enforcement point.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by sending a direct HTTP POST to the event creation endpoint (bypassing the frontend entirely) when the cap is reached — the server must reject it.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the configured cap is reached, **When** a direct HTTP POST is made to the event creation endpoint (bypassing the frontend), **Then** the server returns an error response indicating the instance is at capacity.
|
|
||||||
2. **Given** the configured cap is reached, **When** no personal data is included in the rejection response or logs, **Then** the server returns only the rejection status — no PII is logged.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens when `MAX_ACTIVE_EVENTS=0`? [NEEDS EXPANSION — treat as "no limit" or "reject all"? Clarify during implementation.]
|
|
||||||
- What happens when `MAX_ACTIVE_EVENTS` is set to a non-integer value? The server should fail fast at startup with a clear configuration error.
|
|
||||||
- Race condition: two concurrent creation requests when the cap is at N-1. The server must handle this atomically — one request succeeds, the other is rejected.
|
|
||||||
- Expired events that have not yet been cleaned up must not count toward the limit. The check must query only non-expired events.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: The server MUST read a `MAX_ACTIVE_EVENTS` environment variable at startup to determine the event creation cap.
|
|
||||||
- **FR-002**: If `MAX_ACTIVE_EVENTS` is set to a positive integer and the number of non-expired events equals or exceeds that value, the server MUST reject new event creation requests with a clear error response.
|
|
||||||
- **FR-003**: The frontend MUST surface the capacity error on the event creation form — not as a silent failure or generic error.
|
|
||||||
- **FR-004**: If `MAX_ACTIVE_EVENTS` is unset or empty, the server MUST apply no limit — event creation is unlimited.
|
|
||||||
- **FR-005**: Only non-expired events MUST count toward the limit; expired events awaiting cleanup are excluded from the count.
|
|
||||||
- **FR-006**: The limit MUST be enforced server-side; client-side state or input cannot bypass it.
|
|
||||||
- **FR-007**: No personal data or PII MUST be logged when a creation request is rejected due to the cap.
|
|
||||||
- **FR-008**: The `MAX_ACTIVE_EVENTS` environment variable MUST be documented in the README's self-hosting section (configuration table).
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **Event (active count)**: The count of events whose `expiry_date` is in the future. This is the value checked against `MAX_ACTIVE_EVENTS` at event creation time.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: When the cap is reached, a POST to the event creation endpoint returns an appropriate HTTP error status with a machine-readable error body.
|
|
||||||
- **SC-002**: The capacity error is displayed to the user on the creation form with a message that does not expose internal state or configuration values.
|
|
||||||
- **SC-003**: Creating events up to but not exceeding the cap succeeds without any change in behavior compared to uncapped instances.
|
|
||||||
- **SC-004**: The `MAX_ACTIVE_EVENTS` variable appears in the README configuration table with its type, default, and description documented.
|
|
||||||
- **SC-005**: Expired events (past `expiry_date`) are never counted toward the cap, verifiable by inspecting the query or checking behavior after expiry.
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# Feature Specification: Install as Progressive Web App
|
|
||||||
|
|
||||||
**Feature**: `020-pwa`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - Install the app on a mobile or desktop device (Priority: P1)
|
|
||||||
|
|
||||||
**As a** guest,
|
|
||||||
**I want to** install the app on my device from the browser,
|
|
||||||
**so that** it feels like a native app and I can launch it directly from my home screen.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the app is open in a supported mobile browser, **When** the user opens the browser menu, **Then** an "Add to Home Screen" or install prompt is available because the app serves a valid manifest and a registered service worker.
|
|
||||||
|
|
||||||
2. **Given** the user has installed the app, **When** they launch it from the home screen, **Then** the app opens in standalone mode — without browser address bar or navigation chrome.
|
|
||||||
|
|
||||||
3. **Given** the app is installed, **When** the user views their device's home screen or app switcher, **Then** the configured icon and app name are displayed.
|
|
||||||
|
|
||||||
4. **Given** the user has visited the app previously, **When** they open the app again (including on a slow or offline connection), **Then** previously loaded pages and assets load quickly due to service worker caching.
|
|
||||||
|
|
||||||
5. **Given** the app is installed and launched from the home screen, **When** the app starts, **Then** the start URL is the root page (`/`), which serves the local event overview (US-7), so returning users see their tracked events immediately.
|
|
||||||
|
|
||||||
### User Story 2 - Serve a valid web app manifest (Priority: P1)
|
|
||||||
|
|
||||||
**As a** browser,
|
|
||||||
**I want** the app to serve a well-formed web app manifest,
|
|
||||||
**so that** I can offer the user an install prompt and render the installed app correctly.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a browser fetches the app's manifest, **Then** it includes at minimum: app name, icons in multiple sizes, standalone display mode, theme color, and a start URL.
|
|
||||||
|
|
||||||
2. **Given** the manifest and service worker are present, **Then** the app meets browser installability requirements (manifest + registered service worker) so that the install prompt is available on supported browsers.
|
|
||||||
|
|
||||||
3. **Given** the manifest or service worker loads assets, **Then** no external resources are fetched — all assets referenced are self-hosted.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- Browsers that do not support service workers or PWA installation should still render the app normally — PWA features are an enhancement, not a requirement.
|
|
||||||
- If the service worker fails to install (e.g. due to a browser policy), the app must still function as a standard web page.
|
|
||||||
- Service worker caching strategy (cache-first, network-first, stale-while-revalidate) is an implementation decision to be determined during implementation.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-01**: The app serves a valid web app manifest (JSON) accessible at a standard path (e.g. `/manifest.webmanifest` or `/manifest.json`).
|
|
||||||
- **FR-02**: The manifest includes at minimum: app name, short name, icons in multiple sizes (e.g. 192x192 and 512x512), `display: "standalone"`, `theme_color`, `background_color`, and `start_url: "/"`.
|
|
||||||
- **FR-03**: The app registers a service worker that meets browser installability requirements alongside the manifest.
|
|
||||||
- **FR-04**: When launched from the home screen, the app opens in standalone mode without browser navigation chrome.
|
|
||||||
- **FR-05**: The app icon and name are shown on the device home screen and in the OS app switcher after installation.
|
|
||||||
- **FR-06**: The service worker caches app assets so that repeat visits load quickly.
|
|
||||||
- **FR-07**: The manifest's start URL is `/`, serving the local event overview (US-7).
|
|
||||||
- **FR-08**: No external resources (CDNs, external fonts, remote icons) are fetched by the manifest or service worker — all assets are self-hosted.
|
|
||||||
- **FR-09**: PWA installability does not depend on any backend state — it is a purely frontend concern served by the static assets.
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **Web App Manifest**: A static JSON file describing the app's identity, icons, display mode, and start URL. No database storage required.
|
|
||||||
- **Service Worker**: A client-side script registered by the app to handle caching and (optionally) offline behavior. No server-side representation.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
1. A supported mobile browser shows an "Add to Home Screen" prompt or install banner for the app.
|
|
||||||
2. After installation, the app launches in standalone mode with the correct icon and name.
|
|
||||||
3. The installed app's start URL leads directly to the local event overview (`/`).
|
|
||||||
4. Repeat visits are noticeably faster due to service worker asset caching.
|
|
||||||
5. No external resources are loaded by the manifest or service worker — all assets are served from the app's own origin.
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# Feature Specification: Choose Event Color Theme
|
|
||||||
|
|
||||||
**Feature**: `021-color-themes`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - Select color theme during event creation (Priority: P1)
|
|
||||||
|
|
||||||
An organizer creating a new event can select a visual color theme from a predefined set. If no theme is selected, a default theme is applied automatically. The choice is persisted server-side and the guest-facing event page renders with the selected theme.
|
|
||||||
|
|
||||||
**Why this priority**: Core feature value — without theme selection during creation, the feature has no entry point.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by creating an event, selecting a non-default theme, and verifying the event page renders with the correct theme applied.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the event creation form is open, **When** the organizer selects a predefined color theme and submits the form, **Then** the theme is persisted server-side alongside the event data.
|
|
||||||
2. **Given** an event has been created with a specific theme, **When** a guest opens the event page, **Then** the page renders with the selected color theme applied.
|
|
||||||
3. **Given** the event creation form is open, **When** the organizer submits the form without selecting a theme, **Then** a default theme is applied and persisted server-side.
|
|
||||||
4. **Given** a theme has been applied to an event, **When** the event page renders, **Then** only the event page is themed — the app's global UI (navigation, local overview, forms) is unaffected.
|
|
||||||
5. **Given** a predefined theme is applied, **When** the event page renders, **Then** no external resources are required — all styles are self-contained.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Update color theme via event editing (Priority: P2)
|
|
||||||
|
|
||||||
An organizer editing an existing event can change the event's color theme. The updated theme is persisted server-side and reflected immediately on the guest-facing event page.
|
|
||||||
|
|
||||||
**Why this priority**: Extends the P1 creation flow to editing. Less critical than initial theme selection but necessary for full feature completeness.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by editing an existing event, changing its theme, and verifying the event page updates accordingly.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an event with an existing theme, **When** the organizer opens the edit form (US-5), **Then** the current theme selection is shown in the customization UI.
|
|
||||||
2. **Given** the organizer changes the theme in the edit form and saves, **When** a guest opens the event page, **Then** the updated theme is applied.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens when the theme value stored server-side no longer matches any predefined theme (e.g. after an app upgrade removes a theme)? The system must fall back to the default theme gracefully — no broken styles.
|
|
||||||
- How does the event-level color theme interact with the app-level dark/light mode (US-17)? Predefined themes must remain readable and visually coherent regardless of whether dark or light mode is active in the surrounding app chrome.
|
|
||||||
- What if no theme is stored at all (legacy events created before this feature)? The default theme must be applied as a safe fallback.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: The system MUST offer a set of predefined color themes for event pages.
|
|
||||||
- **FR-002**: The event creation form MUST include a theme selection UI (e.g. color swatches or named options).
|
|
||||||
- **FR-003**: The event editing form (US-5) MUST include the same theme selection UI, pre-populated with the current theme.
|
|
||||||
- **FR-004**: The selected theme MUST be persisted server-side as part of the event record.
|
|
||||||
- **FR-005**: A default theme MUST be applied if the organizer makes no explicit selection.
|
|
||||||
- **FR-006**: The guest-facing event page MUST render with the event's stored color theme.
|
|
||||||
- **FR-007**: Event-level color themes MUST affect only the event page — not the app's global UI (navigation, local overview, forms, or any other chrome).
|
|
||||||
- **FR-008**: All theme styles MUST be self-contained — no external resources (CDNs, external stylesheets) may be required for any predefined theme.
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **ColorTheme**: A predefined named color scheme. Stored as a reference (e.g. a string identifier) on the Event entity. Not an independent database entity — it is a value type on the Event.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: An organizer can select a color theme during event creation and the selection is reflected on the event page without additional steps.
|
|
||||||
- **SC-002**: All predefined themes render correctly without external network requests.
|
|
||||||
- **SC-003**: Changing the theme via the edit form (US-5) is reflected immediately on the next event page load.
|
|
||||||
- **SC-004**: The default theme is applied automatically to all events without an explicit theme selection, including legacy events created before this feature.
|
|
||||||
- **SC-005**: Event-level theming does not interfere with the app-level dark/light mode (US-17) — both can coexist without breaking contrast or readability (WCAG AA).
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
# Feature Specification: Select Event Header Image from Unsplash
|
|
||||||
|
|
||||||
**Feature**: `022-header-image`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - Select header image during creation or editing (Priority: P1)
|
|
||||||
|
|
||||||
The event organizer can search for a header image via an integrated Unsplash search during event creation or editing. The search is server-proxied — the client never contacts Unsplash directly. When an image is selected, the server downloads and stores it locally. Proper Unsplash attribution is displayed on the event page.
|
|
||||||
|
|
||||||
**Why this priority**: Core functionality of this feature. All other stories depend on an image being selectable and stored.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by creating an event with a header image selected, verifying the event page renders the image served from the app's own domain with attribution.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the organizer is on the event creation or editing form and the server has an Unsplash API key configured, **When** the organizer enters a search query, **Then** the client sends the query to the app's backend which proxies it to Unsplash and returns results — the client never contacts Unsplash directly.
|
|
||||||
2. **Given** the organizer selects an image from search results, **When** the selection is confirmed, **Then** the server downloads and stores the image locally on disk and serves it from the app's own domain.
|
|
||||||
3. **Given** an event has a stored header image, **When** a guest views the event page, **Then** the header image is rendered and Unsplash attribution (photographer name and link to Unsplash) is displayed alongside it.
|
|
||||||
4. **Given** an event has a header image, **When** the organizer removes it, **Then** the image is no longer displayed on the event page.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Event page renders header image (Priority: P1)
|
|
||||||
|
|
||||||
The guest-facing event page renders the selected header image if one is set. The image is served from the app's own domain, not from Unsplash's CDN, so no guest data or IP address is transmitted to Unsplash.
|
|
||||||
|
|
||||||
**Why this priority**: Directly impacts the visual presentation that motivated the feature.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by loading an event page with a stored header image and verifying the image URL is on the app's domain and no network request is made to Unsplash domains.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an event has a stored header image, **When** a guest opens the event page, **Then** the image is loaded from the app's own domain and no request is made to Unsplash or any third-party CDN.
|
|
||||||
2. **Given** an event has no header image set, **When** a guest opens the event page, **Then** the page renders without a header image and no error is shown.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Feature unavailable when API key not configured (Priority: P2)
|
|
||||||
|
|
||||||
If the server has no Unsplash API key configured, the image search feature is simply not shown in the UI. No error is displayed. Existing stored images continue to serve normally if the key is removed after images were already stored.
|
|
||||||
|
|
||||||
**Why this priority**: Required for graceful degradation — the feature must not break the app when the API key is absent.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by starting the server without `UNSPLASH_API_KEY` set and verifying the image search UI is absent from the creation and editing forms, and that any previously stored images still render.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the server has no `UNSPLASH_API_KEY` environment variable set, **When** the organizer opens the event creation or editing form, **Then** the image search option is not shown — no error, no broken UI element.
|
|
||||||
2. **Given** the API key is removed from the config after images were already stored, **When** a guest opens an event page with a previously stored image, **Then** the image still renders from disk and only the search/select UI becomes unavailable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 4 - Image deleted with event on expiry (Priority: P2)
|
|
||||||
|
|
||||||
When an event expires and is deleted by the cleanup process (US-12), the stored header image file is deleted along with all other event data.
|
|
||||||
|
|
||||||
**Why this priority**: Privacy requirement — stored files must not outlive the event data.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by creating an event with a header image, expiring it, running the cleanup process, and verifying the image file no longer exists on disk.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an event with a stored header image has passed its expiry date, **When** the cleanup process runs (US-12), **Then** the image file is deleted from disk along with the event record and all associated data.
|
|
||||||
2. **Given** an event with a header image is explicitly deleted by the organizer (US-19), **When** the deletion is confirmed, **Then** the image file is deleted from disk immediately.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens when the Unsplash API returns an error or rate-limit response? — Server returns a clear error to the client; the organizer can retry.
|
|
||||||
- What happens when disk is full and the server cannot store the downloaded image? — Server returns an error; the event can still be created/saved without an image.
|
|
||||||
- What happens if an image download from Unsplash fails mid-transfer? — Server returns an error; no partial file is stored.
|
|
||||||
- What happens if the `UNSPLASH_API_KEY` is set but invalid? — Server proxies the call and returns the Unsplash API error to the client (e.g. "unauthorized").
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: The server MUST expose an Unsplash image search proxy endpoint that accepts a query string, calls the Unsplash API server-side, and returns results to the client — the client MUST NOT contact Unsplash directly.
|
|
||||||
- **FR-002**: When an image is selected, the server MUST download and store the image file locally on disk; the image MUST be served from the app's own domain.
|
|
||||||
- **FR-003**: The server MUST store Unsplash attribution metadata (photographer name and Unsplash URL) alongside the image reference and display it on the event page.
|
|
||||||
- **FR-004**: The organizer MUST be able to remove a previously selected header image.
|
|
||||||
- **FR-005**: The guest-facing event page MUST render the header image if one is set, served from the app's own domain.
|
|
||||||
- **FR-006**: If `UNSPLASH_API_KEY` is not configured, the image search UI MUST NOT be shown; no error is displayed.
|
|
||||||
- **FR-007**: If the API key is removed after images were stored, existing images MUST continue to render from disk; only the search/select UI becomes unavailable.
|
|
||||||
- **FR-008**: The server MUST NOT log any guest IP address or identifier when serving the stored image.
|
|
||||||
- **FR-009**: When an event is deleted (US-12 or US-19), the server MUST delete the stored image file along with all other event data.
|
|
||||||
- **FR-010**: The `UNSPLASH_API_KEY` environment variable and the persistent volume requirement for image storage MUST be documented in the README's self-hosting section.
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **HeaderImage**: Stored image file on disk, associated with an event. Attributes: local file path, Unsplash image ID, photographer name, photographer URL, Unsplash attribution URL. Not independently stored — deleted with the event.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: A guest opening an event page with a header image makes no network requests to Unsplash or any third-party domain.
|
|
||||||
- **SC-002**: Removing the `UNSPLASH_API_KEY` does not break the app, the event creation form, or the rendering of previously stored images.
|
|
||||||
- **SC-003**: After event expiry and cleanup, no image file remains on disk for that event.
|
|
||||||
- **SC-004**: Unsplash attribution (photographer name and link) is visible on every event page that has a header image.
|
|
||||||
- **SC-005**: The image search, download, and storage flow works end-to-end when a valid API key is configured.
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# Feature Specification: Dark/Light Mode
|
|
||||||
|
|
||||||
**Feature**: `023-dark-mode`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - System preference respected on first visit (Priority: P1)
|
|
||||||
|
|
||||||
A user opens the app for the first time. The app automatically adopts their operating system or browser dark/light preference without any manual configuration required. No preference data is transmitted to the server.
|
|
||||||
|
|
||||||
**Why this priority**: This is the baseline behavior — it works without any user interaction and provides the correct experience immediately.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by opening the app in a browser with `prefers-color-scheme: dark` set at the OS level and verifying the dark theme is applied, then repeating with light preference.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a user opens the app for the first time with no manual preference stored, **When** the OS/browser preference is `prefers-color-scheme: dark`, **Then** the app renders in dark mode.
|
|
||||||
2. **Given** a user opens the app for the first time with no manual preference stored, **When** the OS/browser preference is `prefers-color-scheme: light`, **Then** the app renders in light mode.
|
|
||||||
3. **Given** the app is rendering in either mode, **When** the page is loaded, **Then** no server request is made and no preference data is transmitted.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Manual toggle overrides system preference (Priority: P1)
|
|
||||||
|
|
||||||
A user can switch between dark and light mode using a visible toggle available on any page. Their choice is persisted in localStorage and takes precedence over the OS preference on all subsequent visits.
|
|
||||||
|
|
||||||
**Why this priority**: The explicit user preference must be honoured and must persist — without this, the toggle would reset on every visit, making it unusable.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by toggling the mode, closing and reopening the browser, and verifying the manually selected mode is still active even if it differs from the OS preference.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the app is in light mode (system preference), **When** the user activates the dark mode toggle, **Then** the UI immediately switches to dark mode and the preference is stored in localStorage.
|
|
||||||
2. **Given** the user has a dark mode preference stored in localStorage, **When** the user revisits the app, **Then** dark mode is applied regardless of the current OS/browser preference.
|
|
||||||
3. **Given** the user has a light mode preference stored in localStorage and the OS preference is dark, **When** the user revisits the app, **Then** light mode is applied (localStorage takes precedence).
|
|
||||||
4. **Given** the app is running, **When** the user toggles the mode, **Then** no server request is made and no preference data is transmitted.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Toggle accessible from any page (Priority: P2)
|
|
||||||
|
|
||||||
The dark/light mode toggle is reachable from every page of the app — event pages, local event overview, creation form, etc. — so the user never has to navigate away to change their preference.
|
|
||||||
|
|
||||||
**Why this priority**: This is a usability enhancement. The feature works without it (if the toggle were only on one page), but accessibility from any page is important for a good experience.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by navigating to the event page, the local event overview, and the creation form and verifying the toggle is visible and functional on each.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the user is on the local event overview page (`/`), **When** they look for the mode toggle, **Then** it is visible and functional.
|
|
||||||
2. **Given** the user is on an event page, **When** they look for the mode toggle, **Then** it is visible and functional.
|
|
||||||
3. **Given** the user is on the event creation form, **When** they look for the mode toggle, **Then** it is visible and functional.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 4 - Dark/light mode does not affect event-level color themes (Priority: P2)
|
|
||||||
|
|
||||||
Dark/light mode affects only the app's global UI chrome (navigation, local event overview, forms, etc.). Individual event pages use their own color theme (US-15), which is independent of the app-level dark/light setting.
|
|
||||||
|
|
||||||
**Why this priority**: Necessary to define the boundary between app-level theming and event-level theming clearly, but secondary to the core toggle behaviour.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by creating an event with a custom color theme, then toggling dark/light mode and verifying the event page theme is unaffected while the surrounding chrome does change.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an event page is rendered with a custom color theme (US-15), **When** the user switches the app to dark mode, **Then** the event page color theme remains unchanged (only surrounding chrome changes).
|
|
||||||
2. **Given** the app is in dark mode, **When** the user navigates to the local event overview, **Then** the overview uses the dark color scheme.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 5 - Both modes meet WCAG AA contrast (Priority: P1)
|
|
||||||
|
|
||||||
Both dark and light modes must meet accessibility contrast requirements at the WCAG AA minimum level, ensuring the app is usable for users with visual impairments in both modes.
|
|
||||||
|
|
||||||
**Why this priority**: Accessibility is a baseline requirement per the project statutes, not an afterthought.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested using automated contrast checking tools against both mode variants.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the app is in dark mode, **When** text and interactive elements are checked for contrast ratio, **Then** all text/background pairings meet WCAG AA minimum (4.5:1 for normal text, 3:1 for large text).
|
|
||||||
2. **Given** the app is in light mode, **When** text and interactive elements are checked for contrast ratio, **Then** all text/background pairings meet WCAG AA minimum.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens when the OS `prefers-color-scheme` value changes while the app is open (e.g. user switches OS theme at runtime)? If no manual preference is stored in localStorage, should the app react? [NEEDS EXPANSION during implementation]
|
|
||||||
- What happens when localStorage is unavailable (private browsing with strict settings)? The system preference fallback must still work without crashing.
|
|
||||||
- How does app-level dark/light mode interact with the event-level color themes (US-15) when an event page is embedded in the app chrome? Themes should remain readable in both modes.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: The app MUST detect and apply `prefers-color-scheme` as the default on first visit when no manual preference is stored in localStorage.
|
|
||||||
- **FR-002**: The app MUST provide a visible toggle UI element to switch between dark and light mode, accessible from any page.
|
|
||||||
- **FR-003**: The app MUST persist the user's manual mode preference in localStorage and apply it on subsequent visits, overriding the system preference.
|
|
||||||
- **FR-004**: Dark/light mode MUST affect all global app chrome: navigation, local event overview, event creation/editing forms, and all non-event-page UI elements.
|
|
||||||
- **FR-005**: Dark/light mode MUST NOT affect individual event page color themes (US-15); event pages are styled independently.
|
|
||||||
- **FR-006**: The mode switch MUST be entirely client-side; no server request is made and no preference data is transmitted.
|
|
||||||
- **FR-007**: Both dark and light modes MUST meet WCAG AA contrast requirements for all text and interactive elements.
|
|
||||||
- **FR-008**: The toggle MUST be accessible (keyboard-navigable, labelled for screen readers).
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **DarkLightPreference**: A client-side-only value (`"dark"` | `"light"` | absent) stored in localStorage. No server-side equivalent. Determines which CSS theme is applied to the global app chrome.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: On first visit with `prefers-color-scheme: dark`, the dark theme is applied without any user interaction.
|
|
||||||
- **SC-002**: A user's manual toggle selection persists across browser sessions and overrides the OS preference.
|
|
||||||
- **SC-003**: The mode toggle is visible and functional on all primary app pages (local event overview, event page, creation form).
|
|
||||||
- **SC-004**: Automated contrast checks pass WCAG AA thresholds for all text elements in both dark and light modes.
|
|
||||||
- **SC-005**: No network request is made when toggling the mode or when the stored preference is applied on page load.
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# Feature Specification: Cancel an Event as Organizer
|
|
||||||
|
|
||||||
**Feature**: `024-cancel-event`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - Cancel event with optional message (Priority: P1)
|
|
||||||
|
|
||||||
The event organizer, from the organizer view, triggers a dedicated "Cancel event" action. A confirmation step is required before finalising. The organizer may optionally enter a cancellation message (reason or explanation). After confirmation, the server sets the event to cancelled state with the provided message. The event page immediately displays a "cancelled" state visible to all visitors. No RSVP submissions are accepted by the server from this point.
|
|
||||||
|
|
||||||
**Why this priority**: Cancellation is a fundamental lifecycle action — guests need to be clearly informed when an event is cancelled rather than discovering it silently. This is the core of US-18.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by creating an event, triggering cancel (with and without a message), and verifying the event page shows a "cancelled" indicator and the RSVP form is absent.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an organizer with a valid organizer token in localStorage, **When** they click "Cancel event", **Then** a confirmation step is shown before any change is made.
|
|
||||||
2. **Given** the organizer confirms cancellation without entering a message, **When** the server processes the request, **Then** the event is marked cancelled and the event page shows a "cancelled" state with no message.
|
|
||||||
3. **Given** the organizer confirms cancellation with a message, **When** the server processes the request, **Then** the event page displays the "cancelled" state along with the cancellation message.
|
|
||||||
4. **Given** a cancelled event, **When** a guest attempts to submit an RSVP, **Then** the server rejects the submission and the RSVP form is not shown on the event page.
|
|
||||||
5. **Given** a cancelled event that has not yet expired, **When** any visitor opens the event URL, **Then** the full event page renders with a clear "cancelled" indicator — no partial data is hidden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Adjust expiry date during cancellation (Priority: P2)
|
|
||||||
|
|
||||||
When cancelling, the organizer can optionally adjust the event's expiry date to control how long the cancellation notice remains visible before automatic data deletion (US-12). The adjusted date must be in the future, consistent with US-5's expiry date constraint.
|
|
||||||
|
|
||||||
**Why this priority**: The expiry date adjustment is a convenience — organizers may want to keep the cancellation notice visible for a specific period (e.g. one more week) or trigger earlier cleanup. The core cancellation (P1) works without it.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by cancelling an event while setting a new expiry date in the future, then verifying the event's expiry date was updated and data persists until that date.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the organizer confirms cancellation with a new expiry date set in the future, **When** the server processes the request, **Then** the event is cancelled and its expiry date is updated to the provided value.
|
|
||||||
2. **Given** the organizer provides an expiry date in the past or set to today during cancellation, **When** the server processes the request, **Then** the request is rejected with a clear validation error.
|
|
||||||
3. **Given** the organizer confirms cancellation without adjusting the expiry date, **When** the server processes the request, **Then** the existing expiry date is unchanged.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Edit cancellation message after cancellation (Priority: P3)
|
|
||||||
|
|
||||||
After an event is cancelled, the organizer can update the cancellation message (e.g. to correct a typo or add further explanation). The cancelled state itself cannot be changed.
|
|
||||||
|
|
||||||
**Why this priority**: Editing the message is a refinement capability. The core behaviour (cancellation + message at time of cancellation) is sufficient for P1 and P2.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by cancelling an event, then submitting an updated cancellation message via the organizer view, and verifying the new message is displayed on the event page.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a cancelled event with a valid organizer token, **When** the organizer submits an updated cancellation message, **Then** the event page displays the new message.
|
|
||||||
2. **Given** a cancelled event with a valid organizer token, **When** the organizer attempts to un-cancel (set the event back to active), **Then** the server rejects the request — cancellation is a one-way state transition.
|
|
||||||
3. **Given** a cancelled event with an absent or invalid organizer token, **When** a request is made to edit the cancellation message, **Then** the server rejects the request.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- Organizer token absent or invalid: the "Cancel event" action is not shown in the UI and the server rejects any cancel or message-edit request with an appropriate error response.
|
|
||||||
- RSVP on a cancelled event: the server rejects RSVP submissions with a clear error; the RSVP form is hidden on the client.
|
|
||||||
- Cancellation message is optional: omitting it is valid — the event still transitions to cancelled state with no message displayed.
|
|
||||||
- Cancellation + expiry: after the expiry date passes, the event is deleted by the cleanup process (US-12) regardless of cancelled state; the cancellation data is removed as part of the full event deletion.
|
|
||||||
- Already-expired event at time of cancellation: [NEEDS EXPANSION] — it is unclear whether cancellation should be allowed if the event has already expired but not yet been cleaned up. This edge case should be addressed during implementation.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: The Event entity MUST persist a `is_cancelled` boolean (default false) and an optional `cancellation_message` string, both server-side.
|
|
||||||
- **FR-002**: The cancel endpoint MUST require a valid organizer token; requests without a valid token are rejected.
|
|
||||||
- **FR-003**: The cancel endpoint MUST accept an optional plain-text cancellation message.
|
|
||||||
- **FR-004**: The cancel endpoint MUST accept an optional updated expiry date, which MUST be in the future; if provided and not in the future, the request is rejected with a clear validation error.
|
|
||||||
- **FR-005**: Cancellation MUST be a one-way state transition: once cancelled, the event cannot be set back to active via any API endpoint.
|
|
||||||
- **FR-006**: The event page MUST display a "cancelled" state indicator and the cancellation message (if present) for any visitor once the event is cancelled.
|
|
||||||
- **FR-007**: The RSVP endpoint MUST reject submissions for cancelled events; the RSVP form MUST be hidden on the client for cancelled events.
|
|
||||||
- **FR-008**: The organizer MUST be able to update the cancellation message after cancellation via a dedicated update action; this action MUST require a valid organizer token.
|
|
||||||
- **FR-009**: The UI MUST present a confirmation step before submitting the cancellation request; the cancel action MUST NOT be triggerable in a single click without confirmation.
|
|
||||||
- **FR-010**: The "Cancel event" action and organizer-specific cancel UI MUST NOT be rendered when no valid organizer token is present in localStorage.
|
|
||||||
- **FR-011**: No personal data, IP addresses, or identifiers MUST be logged during cancellation or cancellation message updates.
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **CancellationState**: Value type on Event. Fields: `is_cancelled` (boolean, persistent), `cancellation_message` (optional string, persistent). Not a separate entity — stored on the Event record.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: An organizer with a valid token can cancel an event (with or without a message) in a single confirmed action, and the cancelled state is immediately reflected on the event page.
|
|
||||||
- **SC-002**: After cancellation, no guest can successfully submit an RSVP via the API, regardless of client-side state.
|
|
||||||
- **SC-003**: The event page correctly renders a "cancelled" indicator (and message if provided) for any visitor after cancellation — no RSVP form, no false "active" state.
|
|
||||||
- **SC-004**: Cancellation data (state and message) is removed automatically when the event expires and is deleted by the cleanup process (US-12).
|
|
||||||
- **SC-005**: No client or API call can revert a cancelled event to an active state.
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# Feature Specification: Delete an Event as Organizer
|
|
||||||
|
|
||||||
**Feature**: `025-delete-event`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - Immediately delete an active event (Priority: P1)
|
|
||||||
|
|
||||||
The organizer wants to permanently remove an event they created — because it was a mistake, contains wrong information, or is no longer needed at all. From the organizer view, they trigger a dedicated "Delete event" action. After confirming a clear warning, the server immediately and permanently deletes all event data. The organizer is redirected to the root page and the event's local entry is removed.
|
|
||||||
|
|
||||||
**Why this priority**: This is the core action of the story. Without it, organizers have no manual removal mechanism — they can only wait for automatic expiry (US-12). It is the "nuclear option" that complements editing (US-5) and cancellation (US-18).
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by creating an event, navigating to the organizer view, triggering deletion, confirming the warning, and verifying the event URL returns "not found" and the root page no longer lists the event.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a valid organizer token is present in localStorage for an event, **When** the organizer triggers "Delete event" and confirms the warning, **Then** the server permanently deletes the event record and all associated data (RSVPs, update messages, field-change metadata, stored header images, cancellation state), and the event's public URL returns an "event not found" response.
|
|
||||||
2. **Given** deletion succeeds, **When** the server responds with success, **Then** the app removes the event's organizer token and metadata from localStorage and redirects the organizer to the root page (`/`).
|
|
||||||
3. **Given** the organizer view is open, **When** the organizer triggers "Delete event", **Then** a confirmation warning is shown that clearly states the action is immediate, permanent, and irreversible — all event data including RSVPs, update messages, and images will be lost.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Delete a cancelled or expired event (Priority: P2)
|
|
||||||
|
|
||||||
The organizer wants to delete an event regardless of its current state — whether it is still active, already cancelled (US-18), or past its expiry date. Deletion must not be gated on event state.
|
|
||||||
|
|
||||||
**Why this priority**: Extending P1 to cover all event states. A cancelled event may have data the organizer wants removed immediately, without waiting for the expiry date.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by cancelling an event (US-18), then triggering deletion from the organizer view and verifying that the deletion succeeds.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an event that has been cancelled (US-18), **When** the organizer confirms deletion, **Then** the server deletes the event and all its data, including the cancellation state, exactly as for an active event.
|
|
||||||
2. **Given** an event that has passed its expiry date but not yet been cleaned up by US-12, **When** the organizer confirms deletion, **Then** the deletion succeeds and the event is immediately removed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Reject deletion without a valid organizer token (Priority: P2)
|
|
||||||
|
|
||||||
A visitor without a valid organizer token must not be able to delete an event — the delete action is not shown to them and the server rejects any deletion request.
|
|
||||||
|
|
||||||
**Why this priority**: Security boundary. Without this, any visitor who knows the event token could delete the event.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by attempting a DELETE request to the event endpoint without a valid organizer token and verifying the server rejects it with an appropriate error.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** no organizer token is present in localStorage for a given event, **When** a visitor opens the event page, **Then** the delete action is not shown anywhere in the UI.
|
|
||||||
2. **Given** an organizer token is absent or invalid, **When** a DELETE request is sent to the server for that event, **Then** the server rejects the request and does not delete any data.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens if the organizer dismisses the confirmation warning? The deletion is aborted; no data is changed; the organizer remains on the event page.
|
|
||||||
- What happens if the network fails after confirmation but before the server responds? The event may or may not be deleted depending on whether the request reached the server. The app should display an error and not redirect; the organizer can retry.
|
|
||||||
- What happens if the organizer token in localStorage is valid but the event was already deleted (e.g. by US-12 cleanup between page load and deletion attempt)? The server returns "event not found"; the app should handle this gracefully and redirect to the root page, removing the stale localStorage entry.
|
|
||||||
- What if a stored header image file deletion fails during event deletion? The server must still delete the database record and must not leave the event in a partially-deleted state — image file cleanup failure should be logged server-side but must not abort deletion.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: The server MUST expose a DELETE endpoint for events, authenticated via the organizer token.
|
|
||||||
- **FR-002**: The server MUST permanently delete all associated data on deletion: RSVPs, update messages (US-10a), field-change metadata (US-9), stored header images (US-16), and cancellation state (US-18), in addition to the event record itself.
|
|
||||||
- **FR-003**: After deletion, the event's public URL MUST return an "event not found" response — no partial data may be served.
|
|
||||||
- **FR-004**: The frontend MUST show a confirmation warning before deletion, clearly stating that the action is immediate, permanent, and irreversible.
|
|
||||||
- **FR-005**: On successful deletion, the frontend MUST remove the event's organizer token and associated metadata from localStorage.
|
|
||||||
- **FR-006**: On successful deletion, the frontend MUST redirect the organizer to the root page (`/`).
|
|
||||||
- **FR-007**: The delete action MUST be accessible regardless of event state (active, cancelled, or expired).
|
|
||||||
- **FR-008**: If no valid organizer token is present, the delete action MUST NOT be shown in the UI and the server MUST reject the request.
|
|
||||||
- **FR-009**: No personal data or event data MUST be logged during deletion — deletion is silent from a logging perspective.
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **Event**: The primary entity being deleted. Deletion removes it and all child data (RSVPs, update messages, field-change metadata, images, cancellation state).
|
|
||||||
- **OrganizerToken**: The authentication credential (UUID) stored in localStorage and validated server-side. Required to authorize deletion.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: After deletion, a GET request to the event's public URL returns "event not found" — verifiable in an automated test immediately after deletion.
|
|
||||||
- **SC-002**: After deletion, no record of the event, its RSVPs, or its messages exists in the database — verifiable via database assertion in integration tests.
|
|
||||||
- **SC-003**: After deletion, the localStorage entry for the event (organizer token + metadata) is removed — verifiable in an E2E test.
|
|
||||||
- **SC-004**: A DELETE request without a valid organizer token is rejected by the server — verifiable in an automated API test.
|
|
||||||
- **SC-005**: The confirmation warning is always shown before deletion is triggered — verifiable in an E2E test by asserting the dialog appears before any data is sent to the server.
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# Feature Specification: 404 Page
|
|
||||||
|
|
||||||
**Feature**: `026-404-page`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - See a helpful error page for unknown URLs (Priority: P1)
|
|
||||||
|
|
||||||
As a user who navigates to a non-existent URL, I want to see a helpful error page so that I can find my way back instead of seeing a blank screen.
|
|
||||||
|
|
||||||
**Why this priority**: Without this, users see a blank page on invalid routes. Basic UX hygiene.
|
|
||||||
|
|
||||||
**Independent Test**: Navigate to any non-existent path (e.g. `/does-not-exist`) and verify the error page renders with a way back.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a user navigates to a URL that does not match any defined route, **When** the page loads, **Then** a "Page not found" message is displayed.
|
|
||||||
2. **Given** the 404 page is displayed, **When** the user looks for navigation, **Then** a link back to the home page is visible and functional.
|
|
||||||
3. **Given** the 404 page is displayed, **When** its appearance is evaluated, **Then** it follows the project design system (Electric Dusk + Sora).
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens when the URL contains special characters or very long paths? The catch-all route should still match and display the 404 page.
|
|
||||||
- What happens when JavaScript is disabled? [NEEDS EXPANSION — SPA limitation]
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: The Vue Router MUST include a catch-all route that matches any undefined path.
|
|
||||||
- **FR-002**: The catch-all route MUST render a dedicated 404 component with a "Page not found" message.
|
|
||||||
- **FR-003**: The 404 page MUST include a link back to the home page.
|
|
||||||
- **FR-004**: The 404 page MUST follow the design system defined in `.specify/memory/design-system.md`.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: Navigating to any undefined route displays the 404 page instead of a blank screen.
|
|
||||||
- **SC-002**: The home page link on the 404 page navigates back to `/` successfully.
|
|
||||||
- **SC-003**: The 404 page passes WCAG AA contrast requirements.
|
|
||||||
|
|
||||||
**Dependencies:** None (requires frontend scaffold from T-1/T-4 to be practically implementable)
|
|
||||||
|
|
||||||
**Notes:** Identified during US-1 post-review. Navigating to an unknown path currently shows a blank page because the Vue Router has no catch-all route.
|
|
||||||
Reference in New Issue
Block a user