11 Commits

Author SHA1 Message Date
ffea279b54 Update CLAUDE.md with build commands and project conventions
All checks were successful
CI / backend-test (push) Successful in 1m3s
CI / frontend-test (push) Successful in 19s
CI / build-and-publish (push) Successful in 55s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:58:20 +01:00
4cfac860aa Add US-20 (404 page) to user stories
Catch-all route for unknown paths with link back to home.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:58:10 +01:00
e3ca613210 Add agent research and implementation plan docs for US-1
Research reports on datetime handling, RFC 9457, font selection.
Implementation plans for US-1 create event and post-review fixes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:57:44 +01:00
14f11875a4 Add Vite dev proxy for backend API
Proxy /api requests to localhost:8080 during development.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:57:20 +01:00
a029e951b8 Add event stub page with clipboard sharing
Post-creation confirmation page showing shareable event URL with
copy-to-clipboard and fallback feedback on failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:57:10 +01:00
84feeb9997 Implement event creation frontend (EventCreateView)
Form with client-side validation, server error handling, aria-invalid/
aria-describedby for a11y, localStorage persistence via useEventStorage
composable. Routes for /create and /events/:token.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:56:59 +01:00
f3d4b5fa17 Add REST controller with RFC 9457 error handling
EventController for POST /events, GlobalExceptionHandler mapping
validation and business exceptions to problem+json responses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:56:48 +01:00
c80074093c Add persistence layer with Liquibase migration
JPA entity, repository, persistence adapter for events. Liquibase
changelog creates the events table with BIGSERIAL ID and UUID tokens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:56:39 +01:00
830ca55f20 Implement event domain model and application service
Add Event entity, CreateEventCommand, ports (CreateEventUseCase,
EventRepository), and EventService with Clock injection for
deterministic testing. Expiry date must be in the future.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:56:28 +01:00
eeadaf58c7 Add OpenAPI spec for POST /events endpoint
Define CreateEventRequest, CreateEventResponse, ProblemDetail, and
ValidationProblemDetail schemas. RFC 9457 problem details for errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:56:17 +01:00
387445e089 Remove Vue scaffolding and add design system foundation
Strip default Vue components (HelloWorld, TheWelcome, icons, AboutView),
base.css, and placeholder assets. Add Sora font (self-hosted WOFF2),
Electric Dusk color palette, and design system spec.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:55:53 +01:00
56 changed files with 4714 additions and 521 deletions

View File

@@ -32,6 +32,11 @@ These are the non-negotiable principles of this project. Every decision — arch
- For every feature, critically evaluate what data is necessary. Only store what is absolutely required for functionality.
- Never include external dependencies that phone home: no CDNs, no Google Fonts, no tracking-capable libraries.
### Design
- The visual design system is defined in `spec/design-system.md`. All frontend implementation must follow it.
- Color palette, typography, component patterns, and layout rules are specified there — do not deviate without explicit approval.
### Quality
- KISS and grugbrain. Engineer it properly, but don't over-engineer.

View File

@@ -0,0 +1,46 @@
package de.fete.adapter.in.web;
import de.fete.adapter.in.web.api.EventsApi;
import de.fete.adapter.in.web.model.CreateEventRequest;
import de.fete.adapter.in.web.model.CreateEventResponse;
import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event;
import de.fete.domain.port.in.CreateEventUseCase;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
/** REST controller for event operations. */
@RestController
public class EventController implements EventsApi {
private final CreateEventUseCase createEventUseCase;
/** Creates a new controller with the given use case. */
public EventController(CreateEventUseCase createEventUseCase) {
this.createEventUseCase = createEventUseCase;
}
@Override
public ResponseEntity<CreateEventResponse> createEvent(
CreateEventRequest request) {
var command = new CreateEventCommand(
request.getTitle(),
request.getDescription(),
request.getDateTime(),
request.getLocation(),
request.getExpiryDate()
);
Event event = createEventUseCase.createEvent(command);
var response = new CreateEventResponse();
response.setEventToken(event.getEventToken());
response.setOrganizerToken(event.getOrganizerToken());
response.setTitle(event.getTitle());
response.setDateTime(event.getDateTime());
response.setExpiryDate(event.getExpiryDate());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}

View File

@@ -0,0 +1,71 @@
package de.fete.adapter.in.web;
import de.fete.application.service.ExpiryDateInPastException;
import java.net.URI;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
/** Global exception handler producing RFC 9457 Problem Details responses. */
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
ProblemDetail problemDetail = ex.getBody();
problemDetail.setTitle("Validation Failed");
problemDetail.setType(URI.create("urn:problem-type:validation-error"));
List<Map<String, String>> fieldErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(fe -> Map.of(
"field", fe.getField(),
"message", fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "invalid"
))
.toList();
problemDetail.setProperty("fieldErrors", fieldErrors);
return handleExceptionInternal(ex, problemDetail, headers, status, request);
}
/** Handles expiry date validation failures. */
@ExceptionHandler(ExpiryDateInPastException.class)
public ResponseEntity<ProblemDetail> handleExpiryDateInPast(
ExpiryDateInPastException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, ex.getMessage());
problemDetail.setTitle("Invalid Expiry Date");
problemDetail.setType(URI.create("urn:problem-type:expiry-date-in-past"));
return ResponseEntity.badRequest()
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Catches all unhandled exceptions. */
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleAll(Exception ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR,
"An unexpected error occurred.");
problemDetail.setTitle("Internal Server Error");
return ResponseEntity.internalServerError()
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
}

View File

@@ -0,0 +1,135 @@
package de.fete.adapter.out.persistence;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.UUID;
/** JPA entity mapping to the events table. */
@Entity
@Table(name = "events")
public class EventJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "event_token", nullable = false, unique = true)
private UUID eventToken;
@Column(name = "organizer_token", nullable = false, unique = true)
private UUID organizerToken;
@Column(nullable = false, length = 200)
private String title;
@Column(length = 2000)
private String description;
@Column(name = "date_time", nullable = false)
private OffsetDateTime dateTime;
@Column(length = 500)
private String location;
@Column(name = "expiry_date", nullable = false)
private LocalDate expiryDate;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
/** Returns the internal database ID. */
public Long getId() {
return id;
}
/** Sets the internal database ID. */
public void setId(Long id) {
this.id = id;
}
/** Returns the public event token. */
public UUID getEventToken() {
return eventToken;
}
/** Sets the public event token. */
public void setEventToken(UUID eventToken) {
this.eventToken = eventToken;
}
/** Returns the secret organizer token. */
public UUID getOrganizerToken() {
return organizerToken;
}
/** Sets the secret organizer token. */
public void setOrganizerToken(UUID organizerToken) {
this.organizerToken = organizerToken;
}
/** Returns the event title. */
public String getTitle() {
return title;
}
/** Sets the event title. */
public void setTitle(String title) {
this.title = title;
}
/** Returns the event description. */
public String getDescription() {
return description;
}
/** Sets the event description. */
public void setDescription(String description) {
this.description = description;
}
/** Returns the event date and time. */
public OffsetDateTime getDateTime() {
return dateTime;
}
/** Sets the event date and time. */
public void setDateTime(OffsetDateTime dateTime) {
this.dateTime = dateTime;
}
/** Returns the event location. */
public String getLocation() {
return location;
}
/** Sets the event location. */
public void setLocation(String location) {
this.location = location;
}
/** Returns the expiry date. */
public LocalDate getExpiryDate() {
return expiryDate;
}
/** Sets the expiry date. */
public void setExpiryDate(LocalDate expiryDate) {
this.expiryDate = expiryDate;
}
/** Returns the creation timestamp. */
public OffsetDateTime getCreatedAt() {
return createdAt;
}
/** Sets the creation timestamp. */
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,12 @@
package de.fete.adapter.out.persistence;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
/** Spring Data JPA repository for event entities. */
public interface EventJpaRepository extends JpaRepository<EventJpaEntity, Long> {
/** Finds an event by its public event token. */
Optional<EventJpaEntity> findByEventToken(UUID eventToken);
}

View File

@@ -0,0 +1,59 @@
package de.fete.adapter.out.persistence;
import de.fete.domain.model.Event;
import de.fete.domain.port.out.EventRepository;
import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Repository;
/** Persistence adapter implementing the EventRepository outbound port. */
@Repository
public class EventPersistenceAdapter implements EventRepository {
private final EventJpaRepository jpaRepository;
/** Creates a new adapter with the given JPA repository. */
public EventPersistenceAdapter(EventJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Event save(Event event) {
EventJpaEntity entity = toEntity(event);
EventJpaEntity saved = jpaRepository.save(entity);
return toDomain(saved);
}
@Override
public Optional<Event> findByEventToken(UUID eventToken) {
return jpaRepository.findByEventToken(eventToken).map(this::toDomain);
}
private EventJpaEntity toEntity(Event event) {
var entity = new EventJpaEntity();
entity.setId(event.getId());
entity.setEventToken(event.getEventToken());
entity.setOrganizerToken(event.getOrganizerToken());
entity.setTitle(event.getTitle());
entity.setDescription(event.getDescription());
entity.setDateTime(event.getDateTime());
entity.setLocation(event.getLocation());
entity.setExpiryDate(event.getExpiryDate());
entity.setCreatedAt(event.getCreatedAt());
return entity;
}
private Event toDomain(EventJpaEntity entity) {
var event = new Event();
event.setId(entity.getId());
event.setEventToken(entity.getEventToken());
event.setOrganizerToken(entity.getOrganizerToken());
event.setTitle(entity.getTitle());
event.setDescription(entity.getDescription());
event.setDateTime(entity.getDateTime());
event.setLocation(entity.getLocation());
event.setExpiryDate(entity.getExpiryDate());
event.setCreatedAt(entity.getCreatedAt());
return event;
}
}

View File

@@ -0,0 +1,44 @@
package de.fete.application.service;
import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event;
import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.out.EventRepository;
import java.time.Clock;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.UUID;
import org.springframework.stereotype.Service;
/** Application service implementing event creation. */
@Service
public class EventService implements CreateEventUseCase {
private final EventRepository eventRepository;
private final Clock clock;
/** Creates a new EventService with the given repository and clock. */
public EventService(EventRepository eventRepository, Clock clock) {
this.eventRepository = eventRepository;
this.clock = clock;
}
@Override
public Event createEvent(CreateEventCommand command) {
if (!command.expiryDate().isAfter(LocalDate.now(clock))) {
throw new ExpiryDateInPastException(command.expiryDate());
}
var event = new Event();
event.setEventToken(UUID.randomUUID());
event.setOrganizerToken(UUID.randomUUID());
event.setTitle(command.title());
event.setDescription(command.description());
event.setDateTime(command.dateTime());
event.setLocation(command.location());
event.setExpiryDate(command.expiryDate());
event.setCreatedAt(OffsetDateTime.now(clock));
return eventRepository.save(event);
}
}

View File

@@ -0,0 +1,20 @@
package de.fete.application.service;
import java.time.LocalDate;
/** Thrown when an event's expiry date is not in the future. */
public class ExpiryDateInPastException extends RuntimeException {
private final LocalDate expiryDate;
/** Creates a new exception for the given invalid expiry date. */
public ExpiryDateInPastException(LocalDate expiryDate) {
super("Expiry date must be in the future: " + expiryDate);
this.expiryDate = expiryDate;
}
/** Returns the invalid expiry date. */
public LocalDate getExpiryDate() {
return expiryDate;
}
}

View File

@@ -1,6 +1,8 @@
package de.fete.config;
import java.io.IOException;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
@@ -14,6 +16,11 @@ import org.springframework.web.servlet.resource.PathResourceResolver;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
Clock clock() {
return Clock.systemDefaultZone();
}
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.addPathPrefix("/api", c -> c.isAnnotationPresent(RestController.class));

View File

@@ -0,0 +1,13 @@
package de.fete.domain.model;
import java.time.LocalDate;
import java.time.OffsetDateTime;
/** Command carrying the data needed to create an event. */
public record CreateEventCommand(
String title,
String description,
OffsetDateTime dateTime,
String location,
LocalDate expiryDate
) {}

View File

@@ -0,0 +1,109 @@
package de.fete.domain.model;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.UUID;
/** Domain entity representing an event. */
public class Event {
private Long id;
private UUID eventToken;
private UUID organizerToken;
private String title;
private String description;
private OffsetDateTime dateTime;
private String location;
private LocalDate expiryDate;
private OffsetDateTime createdAt;
/** Returns the internal database ID. */
public Long getId() {
return id;
}
/** Sets the internal database ID. */
public void setId(Long id) {
this.id = id;
}
/** Returns the public event token (UUID). */
public UUID getEventToken() {
return eventToken;
}
/** Sets the public event token. */
public void setEventToken(UUID eventToken) {
this.eventToken = eventToken;
}
/** Returns the secret organizer token (UUID). */
public UUID getOrganizerToken() {
return organizerToken;
}
/** Sets the secret organizer token. */
public void setOrganizerToken(UUID organizerToken) {
this.organizerToken = organizerToken;
}
/** Returns the event title. */
public String getTitle() {
return title;
}
/** Sets the event title. */
public void setTitle(String title) {
this.title = title;
}
/** Returns the event description. */
public String getDescription() {
return description;
}
/** Sets the event description. */
public void setDescription(String description) {
this.description = description;
}
/** Returns the event date and time with UTC offset. */
public OffsetDateTime getDateTime() {
return dateTime;
}
/** Sets the event date and time. */
public void setDateTime(OffsetDateTime dateTime) {
this.dateTime = dateTime;
}
/** Returns the event location. */
public String getLocation() {
return location;
}
/** Sets the event location. */
public void setLocation(String location) {
this.location = location;
}
/** Returns the expiry date after which event data is deleted. */
public LocalDate getExpiryDate() {
return expiryDate;
}
/** Sets the expiry date. */
public void setExpiryDate(LocalDate expiryDate) {
this.expiryDate = expiryDate;
}
/** Returns the creation timestamp. */
public OffsetDateTime getCreatedAt() {
return createdAt;
}
/** Sets the creation timestamp. */
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,11 @@
package de.fete.domain.port.in;
import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event;
/** Inbound port for creating a new event. */
public interface CreateEventUseCase {
/** Creates an event from the given command and returns the persisted event. */
Event createEvent(CreateEventCommand command);
}

View File

@@ -0,0 +1,15 @@
package de.fete.domain.port.out;
import de.fete.domain.model.Event;
import java.util.Optional;
import java.util.UUID;
/** Outbound port for persisting and retrieving events. */
public interface EventRepository {
/** Persists the given event and returns it with generated fields populated. */
Event save(Event event);
/** Finds an event by its public event token. */
Optional<Event> findByEventToken(UUID eventToken);
}

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="001-create-events-table" author="fete">
<createTable tableName="events">
<column name="id" type="bigserial" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="event_token" type="uuid">
<constraints nullable="false" unique="true"/>
</column>
<column name="organizer_token" type="uuid">
<constraints nullable="false" unique="true"/>
</column>
<column name="title" type="varchar(200)">
<constraints nullable="false"/>
</column>
<column name="description" type="varchar(2000)"/>
<column name="date_time" type="timestamptz">
<constraints nullable="false"/>
</column>
<column name="location" type="varchar(500)"/>
<column name="expiry_date" type="date">
<constraints nullable="false"/>
</column>
<column name="created_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="events" indexName="idx_events_event_token">
<column name="event_token"/>
</createIndex>
<createIndex tableName="events" indexName="idx_events_expiry_date">
<column name="expiry_date"/>
</createIndex>
</changeSet>
</databaseChangeLog>

View File

@@ -6,5 +6,6 @@
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<include file="db/changelog/000-baseline.xml"/>
<include file="db/changelog/001-create-events-table.xml"/>
</databaseChangeLog>

View File

@@ -11,27 +11,120 @@ servers:
- url: /api
paths:
/health:
get:
operationId: getHealth
summary: Health check
/events:
post:
operationId: createEvent
summary: Create a new event
tags:
- health
- events
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateEventRequest"
responses:
"200":
description: Service is healthy
"201":
description: Event created successfully
content:
application/json:
schema:
$ref: "#/components/schemas/HealthResponse"
$ref: "#/components/schemas/CreateEventResponse"
"400":
description: Validation failed
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ValidationProblemDetail"
components:
schemas:
HealthResponse:
CreateEventRequest:
type: object
required:
- status
- title
- dateTime
- expiryDate
properties:
status:
title:
type: string
example: UP
minLength: 1
maxLength: 200
description:
type: string
maxLength: 2000
dateTime:
type: string
format: date-time
description: Event date and time with UTC offset (ISO 8601)
example: "2026-03-15T20:00:00+01:00"
location:
type: string
maxLength: 500
expiryDate:
type: string
format: date
description: Date after which event data is deleted. Must be in the future.
example: "2026-06-15"
CreateEventResponse:
type: object
required:
- eventToken
- organizerToken
- title
- dateTime
- expiryDate
properties:
eventToken:
type: string
format: uuid
description: Public token for the event URL
organizerToken:
type: string
format: uuid
description: Secret token for organizer access
title:
type: string
dateTime:
type: string
format: date-time
expiryDate:
type: string
format: date
ProblemDetail:
type: object
properties:
type:
type: string
format: uri
default: "about:blank"
title:
type: string
status:
type: integer
detail:
type: string
instance:
type: string
format: uri
additionalProperties: true
ValidationProblemDetail:
allOf:
- $ref: "#/components/schemas/ProblemDetail"
- type: object
properties:
fieldErrors:
type: array
items:
type: object
required:
- field
- message
properties:
field:
type: string
message:
type: string

View File

@@ -0,0 +1,180 @@
package de.fete.adapter.in.web;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import de.fete.TestcontainersConfig;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfig.class)
class EventControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void createEventWithValidBody() throws Exception {
String body =
"""
{
"title": "Birthday Party",
"description": "Come celebrate!",
"dateTime": "2026-06-15T20:00:00+02:00",
"location": "Berlin",
"expiryDate": "%s"
}
""".formatted(LocalDate.now().plusDays(30));
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.eventToken").isNotEmpty())
.andExpect(jsonPath("$.organizerToken").isNotEmpty())
.andExpect(jsonPath("$.title").value("Birthday Party"))
.andExpect(jsonPath("$.dateTime").isNotEmpty())
.andExpect(jsonPath("$.expiryDate").isNotEmpty());
}
@Test
void createEventWithOptionalFieldsNull() throws Exception {
String body =
"""
{
"title": "Minimal Event",
"dateTime": "2026-06-15T20:00:00+02:00",
"expiryDate": "%s"
}
""".formatted(LocalDate.now().plusDays(30));
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.eventToken").isNotEmpty())
.andExpect(jsonPath("$.organizerToken").isNotEmpty())
.andExpect(jsonPath("$.title").value("Minimal Event"));
}
@Test
void createEventMissingTitleReturns400() throws Exception {
String body =
"""
{
"dateTime": "2026-06-15T20:00:00+02:00",
"expiryDate": "%s"
}
""".formatted(LocalDate.now().plusDays(30));
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.title").value("Validation Failed"))
.andExpect(jsonPath("$.fieldErrors").isArray());
}
@Test
void createEventMissingDateTimeReturns400() throws Exception {
String body =
"""
{
"title": "No Date",
"expiryDate": "%s"
}
""".formatted(LocalDate.now().plusDays(30));
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.fieldErrors").isArray());
}
@Test
void createEventMissingExpiryDateReturns400() throws Exception {
String body =
"""
{
"title": "No Expiry",
"dateTime": "2026-06-15T20:00:00+02:00"
}
""";
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.fieldErrors").isArray());
}
@Test
void createEventExpiryDateInPastReturns400() throws Exception {
String body =
"""
{
"title": "Past Expiry",
"dateTime": "2026-06-15T20:00:00+02:00",
"expiryDate": "2025-01-01"
}
""";
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
}
@Test
void createEventExpiryDateTodayReturns400() throws Exception {
String body =
"""
{
"title": "Today Expiry",
"dateTime": "2026-06-15T20:00:00+02:00",
"expiryDate": "%s"
}
""".formatted(LocalDate.now());
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
}
@Test
void errorResponseContentTypeIsProblemJson() throws Exception {
String body =
"""
{
"title": "",
"dateTime": "2026-06-15T20:00:00+02:00",
"expiryDate": "%s"
}
""".formatted(LocalDate.now().plusDays(30));
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"));
}
}

View File

@@ -0,0 +1,97 @@
package de.fete.adapter.out.persistence;
import static org.assertj.core.api.Assertions.assertThat;
import de.fete.TestcontainersConfig;
import de.fete.domain.model.Event;
import de.fete.domain.port.out.EventRepository;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
@SpringBootTest
@Import(TestcontainersConfig.class)
class EventPersistenceAdapterTest {
@Autowired
private EventRepository eventRepository;
@Test
void saveReturnsEventWithGeneratedId() {
Event event = buildEvent();
Event saved = eventRepository.save(event);
assertThat(saved.getId()).isNotNull();
assertThat(saved.getTitle()).isEqualTo("Test Event");
}
@Test
void savedEventIsFoundByEventToken() {
Event event = buildEvent();
Event saved = eventRepository.save(event);
Optional<Event> found = eventRepository.findByEventToken(saved.getEventToken());
assertThat(found).isPresent();
assertThat(found.get().getTitle()).isEqualTo("Test Event");
assertThat(found.get().getId()).isEqualTo(saved.getId());
}
@Test
void findByUnknownEventTokenReturnsEmpty() {
Optional<Event> found = eventRepository.findByEventToken(UUID.randomUUID());
assertThat(found).isEmpty();
}
@Test
void allFieldsRoundTripCorrectly() {
OffsetDateTime dateTime =
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2));
LocalDate expiryDate = LocalDate.of(2026, 7, 15);
OffsetDateTime createdAt =
OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC);
var event = new Event();
event.setEventToken(UUID.randomUUID());
event.setOrganizerToken(UUID.randomUUID());
event.setTitle("Full Event");
event.setDescription("A detailed description");
event.setDateTime(dateTime);
event.setLocation("Berlin, Germany");
event.setExpiryDate(expiryDate);
event.setCreatedAt(createdAt);
Event saved = eventRepository.save(event);
Event found = eventRepository.findByEventToken(saved.getEventToken()).orElseThrow();
assertThat(found.getEventToken()).isEqualTo(event.getEventToken());
assertThat(found.getOrganizerToken()).isEqualTo(event.getOrganizerToken());
assertThat(found.getTitle()).isEqualTo("Full Event");
assertThat(found.getDescription()).isEqualTo("A detailed description");
assertThat(found.getDateTime().toInstant()).isEqualTo(dateTime.toInstant());
assertThat(found.getLocation()).isEqualTo("Berlin, Germany");
assertThat(found.getExpiryDate()).isEqualTo(expiryDate);
assertThat(found.getCreatedAt().toInstant()).isEqualTo(createdAt.toInstant());
}
private Event buildEvent() {
var event = new Event();
event.setEventToken(UUID.randomUUID());
event.setOrganizerToken(UUID.randomUUID());
event.setTitle("Test Event");
event.setDescription("Test description");
event.setDateTime(OffsetDateTime.now().plusDays(7));
event.setLocation("Somewhere");
event.setExpiryDate(LocalDate.now().plusDays(30));
event.setCreatedAt(OffsetDateTime.now());
return event;
}
}

View File

@@ -0,0 +1,140 @@
package de.fete.application.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event;
import de.fete.domain.port.out.EventRepository;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class EventServiceTest {
private static final ZoneId ZONE = ZoneId.of("Europe/Berlin");
private static final Instant FIXED_INSTANT =
LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant();
private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE);
@Mock
private EventRepository eventRepository;
private EventService eventService;
@BeforeEach
void setUp() {
eventService = new EventService(eventRepository, FIXED_CLOCK);
}
@Test
void createEventWithValidCommand() {
when(eventRepository.save(any(Event.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
var command = new CreateEventCommand(
"Birthday Party",
"Come celebrate!",
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
"Berlin",
LocalDate.of(2026, 7, 15)
);
Event result = eventService.createEvent(command);
assertThat(result.getTitle()).isEqualTo("Birthday Party");
assertThat(result.getDescription()).isEqualTo("Come celebrate!");
assertThat(result.getLocation()).isEqualTo("Berlin");
assertThat(result.getEventToken()).isNotNull();
assertThat(result.getOrganizerToken()).isNotNull();
assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.now(FIXED_CLOCK));
}
@Test
void eventTokenAndOrganizerTokenAreDifferent() {
when(eventRepository.save(any(Event.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
var command = new CreateEventCommand(
"Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
LocalDate.now(FIXED_CLOCK).plusDays(30)
);
Event result = eventService.createEvent(command);
assertThat(result.getEventToken()).isNotEqualTo(result.getOrganizerToken());
}
@Test
void repositorySaveCalledExactlyOnce() {
when(eventRepository.save(any(Event.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
var command = new CreateEventCommand(
"Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
LocalDate.now(FIXED_CLOCK).plusDays(30)
);
eventService.createEvent(command);
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
verify(eventRepository, times(1)).save(captor.capture());
assertThat(captor.getValue().getTitle()).isEqualTo("Test");
}
@Test
void expiryDateTodayThrowsException() {
var command = new CreateEventCommand(
"Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
LocalDate.now(FIXED_CLOCK)
);
assertThatThrownBy(() -> eventService.createEvent(command))
.isInstanceOf(ExpiryDateInPastException.class);
}
@Test
void expiryDateInPastThrowsException() {
var command = new CreateEventCommand(
"Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
LocalDate.now(FIXED_CLOCK).minusDays(5)
);
assertThatThrownBy(() -> eventService.createEvent(command))
.isInstanceOf(ExpiryDateInPastException.class);
}
@Test
void expiryDateTomorrowSucceeds() {
when(eventRepository.save(any(Event.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
var command = new CreateEventCommand(
"Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
LocalDate.now(FIXED_CLOCK).plusDays(1)
);
Event result = eventService.createEvent(command);
assertThat(result.getExpiryDate()).isEqualTo(LocalDate.of(2026, 3, 6));
}
}

View File

@@ -29,8 +29,8 @@ class WebConfigTest {
@Test
void apiPrefixNotAccessibleWithoutIt() throws Exception {
// /health without /api prefix should not resolve to the API endpoint
mockMvc.perform(get("/health"))
// /events without /api prefix should not resolve to the API endpoint
mockMvc.perform(get("/events"))
.andExpect(status().isNotFound());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,199 @@
# US-1 Post-Review Fixes — Implementation Plan
Date: 2026-03-05
Origin: Deep review of all unstaged US-1 changes before commit
## Context
US-1 "Create Event" is fully implemented (backend + frontend, 7 phases) with 4 review fixes already applied (reactive error clearing, network error handling, page title, favicon). A comprehensive review of ALL unstaged files revealed additional issues that must be fixed before committing.
## Task 1: Backend — Clock injection in EventService [x]
**Problem:** `EventService` uses `LocalDate.now()` and `OffsetDateTime.now()` directly, making deterministic time-based testing impossible.
**Files:**
- `backend/src/main/java/de/fete/application/service/EventService.java`
- `backend/src/test/java/de/fete/application/service/EventServiceTest.java`
**Fix:**
1. Inject a `java.time.Clock` bean into `EventService` via constructor
2. Replace `LocalDate.now()` with `LocalDate.now(clock)` and `OffsetDateTime.now()` with `OffsetDateTime.now(clock)`
3. Add a `Clock` bean to the Spring config (or rely on a `@Bean Clock clock() { return Clock.systemDefaultZone(); }` in a config class)
4. Update `EventServiceTest` to use `Clock.fixed(...)` for deterministic tests
**Verification:** `cd backend && ./mvnw test`
## Task 2: Frontend A11y — Error spans should only render when error present [x]
**Problem:** Every form field has `<span class="field-error" role="alert">{{ errors.title }}</span>` that is always in the DOM, even when empty. Screen readers may announce empty `role="alert"` elements.
**File:** `frontend/src/views/EventCreateView.vue`
**Fix:** Use `v-if` to conditionally render error spans:
```html
<span v-if="errors.title" class="field-error" role="alert">{{ errors.title }}</span>
```
Apply to all 5 field error spans (title, description, dateTime, location, expiryDate).
**Note:** This removes the `min-height: 1.2em` layout reservation. Accept the layout shift as a trade-off for accessibility, OR add a wrapper div with `min-height` that doesn't carry `role="alert"`.
**Verification:** `cd frontend && npm run test:unit` — existing tests use `.querySelector('[role="alert"]')` so they may need adjustment since empty alerts will no longer be in the DOM.
## Task 3: Frontend A11y — aria-invalid and aria-describedby on fields [x]
**Problem:** When a field fails validation, there is no `aria-invalid="true"` or `aria-describedby` linking the input to its error message. Assistive technologies cannot associate errors with fields.
**File:** `frontend/src/views/EventCreateView.vue`
**Fix:**
1. Add unique `id` to each error span (e.g., `id="title-error"`)
2. Add `:aria-describedby="errors.title ? 'title-error' : undefined"` to each input
3. Add `:aria-invalid="!!errors.title"` to each input
Example for title:
```html
<input
id="title"
v-model="form.title"
type="text"
class="form-field"
required
maxlength="200"
placeholder="What's the event?"
:aria-invalid="!!errors.title"
:aria-describedby="errors.title ? 'title-error' : undefined"
/>
<span v-if="errors.title" id="title-error" class="field-error" role="alert">{{ errors.title }}</span>
```
Apply the same pattern to all 5 fields (title, description, dateTime, location, expiryDate).
**Verification:** `cd frontend && npm run test:unit`
## Task 4: Frontend A11y — Error text contrast [x]
**Problem:** White (`#fff`) error text on the pink gradient start (`#F06292`) has a contrast ratio of only 3.06:1, which fails WCAG AA for small text (0.8rem). The project statute requires WCAG AA compliance.
**File:** `frontend/src/assets/main.css`
**Fix options (pick one):**
- **Option A:** Use a light yellow/cream color like `#FFF9C4` or `#FFECB3` that has higher contrast on the gradient
- **Option B:** Add a subtle dark text-shadow to the error text: `text-shadow: 0 1px 2px rgba(0,0,0,0.3)`
- **Option C:** Make error text slightly larger/bolder to qualify for WCAG AA-large (18px+ or 14px+ bold)
**Recommended:** Option C — bump `.field-error` to `font-size: 0.85rem; font-weight: 600;` which at 600 weight qualifies for AA-large text at 14px+ (0.85rem ≈ 13.6px — close but may not quite qualify). Alternatively combine with option B for safety.
**Note:** Verify the final choice against the design system spec in `spec/design-system.md`. The spec notes that gradient start only passes AA-large. The error text must work across the full gradient.
**Verification:** Manual contrast check with a tool like WebAIM contrast checker.
## Task 5: Test — Happy-path submission in EventCreateView [x]
**Problem:** No test verifies successful form submission (the most important behavior).
**File:** `frontend/src/views/__tests__/EventCreateView.spec.ts`
**Fix:** Add a test that:
1. Mocks `api.POST` to return `{ data: { eventToken: 'abc', organizerToken: 'xyz', title: 'Test', dateTime: '...', expiryDate: '...' } }`
2. Fills all required fields
3. Submits the form
4. Asserts `api.POST` was called with the correct body
5. Asserts navigation to `/events/abc` occurred
6. Asserts `saveCreatedEvent` was called (need to mock `useEventStorage`)
**Note:** `useEventStorage` must be mocked. Use `vi.mock('@/composables/useEventStorage')`.
**Verification:** `cd frontend && npm run test:unit`
## Task 6: Test — EventStubView component tests [x]
**Problem:** No test file exists for `EventStubView.vue`.
**New file:** `frontend/src/views/__tests__/EventStubView.spec.ts`
**Fix:** Create tests covering:
1. Renders the event URL based on route param `:token`
2. Shows the correct share URL (`window.location.origin + /events/:token`)
3. Copy button exists
4. Back link navigates to home
**Note:** Read `frontend/src/views/EventStubView.vue` first to understand the component structure.
**Verification:** `cd frontend && npm run test:unit`
## Task 7: Test — Server-side field errors in EventCreateView [x]
**Problem:** The `fieldErrors` handling branch (lines 184-196 of EventCreateView.vue) is untested.
**File:** `frontend/src/views/__tests__/EventCreateView.spec.ts`
**Fix:** Add a test that:
1. Mocks `api.POST` to return `{ error: { fieldErrors: [{ field: 'title', message: 'Title already taken' }] } }`
2. Fills all required fields and submits
3. Asserts the title field error shows "Title already taken"
4. Asserts other field errors are empty
**Verification:** `cd frontend && npm run test:unit`
## Task 8: Fix border-radius on EventStubView copy button [x]
**Problem:** `border-radius: 10px` is hardcoded instead of using the design token `var(--radius-button)` (14px).
**File:** `frontend/src/views/EventStubView.vue`
**Fix:** Replace `border-radius: 10px` with `border-radius: var(--radius-button)` in the `.stub__copy` CSS class.
**Verification:** Visual check.
## Task 9: Add 404 catch-all route user story [x]
**Problem:** Navigating to an unknown path shows a blank page.
**File:** `spec/userstories.md`
**Fix:** Add a new user story for a 404/catch-all route. Something like:
```
### US-X: 404 Page
As a user who navigates to a non-existent URL, I want to see a helpful error page so I can find my way back.
Acceptance Criteria:
- [ ] Unknown routes show a "Page not found" message
- [ ] The page includes a link back to the home page
- [ ] The page follows the design system
```
Read the existing user stories first to match the format.
**Verification:** N/A (spec only).
## Task 10: EventStubView silent clipboard failure [x]
**Problem:** In `EventStubView.vue`, the `catch` block on `navigator.clipboard.writeText()` is empty. If clipboard is unavailable (HTTP, older browser), the user gets no feedback.
**File:** `frontend/src/views/EventStubView.vue`
**Fix:** In the catch block, show a fallback message (e.g., set `copied` text to "Copy failed" or select the URL text for manual copying).
**Verification:** `cd frontend && npm run test:unit`
## Execution Order
1. Task 1 (Clock injection — backend, independent)
2. Tasks 2 + 3 (A11y fixes — can be done together since they touch the same file)
3. Task 4 (Contrast fix — CSS only)
4. Tasks 5 + 7 (EventCreateView tests — same test file)
5. Task 6 (EventStubView tests — new file)
6. Tasks 8 + 10 (EventStubView fixes — same file)
7. Task 9 (User story — spec only)
8. Run all tests: `cd backend && ./mvnw test` and `cd frontend && npm run test:unit`
## Constraints
- TDD: write/update tests first, then fix (where applicable)
- Follow existing code style and patterns
- Do not refactor unrelated code
- Do not add dependencies
- Update design system spec if contrast solution changes the spec

View File

@@ -0,0 +1,109 @@
# US-1 Review Fixes — Agent Instructions
Date: 2026-03-05
Origin: Code review and exploratory browser testing of US-1 "Create Event"
## Context
US-1 has been implemented across all 7 phases (OpenAPI spec, DB migration, domain model, application service, persistence adapter, web adapter, frontend). All 42 tests pass. A code review with exploratory browser testing found 2 bugs and 2 minor issues that need to be fixed before the story can be committed.
### Resources
- **Test report:** `.agent-tests/2026-03-05-us1-review-test/report.md` — full browser test protocol with screenshots
- **Screenshots:** `.agent-tests/2026-03-05-us1-review-test/screenshots/` — visual evidence (0108)
- **US-1 spec:** `spec/userstories.md` — acceptance criteria
- **Implementation plan:** `docs/agents/plan/2026-03-04-us1-create-event.md`
- **Design system:** `spec/design-system.md`
- **Primary file to modify:** `frontend/src/views/EventCreateView.vue`
- **Secondary file to modify:** `frontend/index.html`
## Fix Instructions
### Fix 1: Validation errors must clear reactively (Bug — Medium)
**Problem:** After submitting the empty form, validation errors appear correctly. But when the user then fills in the fields, the error messages persist until the next submit. See screenshot `05-form-filled.png` — all fields filled, errors still visible.
**Root cause:** `validate()` (line 125) calls `clearErrors()` only on submit. There is no reactive clearing on input.
**Fix:** Add a `watch` on the `form` reactive object that clears the corresponding field error when the value changes. Do NOT re-validate on every keystroke — just clear the error for the field that was touched.
```typescript
// Clear individual field errors when the user types
watch(() => form.title, () => { errors.title = '' })
watch(() => form.dateTime, () => { errors.dateTime = '' })
watch(() => form.expiryDate, () => { errors.expiryDate = '' })
```
Also clear `serverError` when any field changes, so stale server errors don't linger.
**Test:** Add a test to `frontend/src/views/__tests__/EventCreateView.spec.ts` that:
1. Submits the empty form (triggers validation errors)
2. Types into the title field
3. Asserts that the title error is cleared but other errors remain
### Fix 2: Network errors must show a user-visible message (Bug — High)
**Problem:** When the backend is unreachable, the form submits silently — no error message, no feedback. The `serverError` element (line 77) exists but is never populated because `openapi-fetch` throws an unhandled exception on network errors instead of returning an `{ error }` object.
**Root cause:** `handleSubmit()` (line 150) has no `try-catch` around the `api.POST()` call (line 164). When `fetch` fails (network error), `openapi-fetch` throws, the promise rejects, and the function exits without setting `serverError` or resetting `submitting`.
**Fix:** Wrap the API call and response handling in a `try-catch`:
```typescript
try {
const { data, error } = await api.POST('/events', { body: { ... } })
submitting.value = false
if (error) {
// ... existing error handling ...
return
}
if (data) {
// ... existing success handling ...
}
} catch {
submitting.value = false
serverError.value = 'Could not reach the server. Please try again.'
}
```
**Test:** Add a test to `EventCreateView.spec.ts` that mocks the API to throw (simulating network failure) and asserts that `serverError` text appears in the DOM.
### Fix 3: Page title (Minor — Low)
**Problem:** `frontend/index.html` line 7 still has `<title>Vite App</title>`.
**Fix:** Change to `<title>fete</title>`. Also set `lang="en"` on the `<html>` tag (line 2 currently has `lang=""`).
**File:** `frontend/index.html`
### Fix 4: Favicon (Minor — Low)
**Problem:** The favicon is the Vite default. The project should either have its own favicon or remove the link entirely.
**Fix:** For now, remove the `<link rel="icon" href="/favicon.ico">` line and delete `frontend/public/favicon.ico` if it exists. A proper favicon can be added later as part of branding work.
**File:** `frontend/index.html`, `frontend/public/favicon.ico`
## Execution Order
1. Fix 3 + Fix 4 (trivial, `index.html` + favicon cleanup)
2. Fix 1 (reactive error clearing + test)
3. Fix 2 (try-catch + test)
4. Run all frontend tests: `cd frontend && npm run test:unit`
5. Verify visually with `browser-interactive-testing` skill:
- Start dev server, open `/create`
- Submit empty → errors appear
- Fill title → title error clears, others remain
- Fill all fields → all errors gone
- Submit with no backend → "Could not reach the server" message appears
## Constraints
- Follow existing code style and patterns in `EventCreateView.vue`
- Do not refactor unrelated code
- Do not add dependencies
- Tests must follow existing test patterns in `EventCreateView.spec.ts`
- TDD: write/update tests first, then fix

View File

@@ -0,0 +1,107 @@
---
date: 2026-03-04T21:15:50+00:00
git_commit: b8421274b47c6d1778b83c6b0acb70fd82891e71
branch: master
topic: "Date/Time Handling Best Practices for the fete Stack"
tags: [research, datetime, java, postgresql, openapi, typescript]
status: complete
---
# Research: Date/Time Handling Best Practices
## Research Question
What are the best practices for handling dates and times across the full fete stack (Java 25 / Spring Boot 3.5.x / PostgreSQL / OpenAPI 3.1 / Vue 3 / TypeScript)?
## Summary
The project has two distinct date/time concepts: **event date/time** (when something happens) and **expiry date** (after which data is deleted). These map to different types at every layer. The recommendations align Java types, PostgreSQL column types, OpenAPI formats, and TypeScript representations into a consistent stack-wide approach.
## Detailed Findings
### Type Mapping Across the Stack
| Concept | Java | PostgreSQL | OpenAPI | TypeScript | Example |
|---------|------|------------|---------|------------|---------|
| Event date/time | `OffsetDateTime` | `timestamptz` | `string`, `format: date-time` | `string` | `2026-03-15T20:00:00+01:00` |
| Expiry date | `LocalDate` | `date` | `string`, `format: date` | `string` | `2026-06-15` |
| Audit timestamps (createdAt, etc.) | `OffsetDateTime` | `timestamptz` | `string`, `format: date-time` | `string` | `2026-03-04T14:22:00Z` |
### Event Date/Time: `OffsetDateTime` + `timestamptz`
**Why `OffsetDateTime`, not `LocalDateTime`:**
- PostgreSQL best practice explicitly recommends `timestamptz` over `timestamp` — the PostgreSQL wiki says ["don't use `timestamp`"](https://wiki.postgresql.org/wiki/Don't_Do_This). `timestamptz` maps naturally to `OffsetDateTime`.
- Hibernate 6 (Spring Boot 3.5.x) has native `OffsetDateTime``timestamptz` support. `LocalDateTime` requires extra care to avoid silent timezone bugs at the JDBC driver level.
- An ISO 8601 string with offset (`2026-03-15T20:00:00+01:00`) is unambiguous in the API. A bare `LocalDateTime` string forces the client to guess the timezone.
- The OpenAPI `date-time` format and `openapi-generator` default to `OffsetDateTime` in Java — no custom type mappings needed.
**Why not `ZonedDateTime`:** Carries IANA zone IDs (e.g. `Europe/Berlin`) which add complexity without value for this use case. Worse JDBC support than `OffsetDateTime`.
**How PostgreSQL stores it:** `timestamptz` does **not** store the timezone. It converts input to UTC and stores UTC. On retrieval, it converts to the session's timezone setting. The offset is preserved in the Java `OffsetDateTime` via the JDBC driver.
**Practical flow:** The frontend sends the offset based on the organizer's browser locale. The server stores UTC. Display-side conversion happens in the frontend.
### Expiry Date: `LocalDate` + `date`
The expiry date is a calendar-date concept ("after which day should data be deleted"), not a point-in-time. A cleanup job runs periodically and deletes events where `expiryDate < today`. Sub-day precision adds no value and complicates the UX.
### Jackson Serialization (Spring Boot 3.5.x)
Spring Boot 3.x auto-configures `jackson-datatype-jsr310` (JavaTimeModule) and disables `WRITE_DATES_AS_TIMESTAMPS` by default:
- `OffsetDateTime` serializes to `"2026-03-15T20:00:00+01:00"` (ISO 8601 string)
- `LocalDate` serializes to `"2026-06-15"`
No additional configuration needed. For explicitness, can add to `application.properties`:
```properties
spring.jackson.serialization.write-dates-as-timestamps=false
```
### Hibernate 6 Configuration
With Hibernate 6, `OffsetDateTime` maps to `timestamptz` using the `NATIVE` timezone storage strategy by default on PostgreSQL. Can be made explicit:
```properties
spring.jpa.properties.hibernate.timezone.default_storage=NATIVE
```
This tells Hibernate to use the database's native `TIMESTAMP WITH TIME ZONE` type directly.
### OpenAPI Schema Definitions
```yaml
# Event date/time
eventDateTime:
type: string
format: date-time
example: "2026-03-15T20:00:00+01:00"
# Expiry date
expiryDate:
type: string
format: date
example: "2026-06-15"
```
**Code-generation mapping (defaults, no customization needed):**
| OpenAPI format | Java type (openapi-generator) | TypeScript type (openapi-typescript) |
|---------------|-------------------------------|--------------------------------------|
| `date-time` | `java.time.OffsetDateTime` | `string` |
| `date` | `java.time.LocalDate` | `string` |
### Frontend (TypeScript)
`openapi-typescript` generates `string` for both `format: date-time` and `format: date`. This is correct — JSON has no native date type, so dates travel as strings. Parsing to `Date` objects happens explicitly at the application boundary when needed (e.g. for display formatting).
## Sources
- [PostgreSQL Wiki: Don't Do This](https://wiki.postgresql.org/wiki/Don't_Do_This) — recommends `timestamptz` over `timestamp`
- [PostgreSQL Docs: Date/Time Types](https://www.postgresql.org/docs/current/datatype-datetime.html)
- [Thorben Janssen: Hibernate 6 OffsetDateTime and ZonedDateTime](https://thorben-janssen.com/hibernate-6-offsetdatetime-and-zoneddatetime/)
- [Baeldung: OffsetDateTime Serialization With Jackson](https://www.baeldung.com/java-jackson-offsetdatetime)
- [Baeldung: Map Date Types With OpenAPI Generator](https://www.baeldung.com/openapi-map-date-types)
- [Baeldung: ZonedDateTime vs OffsetDateTime](https://www.baeldung.com/java-zoneddatetime-offsetdatetime)
- [Reflectoring: Handling Timezones in Spring Boot](https://reflectoring.io/spring-timezones/)
- [openapi-typescript documentation](https://openapi-ts.dev/)

View File

@@ -0,0 +1,202 @@
---
date: 2026-03-04T21:15:50+00:00
git_commit: b8421274b47c6d1778b83c6b0acb70fd82891e71
branch: master
topic: "RFC 9457 Problem Details for HTTP API Error Responses"
tags: [research, error-handling, rfc9457, spring-boot, openapi]
status: complete
---
# Research: RFC 9457 Problem Details
## Research Question
How should the fete API structure error responses? What does RFC 9457 (Problem Details) specify, and how does it integrate with Spring Boot 3.5.x, OpenAPI 3.1, and openapi-fetch?
## Summary
RFC 9457 (successor to RFC 7807) defines a standard JSON format (`application/problem+json`) for machine-readable HTTP API errors. Spring Boot 3.x has first-class support via `ProblemDetail`, `ErrorResponseException`, and `ResponseEntityExceptionHandler`. The recommended approach is a single `@RestControllerAdvice` that handles all exceptions consistently — no `spring.mvc.problemdetails.enabled` property, no fallback to legacy error format.
## Detailed Findings
### RFC 9457 Format
Standard fields:
| Field | Type | Description |
|-------|------|-------------|
| `type` | URI | Identifies the problem type. Defaults to `about:blank`. |
| `title` | string | Short, human-readable summary. Should not change between occurrences. |
| `status` | int | HTTP status code. |
| `detail` | string | Human-readable explanation specific to this occurrence. |
| `instance` | URI | Identifies the specific occurrence (e.g. correlation ID). |
Extension members (additional JSON properties) are explicitly permitted. This is the mechanism for validation errors, error codes, etc.
**Key rule:** With `type: "about:blank"`, the `title` must match the HTTP status phrase exactly. Use a custom `type` URI when providing a custom `title`.
### Spring Boot 3.x Built-in Support
- **`ProblemDetail`** — container class for the five standard fields + a `properties` Map for extensions.
- **`ErrorResponseException`** — base class for custom exceptions that carry their own `ProblemDetail`.
- **`ResponseEntityExceptionHandler`** — `@ControllerAdvice` base class that handles all Spring MVC exceptions and renders them as `application/problem+json`.
- **`ProblemDetailJacksonMixin`** — automatically unwraps the `properties` Map as top-level JSON fields during serialization.
### Recommended Configuration
Use a single `@RestControllerAdvice` extending `ResponseEntityExceptionHandler`. Do **not** use the `spring.mvc.problemdetails.enabled` property.
```java
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
// All Spring MVC exceptions are handled automatically.
// Add @ExceptionHandler methods for domain exceptions here.
// Add a catch-all for Exception.class to prevent legacy error format.
}
```
Reasons to avoid the property-based approach:
1. No place to add custom `@ExceptionHandler` methods.
2. Having both the property AND a custom `ResponseEntityExceptionHandler` bean causes a conflict.
3. The property ignores `server.error.include-*` properties.
### Validation Errors (Field-Level)
Spring deliberately does **not** include field-level validation errors in `ProblemDetail` by default (security rationale). Override `handleMethodArgumentNotValid`:
```java
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
ProblemDetail problemDetail = ex.getBody();
problemDetail.setTitle("Validation Failed");
problemDetail.setType(URI.create("urn:problem-type:validation-error"));
List<Map<String, String>> fieldErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(fe -> Map.of(
"field", fe.getField(),
"message", fe.getDefaultMessage()
))
.toList();
problemDetail.setProperty("fieldErrors", fieldErrors);
return handleExceptionInternal(ex, problemDetail, headers, status, request);
}
```
Resulting response:
```json
{
"type": "urn:problem-type:validation-error",
"title": "Validation Failed",
"status": 400,
"detail": "Invalid request content.",
"instance": "/api/events",
"fieldErrors": [
{ "field": "title", "message": "must not be blank" },
{ "field": "expiryDate", "message": "must be a future date" }
]
}
```
### OpenAPI Schema Definition
```yaml
components:
schemas:
ProblemDetail:
type: object
properties:
type:
type: string
format: uri
default: "about:blank"
title:
type: string
status:
type: integer
detail:
type: string
instance:
type: string
format: uri
additionalProperties: true
ValidationProblemDetail:
allOf:
- $ref: '#/components/schemas/ProblemDetail'
- type: object
properties:
fieldErrors:
type: array
items:
type: object
properties:
field:
type: string
message:
type: string
required:
- field
- message
responses:
BadRequest:
description: Validation failed
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ValidationProblemDetail'
NotFound:
description: Resource not found
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetail'
```
Use media type `application/problem+json` in response definitions. Set `additionalProperties: true` on the base schema.
### Frontend Consumption (openapi-fetch)
openapi-fetch uses a discriminated union for responses:
```typescript
const { data, error } = await client.POST('/api/events', { body: eventData })
if (error) {
// `error` is typed from the OpenAPI error response schema
console.log(error.title) // "Validation Failed"
console.log(error.fieldErrors) // [{ field: "title", message: "..." }]
return
}
// `data` is the typed success response
```
The `error` object is already typed from the generated schema — no manual type assertions needed for defined error shapes.
### Known Pitfalls
| Pitfall | Description | Mitigation |
|---------|-------------|------------|
| **Inconsistent formats** | Exceptions escaping to Spring Boot's `BasicErrorController` return legacy format (`timestamp`, `error`, `path`), not Problem Details. | Add a catch-all `@ExceptionHandler(Exception.class)` in the `@RestControllerAdvice`. |
| **`server.error.include-*` ignored** | When Problem Details is active, these properties have no effect. | Control content via `ProblemDetail` directly. |
| **Validation errors hidden by default** | Spring returns only `"Invalid request content."` without field details. | Override `handleMethodArgumentNotValid` explicitly. |
| **Content negotiation** | `application/problem+json` is only returned when the client accepts it. `openapi-fetch` sends `Accept: application/json` which Spring considers compatible. | No action needed for SPA clients. |
| **`about:blank` semantics** | With `type: "about:blank"`, `title` must match the HTTP status phrase. Custom titles require a custom `type` URI. | Use `urn:problem-type:*` URIs for custom problem types. |
## Sources
- [RFC 9457 Full Text](https://www.rfc-editor.org/rfc/rfc9457.html)
- [Spring Framework Docs: Error Responses](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-rest-exceptions.html)
- [Swagger Blog: Problem Details RFC 9457](https://swagger.io/blog/problem-details-rfc9457-doing-api-errors-well/)
- [Baeldung: Returning Errors Using ProblemDetail](https://www.baeldung.com/spring-boot-return-errors-problemdetail)
- [SivaLabs: Spring Boot 3 Error Reporting](https://www.sivalabs.in/blog/spring-boot-3-error-reporting-using-problem-details/)
- [Spring Boot Issue #43850: Render global errors as Problem Details](https://github.com/spring-projects/spring-boot/issues/43850)

View File

@@ -0,0 +1,404 @@
# Research: Modern Sans-Serif Fonts for Mobile-First PWA
**Date:** 2026-03-04
**Context:** Selecting a primary typeface for fete, a privacy-focused PWA for event announcements and RSVPs. The font must be open-source with permissive licensing, modern geometric/neo-grotesque style, excellent mobile readability, and strong weight range.
---
## Executive Summary
Based on research of 9 candidate fonts, **6 meet all requirements** for self-hosting and redistribution under permissive licenses. Two do not qualify:
- **General Sans**: Proprietary (ITF Free Font License, non-commercial personal use only)
- **Satoshi**: License ambiguity; sources conflict between full OFL and ITF restrictions
The remaining **6 fonts are fully open-source** and suitable for the project:
| Font | License | Design | Weights | Status |
|------|---------|--------|---------|--------|
| Inter | OFL-1.1 | Neo-grotesque, humanist | 9 (ThinBlack) | ✅ Recommended |
| Plus Jakarta Sans | OFL-1.1 | Geometric, modern | 7 (ExtraLightExtraBold) | ✅ Recommended |
| Outfit | OFL-1.1 | Geometric | 9 (ThinBlack) | ✅ Recommended |
| Space Grotesk | OFL-1.1 | Neo-grotesque, distinctive | 5 (LightBold) | ✅ Recommended |
| Manrope | OFL-1.1 | Geometric, humanist | 7 (ExtraLightExtraBold) | ✅ Recommended |
| DM Sans | OFL-1.1 | Geometric, low-contrast | 9 (ThinBlack) | ✅ Recommended |
| Sora | OFL-1.1 | Geometric | 8 (ThinExtraBold) | ✅ Recommended |
---
## Detailed Candidate Analysis
### 1. Inter
**License:** SIL Open Font License 1.1 (OFL-1.1)
**Download Location:**
- **Official:** https://github.com/rsms/inter (releases page)
- **NPM:** `inter-ui` package
- **Homebrew:** `font-inter`
- **Official CDN:** https://rsms.me/inter/inter.css
**Design Character:** Neo-grotesque with humanist touches. High x-height for enhanced legibility on screens. Geometric letterforms with open apertures. Designed specifically for UI and on-screen use.
**Available Weights:** 9 weights from Thin (100) to Black (900), each with italic variant. Also available as a variable font with weight axis.
**Notable Apps/Products:**
- **UX/Design tools:** Figma, Notion, Pixar Presto
- **OS:** Elementary OS, GNOME
- **Web:** GitLab, ISO, Mozilla, NASA
- **Why:** Chosen by product teams valuing clarity and modern minimalism; default choice for UI designers
**Mobile Suitability:** Excellent. Specifically engineered for screen readability with high x-height and open apertures. Performs well at 1416px body text.
**Distinctive Strengths:**
- Purpose-built for digital interfaces
- Exceptional clarity in dense UI layouts
- Strong brand identity (recognizable across tech products)
- Extensive OpenType features
**Weakness:** Very widely used; less distinctive for a bold brand identity. Considered the "safe" choice.
---
### 2. Plus Jakarta Sans
**License:** SIL Open Font License 1.1 (OFL-1.1)
**Download Location:**
- **Official Repository:** https://github.com/tokotype/PlusJakartaSans
- **Source Files:** `sources/`, compiled fonts in `fonts/` directory
- **Designer Contact:** mail@tokotype.com (Gumpita Rahayu, Tokotype)
- **Latest Version:** 2.7.1 (May 2023)
- **Build Command:** `gftools builder sources/builder.yaml`
**Design Character:** Geometric sans-serif with modern, clean-cut forms. Inspired by Neuzeit Grotesk and Futura but with contemporary refinement. Slightly taller x-height for clear spacing between caps and lowercase. Open counters and balanced spacing for legibility across sizes. **Bold, distinctive look** with personality.
**Available Weights:** 7 weights from ExtraLight (200) to ExtraBold (800), with matching italics.
**Notable Apps/Products:**
- Original commission: Jakarta Provincial Government's "+Jakarta City of Collaboration" program (2020)
- Now widely used in: Branding projects, modern web design, UI design
- **Why:** Chosen for fresh, contemporary feel without generic blandness
**Mobile Suitability:** Excellent. Designed with mobile UI in mind. Clean letterforms render crisply on small screens.
**Distinctive Strengths:**
- **Stylistic sets:** Sharp, Straight, and Swirl variants add design flexibility
- Modern geometric with Indonesian design heritage (unique perspective)
- Excellent for branding (not generic like Inter)
- OpenType features for sophisticated typography
- Well-maintained, active development
**Weakness:** Less ubiquitous than Inter; smaller ecosystem of design tool integrations.
---
### 3. Outfit
**License:** SIL Open Font License 1.1 (OFL-1.1)
**Download Location:**
- **Official Repository:** https://github.com/Outfitio/Outfit-Fonts
- **Fonts Directory:** `/fonts` in repository
- **OFL Text:** `OFL.txt` in repository
- **Designer:** Rodrigo Fuenzalida (originally for Outfit.io)
- **Status:** Repository archived Feb 25, 2025 (read-only, downloads remain accessible)
**Design Character:** Geometric sans-serif with warm, friendly appearance. Generous x-height, balanced spacing, low contrast. Nine static weights plus variable font with weight axis.
**Available Weights:** 9 weights from Thin (100) to Black (900). No italics.
**Notable Apps/Products:**
- Originally created for Outfit.io platform
- Good readability for body text (≈16px) and strong headline presence
- Used in design tools (Figma integration)
**Mobile Suitability:** Good. Geometric forms and generous spacing work well on mobile, though low contrast may require careful pairing with sufficient color contrast.
**Distinctive Strengths:**
- Full weight range (ThinBlack)
- Variable font option for granular weight control
- Stylistic alternates and rare ligatures
- Accessible character set
**Weakness:** Archived repository; no active development. Low contrast design requires careful color/contrast pairing for accessibility.
---
### 4. Space Grotesk
**License:** SIL Open Font License 1.1 (OFL-1.1)
**Download Location:**
- **Official Repository:** https://github.com/floriankarsten/space-grotesk
- **Official Site:** https://fonts.floriankarsten.com/space-grotesk
- **Designer:** Florian Karsten
- **Variants:** Variable font with weight axis
**Design Character:** Neo-grotesque with distinctive personality. Proportional variant of Space Mono (Colophon Foundry, 2016). Retains Space Mono's idiosyncratic details while optimizing for improved readability. Bold, tech-forward aesthetic with monowidth heritage visible in character design.
**Available Weights:** 5 weights—Light (300), Regular (400), Medium (500), SemiBold (600), Bold (700). No italics.
**Notable Apps/Products:**
- Modern tech companies and startups seeking distinctive branding
- Popular in neo-brutalist web design
- Good for headlines and display use
**Mobile Suitability:** Good. Clean proportional forms with distinctive character. Works well for headlines; body text at 14px+ is readable.
**Distinctive Strengths:**
- **Bold, tech-forward personality** — immediately recognizable
- Heritage from Space Mono adds character without looking dated
- Excellent OpenType support (old-style figures, tabular figures, superscript, subscript, fractions, stylistic alternates)
- **Supports extended language coverage:** Latin, Vietnamese, Pinyin, Central/South-Eastern European
**Weakness:** Only 5 weights (lightest is 300, no Thin). Fewer weight options than Inter or DM Sans.
---
### 5. Manrope
**License:** SIL Open Font License 1.1 (OFL-1.1)
**Download Location:**
- **Official Repository:** https://github.com/sharanda/manrope
- **Designer:** Mikhail Sharanda (2018), converted to variable by Mirko Velimirovic (2019)
- **Alternative Sources:** Multiple community forks on GitHub, npm packages
- **NPM Package:** `@fontsource/manrope`, `@fontsource-variable/manrope`
**Design Character:** Modern geometric sans-serif blending geometric shapes with humanistic elements. Semi-condensed structure with clean, contemporary feel. Geometric digits, packed with OpenType features.
**Available Weights:** 7 weights from ExtraLight (200) to ExtraBold (800). Available as variable font.
**Notable Apps/Products:**
- Widely used in modern design systems
- Popular in product/SaaS design
- Good for both UI and branding
**Mobile Suitability:** Excellent. Clean geometric design with humanistic touches; balanced proportions work well on mobile.
**Distinctive Strengths:**
- Geometric + humanistic blend (best of both worlds)
- Well-maintained active project
- Variable font available
- Strong design community around the font
**Weakness:** None significant; solid all-around choice.
---
### 6. DM Sans
**License:** SIL Open Font License 1.1 (OFL-1.1)
**Download Location:**
- **Official Repository:** https://github.com/googlefonts/dm-fonts
- **Releases Page:** https://github.com/googlefonts/dm-fonts/releases
- **Google Fonts:** https://fonts.google.com/specimen/DM+Sans
- **Design:** Commissioned from Colophon Foundry; Creative Direction: MultiAdaptor & DeepMind
**Design Character:** Low-contrast geometric sans-serif optimized for text at smaller sizes. Part of the DM suite (DM Sans, DM Serif Text, DM Serif Display). Designed for clarity and efficiency in dense typography.
**Available Weights:** 9 weights from Thin (100) to Black (900), each with italic variant.
**Notable Apps/Products:**
- DeepMind products (by commission)
- Tech companies favoring geometric clarity
- Professional and commercial products requiring text legibility
**Mobile Suitability:** Excellent. Specifically optimized for small text sizes; low contrast minimizes visual noise on mobile screens.
**Distinctive Strengths:**
- **Optimized for small text** — superior at 1214px
- Full weight range (ThinBlack)
- Active Google Fonts maintenance
- Italic variants (unlike Outfit or Space Grotesk)
- Commissioned by reputable team (DeepMind)
**Weakness:** Low contrast may feel less bold on headlines without careful sizing/weight adjustment.
---
### 7. Sora
**License:** SIL Open Font License 1.1 (OFL-1.1)
**Download Location:**
- **Official Repository:** https://github.com/sora-xor/sora-font
- **GitHub Releases:** Direct TTF/OTF downloads available
- **NPM Packages:** `@fontsource/sora`, `@fontsource-variable/sora`
- **Original Purpose:** Custom typeface for SORA decentralized autonomous economy
**Design Character:** Geometric sans-serif with contemporary, clean aesthetic. Available as both static fonts and variable font. Designed as a branding solution for decentralized systems.
**Available Weights:** 8 weights from Thin (100) to ExtraBold (800), each with italic variant. Variable font available.
**Notable Apps/Products:**
- Sora (XOR) decentralized projects
- Crypto/blockchain projects using modern typography
- Web3 products seeking distinctive branding
**Mobile Suitability:** Good. Clean geometric forms render well on mobile; italics available for emphasis.
**Distinctive Strengths:**
- Full weight range with italics
- Variable font option
- Designed for digital-first branding
- GitHub-native distribution
**Weakness:** Less established than Inter or DM Sans in mainstream product design; smaller ecosystem.
---
## Rejected Candidates
### General Sans
**Status:** ❌ Does not meet licensing requirements
**License:** ITF Free Font License (proprietary, non-commercial personal use only)
**Why Rejected:** This is a **paid commercial font** distributed by the Indian Type Foundry (not open-source). The ITF Free Font License permits personal use only; commercial use requires a separate paid license. Does not meet the "open-source with permissive license" requirement.
**Designer:** Frode Helland (published by Indian Type Foundry)
---
### Satoshi
**Status:** ⚠️ License ambiguity — conflicting sources
**Documented License:**
- Some sources claim SIL Open Font License (OFL-1.1)
- Other sources indicate ITF Free Font License (personal use only) similar to General Sans
**Design:** Swiss-style modernist sans-serif (Light to Black, 510 weights)
**Download:** Fontshare (Indian Type Foundry's free font service)
**Why Not Recommended:** The license status is unclear. While Fontshare advertises "free for personal and commercial use," the font's origin (Indian Type Foundry) and conflicting license documentation create uncertainty. For a privacy-focused project with clear open-source requirements, Satoshi's ambiguous licensing creates unnecessary legal risk. Better alternatives with unambiguous OFL-1.1 licensing are available.
**Recommendation:** If clarity is needed, contact Fontshare/ITF directly. For now, exclude from consideration to reduce licensing complexity.
---
## Comparative Table: Qualified Fonts
| Metric | Inter | Plus Jakarta Sans | Outfit | Space Grotesk | Manrope | DM Sans | Sora |
|--------|-------|-------------------|--------|---------------|---------|---------|------|
| **License** | OFL-1.1 | OFL-1.1 | OFL-1.1 | OFL-1.1 | OFL-1.1 | OFL-1.1 | OFL-1.1 |
| **Weights** | 9 | 7 | 9 | 5 | 7 | 9 | 8 |
| **Italics** | ✅ Yes | ✅ Yes | ❌ No | ❌ No | ❌ No | ✅ Yes | ✅ Yes |
| **Variable Font** | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
| **Design** | Neo-grotesque | Geometric | Geometric | Neo-grotesque | Geo + Humanist | Geometric | Geometric |
| **Personality** | Generic/Safe | Bold/Fresh | Warm/Friendly | Tech-Forward | Balanced | Efficient/Clean | Contemporary |
| **Mobile Text** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| **Distinctiveness** | Low | High | Medium | High | High | Medium | Medium |
| **Ecosystem** | Very Large | Growing | Medium | Growing | Growing | Large | Small |
| **Active Dev** | ✅ Yes | ✅ Yes | ❌ Archived | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
---
## Recommendations
### For Bold App-Native Branding
**Primary Choice: Plus Jakarta Sans**
**Rationale:**
- Fully open-source (OFL-1.1) with unambiguous licensing
- Bold, modern geometric aesthetic suitable for app branding
- Stylistic sets (Sharp, Straight, Swirl) provide design flexibility
- Well-maintained by Tokotype with clear development history
- Strong presence in modern UI/web design
- Excellent mobile readability with thoughtful character spacing
- Indonesian design heritage adds unique perspective (not generic)
**Alternative: Space Grotesk**
If you prefer **even more distinctive character:**
- Neo-grotesque with tech-forward personality
- Smaller weight range (5 weights) but strong identity
- Popular in contemporary design circles
- Good for headlines; pair with a more neutral font for body text if needed
---
### For Safe, Professional UI
**Primary Choice: Inter or DM Sans**
**Inter if:**
- Maximum ecosystem and tool support desired
- Designing for broad recognition and trust
- Team already familiar with Inter (widespread in tech)
**DM Sans if:**
- Emphasis on small text legibility (optimized for 1214px)
- Prefer italic variants
- Want active maintenance from Google Fonts community
---
### For Balanced Approach
**Manrope**
- Geometric + humanistic blend (versatile)
- Excellent mobile performance
- Strong weight range (7 weights)
- Underrated choice; often overlooked for bolder options but delivers polish
---
## Implementation Notes for Self-Hosting
All recommended fonts can be self-hosted:
1. **Download:** Clone repository or download from releases page
2. **Generate Web Formats:** Use FontForge, FontTools, or online converters to generate WOFF2 (required for modern browsers)
3. **CSS:** Include via `@font-face` with local file paths
4. **License:** Include `LICENSE.txt` or `OFL.txt` in the distribution
Example self-hosted CSS:
```css
@font-face {
font-family: 'Plus Jakarta Sans';
src: url('/fonts/PlusJakartaSans-Regular.woff2') format('woff2');
font-weight: 400;
font-display: swap;
}
```
---
## Privacy Considerations
All selected fonts are self-hosted open-source projects with no telemetry, no external CDN dependencies, and no tracking. Fully compliant with the project's privacy-first principles.
---
## Conclusion
**Inter, Plus Jakarta Sans, and Space Grotesk** are the strongest candidates. The choice depends on brand positioning:
- **Generic + Safe → Inter**
- **Bold + Modern → Plus Jakarta Sans**
- **Tech-Forward + Distinctive → Space Grotesk**
All seven recommended fonts meet the strict licensing, openness, mobile readability, and weight-range requirements. Any of them are viable; the decision is primarily aesthetic.
---
## Sources
- [Inter Font GitHub Repository](https://github.com/rsms/inter)
- [Plus Jakarta Sans GitHub Repository](https://github.com/tokotype/PlusJakartaSans)
- [Outfit Fonts GitHub Repository](https://github.com/Outfitio/Outfit-Fonts)
- [Space Grotesk GitHub Repository](https://github.com/floriankarsten/space-grotesk)
- [Manrope GitHub Repository](https://github.com/sharanda/manrope)
- [DM Fonts GitHub Repository](https://github.com/googlefonts/dm-fonts)
- [Sora Font GitHub Repository](https://github.com/sora-xor/sora-font)
- [SIL Open Font License](https://openfontlicense.org/)
- [Google Fonts (reference)](https://fonts.google.com)
- [Fontshare (reference)](https://www.fontshare.com)

View File

@@ -0,0 +1,195 @@
---
date: 2026-03-04T21:04:31+00:00
git_commit: 747ed189456d2328147051bb8e7b3bbb43f47ea6
branch: master
topic: "US-1: Create an Event — Codebase Research"
tags: [research, codebase, us-1, event-creation, hexagonal-architecture]
status: complete
---
# Research: US-1 — Create an Event
## Research Question
What is the current state of the codebase relevant to implementing US-1 (Create an event)? What exists, what infrastructure is in place, and what needs to be built?
## Summary
US-1 is the first user story to be implemented. All setup tasks (T-1 through T-5) are complete. The codebase provides a hexagonal architecture skeleton with ArchUnit enforcement, an API-first workflow (OpenAPI spec → generated interfaces + TypeScript types), Liquibase migration tooling with an empty baseline, Testcontainers for integration tests, and a Vue 3 SPA frontend with typed API client. No domain models, use cases, persistence adapters, or controllers exist yet — the entire business logic layer is empty and waiting for US-1.
## US-1 Acceptance Criteria (from spec/userstories.md:21-40)
- [ ] Organizer fills in: title (required), description (optional), date/time (required), location (optional), expiry date (required)
- [ ] Server stores event, returns event token (UUID) + organizer token (UUID) in creation response
- [ ] Organizer redirected to event page after creation
- [ ] Organizer token stored in localStorage for organizer access on this device
- [ ] Event token, title, date stored in localStorage for local overview (US-7)
- [ ] No account, login, or personal data required
- [ ] Expiry date is mandatory, cannot be left blank
- [ ] Event not discoverable except via direct link
Dependencies: T-4 (complete).
## Detailed Findings
### 1. Backend Architecture Skeleton
The hexagonal architecture is fully scaffolded but empty. All business-logic packages contain only `package-info.java` documentation files:
| Package | Location | Status |
|---------|----------|--------|
| `de.fete.domain.model` | `backend/src/main/java/de/fete/domain/model/` | Empty — domain entities go here |
| `de.fete.domain.port.in` | `backend/src/main/java/de/fete/domain/port/in/` | Empty — use case interfaces go here |
| `de.fete.domain.port.out` | `backend/src/main/java/de/fete/domain/port/out/` | Empty — repository ports go here |
| `de.fete.application.service` | `backend/src/main/java/de/fete/application/service/` | Empty — use case implementations go here |
| `de.fete.adapter.in.web` | `backend/src/main/java/de/fete/adapter/in/web/` | Empty hand-written code — generated HealthApi interface exists in target/ |
| `de.fete.adapter.out.persistence` | `backend/src/main/java/de/fete/adapter/out/persistence/` | Empty — JPA entities + Spring Data repos go here |
Architecture constraints are enforced by ArchUnit (`HexagonalArchitectureTest.java:1-63`):
- Domain layer must not depend on adapters, application, config, or Spring
- Inbound and outbound ports must be interfaces
- Web adapter and persistence adapter must not depend on each other
- Onion architecture layers validated via `onionArchitecture()` rule
### 2. OpenAPI Spec — Current State and Extension Point
The OpenAPI spec at `backend/src/main/resources/openapi/api.yaml:1-38` currently defines only the health check endpoint. US-1 requires adding:
- **New path:** `POST /events` — create event endpoint
- **New schemas:** Request body (title, description, dateTime, location, expiryDate) and response (eventToken, organizerToken)
- **Error responses:** RFC 9457 Problem Details format (see `docs/agents/research/2026-03-04-rfc9457-problem-details.md`)
- **Server base:** Already set to `/api` (line 11), matching `WebConfig.java:19`
Generated code lands in `target/generated-sources/openapi/`:
- Interfaces: `de.fete.adapter.in.web.api` — controller must implement generated interface
- Models: `de.fete.adapter.in.web.model` — request/response DTOs
Frontend types are generated via `npm run generate:api` into `frontend/src/api/schema.d.ts`.
### 3. Web Configuration
`WebConfig.java:1-41` configures two things relevant to US-1:
1. **API prefix** (line 19): All `@RestController` beans are prefixed with `/api`. So the OpenAPI path `/events` becomes `/api/events` at runtime.
2. **SPA fallback** (lines 23-39): Any non-API, non-static-asset request falls through to `index.html`. This means Vue Router handles client-side routes like `/events/:token`.
### 4. Database Infrastructure
**Liquibase** is configured in `application.properties:8`:
```
spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml
```
The master changelog (`db.changelog-master.xml:1-10`) includes a single empty baseline (`000-baseline.xml:1-13`). US-1 needs a new migration file (e.g. `001-create-event-table.xml`) added to the master changelog.
**JPA** is configured with `ddl-auto=validate` (`application.properties:4`), meaning Hibernate validates entity mappings against the schema but never auto-creates tables. Liquibase is the sole schema management tool.
**PostgreSQL** connection is externalized via environment variables in `application-prod.properties:1-4`:
```
spring.datasource.url=${DATABASE_URL}
spring.datasource.username=${DATABASE_USERNAME}
spring.datasource.password=${DATABASE_PASSWORD}
```
### 5. Test Infrastructure
**Backend:**
- JUnit 5 + Spring Boot Test + MockMvc (see `FeteApplicationTest.java`)
- Testcontainers PostgreSQL (`TestcontainersConfig.java:1-17`) — real database for integration tests
- ArchUnit for architecture validation
- Checkstyle (Google Checks) and SpotBugs configured as build plugins
**Frontend:**
- Vitest with jsdom environment (`vitest.config.ts`)
- `@vue/test-utils` for component testing
- Single placeholder test exists (`HelloWorld.spec.ts`)
- Test pattern: `src/**/__tests__/*.spec.ts`
### 6. Frontend — Router, API Client, and localStorage
**Router** (`frontend/src/router/index.ts:1-23`): Currently has two placeholder routes (`/` and `/about`). US-1 needs:
- A route for the event creation form (e.g. `/create`)
- A route for the event page (e.g. `/events/:token`) — needed for post-creation redirect
**API client** (`frontend/src/api/client.ts:1-4`): Singleton `openapi-fetch` client typed against generated schema. Base URL `/api`. Ready for use — just needs the new endpoints in the generated types.
**localStorage:** No utilities exist yet. The `composables/` directory contains only `.gitkeep`. US-1 needs:
- A composable or utility for storing/retrieving organizer tokens per event
- Storage of event token, title, and date for the local overview (US-7)
**Components:** Only Vue/Vite scaffold defaults (HelloWorld, TheWelcome, icons). All need to be replaced with the actual event creation form.
### 7. Token Model
The spec defines three token types (`userstories.md:12-18`):
- **Event token**: Public UUID v4 in the event URL. Used by guests to access event pages.
- **Organizer token**: Secret UUID v4 stored in localStorage. Used to authenticate organizer actions.
- **Internal DB ID**: Never exposed — implementation detail only.
UUID v4 (random) is used for both tokens. KISS — no time-ordering (v7) needed for this use case. Generated server-side via `java.util.UUID.randomUUID()`.
### 8. Cross-Cutting Concerns
- **Date/time handling:** See `docs/agents/research/2026-03-04-datetime-best-practices.md` for the full stack-wide type mapping. Event dateTime → `OffsetDateTime` / `timestamptz`. Expiry date → `LocalDate` / `date`.
- **Error responses:** RFC 9457 Problem Details format. See `docs/agents/research/2026-03-04-rfc9457-problem-details.md`.
- **Honeypot fields:** Removed from scope — overengineered for this project.
## Code References
- `spec/userstories.md:21-40` — US-1 full specification
- `spec/implementation-phases.md:7` — US-1 is first in implementation order
- `backend/src/main/resources/openapi/api.yaml:1-38` — OpenAPI spec (extension point)
- `backend/src/main/java/de/fete/config/WebConfig.java:19` — API prefix `/api`
- `backend/src/main/java/de/fete/config/WebConfig.java:23-39` — SPA fallback routing
- `backend/src/main/resources/application.properties:4` — JPA ddl-auto=validate
- `backend/src/main/resources/application.properties:8` — Liquibase changelog config
- `backend/src/main/resources/db/changelog/db.changelog-master.xml:8` — Single include, extend here
- `backend/src/main/resources/db/changelog/000-baseline.xml:8-10` — Empty baseline changeset
- `backend/src/main/resources/application-prod.properties:1-4` — DB env vars
- `backend/src/test/java/de/fete/HexagonalArchitectureTest.java:1-63` — Architecture constraints
- `backend/src/test/java/de/fete/TestcontainersConfig.java:1-17` — Test DB container
- `frontend/src/router/index.ts:1-23` — Vue Router (extend with event routes)
- `frontend/src/api/client.ts:1-4` — API client (ready to use with generated types)
- `frontend/src/composables/.gitkeep` — Empty composables directory
## Architecture Documentation
### Hexagonal Layer Mapping for US-1
| Layer | Package | US-1 Artifacts |
|-------|---------|----------------|
| **Domain Model** | `de.fete.domain.model` | `Event` entity (title, description, dateTime, location, expiryDate, eventToken, organizerToken, createdAt) |
| **Inbound Port** | `de.fete.domain.port.in` | `CreateEventUseCase` interface |
| **Outbound Port** | `de.fete.domain.port.out` | `EventRepository` interface (save, findByToken) |
| **Application Service** | `de.fete.application.service` | `EventService` implementing `CreateEventUseCase` |
| **Web Adapter** | `de.fete.adapter.in.web` | Controller implementing generated `EventsApi` interface |
| **Persistence Adapter** | `de.fete.adapter.out.persistence` | JPA entity + Spring Data repository implementing `EventRepository` port |
| **Config** | `de.fete.config` | (existing WebConfig sufficient) |
### API-First Flow
```
api.yaml (edit) → mvn compile → HealthApi.java + EventsApi.java (generated)
HealthResponse.java + CreateEventRequest.java + CreateEventResponse.java (generated)
→ npm run generate:api → schema.d.ts (generated TypeScript types)
```
The hand-written controller in `adapter.in.web` implements the generated interface. The frontend uses the generated types via `openapi-fetch`.
### Database Schema Required
US-1 needs a single `events` table with columns mapping to the domain model. The migration file goes into `db/changelog/` and must be included in `db.changelog-master.xml`.
### Frontend Data Flow
```
EventCreateForm.vue → api.post('/events', body) → backend
← { eventToken, organizerToken }
→ localStorage.setItem (organizer token, event meta)
→ router.push(`/events/${eventToken}`)
```
## Resolved Questions
- **Expiry date validation at creation:** Yes — the server enforces that the expiry date is in the future at creation time, not only at edit time (US-5). Rationale: an event should never exist in an invalid state. If it's never edited, a past expiry date would be nonsensical. This extends US-1 AC7 beyond "mandatory" to "mandatory and in the future".
- **Event page after creation:** Option A — create a minimal stub route (`/events/:token`) with a placeholder view (e.g. "Event created" confirmation). The full event page is built in US-2. This keeps story boundaries clean while satisfying US-1 AC3 (redirect after creation).

1
frontend/.gitignore vendored
View File

@@ -37,3 +37,4 @@ __screenshots__/
# Vite
*.timestamp-*-*.mjs
.rodney/

View File

@@ -1,10 +1,9 @@
<!DOCTYPE html>
<html lang="">
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<title>fete</title>
</head>
<body>
<div id="app"></div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,85 +1,9 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import HelloWorld from './components/HelloWorld.vue'
</script>
<template>
<header>
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
<div class="wrapper">
<HelloWorld msg="You did it!" />
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
</div>
</header>
<RouterView />
<div class="app-container">
<RouterView />
</div>
</template>
<style scoped>
header {
line-height: 1.5;
max-height: 100vh;
}
.logo {
display: block;
margin: 0 auto 2rem;
}
nav {
width: 100%;
font-size: 12px;
text-align: center;
margin-top: 2rem;
}
nav a.router-link-exact-active {
color: var(--color-text);
}
nav a.router-link-exact-active:hover {
background-color: transparent;
}
nav a {
display: inline-block;
padding: 0 1rem;
border-left: 1px solid var(--color-border);
}
nav a:first-of-type {
border: 0;
}
@media (min-width: 1024px) {
header {
display: flex;
place-items: center;
padding-right: calc(var(--section-gap) / 2);
}
.logo {
margin: 0 2rem 0 0;
}
header .wrapper {
display: flex;
place-items: flex-start;
flex-wrap: wrap;
}
nav {
text-align: left;
margin-left: -1rem;
font-size: 1rem;
padding: 1rem 0;
margin-top: 1rem;
}
}
</style>
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>

View File

@@ -1,86 +0,0 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

Binary file not shown.

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

Before

Width:  |  Height:  |  Size: 276 B

View File

@@ -1,35 +1,181 @@
@import './base.css';
@font-face {
font-family: 'Sora';
src: url('@/assets/fonts/Sora-Variable.woff2') format('woff2');
font-weight: 100 800;
font-display: swap;
font-style: normal;
}
:root {
/* Colors: Electric Dusk */
--color-gradient-start: #f06292;
--color-gradient-mid: #ab47bc;
--color-gradient-end: #5c6bc0;
--color-accent: #ff7043;
--color-text: #1c1c1e;
--color-text-on-gradient: #ffffff;
--color-surface: #fff5f8;
--color-card: #ffffff;
/* Gradient */
--gradient-primary: linear-gradient(135deg, #f06292 0%, #ab47bc 50%, #5c6bc0 100%);
/* Spacing */
--spacing-xs: 0.5rem;
--spacing-sm: 0.75rem;
--spacing-md: 1rem;
--spacing-lg: 1.2rem;
--spacing-xl: 1.5rem;
--spacing-2xl: 2rem;
/* Borders */
--radius-card: 14px;
--radius-button: 14px;
/* Shadows */
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1);
--shadow-button: 0 2px 8px rgba(0, 0, 0, 0.15);
/* Layout */
--content-max-width: 480px;
--content-padding: 1.2rem;
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: 'Sora', system-ui, -apple-system, sans-serif;
font-size: 16px;
line-height: 1.5;
color: var(--color-text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
min-height: 100vh;
background: var(--gradient-primary);
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
a,
.green {
.app-container {
width: 100%;
max-width: var(--content-max-width);
min-height: 100vh;
padding: var(--content-padding);
display: flex;
flex-direction: column;
}
/* Card-style form fields */
.form-field {
background: var(--color-card);
border: none;
border-radius: var(--radius-card);
padding: var(--spacing-md) var(--spacing-md);
box-shadow: var(--shadow-card);
width: 100%;
font-family: inherit;
font-size: 0.95rem;
font-weight: 400;
color: var(--color-text);
outline: none;
transition: box-shadow 0.2s ease;
}
.form-field:focus {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
}
.form-field::placeholder {
color: #999;
font-weight: 400;
}
textarea.form-field {
resize: vertical;
min-height: 5rem;
}
/* Form group (label + input) */
.form-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.form-label {
font-size: 0.85rem;
font-weight: 700;
color: var(--color-text-on-gradient);
padding-left: 0.25rem;
}
/* Primary action button */
.btn-primary {
display: block;
width: 100%;
padding: var(--spacing-md) var(--spacing-lg);
background: var(--color-accent);
color: var(--color-text);
border: none;
border-radius: var(--radius-button);
font-family: inherit;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
box-shadow: var(--shadow-button);
transition: opacity 0.2s ease, transform 0.1s ease;
text-align: center;
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
.btn-primary:hover {
opacity: 0.92;
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
.btn-primary:active {
transform: scale(0.98);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Error message */
.field-error {
color: #fff;
font-size: 0.875rem;
font-weight: 600;
padding-left: 0.25rem;
}
/* Utility */
.text-center {
text-align: center;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

View File

@@ -1,41 +0,0 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>

View File

@@ -1,95 +0,0 @@
<script setup lang="ts">
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
>Vue - Official</a
>. If you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>

View File

@@ -1,87 +0,0 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>

View File

@@ -1,11 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import HelloWorld from '../HelloWorld.vue'
describe('HelloWorld', () => {
it('renders properly', () => {
const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
expect(wrapper.text()).toContain('Hello Vitest')
})
})

View File

@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View File

@@ -1,19 +0,0 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

View File

@@ -0,0 +1,119 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useEventStorage } from '../useEventStorage'
// jsdom provides a working localStorage in the window object
// but Node's --localstorage-file warning can be ignored
function clearStorage() {
try {
window.localStorage.setItem('fete:events', '[]')
} catch {
// Provide a minimal mock if localStorage is broken
const store: Record<string, string> = {}
Object.defineProperty(globalThis, 'localStorage', {
value: {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, val: string) => {
store[key] = val
},
removeItem: (key: string) => {
delete store[key]
},
},
writable: true,
configurable: true,
})
}
}
describe('useEventStorage', () => {
beforeEach(() => {
clearStorage()
})
it('returns empty array when no events stored', () => {
const { getStoredEvents } = useEventStorage()
expect(getStoredEvents()).toEqual([])
})
it('saves and retrieves a created event', () => {
const { saveCreatedEvent, getStoredEvents } = useEventStorage()
saveCreatedEvent({
eventToken: 'abc-123',
organizerToken: 'org-456',
title: 'Birthday',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
const events = getStoredEvents()
expect(events).toHaveLength(1)
expect(events[0]!.eventToken).toBe('abc-123')
expect(events[0]!.organizerToken).toBe('org-456')
expect(events[0]!.title).toBe('Birthday')
})
it('returns organizer token for known event', () => {
const { saveCreatedEvent, getOrganizerToken } = useEventStorage()
saveCreatedEvent({
eventToken: 'abc-123',
organizerToken: 'org-456',
title: 'Test',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
expect(getOrganizerToken('abc-123')).toBe('org-456')
})
it('returns undefined organizer token for unknown event', () => {
const { getOrganizerToken } = useEventStorage()
expect(getOrganizerToken('unknown')).toBeUndefined()
})
it('stores multiple events independently', () => {
const { saveCreatedEvent, getStoredEvents } = 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',
})
const events = getStoredEvents()
expect(events).toHaveLength(2)
expect(events.map((e) => e.eventToken)).toContain('event-1')
expect(events.map((e) => e.eventToken)).toContain('event-2')
})
it('overwrites event with same token', () => {
const { saveCreatedEvent, getStoredEvents } = useEventStorage()
saveCreatedEvent({
eventToken: 'abc-123',
title: 'Old Title',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
saveCreatedEvent({
eventToken: 'abc-123',
title: 'New Title',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
const events = getStoredEvents()
expect(events).toHaveLength(1)
expect(events[0]!.title).toBe('New Title')
})
})

View File

@@ -0,0 +1,41 @@
export interface StoredEvent {
eventToken: string
organizerToken?: string
title: string
dateTime: string
expiryDate: string
}
const STORAGE_KEY = 'fete:events'
function readEvents(): StoredEvent[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as StoredEvent[]) : []
} catch {
return []
}
}
function writeEvents(events: StoredEvent[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(events))
}
export function useEventStorage() {
function saveCreatedEvent(event: StoredEvent): void {
const events = readEvents().filter((e) => e.eventToken !== event.eventToken)
events.push(event)
writeEvents(events)
}
function getStoredEvents(): StoredEvent[] {
return readEvents()
}
function getOrganizerToken(eventToken: string): string | undefined {
const event = readEvents().find((e) => e.eventToken === eventToken)
return event?.organizerToken
}
return { saveCreatedEvent, getStoredEvents, getOrganizerToken }
}

View File

@@ -10,12 +10,14 @@ const router = createRouter({
component: HomeView,
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue'),
path: '/create',
name: 'create-event',
component: () => import('../views/EventCreateView.vue'),
},
{
path: '/events/:token',
name: 'event',
component: () => import('../views/EventStubView.vue'),
},
],
})

View File

@@ -1,15 +0,0 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>

View File

@@ -0,0 +1,258 @@
<template>
<main class="create">
<header class="create__header">
<RouterLink to="/" class="create__back" aria-label="Back to home">&larr;</RouterLink>
<h1 class="create__title">Create</h1>
</header>
<form class="create__form" novalidate @submit.prevent="handleSubmit">
<div class="form-group">
<label for="title" class="form-label">Title *</label>
<input
id="title"
v-model="form.title"
type="text"
class="form-field"
required
maxlength="200"
placeholder="What's the event?"
:aria-invalid="!!errors.title"
:aria-describedby="errors.title ? 'title-error' : undefined"
/>
<span v-if="errors.title" id="title-error" class="field-error" role="alert">{{ errors.title }}</span>
</div>
<div class="form-group">
<label for="description" class="form-label">Description</label>
<textarea
id="description"
v-model="form.description"
class="form-field"
maxlength="2000"
placeholder="Tell people more about it…"
:aria-invalid="!!errors.description"
:aria-describedby="errors.description ? 'description-error' : undefined"
/>
<span v-if="errors.description" id="description-error" class="field-error" role="alert">{{ errors.description }}</span>
</div>
<div class="form-group">
<label for="dateTime" class="form-label">Date &amp; Time *</label>
<input
id="dateTime"
v-model="form.dateTime"
type="datetime-local"
class="form-field"
required
:aria-invalid="!!errors.dateTime"
:aria-describedby="errors.dateTime ? 'dateTime-error' : undefined"
/>
<span v-if="errors.dateTime" id="dateTime-error" class="field-error" role="alert">{{ errors.dateTime }}</span>
</div>
<div class="form-group">
<label for="location" class="form-label">Location</label>
<input
id="location"
v-model="form.location"
type="text"
class="form-field"
maxlength="500"
placeholder="Where is it?"
:aria-invalid="!!errors.location"
:aria-describedby="errors.location ? 'location-error' : undefined"
/>
<span v-if="errors.location" id="location-error" class="field-error" role="alert">{{ errors.location }}</span>
</div>
<div class="form-group">
<label for="expiryDate" class="form-label">Expiry Date *</label>
<input
id="expiryDate"
v-model="form.expiryDate"
type="date"
class="form-field"
required
:min="tomorrow"
:aria-invalid="!!errors.expiryDate"
:aria-describedby="errors.expiryDate ? 'expiryDate-error' : undefined"
/>
<span v-if="errors.expiryDate" id="expiryDate-error" class="field-error" role="alert">{{ errors.expiryDate }}</span>
</div>
<button type="submit" class="btn-primary" :disabled="submitting">
{{ submitting ? 'Creating…' : 'Create Event' }}
</button>
<p v-if="serverError" class="field-error text-center" role="alert">{{ serverError }}</p>
</form>
</main>
</template>
<script setup lang="ts">
import { reactive, ref, computed, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { api } from '@/api/client'
import { useEventStorage } from '@/composables/useEventStorage'
const router = useRouter()
const { saveCreatedEvent } = useEventStorage()
const form = reactive({
title: '',
description: '',
dateTime: '',
location: '',
expiryDate: '',
})
const errors = reactive({
title: '',
description: '',
dateTime: '',
location: '',
expiryDate: '',
})
const submitting = ref(false)
const serverError = ref('')
const tomorrow = computed(() => {
const d = new Date()
d.setDate(d.getDate() + 1)
return d.toISOString().split('T')[0]
})
function clearErrors() {
errors.title = ''
errors.description = ''
errors.dateTime = ''
errors.location = ''
errors.expiryDate = ''
serverError.value = ''
}
// Clear individual field errors when the user types
watch(() => form.title, () => { errors.title = ''; serverError.value = '' })
watch(() => form.dateTime, () => { errors.dateTime = ''; serverError.value = '' })
watch(() => form.expiryDate, () => { errors.expiryDate = ''; serverError.value = '' })
watch(() => form.description, () => { serverError.value = '' })
watch(() => form.location, () => { serverError.value = '' })
function validate(): boolean {
clearErrors()
let valid = true
if (!form.title.trim()) {
errors.title = 'Title is required.'
valid = false
}
if (!form.dateTime) {
errors.dateTime = 'Date and time are required.'
valid = false
}
if (!form.expiryDate) {
errors.expiryDate = 'Expiry date is required.'
valid = false
} else if (form.expiryDate <= (new Date().toISOString().split('T')[0] ?? '')) {
errors.expiryDate = 'Expiry date must be in the future.'
valid = false
}
return valid
}
async function handleSubmit() {
if (!validate()) return
submitting.value = true
// Build ISO 8601 dateTime with local timezone offset
const localDate = new Date(form.dateTime)
const offsetMinutes = -localDate.getTimezoneOffset()
const sign = offsetMinutes >= 0 ? '+' : '-'
const absOffset = Math.abs(offsetMinutes)
const offsetHours = String(Math.floor(absOffset / 60)).padStart(2, '0')
const offsetMins = String(absOffset % 60).padStart(2, '0')
const dateTimeWithOffset = form.dateTime + ':00' + sign + offsetHours + ':' + offsetMins
try {
const { data, error } = await api.POST('/events', {
body: {
title: form.title.trim(),
description: form.description.trim() || undefined,
dateTime: dateTimeWithOffset,
location: form.location.trim() || undefined,
expiryDate: form.expiryDate,
},
})
submitting.value = false
if (error) {
if ('fieldErrors' in error && Array.isArray(error.fieldErrors)) {
for (const fe of error.fieldErrors) {
const field = fe.field as keyof typeof errors
if (field in errors) {
errors[field] = fe.message
}
}
} else {
serverError.value = error.detail || 'Something went wrong. Please try again.'
}
return
}
if (data) {
saveCreatedEvent({
eventToken: data.eventToken,
organizerToken: data.organizerToken,
title: data.title,
dateTime: data.dateTime,
expiryDate: data.expiryDate,
})
router.push({ name: 'event', params: { token: data.eventToken } })
}
} catch {
submitting.value = false
serverError.value = 'Could not reach the server. Please try again.'
}
}
</script>
<style scoped>
.create {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
padding-top: var(--spacing-lg);
}
.create__header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.create__back {
color: var(--color-text-on-gradient);
font-size: 1.5rem;
text-decoration: none;
line-height: 1;
}
.create__title {
font-size: 1.3rem;
font-weight: 700;
color: var(--color-text-on-gradient);
}
.create__form {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
</style>

View File

@@ -0,0 +1,132 @@
<template>
<main class="stub">
<header class="stub__header">
<RouterLink to="/" class="stub__back" aria-label="Back to home">&larr;</RouterLink>
<span class="stub__brand">fete</span>
</header>
<div class="stub__content">
<p class="stub__check">&check; Event created!</p>
<p class="stub__share-label">Share this link:</p>
<div class="stub__link-box">
<span class="stub__link">{{ eventUrl }}</span>
<button class="stub__copy" type="button" @click="copyLink" :aria-label="copyLabel">
{{ copyState === 'copied' ? 'Copied!' : copyState === 'failed' ? 'Failed' : 'Copy' }}
</button>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
const route = useRoute()
const copyState = ref<'idle' | 'copied' | 'failed'>('idle')
const eventUrl = computed(() => {
return window.location.origin + '/events/' + route.params.token
})
const copyLabel = computed(() => {
if (copyState.value === 'copied') return 'Link copied to clipboard'
if (copyState.value === 'failed') return 'Copy failed — select the link to copy manually'
return 'Copy event link to clipboard'
})
async function copyLink() {
try {
await navigator.clipboard.writeText(eventUrl.value)
copyState.value = 'copied'
setTimeout(() => {
copyState.value = 'idle'
}, 2000)
} catch {
copyState.value = 'failed'
setTimeout(() => {
copyState.value = 'idle'
}, 3000)
}
}
</script>
<style scoped>
.stub {
display: flex;
flex-direction: column;
gap: var(--spacing-2xl);
padding-top: var(--spacing-lg);
}
.stub__header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.stub__back {
color: var(--color-text-on-gradient);
font-size: 1.5rem;
text-decoration: none;
line-height: 1;
}
.stub__brand {
font-size: 1.3rem;
font-weight: 700;
color: var(--color-text-on-gradient);
}
.stub__content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-lg);
text-align: center;
}
.stub__check {
font-size: 1.4rem;
font-weight: 700;
color: var(--color-text-on-gradient);
}
.stub__share-label {
font-size: 0.95rem;
color: var(--color-text-on-gradient);
opacity: 0.9;
}
.stub__link-box {
background: var(--color-card);
border-radius: var(--radius-card);
padding: var(--spacing-md);
box-shadow: var(--shadow-card);
display: flex;
align-items: center;
gap: var(--spacing-sm);
width: 100%;
word-break: break-all;
}
.stub__link {
flex: 1;
font-size: 0.85rem;
color: var(--color-text);
}
.stub__copy {
background: var(--color-accent);
color: var(--color-text);
border: none;
border-radius: var(--radius-button);
padding: 0.4rem 0.8rem;
font-family: inherit;
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
</style>

View File

@@ -1,9 +1,41 @@
<script setup lang="ts">
import TheWelcome from '../components/TheWelcome.vue'
</script>
<template>
<main>
<TheWelcome />
<main class="home">
<h1 class="home__title">fete</h1>
<p class="home__subtitle">No events yet.<br />Create your first one!</p>
<RouterLink to="/create" class="btn-primary home__cta">+ Create Event</RouterLink>
</main>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>
<style scoped>
.home {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-lg);
text-align: center;
}
.home__title {
font-size: 2rem;
font-weight: 800;
color: var(--color-text-on-gradient);
}
.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>

View File

@@ -0,0 +1,255 @@
import { describe, it, expect, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import EventCreateView from '../EventCreateView.vue'
import { api } from '@/api/client'
vi.mock('@/api/client', () => ({
api: {
POST: vi.fn(),
},
}))
vi.mock('@/composables/useEventStorage', () => ({
useEventStorage: vi.fn(() => ({
saveCreatedEvent: vi.fn(),
getStoredEvents: vi.fn(() => []),
getOrganizerToken: vi.fn(),
})),
}))
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div />' } },
{ path: '/create', name: 'create-event', component: EventCreateView },
{ path: '/events/:token', name: 'event', component: { template: '<div />' } },
],
})
}
describe('EventCreateView', () => {
it('renders all form fields', async () => {
const router = createTestRouter()
await router.push('/create')
await router.isReady()
const wrapper = mount(EventCreateView, {
global: { plugins: [router] },
})
expect(wrapper.find('#title').exists()).toBe(true)
expect(wrapper.find('#description').exists()).toBe(true)
expect(wrapper.find('#dateTime').exists()).toBe(true)
expect(wrapper.find('#location').exists()).toBe(true)
expect(wrapper.find('#expiryDate').exists()).toBe(true)
})
it('has required attribute on required fields', async () => {
const router = createTestRouter()
await router.push('/create')
await router.isReady()
const wrapper = mount(EventCreateView, {
global: { plugins: [router] },
})
expect(wrapper.find('#title').attributes('required')).toBeDefined()
expect(wrapper.find('#dateTime').attributes('required')).toBeDefined()
expect(wrapper.find('#expiryDate').attributes('required')).toBeDefined()
})
it('does not have required attribute on optional fields', async () => {
const router = createTestRouter()
await router.push('/create')
await router.isReady()
const wrapper = mount(EventCreateView, {
global: { plugins: [router] },
})
expect(wrapper.find('#description').attributes('required')).toBeUndefined()
expect(wrapper.find('#location').attributes('required')).toBeUndefined()
})
it('has a submit button', async () => {
const router = createTestRouter()
await router.push('/create')
await router.isReady()
const wrapper = mount(EventCreateView, {
global: { plugins: [router] },
})
const button = wrapper.find('button[type="submit"]')
expect(button.exists()).toBe(true)
expect(button.text()).toBe('Create Event')
})
it('shows server error when network request fails', async () => {
vi.mocked(api.POST).mockRejectedValueOnce(new TypeError('Failed to fetch'))
const router = createTestRouter()
await router.push('/create')
await router.isReady()
const wrapper = mount(EventCreateView, {
global: { plugins: [router] },
})
// Fill required fields
await wrapper.find('#title').setValue('My Event')
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
await wrapper.find('#expiryDate').setValue('2026-12-24')
await wrapper.find('form').trigger('submit')
await flushPromises()
const alerts = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0)
expect(alerts).toContain('Could not reach the server. Please try again.')
// Submit button should not remain disabled
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeUndefined()
})
it('clears field error when user types into that field', async () => {
const router = createTestRouter()
await router.push('/create')
await router.isReady()
const wrapper = mount(EventCreateView, {
global: { plugins: [router] },
})
// Submit empty form to trigger validation errors
await wrapper.find('form').trigger('submit')
const errorsBefore = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0)
expect(errorsBefore.length).toBeGreaterThanOrEqual(3)
// Type into title field
await wrapper.find('#title').setValue('My Event')
// Title error should be cleared (span removed from DOM), but other errors should remain
const titleError = wrapper.find('#title').element.closest('.form-group')!.querySelector('[role="alert"]')
expect(titleError).toBeNull()
const dateTimeError = wrapper.find('#dateTime').element.closest('.form-group')!.querySelector('[role="alert"]')!
expect(dateTimeError.textContent).not.toBe('')
const expiryError = wrapper.find('#expiryDate').element.closest('.form-group')!.querySelector('[role="alert"]')!
expect(expiryError.textContent).not.toBe('')
})
it('shows validation errors when submitting empty form', async () => {
const router = createTestRouter()
await router.push('/create')
await router.isReady()
const wrapper = mount(EventCreateView, {
global: { plugins: [router] },
})
await wrapper.find('form').trigger('submit')
const errorElements = wrapper.findAll('[role="alert"]')
const errorTexts = errorElements.map((el) => el.text()).filter((t) => t.length > 0)
expect(errorTexts.length).toBeGreaterThanOrEqual(3)
})
it('submits successfully, saves to storage, and navigates to event page', async () => {
const mockSave = vi.fn()
vi.mocked(vi.importActual<typeof import('@/composables/useEventStorage')>)
const { useEventStorage } = await import('@/composables/useEventStorage')
vi.mocked(useEventStorage).mockReturnValue({
saveCreatedEvent: mockSave,
getStoredEvents: vi.fn(() => []),
getOrganizerToken: vi.fn(),
})
vi.mocked(api.POST).mockResolvedValueOnce({
data: {
eventToken: 'abc-123',
organizerToken: 'org-456',
title: 'Birthday Party',
dateTime: '2026-12-25T18:00:00+01:00',
expiryDate: '2026-12-24',
},
error: undefined,
response: new Response(),
})
const router = createTestRouter()
const pushSpy = vi.spyOn(router, 'push')
await router.push('/create')
await router.isReady()
const wrapper = mount(EventCreateView, {
global: { plugins: [router] },
})
await wrapper.find('#title').setValue('Birthday Party')
await wrapper.find('#description').setValue('Come celebrate!')
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
await wrapper.find('#location').setValue('Berlin')
await wrapper.find('#expiryDate').setValue('2026-12-24')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events', {
body: expect.objectContaining({
title: 'Birthday Party',
description: 'Come celebrate!',
location: 'Berlin',
expiryDate: '2026-12-24',
}),
})
expect(mockSave).toHaveBeenCalledWith({
eventToken: 'abc-123',
organizerToken: 'org-456',
title: 'Birthday Party',
dateTime: '2026-12-25T18:00:00+01:00',
expiryDate: '2026-12-24',
})
expect(pushSpy).toHaveBeenCalledWith({
name: 'event',
params: { token: 'abc-123' },
})
})
it('displays server-side field errors on the correct fields', async () => {
vi.mocked(api.POST).mockResolvedValueOnce({
data: undefined,
error: {
fieldErrors: [{ field: 'title', message: 'Title already taken' }],
},
response: new Response(),
} as ReturnType<typeof api.POST> extends Promise<infer R> ? R : never)
const router = createTestRouter()
await router.push('/create')
await router.isReady()
const wrapper = mount(EventCreateView, {
global: { plugins: [router] },
})
await wrapper.find('#title').setValue('Duplicate Event')
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
await wrapper.find('#expiryDate').setValue('2026-12-24')
await wrapper.find('form').trigger('submit')
await flushPromises()
const titleError = wrapper.find('#title-error')
expect(titleError.exists()).toBe(true)
expect(titleError.text()).toBe('Title already taken')
// Other field errors should not be present
expect(wrapper.find('#dateTime-error').exists()).toBe(false)
expect(wrapper.find('#expiryDate-error').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import EventStubView from '../EventStubView.vue'
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div />' } },
{ path: '/events/:token', name: 'event', component: EventStubView },
],
})
}
async function mountWithToken(token = 'test-token-123') {
const router = createTestRouter()
await router.push(`/events/${token}`)
await router.isReady()
return mount(EventStubView, {
global: { plugins: [router] },
})
}
describe('EventStubView', () => {
it('renders the event URL based on route param', async () => {
const wrapper = await mountWithToken('abc-def')
const linkText = wrapper.find('.stub__link').text()
expect(linkText).toContain('/events/abc-def')
})
it('shows the correct share URL with origin', async () => {
const wrapper = await mountWithToken('my-event-token')
const linkText = wrapper.find('.stub__link').text()
expect(linkText).toBe(`${window.location.origin}/events/my-event-token`)
})
it('has a copy button', async () => {
const wrapper = await mountWithToken()
const copyBtn = wrapper.find('.stub__copy')
expect(copyBtn.exists()).toBe(true)
expect(copyBtn.text()).toBe('Copy')
})
it('copies link to clipboard and shows confirmation', async () => {
const writeTextMock = vi.fn().mockResolvedValue(undefined)
Object.assign(navigator, {
clipboard: { writeText: writeTextMock },
})
const wrapper = await mountWithToken('copy-test')
await wrapper.find('.stub__copy').trigger('click')
expect(writeTextMock).toHaveBeenCalledWith(
`${window.location.origin}/events/copy-test`,
)
expect(wrapper.find('.stub__copy').text()).toBe('Copied!')
})
it('shows failure message when clipboard is unavailable', async () => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockRejectedValue(new Error('Not allowed')) },
})
const wrapper = await mountWithToken('fail-test')
await wrapper.find('.stub__copy').trigger('click')
expect(wrapper.find('.stub__copy').text()).toBe('Failed')
expect(wrapper.find('.stub__copy').attributes('aria-label')).toBe(
'Copy failed — select the link to copy manually',
)
})
it('has a back link to home', async () => {
const wrapper = await mountWithToken()
const backLink = wrapper.find('.stub__back')
expect(backLink.exists()).toBe(true)
expect(backLink.attributes('aria-label')).toBe('Back to home')
expect(backLink.attributes('href')).toBe('/')
})
})

View File

@@ -15,4 +15,12 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})

85
spec/design-system.md Normal file
View File

@@ -0,0 +1,85 @@
# Design System
This document defines the visual design language for fete. All frontend implementation must follow these specifications.
## Principles
- **Mobile-first / App-native feel** — not a classic website. Think installed app, not browser page.
- **Desktop:** centered narrow column (max ~480px), gradient background fills the rest.
- **Generous whitespace** — elements breathe, nothing cramped.
- **WCAG AA contrast** as baseline for all color choices.
- **Accessibility is a baseline requirement** — not an afterthought (per project statutes).
## Color Palette: Electric Dusk
Chosen for best balance of style, broad appeal, and accessibility.
| Role | Hex | Description |
|--------------------|-----------|--------------------|
| Gradient Start | `#F06292` | Pink |
| Gradient Mid | `#AB47BC` | Purple |
| Gradient End | `#5C6BC0` | Indigo blue |
| Accent (CTAs) | `#FF7043` | Deep orange |
| Text (light mode) | `#1C1C1E` | Near black |
| Text (dark mode) | `#FFFFFF` | White |
| Surface (light) | `#FFF5F8` | Pinkish white |
| Surface (dark) | `#1B1730` | Deep indigo-black |
| Card (light) | `#FFFFFF` | White |
| Card (dark) | `#2A2545` | Muted indigo |
### Primary Gradient
```css
background: linear-gradient(135deg, #F06292 0%, #AB47BC 50%, #5C6BC0 100%);
```
### Usage Rules
- Gradient for hero/splash areas and page backgrounds — not as direct text background for body copy.
- Cards and content areas use solid surface colors with high-contrast text.
- Accent color (`#FF7043`) for primary action buttons with dark text (`#1C1C1E`).
- White text on gradient mid/end passes WCAG AA (4.82:1 and 4.86:1).
- White text on gradient start passes AA-large (3.06:1) — use for headings 18px+ only.
## Typography: Sora
Contemporary geometric sans-serif with slightly rounded terminals. Modern and friendly without being childish.
- **Font:** Sora
- **License:** SIL Open Font License 1.1 (OFL)
- **Source:** https://github.com/sora-xor/sora-font
- **Format:** Self-hosted WOFF2. No external CDN. No Google Fonts.
- **Weights:** 400 (Regular), 500 (Medium), 600 (SemiBold), 700 (Bold), 800 (ExtraBold)
### Weight Usage
| Context | Weight | Size guideline |
|------------------|--------|-----------------|
| Body text | 400 | 0.851rem |
| Labels | 600700| 0.80.9rem |
| Headlines | 700800| 1.21.6rem |
| Buttons | 700800| 1rem |
| Small/meta text | 400500| 0.750.85rem |
## Component Patterns
### Card-Style Form Fields
- Rounded corners (`border-radius: 14px`)
- Generous padding (`0.9rem 1rem`)
- White/card-colored background on gradient pages
- Subtle shadow (`box-shadow: 0 2px 8px rgba(0,0,0,0.1)`)
- Bold label (font-weight 700), regular-weight input text
### Buttons
- Rounded corners matching card fields (`border-radius: 14px`)
- Accent color background with dark text
- Bold/ExtraBold weight (700800)
- Subtle shadow for depth
### Layout
- Mobile: full-width content with horizontal padding (~1.2rem)
- Desktop: centered column, max-width ~480px, gradient background fills viewport
- Vertical spacing between elements: ~0.75rem (compact), ~1.2rem (sections)

View File

@@ -4,9 +4,9 @@
## Status
- Total stories: 20
- Total stories: 21
- Complete: 0
- Remaining: 20
- Remaining: 21
## Token Model
@@ -467,3 +467,20 @@ The following terms are used consistently across all stories:
**Notes:** The overseer identified that using the expiry date as a deletion mechanism (setting it to today or a past date in US-5) was unintuitive and conflated two different actions. US-5 now enforces that the expiry date can only be set to a future date. If the organizer wants the event gone immediately, they use this explicit deletion feature. Unlike cancellation (US-18), which keeps the event visible with a cancellation notice until the expiry date, deletion removes the event entirely and immediately. This is the organizer's "nuclear option" — useful when the event was created by mistake, contains wrong information, or is no longer needed at all. The deletion behavior is identical to what US-12 does automatically after expiry, but triggered manually and immediately by the organizer.
---
### US-20: 404 page
**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.
**Acceptance Criteria:**
- [ ] Unknown routes show a "Page not found" message
- [ ] The page includes a link back to the home page
- [ ] The page follows the design system
**Dependencies:** None
**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. This is a small UX story but important for polish. Note: This story has no structural dependencies but requires the frontend scaffold from T-4 (which includes T-1) to be practically implementable.