From 80d79c35969c644735de631e67f34664246e4c94 Mon Sep 17 00:00:00 2001 From: nitrix Date: Fri, 6 Mar 2026 22:33:04 +0100 Subject: [PATCH 1/7] Add design artifacts for view event feature (007) Spec, research, data model, API contract, implementation plan, and task breakdown for the public event detail page. Co-Authored-By: Claude Opus 4.6 --- .specify/memory/constitution.md | 6 +- .specify/memory/ideen.md | 1 + CLAUDE.md | 7 + specs/007-view-event/contracts/get-event.yaml | 94 ++++++++ specs/007-view-event/data-model.md | 56 +++++ specs/007-view-event/plan.md | 89 +++++++ specs/007-view-event/quickstart.md | 39 +++ specs/007-view-event/research.md | 100 ++++++++ specs/007-view-event/spec.md | 32 ++- specs/007-view-event/tasks.md | 225 ++++++++++++++++++ 10 files changed, 638 insertions(+), 11 deletions(-) create mode 100644 specs/007-view-event/contracts/get-event.yaml create mode 100644 specs/007-view-event/data-model.md create mode 100644 specs/007-view-event/plan.md create mode 100644 specs/007-view-event/quickstart.md create mode 100644 specs/007-view-event/research.md create mode 100644 specs/007-view-event/tasks.md diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 43cb8d0..cd8cf67 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -107,8 +107,10 @@ Accessibility is a baseline requirement, not an afterthought. rationale. Never rewrite or delete the original decision. - The visual design system in `.specify/memory/design-system.md` is authoritative. All frontend implementation MUST follow it. -- Research reports go to `docs/agents/research/`, implementation plans to - `docs/agents/plan/`. +- Feature specs, research, and plans live in `specs/NNN-feature-name/` + (spec-kit format). Cross-cutting research goes to + `.specify/memory/research/`, cross-cutting plans to + `.specify/memory/plans/`. - Conversation and brainstorming in German; code, comments, commits, and documentation in English. - Documentation lives in the README. No wiki, no elaborate docs site. diff --git a/.specify/memory/ideen.md b/.specify/memory/ideen.md index 809e0f9..15217e4 100644 --- a/.specify/memory/ideen.md +++ b/.specify/memory/ideen.md @@ -33,6 +33,7 @@ Person erstellt via App eine Veranstaltung und schickt seine Freunden irgendwie * Updaten der Veranstaltung * Einsicht angemeldete Gäste, kann bei Bedarf Einträge entfernen * Featureideen: + * Link-Previews (OpenGraph Meta-Tags): Generische OG-Tags mit App-Branding (z.B. "fete — Du wurdest eingeladen") damit geteilte Links in WhatsApp/Signal/Telegram hübsch aussehen. Keine Event-Daten an Crawler aus Privacy-Gründen. → Eigene User Story. * Kalender-Integration: .ics-Download + optional webcal:// für Live-Updates bei Änderungen * Änderungen zum ursprünglichen Inhalt (z.b. geändertes datum/ort) werden iwi hervorgehoben * Veranstalter kann Updatenachrichten im Event posten, pro Device wird via LocalStorage gemerkt was man schon gesehen hat (Badge/Hervorhebung für neue Updates) diff --git a/CLAUDE.md b/CLAUDE.md index 1737c2a..ffc6b4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,3 +49,10 @@ The following skills are available and should be used for their respective purpo - The loop runner is `ralph.sh`. Each run lives in its own directory under `.ralph/`. - Run directories contain: `instructions.md` (prompt), `chief-wiggum.md` (directives), `answers.md` (human answers), `questions.md` (Ralph's questions), `progress.txt` (iteration log), `meta.md` (metadata), `run.log` (execution log). - Project specifications (user stories, setup tasks, personas, etc.) live in `specs/` (feature dirs) and `.specify/memory/` (cross-cutting docs). + +## Active Technologies +- Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript (007-view-event) +- PostgreSQL (JPA via Spring Data, Liquibase migrations) (007-view-event) + +## Recent Changes +- 007-view-event: Added Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript diff --git a/specs/007-view-event/contracts/get-event.yaml b/specs/007-view-event/contracts/get-event.yaml new file mode 100644 index 0000000..7b1338c --- /dev/null +++ b/specs/007-view-event/contracts/get-event.yaml @@ -0,0 +1,94 @@ +# OpenAPI contract addition for GET /events/{token} +# To be merged into backend/src/main/resources/openapi/api.yaml + +paths: + /events/{token}: + get: + operationId: getEvent + summary: Get public event details by token + tags: + - events + parameters: + - name: token + in: path + required: true + schema: + type: string + format: uuid + description: Public event token + responses: + "200": + description: Event found + content: + application/json: + schema: + $ref: "#/components/schemas/GetEventResponse" + "404": + description: Event not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + +components: + schemas: + GetEventResponse: + type: object + required: + - eventToken + - title + - dateTime + - timezone + - attendeeCount + - expired + properties: + eventToken: + type: string + format: uuid + description: Public event token + example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + title: + type: string + description: Event title + example: "Summer BBQ" + description: + type: string + description: Event description (absent if not set) + example: "Bring your own drinks!" + dateTime: + type: string + format: date-time + description: Event date/time with organizer's UTC offset + example: "2026-03-15T20:00:00+01:00" + timezone: + type: string + description: IANA timezone name of the organizer + example: "Europe/Berlin" + location: + type: string + description: Event location (absent if not set) + example: "Central Park, NYC" + attendeeCount: + type: integer + minimum: 0 + description: Number of confirmed attendees (attending=true) + example: 12 + expired: + type: boolean + description: Whether the event's expiry date has passed + example: false + + # Modification to existing CreateEventRequest — add timezone field + # CreateEventRequest (additions): + # timezone: + # type: string + # description: IANA timezone of the organizer + # example: "Europe/Berlin" + # (make required) + + # Modification to existing CreateEventResponse — add timezone field + # CreateEventResponse (additions): + # timezone: + # type: string + # description: IANA timezone of the organizer + # example: "Europe/Berlin" diff --git a/specs/007-view-event/data-model.md b/specs/007-view-event/data-model.md new file mode 100644 index 0000000..d213490 --- /dev/null +++ b/specs/007-view-event/data-model.md @@ -0,0 +1,56 @@ +# Data Model: View Event Landing Page (007) + +**Date**: 2026-03-06 + +## Entities + +### Event (modified — adds `timezone` field) + +| Field | Type | Required | Constraints | Notes | +|-----------------|------------------|----------|--------------------------|----------------------------------| +| id | Long | yes | BIGSERIAL, PK | Internal only, never exposed | +| eventToken | UUID | yes | UNIQUE, NOT NULL | Public identifier in URLs | +| organizerToken | UUID | yes | UNIQUE, NOT NULL | Secret, never in public API | +| title | String | yes | 1–200 chars | | +| description | String | no | max 2000 chars | | +| dateTime | OffsetDateTime | yes | | Organizer's original offset | +| timezone | String | yes | IANA zone ID, max 64 | **NEW** — e.g. "Europe/Berlin" | +| location | String | no | max 500 chars | | +| expiryDate | LocalDate | yes | Must be future at create | Auto-deletion trigger | +| createdAt | OffsetDateTime | yes | Server-generated | | + +**Validation rules**: +- `timezone` must be a valid IANA zone ID (`ZoneId.getAvailableZoneIds()`). +- `expiryDate` must be in the future at creation time (existing rule). + +**State transitions**: +- Active → Expired: when `expiryDate < today` (computed, not stored). +- Active → Cancelled: future (US-18), adds `cancelledAt` + `cancellationMessage`. + +### RSVP (future — not created in this feature) + +Documented here for context only. Created when the RSVP feature (US-8+) is implemented. + +| Field | Type | Required | Constraints | +|------------|---------|----------|------------------------------| +| id | Long | yes | BIGSERIAL, PK | +| eventId | Long | yes | FK → events.id | +| guestName | String | yes | 1–100 chars | +| attending | Boolean | yes | true = attending | +| createdAt | OffsetDateTime | yes | Server-generated | + +## Relationships + +``` +Event 1 ←── * RSVP (future) +``` + +## Type Mapping (full stack) + +| Concept | Java | PostgreSQL | OpenAPI | TypeScript | +|--------------|-------------------|---------------|---------------------|------------| +| Event time | `OffsetDateTime` | `timestamptz` | `string` `date-time`| `string` | +| Timezone | `String` | `varchar(64)` | `string` | `string` | +| Expiry date | `LocalDate` | `date` | `string` `date` | `string` | +| Token | `UUID` | `uuid` | `string` `uuid` | `string` | +| Count | `int` | `integer` | `integer` | `number` | diff --git a/specs/007-view-event/plan.md b/specs/007-view-event/plan.md new file mode 100644 index 0000000..7d8d4ac --- /dev/null +++ b/specs/007-view-event/plan.md @@ -0,0 +1,89 @@ +# Implementation Plan: View Event Landing Page + +**Branch**: `007-view-event` | **Date**: 2026-03-06 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/007-view-event/spec.md` + +## Summary + +Add a public event detail page at `/events/:token` that displays event information (title, date/time with IANA timezone, description, location, attendee count) without requiring authentication. The page handles four states: loaded, expired ("event has ended"), not found (404), and server error (retry button). Loading uses skeleton-shimmer placeholders. Backend adds `GET /events/{token}` endpoint and a `timezone` field to the Event model (cross-cutting change to US-1). + +## Technical Context + +**Language/Version**: Java 25 (backend), TypeScript 5.9 (frontend) +**Primary Dependencies**: Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript +**Storage**: PostgreSQL (JPA via Spring Data, Liquibase migrations) +**Testing**: JUnit (backend), Vitest (frontend unit), Playwright + MSW (frontend E2E) +**Target Platform**: Self-hosted web application (Docker) +**Project Type**: Web service + SPA +**Performance Goals**: N/A (single-user scale, self-hosted) +**Constraints**: No external resources (CDNs, fonts, tracking), WCAG AA, privacy-first +**Scale/Scope**: Single new view + one new API endpoint + one cross-cutting model change + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Privacy by Design | PASS | No PII exposed. Only attendee count shown (not names). No external resources. No tracking. | +| II. Test-Driven Methodology | PASS | TDD enforced: backend unit tests, frontend unit tests, E2E tests per spec. | +| III. API-First Development | PASS | OpenAPI spec updated first. Types generated. Response schemas include `example:` fields. | +| IV. Simplicity & Quality | PASS | Minimal changes: one GET endpoint, one new view, one model field. `attendeeCount` returns 0 (no RSVP stub). Cancelled state deferred. | +| V. Dependency Discipline | PASS | No new dependencies. Skeleton shimmer is CSS-only. | +| VI. Accessibility | PASS | Semantic HTML, ARIA attributes, keyboard navigable, WCAG AA contrast via design system. | + +**Post-Phase-1 re-check**: All gates still pass. The `timezone` field addition is a justified cross-cutting change documented in research.md R-1. + +## Project Structure + +### Documentation (this feature) + +```text +specs/007-view-event/ +├── plan.md # This file +├── spec.md # Feature specification +├── research.md # Phase 0: research decisions +├── data-model.md # Phase 1: entity definitions +├── quickstart.md # Phase 1: implementation overview +├── contracts/ +│ └── get-event.yaml # Phase 1: GET endpoint contract +└── tasks.md # Phase 2: implementation tasks (via /speckit.tasks) +``` + +### Source Code (repository root) + +```text +backend/ +├── src/main/java/de/fete/ +│ ├── domain/ +│ │ ├── model/Event.java # Add timezone field +│ │ └── port/in/GetEventUseCase.java # NEW: inbound port +│ ├── application/service/EventService.java # Implement GetEventUseCase +│ ├── adapter/ +│ │ ├── in/web/EventController.java # Implement getEvent() +│ │ └── out/persistence/ +│ │ ├── EventJpaEntity.java # Add timezone column +│ │ └── EventPersistenceAdapter.java # Map timezone field +│ └── config/ +├── src/main/resources/ +│ ├── openapi/api.yaml # Add GET endpoint + timezone +│ └── db/changelog/ # Liquibase: add timezone column +└── src/test/java/de/fete/ # Unit + integration tests + +frontend/ +├── src/ +│ ├── api/schema.d.ts # Regenerated from OpenAPI +│ ├── views/EventDetailView.vue # NEW: event detail page +│ ├── views/EventCreateView.vue # Add timezone to create request +│ ├── router/index.ts # Point /events/:token to EventDetailView +│ └── assets/main.css # Skeleton shimmer styles +├── e2e/ +│ └── event-view.spec.ts # NEW: E2E tests for view event +└── src/__tests__/ # Unit tests for EventDetailView +``` + +**Structure Decision**: Existing web application structure (backend + frontend). No new packages or modules — extends existing hexagonal architecture with one new inbound port and one new frontend view. + +## Complexity Tracking + +No constitution violations. No entries needed. diff --git a/specs/007-view-event/quickstart.md b/specs/007-view-event/quickstart.md new file mode 100644 index 0000000..b9c27ab --- /dev/null +++ b/specs/007-view-event/quickstart.md @@ -0,0 +1,39 @@ +# Quickstart: View Event Landing Page (007) + +## What this feature does + +Adds a public event detail page at `/events/:token`. Guests open a shared link and see: +- Event title, date/time (with IANA timezone), description, location +- Count of confirmed attendees (no names) +- "Event has ended" state for expired events +- "Event not found" for invalid tokens +- Skeleton shimmer while loading + +## Prerequisites + +- US-1 (Create Event) is implemented — Event entity, JPA persistence, POST endpoint exist. +- No RSVP model yet — attendee count returns 0 until RSVP feature is built. + +## Key changes + +### Backend + +1. **OpenAPI**: Add `GET /events/{token}` endpoint + `GetEventResponse` schema. Add `timezone` field to `CreateEventRequest`, `CreateEventResponse`, and `GetEventResponse`. +2. **Domain**: Add `timezone` (String) to `Event.java`. +3. **Persistence**: Add `timezone` column to `EventJpaEntity`, Liquibase migration. +4. **Use case**: New `GetEventUseCase` (inbound port) + implementation in `EventService`. +5. **Controller**: `EventController` implements `getEvent()` — maps to `GetEventResponse`, computes `expired` and `attendeeCount`. + +### Frontend + +1. **API types**: Regenerate `schema.d.ts` from updated OpenAPI spec. +2. **EventDetailView.vue**: New view component — fetches event by token, renders detail card. +3. **Router**: Replace `EventStubView` import at `/events/:token` with `EventDetailView`. +4. **States**: Loading (skeleton shimmer), loaded, expired, not-found, server-error (retry button). +5. **Create form**: Send `timezone` field (auto-detected via `Intl.DateTimeFormat`). + +### Testing + +- Backend: Unit tests for `GetEventUseCase`, controller tests for GET endpoint (200, 404). +- Frontend: Unit tests for EventDetailView (all states). +- E2E: Playwright tests with MSW mocks for all states (loaded, expired, not-found, error). diff --git a/specs/007-view-event/research.md b/specs/007-view-event/research.md new file mode 100644 index 0000000..3575efb --- /dev/null +++ b/specs/007-view-event/research.md @@ -0,0 +1,100 @@ +# Research: View Event Landing Page (007) + +**Date**: 2026-03-06 | **Status**: Complete + +## R-1: Timezone Field (Cross-Cutting) + +**Decision**: Add `timezone` String field (IANA zone ID) to Event entity, JPA entity, and OpenAPI schemas (both Create and Get). + +**Rationale**: The spec requires displaying the IANA timezone name (e.g. "Europe/Berlin") alongside the event time. `OffsetDateTime` preserves the offset (e.g. `+01:00`) but loses the IANA zone name. Since Europe/Berlin and Africa/Lagos both use `+01:00`, the zone name must be stored separately. + +**Alternatives considered**: +- Store `ZonedDateTime` instead of `OffsetDateTime` — rejected because `OffsetDateTime` is already the established type in the stack (see `datetime-best-practices.md`), and `ZonedDateTime` serialization is non-standard in JSON/OpenAPI. +- Derive timezone from offset — rejected because offset-to-zone mapping is ambiguous. + +**Impact on US-1 (Create Event)**: +- `CreateEventRequest` gains a required `timezone` field (string, IANA zone ID). +- `CreateEventResponse` gains a `timezone` field. +- Frontend auto-detects via `Intl.DateTimeFormat().resolvedOptions().timeZone`. +- Backend validates against `java.time.ZoneId.getAvailableZoneIds()`. +- JPA: new `VARCHAR(64)` column `timezone` on `events` table. +- Liquibase changeset: add `timezone` column. Existing events without timezone get `UTC` as default (pre-launch, destructive migration acceptable). + +## R-2: GET Endpoint Design + +**Decision**: `GET /api/events/{token}` returns public event data. Uses the existing hexagonal architecture pattern. + +**Rationale**: Follows the established pattern from `POST /events`. The event token is the public identifier — no auth required. + +**Flow**: +1. `EventController` implements generated `EventsApi.getEvent()`. +2. New inbound port: `GetEventUseCase` with `getByEventToken(UUID): Optional`. +3. `EventService` implements the use case, delegates to `EventRepository.findByEventToken()` (already exists). +4. Controller maps domain `Event` to `GetEventResponse` DTO. +5. 404 returns `ProblemDetail` (RFC 9457) — no event data leaked. + +**Alternatives considered**: +- Separate `/event/{token}` path (singular) — rejected because OpenAPI groups by resource; `/events/{token}` is RESTful convention. +- Note: Frontend route is `/event/:token` (spec clarification), but API path is `/api/events/{token}`. These are independent. + +## R-3: Attendee Count Without RSVP Model + +**Decision**: Include `attendeeCount` (integer) in the `GetEventResponse`. Return `0` until the RSVP feature (US-8+) is implemented. + +**Rationale**: FR-001 requires attendee count display. The API contract should be stable from the start — consumers should not need to change when RSVP is added later. Returning `0` is correct (no RSVPs exist yet). + +**Future hook**: When RSVP is implemented, `EventService` or a dedicated query will `COUNT(*) WHERE event_id = ? AND status = 'ATTENDING'`. + +**Alternatives considered**: +- Omit `attendeeCount` until RSVP exists — rejected because it would require API consumers to handle the field's absence, then handle its presence later. Breaking change. +- Add a stub RSVP table now — rejected (YAGNI, violates Principle IV). + +## R-4: Expired Event Detection + +**Decision**: Server-side. The `GetEventResponse` includes a boolean `expired` field, computed by comparing `expiryDate` with the server's current date. + +**Rationale**: Server is the source of truth for time. Client clocks may be wrong. The frontend uses this flag to toggle the "event has ended" state. + +**Computation**: `event.getExpiryDate().isBefore(LocalDate.now(clock))` — uses the injected `Clock` bean (already exists for testability in `EventService`). + +**Alternatives considered**: +- Client-side comparison — rejected because client clock may differ from server, leading to inconsistent behavior. +- Separate endpoint for status — rejected (over-engineering). + +## R-5: URL Pattern + +**Decision**: Frontend route stays at `/events/:token` (plural). API path is `/api/events/{token}`. Both use the plural RESTful convention consistently. + +**Rationale**: `/events/:token` is the standard REST resource pattern (collection + identifier). The existing router already uses this path. Consistency between frontend route and API resource name reduces cognitive overhead. + +**Impact**: No route change needed — the existing `/events/:token` route in the router is correct. + +## R-6: Skeleton Shimmer Loading State + +**Decision**: CSS-only shimmer animation using a gradient sweep. No additional dependencies. + +**Rationale**: The spec requires skeleton-shimmer placeholders during API loading. A CSS-only approach is lightweight and matches the dependency discipline principle. + +**Implementation pattern**: +```css +.skeleton { + background: linear-gradient(90deg, var(--color-card) 25%, #e0e0e0 50%, var(--color-card) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: var(--radius-card); +} +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} +``` + +Skeleton blocks match the approximate shape/size of the real content fields (title, date, location, etc.). + +## R-7: Cancelled Event State (Deferred) + +**Decision**: The `GetEventResponse` does NOT include cancellation fields yet. US-3 (view cancelled event) is explicitly deferred until US-18 (cancel event) is implemented. + +**Rationale**: Spec says "[Deferred until US-18 is implemented]". Adding unused fields violates Principle IV (KISS). + +**Future hook**: When US-18 lands, add `cancelled: boolean` and `cancellationMessage: string` to the response schema. diff --git a/specs/007-view-event/spec.md b/specs/007-view-event/spec.md index 7c0f8e6..812f711 100644 --- a/specs/007-view-event/spec.md +++ b/specs/007-view-event/spec.md @@ -9,18 +9,18 @@ ### User Story 1 - View event details as guest (Priority: P1) -A guest receives a shared event link, opens it, and sees all relevant event information: title, description (if provided), date and time, location (if provided), and the list of confirmed attendees with a count. +A guest receives a shared event link, opens it, and sees all relevant event information: title, description (if provided), date and time, location (if provided), and the count of confirmed attendees. **Why this priority**: Core value of the feature — without this, no other part of the event page is meaningful. -**Independent Test**: Can be fully tested by navigating to a valid event URL and verifying all event fields are displayed correctly, including attendee list and count. +**Independent Test**: Can be fully tested by navigating to a valid event URL and verifying all event fields are displayed correctly, including attendee count. **Acceptance Scenarios**: 1. **Given** a valid event link, **When** a guest opens the URL, **Then** the page displays the event title, date and time, and attendee count. 2. **Given** a valid event link for an event with optional fields set, **When** a guest opens the URL, **Then** the description and location are also displayed. 3. **Given** a valid event link for an event with optional fields absent, **When** a guest opens the URL, **Then** only the required fields are shown — no placeholder text for missing optional fields. -4. **Given** a valid event with RSVPs, **When** a guest opens the event page, **Then** the names of all confirmed attendees ("attending") are listed and a total count is shown. +4. **Given** a valid event with RSVPs, **When** a guest opens the event page, **Then** only the total count of confirmed attendees is shown — individual names are NOT displayed to guests (names are only visible to the organizer via the organizer view). 5. **Given** an event page, **When** it is rendered, **Then** no external resources (CDNs, fonts, tracking scripts) are loaded — all assets are served from the app's own domain. 6. **Given** a guest with no account, **When** they open the event URL, **Then** the page loads without any login, account, or access code required. @@ -70,9 +70,9 @@ A guest navigates to an event URL that no longer resolves — the event was dele ### Edge Cases -- What happens when the event has no attendees yet? — Attendee list is empty; count shows 0. +- What happens when the event has no attendees yet? — Count shows 0. - What happens when the event has been cancelled after US-18 is implemented? — Renders cancelled state with optional message; RSVP hidden. [Deferred] -- What happens when the server is temporarily unavailable? — [NEEDS EXPANSION] +- What happens when the server is temporarily unavailable? — The page displays a generic, friendly error message with a manual "Retry" button. No automatic retry. - How does the page behave when JavaScript is disabled? — Per Q-3 resolution: the app is a SPA; JavaScript-dependent rendering is acceptable. ## Requirements @@ -81,7 +81,7 @@ A guest navigates to an event URL that no longer resolves — the event was dele - **FR-001**: The event page MUST display: title, date and time, and attendee count for any valid event. - **FR-002**: The event page MUST display description and location when those optional fields are set on the event. -- **FR-003**: The event page MUST list the names of all confirmed attendees (those who RSVPed "attending"). +- **FR-003**: The public event page MUST display only the count of confirmed attendees. Individual attendee names MUST NOT be shown to guests — names are only visible to the organizer (organizer view, separate user story). - **FR-004**: If the event's expiry date has passed, the page MUST render a clear "this event has ended" state and MUST NOT show any RSVP actions. - **FR-005**: If the event has been cancelled (US-18), the page MUST display a "cancelled" state with the cancellation message (if provided) and MUST NOT show any RSVP actions. [Deferred until US-18 is implemented] - **FR-006**: If the event token does not match any event on the server, the page MUST display a clear "event not found" message — no partial data or error traces. @@ -90,15 +90,29 @@ A guest navigates to an event URL that no longer resolves — the event was dele ### Key Entities -- **Event**: Has a public event token (UUID in URL), title, optional description, date/time, optional location, expiry date, and optionally a cancelled state with message. -- **RSVP**: Has a guest name and attending status; confirmed attendees (status = attending) are listed on the public event page. +- **Event**: Has a public event token (UUID in URL), title, optional description, date/time (OffsetDateTime — displayed in the organizer's original timezone, no conversion to viewer timezone), IANA timezone name (e.g. `Europe/Berlin`, stored as separate field — required for human-readable timezone display), optional location, expiry date (LocalDate), and optionally a cancelled state with message. See `.specify/memory/research/datetime-best-practices.md` for full stack type mapping. + - **Note**: The IANA timezone requires a new `timezone` field on the Event entity and API schema. This impacts US-1 (Create Event) — the frontend must send the organizer's IANA zone ID alongside the OffsetDateTime. +- **RSVP**: Has a guest name and binary attending status (attending / not attending — no "maybe"). Only the count of confirmed attendees (status = attending) is exposed on the public event page. Individual names are visible only in the organizer view, sorted alphabetically by name. ## Success Criteria ### Measurable Outcomes - **SC-001**: A guest who opens a valid event URL can see all set event fields (title, date/time, and any optional fields) without logging in. -- **SC-002**: The attendee list and count reflect all current server-side RSVPs with attending status. +- **SC-002**: The attendee count reflects all current server-side RSVPs with attending status. No individual names are exposed on the public event page. - **SC-003**: An expired event URL renders the "ended" state — RSVP controls are absent from the DOM, not merely hidden via CSS. - **SC-004**: An unknown event token URL renders a "not found" message — no event data, no server error details. - **SC-005**: No network requests to external domains are made when loading the event page. + +## Clarifications + +### Session 2026-03-06 + +- Q: What should the event page display when the server is temporarily unavailable? → A: Generic friendly error state with a manual "Retry" button; no automatic retry. +- Q: How should date/time be displayed regarding timezones? → A: Organizer timezone preserved — display the time exactly as entered by the organizer (OffsetDateTime), no conversion to viewer's local timezone. The IANA timezone name (e.g. "Europe/Berlin") MUST be displayed alongside the time. Requires a new `timezone` field on Event entity/API (impacts US-1). +- Q: What is the URL pattern for event pages? → A: `/events/:token` (e.g. `/events/a1b2c3d4-...`). Plural, matching the RESTful API resource name. +- Q: Should guest names be visible to other guests on the public event page? → A: No. Only the attendee count is shown to guests. Individual names are exclusively visible to the organizer, sorted alphabetically. +- Q: How should the loading state look while the API call is in progress? → A: Skeleton-shimmer (placeholder blocks in field shape that shimmer until data arrives). +- Q: Should the event page include OpenGraph meta tags for link previews? → A: Out of scope for US-007. Separate user story — generic app-branding OG-tags only, no event data exposed to crawlers. Noted in `.specify/memory/ideen.md`. +- Q: Should date/time formatting adapt to the viewer's browser locale? → A: Yes, browser-locale-based via `Intl.DateTimeFormat` (e.g. DE: "15. März 2026, 20:00" / EN: "March 15, 2026, 8:00 PM"). +- Q: Is RSVP status binary or are there more states (e.g. "maybe")? → A: Binary — attending or not attending. No "maybe" status. Count reflects only confirmed attendees. diff --git a/specs/007-view-event/tasks.md b/specs/007-view-event/tasks.md new file mode 100644 index 0000000..06fdcc6 --- /dev/null +++ b/specs/007-view-event/tasks.md @@ -0,0 +1,225 @@ +# Tasks: View Event Landing Page + +**Input**: Design documents from `/specs/007-view-event/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/get-event.yaml + +**Tests**: Included — constitution enforces Test-Driven Methodology (Principle II). + +**Organization**: Tasks grouped by user story. US3 (cancelled event) is deferred until US-18. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US4) +- Exact file paths included in descriptions + +--- + +## Phase 1: Setup (Cross-Cutting Schema Changes) + +**Purpose**: OpenAPI contract update, database migration, and type generation — prerequisites for all backend and frontend work. + +- [x] T001 Update OpenAPI spec: add `GET /events/{token}` endpoint, `GetEventResponse` schema, and `timezone` field to `CreateEventRequest`/`CreateEventResponse` in `backend/src/main/resources/openapi/api.yaml` +- [x] T002 [P] Add Liquibase changeset: `timezone VARCHAR(64) NOT NULL DEFAULT 'UTC'` column on `events` table in `backend/src/main/resources/db/changelog/` +- [x] T003 Regenerate frontend TypeScript types from updated OpenAPI spec in `frontend/src/api/schema.d.ts` + +**Checkpoint**: OpenAPI contract finalized, DB schema ready, frontend types available. + +--- + +## Phase 2: Foundational (Backend — Blocks All User Stories) + +**Purpose**: Domain model update, new GET use case, controller endpoint, and backend tests. All user stories depend on this. + +**CRITICAL**: No frontend user story work can begin until this phase is complete. + +### Backend Tests (TDD — write first, verify they fail) + +- [x] T004 [P] Backend unit tests for `GetEventUseCase`: test getByEventToken returns event, returns empty for unknown token, computes expired flag — in `backend/src/test/java/de/fete/` +- [x] T005 [P] Backend controller tests for `GET /events/{token}`: test 200 with full response, 200 with optional fields absent, 404 with ProblemDetail — in `backend/src/test/java/de/fete/` +- [x] T006 [P] Backend tests for timezone in Create Event flow: request validation (valid/invalid IANA zone), persistence round-trip — in `backend/src/test/java/de/fete/` + +### Backend Implementation + +- [x] T007 Add `timezone` field (String) to domain model in `backend/src/main/java/de/fete/domain/model/Event.java` +- [x] T008 [P] Add `timezone` column to JPA entity and update persistence mapping in `backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java` and `EventPersistenceAdapter.java` +- [x] T009 [P] Update Create Event flow to accept and validate `timezone` (must be valid IANA zone ID via `ZoneId.getAvailableZoneIds()`) in `backend/src/main/java/de/fete/application/service/EventService.java` and `EventController.java` +- [x] T010 Create `GetEventUseCase` inbound port with `getByEventToken(UUID): Optional` in `backend/src/main/java/de/fete/domain/port/in/GetEventUseCase.java` +- [x] T011 Implement `GetEventUseCase` in `backend/src/main/java/de/fete/application/service/EventService.java` — delegates to existing `findByEventToken()` repository method +- [x] T012 Implement `getEvent()` in `backend/src/main/java/de/fete/adapter/in/web/EventController.java` — maps domain Event to GetEventResponse, computes `expired` (expiryDate vs server clock) and `attendeeCount` (hardcoded 0) + +**Checkpoint**: Backend complete — `GET /api/events/{token}` returns 200 or 404. All backend tests pass. + +--- + +## Phase 3: User Story 1 — View Event Details as Guest (Priority: P1) MVP + +**Goal**: A guest opens a shared event link and sees all event information: title, date/time with IANA timezone, description, location, attendee count. Loading shows skeleton shimmer. + +**Independent Test**: Navigate to a valid event URL, verify all fields display correctly with locale-formatted date/time. + +### Tests for User Story 1 + +- [x] T013 [P] [US1] Unit tests for EventDetailView loaded state: renders title, date/time (locale-formatted via `Intl.DateTimeFormat`), timezone, description, location, attendee count — in `frontend/src/__tests__/EventDetailView.spec.ts` +- [x] T014 [P] [US1] Unit test for EventDetailView loading state: renders skeleton shimmer placeholders — in `frontend/src/__tests__/EventDetailView.spec.ts` + +### Implementation for User Story 1 + +- [x] T015 [P] [US1] Add skeleton shimmer CSS (CSS-only gradient animation, no dependencies) in `frontend/src/assets/main.css` +- [x] T016 [US1] Create `EventDetailView.vue` with loading (skeleton shimmer) and loaded states — fetches event via `openapi-fetch` GET `/events/{token}`, formats date/time with `Intl.DateTimeFormat` using browser locale — in `frontend/src/views/EventDetailView.vue` +- [x] T017 [US1] Update router to use `EventDetailView` for `/events/:token` route in `frontend/src/router/index.ts` +- [x] T018 [P] [US1] Update `EventCreateView.vue` to send `timezone` field (auto-detected via `Intl.DateTimeFormat().resolvedOptions().timeZone`) in `frontend/src/views/EventCreateView.vue` +- [x] T019 [US1] E2E test for loaded event: navigate to valid event URL, verify all fields displayed, verify no external resource requests — in `frontend/e2e/event-view.spec.ts` + +**Checkpoint**: US1 complete — guest can view event details. Skeleton shimmer during loading. Date/time locale-formatted with timezone label. + +--- + +## Phase 4: User Story 2 — View Expired Event (Priority: P2) + +**Goal**: A guest opens a link to an expired event. The page shows event details plus a clear "event has ended" indicator. No RSVP actions shown. + +**Independent Test**: Create an event with past expiry date, navigate to its URL, verify "event has ended" state renders and no RSVP controls are present. + +**Dependencies**: Requires Phase 3 (EventDetailView exists). + +### Tests for User Story 2 + +- [x] T020 [P] [US2] Unit test for EventDetailView expired state: renders "event has ended" indicator, RSVP controls absent from DOM — in `frontend/src/__tests__/EventDetailView.spec.ts` + +### Implementation for User Story 2 + +- [x] T021 [US2] Add expired state rendering to `EventDetailView.vue`: show event details + "event has ended" banner when `expired === true`, no RSVP actions in DOM — in `frontend/src/views/EventDetailView.vue` +- [x] T022 [US2] E2E test for expired event: MSW returns event with `expired: true`, verify banner and absent RSVP controls — in `frontend/e2e/event-view.spec.ts` + +**Checkpoint**: US2 complete — expired events clearly show "ended" state. + +--- + +## Phase 5: User Story 4 — Event Not Found (Priority: P2) + +**Goal**: A guest navigates to an invalid event URL. The page shows a clear "event not found" message — no partial data, no error traces. + +**Independent Test**: Navigate to a URL with an unknown event token, verify "event not found" message renders. + +**Dependencies**: Requires Phase 3 (EventDetailView exists). No dependency on US2. + +### Tests for User Story 4 + +- [x] T023 [P] [US4] Unit test for EventDetailView not-found state: renders "event not found" message, no event data in DOM — in `frontend/src/__tests__/EventDetailView.spec.ts` + +### Implementation for User Story 4 + +- [x] T024 [US4] Add not-found state rendering to `EventDetailView.vue`: show "event not found" message when API returns 404 — in `frontend/src/views/EventDetailView.vue` +- [x] T025 [US4] E2E test for event not found: MSW returns 404 ProblemDetail, verify message and no event data — in `frontend/e2e/event-view.spec.ts` + +**Checkpoint**: US4 complete — invalid tokens show friendly not-found message. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Server error edge case, final validation, and cleanup. + +- [x] T026 Add server error state with manual retry button to `EventDetailView.vue`: friendly error message + "Retry" button that re-fetches — in `frontend/src/views/EventDetailView.vue` +- [x] T027 [P] Unit test for server error + retry state in `frontend/src/__tests__/EventDetailView.spec.ts` +- [x] T028 [P] E2E test for server error: MSW returns 500, verify error message and retry button functionality — in `frontend/e2e/event-view.spec.ts` +- [x] T029 Run quickstart.md validation: verify all key changes listed in quickstart.md are implemented + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — start immediately +- **Foundational (Phase 2)**: Depends on T001 (OpenAPI spec) and T002 (migration) from Setup +- **US1 (Phase 3)**: Depends on Phase 2 completion (backend endpoint must exist) +- **US2 (Phase 4)**: Depends on Phase 3 (EventDetailView exists) — can parallelize with US4 +- **US4 (Phase 5)**: Depends on Phase 3 (EventDetailView exists) — can parallelize with US2 +- **Polish (Phase 6)**: Depends on Phase 3 minimum; ideally after US2 + US4 + +### User Story Dependencies + +``` +Phase 1 (Setup) ──► Phase 2 (Backend) ──► Phase 3 (US1/MVP) + │ + ┌────┴────┐ + ▼ ▼ + Phase 4 Phase 5 + (US2) (US4) + └────┬────┘ + ▼ + Phase 6 (Polish) +``` + +- **US1 (P1)**: Requires Phase 2 — no dependency on other stories +- **US2 (P2)**: Requires US1 (same component) — no dependency on US4 +- **US4 (P2)**: Requires US1 (same component) — no dependency on US2 +- **US3 (P2)**: DEFERRED until US-18 (cancel event) is implemented + +### Within Each Phase + +- Tests MUST be written and FAIL before implementation (TDD) +- Models/ports before services +- Services before controllers +- Backend before frontend (for the same endpoint) + +### Parallel Opportunities + +**Phase 1**: T002 (migration) can run in parallel with T001 (OpenAPI update) +**Phase 2**: T004, T005, T006 (tests) can run in parallel. T008, T009 can run in parallel after T007. +**Phase 3**: T013, T014 (unit tests) and T015 (CSS) can run in parallel. T018 (create form timezone) is independent. +**Phase 4 + 5**: US2 and US4 can be implemented in parallel (different UI states, same file but non-conflicting sections). + +--- + +## Parallel Example: Phase 2 (Backend) + +```bash +# Write all backend tests in parallel (TDD): +Task T004: "Unit tests for GetEventUseCase" +Task T005: "Controller tests for GET /events/{token}" +Task T006: "Tests for timezone in Create Event flow" + +# Then implement in parallel where possible: +Task T008: "Add timezone to JPA entity + persistence" # parallel +Task T009: "Update Create Event flow for timezone" # parallel +# T010-T012 are sequential (port → service → controller) +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup (OpenAPI + migration + types) +2. Complete Phase 2: Backend (domain + use case + controller + tests) +3. Complete Phase 3: US1 (EventDetailView + router + tests) +4. **STOP and VALIDATE**: Guest can view event details via shared link +5. Deploy/demo if ready + +### Incremental Delivery + +1. Setup + Backend → Backend ready, API testable via curl +2. Add US1 → Guest can view events (MVP!) +3. Add US2 → Expired events show "ended" state +4. Add US4 → Invalid tokens show "not found" +5. Polish → Server error handling, final validation + +### Deferred Work + +- **US3 (Cancelled event)**: Blocked on US-18. No tasks generated. Will require adding `cancelled` + `cancellationMessage` to GetEventResponse and a new UI state. + +--- + +## Notes + +- [P] tasks = different files, no dependencies on incomplete tasks +- [Story] label maps task to specific user story for traceability +- `attendeeCount` returns 0 until RSVP feature (US-8+) is implemented (R-3) +- `expired` is computed server-side using injected Clock bean (R-4) +- Frontend route: `/events/:token` — API path: `/api/events/{token}` (R-5) +- Skeleton shimmer is CSS-only, no additional dependencies (R-6) +- Date/time formatted via `Intl.DateTimeFormat` with browser locale (spec clarification Q7) From e77e479e2a7ff52c0972b907c0e375a8c64fd462 Mon Sep 17 00:00:00 2001 From: nitrix Date: Fri, 6 Mar 2026 22:33:18 +0100 Subject: [PATCH 2/7] Add GET /events/{token} endpoint and timezone field to OpenAPI spec OpenAPI: new GetEventResponse schema, timezone on Create request/response. Liquibase: add timezone VARCHAR(64) NOT NULL DEFAULT 'UTC' column. Co-Authored-By: Claude Opus 4.6 --- .../db/changelog/002-add-timezone-column.xml | 16 ++++ .../db/changelog/db.changelog-master.xml | 1 + backend/src/main/resources/openapi/api.yaml | 84 +++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 backend/src/main/resources/db/changelog/002-add-timezone-column.xml diff --git a/backend/src/main/resources/db/changelog/002-add-timezone-column.xml b/backend/src/main/resources/db/changelog/002-add-timezone-column.xml new file mode 100644 index 0000000..950e554 --- /dev/null +++ b/backend/src/main/resources/db/changelog/002-add-timezone-column.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.xml b/backend/src/main/resources/db/changelog/db.changelog-master.xml index 5e156aa..fdd403c 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -7,5 +7,6 @@ + diff --git a/backend/src/main/resources/openapi/api.yaml b/backend/src/main/resources/openapi/api.yaml index 9cf46bf..7108b4c 100644 --- a/backend/src/main/resources/openapi/api.yaml +++ b/backend/src/main/resources/openapi/api.yaml @@ -37,6 +37,34 @@ paths: schema: $ref: "#/components/schemas/ValidationProblemDetail" + /events/{token}: + get: + operationId: getEvent + summary: Get public event details by token + tags: + - events + parameters: + - name: token + in: path + required: true + schema: + type: string + format: uuid + description: Public event token + responses: + "200": + description: Event found + content: + application/json: + schema: + $ref: "#/components/schemas/GetEventResponse" + "404": + description: Event not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + components: schemas: CreateEventRequest: @@ -44,6 +72,7 @@ components: required: - title - dateTime + - timezone - expiryDate properties: title: @@ -58,6 +87,10 @@ components: format: date-time description: Event date and time with UTC offset (ISO 8601) example: "2026-03-15T20:00:00+01:00" + timezone: + type: string + description: IANA timezone of the organizer + example: "Europe/Berlin" location: type: string maxLength: 500 @@ -74,6 +107,7 @@ components: - organizerToken - title - dateTime + - timezone - expiryDate properties: eventToken: @@ -93,11 +127,61 @@ components: type: string format: date-time example: "2026-03-15T20:00:00+01:00" + timezone: + type: string + description: IANA timezone of the organizer + example: "Europe/Berlin" expiryDate: type: string format: date example: "2026-06-15" + GetEventResponse: + type: object + required: + - eventToken + - title + - dateTime + - timezone + - attendeeCount + - expired + properties: + eventToken: + type: string + format: uuid + description: Public event token + example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + title: + type: string + description: Event title + example: "Summer BBQ" + description: + type: string + description: Event description (absent if not set) + example: "Bring your own drinks!" + dateTime: + type: string + format: date-time + description: Event date/time with organizer's UTC offset + example: "2026-03-15T20:00:00+01:00" + timezone: + type: string + description: IANA timezone name of the organizer + example: "Europe/Berlin" + location: + type: string + description: Event location (absent if not set) + example: "Central Park, NYC" + attendeeCount: + type: integer + minimum: 0 + description: Number of confirmed attendees (attending=true) + example: 12 + expired: + type: boolean + description: Whether the event's expiry date has passed + example: false + ProblemDetail: type: object properties: From e5d0dd5f8fe8e31ecc57dbfb5dcc924a8f262c1f Mon Sep 17 00:00:00 2001 From: nitrix Date: Fri, 6 Mar 2026 22:33:40 +0100 Subject: [PATCH 3/7] Implement GET /events/{token} backend with timezone support Domain: add timezone field to Event and CreateEventCommand. Ports: new GetEventUseCase inbound port. Service: implement getByEventToken, validate IANA timezone on create. Controller: map to GetEventResponse, compute expired flag via Clock. Persistence: timezone column in JPA entity and mapping. Tests: integration tests use DTOs + ObjectMapper instead of inline JSON, GET tests seed DB directly via JpaRepository for isolation. Co-Authored-By: Claude Opus 4.6 --- .../fete/adapter/in/web/EventController.java | 51 +++- .../in/web/GlobalExceptionHandler.java | 28 ++ .../out/persistence/EventJpaEntity.java | 13 + .../persistence/EventPersistenceAdapter.java | 3 + .../service/EventNotFoundException.java | 12 + .../application/service/EventService.java | 12 +- .../service/InvalidTimezoneException.java | 10 + .../fete/domain/model/CreateEventCommand.java | 2 + .../main/java/de/fete/domain/model/Event.java | 12 + .../fete/domain/port/in/GetEventUseCase.java | 12 + .../web/EventControllerIntegrationTest.java | 256 +++++++++++++----- .../EventPersistenceAdapterTest.java | 4 + .../application/service/EventServiceTest.java | 75 +++-- 13 files changed, 391 insertions(+), 99 deletions(-) create mode 100644 backend/src/main/java/de/fete/application/service/EventNotFoundException.java create mode 100644 backend/src/main/java/de/fete/application/service/InvalidTimezoneException.java create mode 100644 backend/src/main/java/de/fete/domain/port/in/GetEventUseCase.java diff --git a/backend/src/main/java/de/fete/adapter/in/web/EventController.java b/backend/src/main/java/de/fete/adapter/in/web/EventController.java index db4a463..a0fc363 100644 --- a/backend/src/main/java/de/fete/adapter/in/web/EventController.java +++ b/backend/src/main/java/de/fete/adapter/in/web/EventController.java @@ -3,9 +3,18 @@ package de.fete.adapter.in.web; import de.fete.adapter.in.web.api.EventsApi; import de.fete.adapter.in.web.model.CreateEventRequest; import de.fete.adapter.in.web.model.CreateEventResponse; +import de.fete.adapter.in.web.model.GetEventResponse; +import de.fete.application.service.EventNotFoundException; +import de.fete.application.service.InvalidTimezoneException; import de.fete.domain.model.CreateEventCommand; import de.fete.domain.model.Event; import de.fete.domain.port.in.CreateEventUseCase; +import de.fete.domain.port.in.GetEventUseCase; +import java.time.Clock; +import java.time.DateTimeException; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.UUID; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RestController; @@ -15,19 +24,29 @@ import org.springframework.web.bind.annotation.RestController; public class EventController implements EventsApi { private final CreateEventUseCase createEventUseCase; + private final GetEventUseCase getEventUseCase; + private final Clock clock; - /** Creates a new controller with the given use case. */ - public EventController(CreateEventUseCase createEventUseCase) { + /** Creates a new controller with the given use cases and clock. */ + public EventController( + CreateEventUseCase createEventUseCase, + GetEventUseCase getEventUseCase, + Clock clock) { this.createEventUseCase = createEventUseCase; + this.getEventUseCase = getEventUseCase; + this.clock = clock; } @Override public ResponseEntity createEvent( CreateEventRequest request) { + ZoneId zoneId = parseTimezone(request.getTimezone()); + var command = new CreateEventCommand( request.getTitle(), request.getDescription(), request.getDateTime(), + zoneId, request.getLocation(), request.getExpiryDate() ); @@ -39,8 +58,36 @@ public class EventController implements EventsApi { response.setOrganizerToken(event.getOrganizerToken()); response.setTitle(event.getTitle()); response.setDateTime(event.getDateTime()); + response.setTimezone(event.getTimezone().getId()); response.setExpiryDate(event.getExpiryDate()); return ResponseEntity.status(HttpStatus.CREATED).body(response); } + + @Override + public ResponseEntity getEvent(UUID token) { + Event event = getEventUseCase.getByEventToken(token) + .orElseThrow(() -> new EventNotFoundException(token)); + + var response = new GetEventResponse(); + response.setEventToken(event.getEventToken()); + response.setTitle(event.getTitle()); + response.setDescription(event.getDescription()); + response.setDateTime(event.getDateTime()); + response.setTimezone(event.getTimezone().getId()); + response.setLocation(event.getLocation()); + response.setAttendeeCount(0); + response.setExpired( + event.getExpiryDate().isBefore(LocalDate.now(clock))); + + return ResponseEntity.ok(response); + } + + private static ZoneId parseTimezone(String timezone) { + try { + return ZoneId.of(timezone); + } catch (DateTimeException e) { + throw new InvalidTimezoneException(timezone); + } + } } diff --git a/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java b/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java index 4ac221a..34c9726 100644 --- a/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java +++ b/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java @@ -1,6 +1,8 @@ package de.fete.adapter.in.web; +import de.fete.application.service.EventNotFoundException; import de.fete.application.service.ExpiryDateInPastException; +import de.fete.application.service.InvalidTimezoneException; import java.net.URI; import java.util.List; import java.util.Map; @@ -57,6 +59,32 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { .body(problemDetail); } + /** Handles event not found. */ + @ExceptionHandler(EventNotFoundException.class) + public ResponseEntity handleEventNotFound( + EventNotFoundException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.NOT_FOUND, ex.getMessage()); + problemDetail.setTitle("Event Not Found"); + problemDetail.setType(URI.create("urn:problem-type:event-not-found")); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(problemDetail); + } + + /** Handles invalid timezone. */ + @ExceptionHandler(InvalidTimezoneException.class) + public ResponseEntity handleInvalidTimezone( + InvalidTimezoneException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.BAD_REQUEST, ex.getMessage()); + problemDetail.setTitle("Invalid Timezone"); + problemDetail.setType(URI.create("urn:problem-type:invalid-timezone")); + return ResponseEntity.badRequest() + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(problemDetail); + } + /** Catches all unhandled exceptions. */ @ExceptionHandler(Exception.class) public ResponseEntity handleAll(Exception ex) { diff --git a/backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java b/backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java index d644503..04a33b0 100644 --- a/backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java +++ b/backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java @@ -34,6 +34,9 @@ public class EventJpaEntity { @Column(name = "date_time", nullable = false) private OffsetDateTime dateTime; + @Column(nullable = false, length = 64) + private String timezone; + @Column(length = 500) private String location; @@ -103,6 +106,16 @@ public class EventJpaEntity { this.dateTime = dateTime; } + /** Returns the IANA timezone name. */ + public String getTimezone() { + return timezone; + } + + /** Sets the IANA timezone name. */ + public void setTimezone(String timezone) { + this.timezone = timezone; + } + /** Returns the event location. */ public String getLocation() { return location; diff --git a/backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java b/backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java index 360e099..c4e78c2 100644 --- a/backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java +++ b/backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java @@ -2,6 +2,7 @@ package de.fete.adapter.out.persistence; import de.fete.domain.model.Event; import de.fete.domain.port.out.EventRepository; +import java.time.ZoneId; import java.util.Optional; import java.util.UUID; import org.springframework.stereotype.Repository; @@ -37,6 +38,7 @@ public class EventPersistenceAdapter implements EventRepository { entity.setTitle(event.getTitle()); entity.setDescription(event.getDescription()); entity.setDateTime(event.getDateTime()); + entity.setTimezone(event.getTimezone().getId()); entity.setLocation(event.getLocation()); entity.setExpiryDate(event.getExpiryDate()); entity.setCreatedAt(event.getCreatedAt()); @@ -51,6 +53,7 @@ public class EventPersistenceAdapter implements EventRepository { event.setTitle(entity.getTitle()); event.setDescription(entity.getDescription()); event.setDateTime(entity.getDateTime()); + event.setTimezone(ZoneId.of(entity.getTimezone())); event.setLocation(entity.getLocation()); event.setExpiryDate(entity.getExpiryDate()); event.setCreatedAt(entity.getCreatedAt()); diff --git a/backend/src/main/java/de/fete/application/service/EventNotFoundException.java b/backend/src/main/java/de/fete/application/service/EventNotFoundException.java new file mode 100644 index 0000000..6e3025f --- /dev/null +++ b/backend/src/main/java/de/fete/application/service/EventNotFoundException.java @@ -0,0 +1,12 @@ +package de.fete.application.service; + +import java.util.UUID; + +/** Thrown when an event cannot be found by its token. */ +public class EventNotFoundException extends RuntimeException { + + /** Creates a new exception for the given event token. */ + public EventNotFoundException(UUID eventToken) { + super("Event not found: " + eventToken); + } +} diff --git a/backend/src/main/java/de/fete/application/service/EventService.java b/backend/src/main/java/de/fete/application/service/EventService.java index fd199d2..315c5a1 100644 --- a/backend/src/main/java/de/fete/application/service/EventService.java +++ b/backend/src/main/java/de/fete/application/service/EventService.java @@ -3,16 +3,18 @@ package de.fete.application.service; import de.fete.domain.model.CreateEventCommand; import de.fete.domain.model.Event; import de.fete.domain.port.in.CreateEventUseCase; +import de.fete.domain.port.in.GetEventUseCase; import de.fete.domain.port.out.EventRepository; import java.time.Clock; import java.time.LocalDate; import java.time.OffsetDateTime; +import java.util.Optional; import java.util.UUID; import org.springframework.stereotype.Service; -/** Application service implementing event creation. */ +/** Application service implementing event creation and retrieval. */ @Service -public class EventService implements CreateEventUseCase { +public class EventService implements CreateEventUseCase, GetEventUseCase { private final EventRepository eventRepository; private final Clock clock; @@ -35,10 +37,16 @@ public class EventService implements CreateEventUseCase { event.setTitle(command.title()); event.setDescription(command.description()); event.setDateTime(command.dateTime()); + event.setTimezone(command.timezone()); event.setLocation(command.location()); event.setExpiryDate(command.expiryDate()); event.setCreatedAt(OffsetDateTime.now(clock)); return eventRepository.save(event); } + + @Override + public Optional getByEventToken(UUID eventToken) { + return eventRepository.findByEventToken(eventToken); + } } diff --git a/backend/src/main/java/de/fete/application/service/InvalidTimezoneException.java b/backend/src/main/java/de/fete/application/service/InvalidTimezoneException.java new file mode 100644 index 0000000..4269804 --- /dev/null +++ b/backend/src/main/java/de/fete/application/service/InvalidTimezoneException.java @@ -0,0 +1,10 @@ +package de.fete.application.service; + +/** Thrown when an invalid IANA timezone ID is provided. */ +public class InvalidTimezoneException extends RuntimeException { + + /** Creates a new exception for the given invalid timezone. */ + public InvalidTimezoneException(String timezone) { + super("Invalid IANA timezone: " + timezone); + } +} diff --git a/backend/src/main/java/de/fete/domain/model/CreateEventCommand.java b/backend/src/main/java/de/fete/domain/model/CreateEventCommand.java index f32ac08..331df0c 100644 --- a/backend/src/main/java/de/fete/domain/model/CreateEventCommand.java +++ b/backend/src/main/java/de/fete/domain/model/CreateEventCommand.java @@ -2,12 +2,14 @@ package de.fete.domain.model; import java.time.LocalDate; import java.time.OffsetDateTime; +import java.time.ZoneId; /** Command carrying the data needed to create an event. */ public record CreateEventCommand( String title, String description, OffsetDateTime dateTime, + ZoneId timezone, String location, LocalDate expiryDate ) {} diff --git a/backend/src/main/java/de/fete/domain/model/Event.java b/backend/src/main/java/de/fete/domain/model/Event.java index cb602c8..1575137 100644 --- a/backend/src/main/java/de/fete/domain/model/Event.java +++ b/backend/src/main/java/de/fete/domain/model/Event.java @@ -2,6 +2,7 @@ package de.fete.domain.model; import java.time.LocalDate; import java.time.OffsetDateTime; +import java.time.ZoneId; import java.util.UUID; /** Domain entity representing an event. */ @@ -13,6 +14,7 @@ public class Event { private String title; private String description; private OffsetDateTime dateTime; + private ZoneId timezone; private String location; private LocalDate expiryDate; private OffsetDateTime createdAt; @@ -77,6 +79,16 @@ public class Event { this.dateTime = dateTime; } + /** Returns the IANA timezone. */ + public ZoneId getTimezone() { + return timezone; + } + + /** Sets the IANA timezone. */ + public void setTimezone(ZoneId timezone) { + this.timezone = timezone; + } + /** Returns the event location. */ public String getLocation() { return location; diff --git a/backend/src/main/java/de/fete/domain/port/in/GetEventUseCase.java b/backend/src/main/java/de/fete/domain/port/in/GetEventUseCase.java new file mode 100644 index 0000000..1731e92 --- /dev/null +++ b/backend/src/main/java/de/fete/domain/port/in/GetEventUseCase.java @@ -0,0 +1,12 @@ +package de.fete.domain.port.in; + +import de.fete.domain.model.Event; +import java.util.Optional; +import java.util.UUID; + +/** Inbound port for retrieving a public event by its token. */ +public interface GetEventUseCase { + + /** Finds an event by its public event token. */ + Optional getByEventToken(UUID eventToken); +} diff --git a/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java b/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java index 6bbdfa7..f47f433 100644 --- a/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java +++ b/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java @@ -1,12 +1,22 @@ package de.fete.adapter.in.web; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.fasterxml.jackson.databind.ObjectMapper; import de.fete.TestcontainersConfig; +import de.fete.adapter.in.web.model.CreateEventRequest; +import de.fete.adapter.in.web.model.CreateEventResponse; +import de.fete.adapter.out.persistence.EventJpaEntity; +import de.fete.adapter.out.persistence.EventJpaRepository; import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -23,63 +33,89 @@ class EventControllerIntegrationTest { @Autowired private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private EventJpaRepository jpaRepository; + + // --- Create Event tests --- + @Test void createEventWithValidBody() throws Exception { - String body = - """ - { - "title": "Birthday Party", - "description": "Come celebrate!", - "dateTime": "2026-06-15T20:00:00+02:00", - "location": "Berlin", - "expiryDate": "%s" - } - """.formatted(LocalDate.now().plusDays(30)); + var request = new CreateEventRequest() + .title("Birthday Party") + .description("Come celebrate!") + .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) + .timezone("Europe/Berlin") + .location("Berlin") + .expiryDate(LocalDate.now().plusDays(30)); - mockMvc.perform(post("/api/events") + var result = mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.eventToken").isNotEmpty()) .andExpect(jsonPath("$.organizerToken").isNotEmpty()) .andExpect(jsonPath("$.title").value("Birthday Party")) + .andExpect(jsonPath("$.timezone").value("Europe/Berlin")) .andExpect(jsonPath("$.dateTime").isNotEmpty()) - .andExpect(jsonPath("$.expiryDate").isNotEmpty()); + .andExpect(jsonPath("$.expiryDate").isNotEmpty()) + .andReturn(); + + var response = objectMapper.readValue( + result.getResponse().getContentAsString(), CreateEventResponse.class); + + EventJpaEntity persisted = jpaRepository + .findByEventToken(response.getEventToken()).orElseThrow(); + assertThat(persisted.getTitle()).isEqualTo("Birthday Party"); + assertThat(persisted.getDescription()).isEqualTo("Come celebrate!"); + assertThat(persisted.getTimezone()).isEqualTo("Europe/Berlin"); + assertThat(persisted.getLocation()).isEqualTo("Berlin"); + assertThat(persisted.getExpiryDate()).isEqualTo(request.getExpiryDate()); + assertThat(persisted.getDateTime().toInstant()) + .isEqualTo(request.getDateTime().toInstant()); + assertThat(persisted.getOrganizerToken()).isNotNull(); + assertThat(persisted.getCreatedAt()).isNotNull(); } @Test void createEventWithOptionalFieldsNull() throws Exception { - String body = - """ - { - "title": "Minimal Event", - "dateTime": "2026-06-15T20:00:00+02:00", - "expiryDate": "%s" - } - """.formatted(LocalDate.now().plusDays(30)); + var request = new CreateEventRequest() + .title("Minimal Event") + .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) + .timezone("UTC") + .expiryDate(LocalDate.now().plusDays(30)); - mockMvc.perform(post("/api/events") + var result = mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.eventToken").isNotEmpty()) .andExpect(jsonPath("$.organizerToken").isNotEmpty()) - .andExpect(jsonPath("$.title").value("Minimal Event")); + .andExpect(jsonPath("$.title").value("Minimal Event")) + .andReturn(); + + var response = objectMapper.readValue( + result.getResponse().getContentAsString(), CreateEventResponse.class); + + EventJpaEntity persisted = jpaRepository + .findByEventToken(response.getEventToken()).orElseThrow(); + assertThat(persisted.getTitle()).isEqualTo("Minimal Event"); + assertThat(persisted.getDescription()).isNull(); + assertThat(persisted.getLocation()).isNull(); } @Test void createEventMissingTitleReturns400() throws Exception { - String body = - """ - { - "dateTime": "2026-06-15T20:00:00+02:00", - "expiryDate": "%s" - } - """.formatted(LocalDate.now().plusDays(30)); + var request = new CreateEventRequest() + .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) + .timezone("Europe/Berlin") + .expiryDate(LocalDate.now().plusDays(30)); mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(jsonPath("$.title").value("Validation Failed")) @@ -88,17 +124,14 @@ class EventControllerIntegrationTest { @Test void createEventMissingDateTimeReturns400() throws Exception { - String body = - """ - { - "title": "No Date", - "expiryDate": "%s" - } - """.formatted(LocalDate.now().plusDays(30)); + var request = new CreateEventRequest() + .title("No Date") + .timezone("Europe/Berlin") + .expiryDate(LocalDate.now().plusDays(30)); mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(jsonPath("$.fieldErrors").isArray()); @@ -106,17 +139,14 @@ class EventControllerIntegrationTest { @Test void createEventMissingExpiryDateReturns400() throws Exception { - String body = - """ - { - "title": "No Expiry", - "dateTime": "2026-06-15T20:00:00+02:00" - } - """; + var request = new CreateEventRequest() + .title("No Expiry") + .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) + .timezone("Europe/Berlin"); mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(jsonPath("$.fieldErrors").isArray()); @@ -124,18 +154,15 @@ class EventControllerIntegrationTest { @Test void createEventExpiryDateInPastReturns400() throws Exception { - String body = - """ - { - "title": "Past Expiry", - "dateTime": "2026-06-15T20:00:00+02:00", - "expiryDate": "2025-01-01" - } - """; + var request = new CreateEventRequest() + .title("Past Expiry") + .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) + .timezone("Europe/Berlin") + .expiryDate(LocalDate.of(2025, 1, 1)); mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past")); @@ -143,18 +170,15 @@ class EventControllerIntegrationTest { @Test void createEventExpiryDateTodayReturns400() throws Exception { - String body = - """ - { - "title": "Today Expiry", - "dateTime": "2026-06-15T20:00:00+02:00", - "expiryDate": "%s" - } - """.formatted(LocalDate.now()); + var request = new CreateEventRequest() + .title("Today Expiry") + .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) + .timezone("Europe/Berlin") + .expiryDate(LocalDate.now()); mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past")); @@ -162,19 +186,101 @@ class EventControllerIntegrationTest { @Test void errorResponseContentTypeIsProblemJson() throws Exception { - String body = - """ - { - "title": "", - "dateTime": "2026-06-15T20:00:00+02:00", - "expiryDate": "%s" - } - """.formatted(LocalDate.now().plusDays(30)); + var request = new CreateEventRequest() + .title("") + .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) + .timezone("Europe/Berlin") + .expiryDate(LocalDate.now().plusDays(30)); mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith("application/problem+json")); } + + @Test + void createEventWithInvalidTimezoneReturns400() throws Exception { + var request = new CreateEventRequest() + .title("Bad TZ") + .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) + .timezone("Not/A/Zone") + .expiryDate(LocalDate.now().plusDays(30)); + + mockMvc.perform(post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")) + .andExpect(jsonPath("$.type").value("urn:problem-type:invalid-timezone")); + } + + // --- GET /events/{token} tests --- + + @Test + void getEventReturnsFullResponse() throws Exception { + EventJpaEntity entity = seedEvent( + "Summer BBQ", "Bring drinks!", "Europe/Berlin", + "Central Park", LocalDate.now().plusDays(30)); + + mockMvc.perform(get("/api/events/" + entity.getEventToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.eventToken").value(entity.getEventToken().toString())) + .andExpect(jsonPath("$.title").value("Summer BBQ")) + .andExpect(jsonPath("$.description").value("Bring drinks!")) + .andExpect(jsonPath("$.timezone").value("Europe/Berlin")) + .andExpect(jsonPath("$.location").value("Central Park")) + .andExpect(jsonPath("$.attendeeCount").value(0)) + .andExpect(jsonPath("$.expired").value(false)) + .andExpect(jsonPath("$.dateTime").isNotEmpty()); + } + + @Test + void getEventWithOptionalFieldsAbsent() throws Exception { + EventJpaEntity entity = seedEvent( + "Minimal", null, "UTC", null, LocalDate.now().plusDays(30)); + + mockMvc.perform(get("/api/events/" + entity.getEventToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("Minimal")) + .andExpect(jsonPath("$.description").doesNotExist()) + .andExpect(jsonPath("$.location").doesNotExist()) + .andExpect(jsonPath("$.attendeeCount").value(0)); + } + + @Test + void getEventNotFoundReturns404() throws Exception { + mockMvc.perform(get("/api/events/" + UUID.randomUUID())) + .andExpect(status().isNotFound()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")) + .andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found")); + } + + @Test + void getExpiredEventReturnsExpiredTrue() throws Exception { + EventJpaEntity entity = seedEvent( + "Past Event", "It happened", "Europe/Berlin", + "Old Venue", LocalDate.now().minusDays(1)); + + mockMvc.perform(get("/api/events/" + entity.getEventToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("Past Event")) + .andExpect(jsonPath("$.expired").value(true)); + } + + private EventJpaEntity seedEvent( + String title, String description, String timezone, + String location, LocalDate expiryDate) { + var entity = new EventJpaEntity(); + entity.setEventToken(UUID.randomUUID()); + entity.setOrganizerToken(UUID.randomUUID()); + entity.setTitle(title); + entity.setDescription(description); + entity.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))); + entity.setTimezone(timezone); + entity.setLocation(location); + entity.setExpiryDate(expiryDate); + entity.setCreatedAt(OffsetDateTime.now()); + return jpaRepository.save(entity); + } } diff --git a/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterTest.java b/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterTest.java index c743872..eedac71 100644 --- a/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterTest.java +++ b/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterTest.java @@ -7,6 +7,7 @@ import de.fete.domain.model.Event; import de.fete.domain.port.out.EventRepository; import java.time.LocalDate; import java.time.OffsetDateTime; +import java.time.ZoneId; import java.time.ZoneOffset; import java.util.Optional; import java.util.UUID; @@ -65,6 +66,7 @@ class EventPersistenceAdapterTest { event.setTitle("Full Event"); event.setDescription("A detailed description"); event.setDateTime(dateTime); + event.setTimezone(ZoneId.of("Europe/Berlin")); event.setLocation("Berlin, Germany"); event.setExpiryDate(expiryDate); event.setCreatedAt(createdAt); @@ -77,6 +79,7 @@ class EventPersistenceAdapterTest { assertThat(found.getTitle()).isEqualTo("Full Event"); assertThat(found.getDescription()).isEqualTo("A detailed description"); assertThat(found.getDateTime().toInstant()).isEqualTo(dateTime.toInstant()); + assertThat(found.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin")); assertThat(found.getLocation()).isEqualTo("Berlin, Germany"); assertThat(found.getExpiryDate()).isEqualTo(expiryDate); assertThat(found.getCreatedAt().toInstant()).isEqualTo(createdAt.toInstant()); @@ -89,6 +92,7 @@ class EventPersistenceAdapterTest { event.setTitle("Test Event"); event.setDescription("Test description"); event.setDateTime(OffsetDateTime.now().plusDays(7)); + event.setTimezone(ZoneId.of("Europe/Berlin")); event.setLocation("Somewhere"); event.setExpiryDate(LocalDate.now().plusDays(30)); event.setCreatedAt(OffsetDateTime.now()); diff --git a/backend/src/test/java/de/fete/application/service/EventServiceTest.java b/backend/src/test/java/de/fete/application/service/EventServiceTest.java index 436b579..7062536 100644 --- a/backend/src/test/java/de/fete/application/service/EventServiceTest.java +++ b/backend/src/test/java/de/fete/application/service/EventServiceTest.java @@ -16,6 +16,8 @@ import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.ZoneOffset; +import java.util.Optional; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -50,6 +52,7 @@ class EventServiceTest { "Birthday Party", "Come celebrate!", OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)), + ZoneId.of("Europe/Berlin"), "Berlin", LocalDate.of(2026, 7, 15) ); @@ -58,28 +61,13 @@ class EventServiceTest { assertThat(result.getTitle()).isEqualTo("Birthday Party"); assertThat(result.getDescription()).isEqualTo("Come celebrate!"); + assertThat(result.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin")); assertThat(result.getLocation()).isEqualTo("Berlin"); assertThat(result.getEventToken()).isNotNull(); assertThat(result.getOrganizerToken()).isNotNull(); assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.now(FIXED_CLOCK)); } - @Test - void eventTokenAndOrganizerTokenAreDifferent() { - when(eventRepository.save(any(Event.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - - var command = new CreateEventCommand( - "Test", null, - OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null, - LocalDate.now(FIXED_CLOCK).plusDays(30) - ); - - Event result = eventService.createEvent(command); - - assertThat(result.getEventToken()).isNotEqualTo(result.getOrganizerToken()); - } - @Test void repositorySaveCalledExactlyOnce() { when(eventRepository.save(any(Event.class))) @@ -87,7 +75,7 @@ class EventServiceTest { var command = new CreateEventCommand( "Test", null, - OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null, + OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null, LocalDate.now(FIXED_CLOCK).plusDays(30) ); @@ -102,7 +90,7 @@ class EventServiceTest { void expiryDateTodayThrowsException() { var command = new CreateEventCommand( "Test", null, - OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null, + OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null, LocalDate.now(FIXED_CLOCK) ); @@ -114,7 +102,7 @@ class EventServiceTest { void expiryDateInPastThrowsException() { var command = new CreateEventCommand( "Test", null, - OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null, + OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null, LocalDate.now(FIXED_CLOCK).minusDays(5) ); @@ -129,7 +117,7 @@ class EventServiceTest { var command = new CreateEventCommand( "Test", null, - OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null, + OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null, LocalDate.now(FIXED_CLOCK).plusDays(1) ); @@ -137,4 +125,51 @@ class EventServiceTest { assertThat(result.getExpiryDate()).isEqualTo(LocalDate.of(2026, 3, 6)); } + + // --- GetEventUseCase tests (T004) --- + + @Test + void getByEventTokenReturnsEvent() { + UUID token = UUID.randomUUID(); + var event = new Event(); + event.setEventToken(token); + event.setTitle("Found Event"); + when(eventRepository.findByEventToken(token)) + .thenReturn(Optional.of(event)); + + Optional result = eventService.getByEventToken(token); + + assertThat(result).isPresent(); + assertThat(result.get().getTitle()).isEqualTo("Found Event"); + } + + @Test + void getByEventTokenReturnsEmptyForUnknownToken() { + UUID token = UUID.randomUUID(); + when(eventRepository.findByEventToken(token)) + .thenReturn(Optional.empty()); + + Optional result = eventService.getByEventToken(token); + + assertThat(result).isEmpty(); + } + + // --- Timezone validation tests (T006) --- + + @Test + void createEventWithValidTimezoneSucceeds() { + when(eventRepository.save(any(Event.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + var command = new CreateEventCommand( + "Test", null, + OffsetDateTime.now(FIXED_CLOCK).plusDays(1), + ZoneId.of("America/New_York"), null, + LocalDate.now(FIXED_CLOCK).plusDays(30) + ); + + Event result = eventService.createEvent(command); + + assertThat(result.getTimezone()).isEqualTo(ZoneId.of("America/New_York")); + } } From 76b48d8b615b2b198570abde22f402734e38054b Mon Sep 17 00:00:00 2001 From: nitrix Date: Fri, 6 Mar 2026 22:34:00 +0100 Subject: [PATCH 4/7] Add EventDetailView with loading, expired, not-found, and error states New view fetches event via openapi-fetch, formats date/time with Intl.DateTimeFormat. Skeleton shimmer during loading (CSS-only). Create form now sends auto-detected timezone. Unit tests for all five view states, E2E tests with MSW mocks. Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/event-create.spec.ts | 3 +- frontend/e2e/event-view.spec.ts | 127 +++++++++++ frontend/src/assets/main.css | 13 ++ frontend/src/router/index.ts | 2 +- frontend/src/views/EventCreateView.vue | 1 + frontend/src/views/EventDetailView.vue | 214 ++++++++++++++++++ .../views/__tests__/EventDetailView.spec.ts | 198 ++++++++++++++++ 7 files changed, 555 insertions(+), 3 deletions(-) create mode 100644 frontend/e2e/event-view.spec.ts create mode 100644 frontend/src/views/EventDetailView.vue create mode 100644 frontend/src/views/__tests__/EventDetailView.spec.ts diff --git a/frontend/e2e/event-create.spec.ts b/frontend/e2e/event-create.spec.ts index 0db9f01..aac9ceb 100644 --- a/frontend/e2e/event-create.spec.ts +++ b/frontend/e2e/event-create.spec.ts @@ -12,7 +12,7 @@ test.describe('US-1: Create an event', () => { await expect(page.getByText('Expiry date is required.')).toBeVisible() }) - test('creates an event and redirects to stub page', async ({ page }) => { + test('creates an event and redirects to event detail page', async ({ page }) => { await page.goto('/create') await page.getByLabel(/title/i).fill('Summer BBQ') @@ -24,7 +24,6 @@ test.describe('US-1: Create an event', () => { await page.getByRole('button', { name: /create event/i }).click() await expect(page).toHaveURL(/\/events\/.+/) - await expect(page.getByText('Event created!')).toBeVisible() }) test('stores event data in localStorage after creation', async ({ page }) => { diff --git a/frontend/e2e/event-view.spec.ts b/frontend/e2e/event-view.spec.ts new file mode 100644 index 0000000..e25c054 --- /dev/null +++ b/frontend/e2e/event-view.spec.ts @@ -0,0 +1,127 @@ +import { http, HttpResponse } from 'msw' +import { test, expect } from './msw-setup' + +const fullEvent = { + eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + title: 'Summer BBQ', + description: 'Bring your own drinks!', + dateTime: '2026-03-15T20:00:00+01:00', + timezone: 'Europe/Berlin', + location: 'Central Park, NYC', + attendeeCount: 12, + expired: false, +} + +test.describe('US-1: View event details', () => { + test('displays all event fields for a valid event', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => { + return HttpResponse.json(fullEvent) + }), + ) + + await page.goto(`/events/${fullEvent.eventToken}`) + + await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible() + await expect(page.getByText('Bring your own drinks!')).toBeVisible() + await expect(page.getByText('Central Park, NYC')).toBeVisible() + await expect(page.getByText('12')).toBeVisible() + await expect(page.getByText('Europe/Berlin')).toBeVisible() + await expect(page.getByText('2026')).toBeVisible() + }) + + test('does not load external resources', async ({ page, network }) => { + const externalRequests: string[] = [] + page.on('request', (req) => { + const url = new URL(req.url()) + if (!['localhost', '127.0.0.1'].includes(url.hostname)) { + externalRequests.push(req.url()) + } + }) + + network.use( + http.get('*/api/events/:token', () => { + return HttpResponse.json(fullEvent) + }), + ) + + await page.goto(`/events/${fullEvent.eventToken}`) + await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible() + + expect(externalRequests).toEqual([]) + }) +}) + +test.describe('US-2: View expired event', () => { + test('shows "event has ended" banner for expired event', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => { + return HttpResponse.json({ ...fullEvent, expired: true }) + }), + ) + + await page.goto(`/events/${fullEvent.eventToken}`) + + await expect(page.getByText('This event has ended.')).toBeVisible() + }) +}) + +test.describe('US-4: Event not found', () => { + test('shows "event not found" for unknown token', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => { + return HttpResponse.json( + { type: 'urn:problem-type:event-not-found', title: 'Event Not Found', status: 404, detail: 'Event not found.' }, + { status: 404, headers: { 'Content-Type': 'application/problem+json' } }, + ) + }), + ) + + await page.goto('/events/00000000-0000-0000-0000-000000000000') + + await expect(page.getByText('Event not found.')).toBeVisible() + // No event data visible + await expect(page.locator('.detail__title')).not.toBeVisible() + }) +}) + +test.describe('Server error', () => { + test('shows error message and retry button on 500', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => { + return HttpResponse.json( + { type: 'about:blank', title: 'Internal Server Error', status: 500, detail: 'An unexpected error occurred.' }, + { status: 500, headers: { 'Content-Type': 'application/problem+json' } }, + ) + }), + ) + + await page.goto(`/events/${fullEvent.eventToken}`) + + await expect(page.getByText('Something went wrong.')).toBeVisible() + await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible() + }) + + test('retry button re-fetches the event', async ({ page, network }) => { + let callCount = 0 + network.use( + http.get('*/api/events/:token', () => { + callCount++ + if (callCount === 1) { + return HttpResponse.json( + { type: 'about:blank', title: 'Error', status: 500 }, + { status: 500, headers: { 'Content-Type': 'application/problem+json' } }, + ) + } + return HttpResponse.json(fullEvent) + }), + ) + + await page.goto(`/events/${fullEvent.eventToken}`) + await expect(page.getByText('Something went wrong.')).toBeVisible() + + await page.getByRole('button', { name: 'Retry' }).click() + + await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible() + }) +}) diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 62440ad..2da8244 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -163,6 +163,19 @@ textarea.form-field { padding-left: 0.25rem; } +/* Skeleton shimmer loading state */ +.skeleton { + background: linear-gradient(90deg, var(--color-card) 25%, #e0e0e0 50%, var(--color-card) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: var(--radius-card); +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + /* Utility */ .text-center { text-align: center; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 07bc62d..3d63d78 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -17,7 +17,7 @@ const router = createRouter({ { path: '/events/:token', name: 'event', - component: () => import('../views/EventStubView.vue'), + component: () => import('../views/EventDetailView.vue'), }, ], }) diff --git a/frontend/src/views/EventCreateView.vue b/frontend/src/views/EventCreateView.vue index e48af62..aa8ea6c 100644 --- a/frontend/src/views/EventCreateView.vue +++ b/frontend/src/views/EventCreateView.vue @@ -184,6 +184,7 @@ async function handleSubmit() { title: form.title.trim(), description: form.description.trim() || undefined, dateTime: dateTimeWithOffset, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, location: form.location.trim() || undefined, expiryDate: form.expiryDate, }, diff --git a/frontend/src/views/EventDetailView.vue b/frontend/src/views/EventDetailView.vue new file mode 100644 index 0000000..b946f98 --- /dev/null +++ b/frontend/src/views/EventDetailView.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/frontend/src/views/__tests__/EventDetailView.spec.ts b/frontend/src/views/__tests__/EventDetailView.spec.ts new file mode 100644 index 0000000..634f94d --- /dev/null +++ b/frontend/src/views/__tests__/EventDetailView.spec.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import EventDetailView from '../EventDetailView.vue' +import { api } from '@/api/client' + +vi.mock('@/api/client', () => ({ + api: { + GET: vi.fn(), + }, +})) + +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'home', component: { template: '
' } }, + { path: '/events/:token', name: 'event', component: EventDetailView }, + ], + }) +} + +async function mountWithToken(token = 'test-token') { + const router = createTestRouter(token) + await router.push(`/events/${token}`) + await router.isReady() + return mount(EventDetailView, { + global: { plugins: [router] }, + }) +} + +const fullEvent = { + eventToken: 'abc-123', + title: 'Summer BBQ', + description: 'Bring your own drinks!', + dateTime: '2026-03-15T20:00:00+01:00', + timezone: 'Europe/Berlin', + location: 'Central Park, NYC', + attendeeCount: 12, + expired: false, +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe('EventDetailView', () => { + // T014: Loading state + it('renders skeleton shimmer placeholders while loading', async () => { + vi.mocked(api.GET).mockReturnValue(new Promise(() => {})) + + const wrapper = await mountWithToken() + + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true) + expect(wrapper.findAll('.skeleton').length).toBeGreaterThanOrEqual(3) + }) + + // T013: Loaded state — all fields + it('renders all event fields when loaded', async () => { + vi.mocked(api.GET).mockResolvedValue({ + data: fullEvent, + error: undefined, + response: new Response(null, { status: 200 }), + } as never) + + const wrapper = await mountWithToken() + await flushPromises() + + expect(wrapper.find('.detail__title').text()).toBe('Summer BBQ') + expect(wrapper.text()).toContain('Bring your own drinks!') + expect(wrapper.text()).toContain('Central Park, NYC') + expect(wrapper.text()).toContain('12') + expect(wrapper.text()).toContain('Europe/Berlin') + }) + + // T013: Loaded state — locale-formatted date/time + it('formats date/time with Intl.DateTimeFormat and timezone', async () => { + vi.mocked(api.GET).mockResolvedValue({ + data: fullEvent, + error: undefined, + response: new Response(null, { status: 200 }), + } as never) + + const wrapper = await mountWithToken() + await flushPromises() + + const dateField = wrapper.findAll('.detail__value')[0] + expect(dateField.text()).toContain('(Europe/Berlin)') + // The formatted date part is locale-dependent but should contain the year + expect(dateField.text()).toContain('2026') + }) + + // T013: Loaded state — optional fields absent + it('does not render description and location when absent', async () => { + vi.mocked(api.GET).mockResolvedValue({ + data: { + ...fullEvent, + description: undefined, + location: undefined, + attendeeCount: 0, + }, + error: undefined, + response: new Response(null, { status: 200 }), + } as never) + + const wrapper = await mountWithToken() + await flushPromises() + + expect(wrapper.text()).not.toContain('Description') + expect(wrapper.text()).not.toContain('Location') + expect(wrapper.text()).toContain('0') + }) + + // T020 (US2): Expired state + it('renders "event has ended" banner when expired', async () => { + vi.mocked(api.GET).mockResolvedValue({ + data: { ...fullEvent, expired: true }, + error: undefined, + response: new Response(null, { status: 200 }), + } as never) + + const wrapper = await mountWithToken() + await flushPromises() + + expect(wrapper.text()).toContain('This event has ended.') + expect(wrapper.find('.detail__banner--expired').exists()).toBe(true) + }) + + // T020 (US2): No expired banner when not expired + it('does not render expired banner when event is active', async () => { + vi.mocked(api.GET).mockResolvedValue({ + data: fullEvent, + error: undefined, + response: new Response(null, { status: 200 }), + } as never) + + const wrapper = await mountWithToken() + await flushPromises() + + expect(wrapper.find('.detail__banner--expired').exists()).toBe(false) + }) + + // T023 (US4): Not found state + it('renders "event not found" when API returns 404', async () => { + vi.mocked(api.GET).mockResolvedValue({ + data: undefined, + error: { type: 'about:blank', title: 'Not Found', status: 404 }, + response: new Response(null, { status: 404 }), + } as never) + + const wrapper = await mountWithToken() + await flushPromises() + + expect(wrapper.text()).toContain('Event not found.') + // No event data in DOM + expect(wrapper.find('.detail__title').exists()).toBe(false) + }) + + // T027: Server error + retry + it('renders error state with retry button on server error', async () => { + vi.mocked(api.GET).mockResolvedValue({ + data: undefined, + error: { type: 'about:blank', title: 'Internal Server Error', status: 500 }, + response: new Response(null, { status: 500 }), + } as never) + + const wrapper = await mountWithToken() + await flushPromises() + + expect(wrapper.text()).toContain('Something went wrong.') + expect(wrapper.find('button').text()).toBe('Retry') + }) + + // T027: Retry button re-fetches + it('retry button triggers a new fetch', async () => { + vi.mocked(api.GET) + .mockResolvedValueOnce({ + data: undefined, + error: { type: 'about:blank', title: 'Error', status: 500 }, + response: new Response(null, { status: 500 }), + } as never) + .mockResolvedValueOnce({ + data: fullEvent, + error: undefined, + response: new Response(null, { status: 200 }), + } as never) + + const wrapper = await mountWithToken() + await flushPromises() + + expect(wrapper.text()).toContain('Something went wrong.') + + await wrapper.find('button').trigger('click') + await flushPromises() + + expect(wrapper.find('.detail__title').text()).toBe('Summer BBQ') + }) +}) From cd71110514631dddf221f7ac25c69fdefc1ce4a0 Mon Sep 17 00:00:00 2001 From: nitrix Date: Fri, 6 Mar 2026 22:34:07 +0100 Subject: [PATCH 5/7] Add test-results/ to gitignore Playwright test artifacts should not be tracked. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 64996ef..27110e2 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ Thumbs.db .agent-tests/ .ralph/*/iteration-*.jsonl +# Test results (Playwright artifacts) +test-results/ + # Java/Maven *.class *.jar From 63108f4eb5a6ca1815fdaae4c21ac26589b4f0ed Mon Sep 17 00:00:00 2001 From: nitrix Date: Fri, 6 Mar 2026 22:53:36 +0100 Subject: [PATCH 6/7] Fix TypeScript type errors in frontend test files - Add missing timezone field to CreateEventResponse mock - Fix createTestRouter signature to accept optional token parameter - Add non-null assertion for dateField element access Co-Authored-By: Claude Opus 4.6 --- frontend/src/views/__tests__/EventCreateView.spec.ts | 1 + frontend/src/views/__tests__/EventDetailView.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/__tests__/EventCreateView.spec.ts b/frontend/src/views/__tests__/EventCreateView.spec.ts index 1807d08..447de76 100644 --- a/frontend/src/views/__tests__/EventCreateView.spec.ts +++ b/frontend/src/views/__tests__/EventCreateView.spec.ts @@ -173,6 +173,7 @@ describe('EventCreateView', () => { organizerToken: 'org-456', title: 'Birthday Party', dateTime: '2026-12-25T18:00:00+01:00', + timezone: 'Europe/Berlin', expiryDate: '2026-12-24', }, error: undefined, diff --git a/frontend/src/views/__tests__/EventDetailView.spec.ts b/frontend/src/views/__tests__/EventDetailView.spec.ts index 634f94d..fddc8dd 100644 --- a/frontend/src/views/__tests__/EventDetailView.spec.ts +++ b/frontend/src/views/__tests__/EventDetailView.spec.ts @@ -10,7 +10,7 @@ vi.mock('@/api/client', () => ({ }, })) -function createTestRouter() { +function createTestRouter(_token?: string) { return createRouter({ history: createMemoryHistory(), routes: [ @@ -84,7 +84,7 @@ describe('EventDetailView', () => { const wrapper = await mountWithToken() await flushPromises() - const dateField = wrapper.findAll('.detail__value')[0] + const dateField = wrapper.findAll('.detail__value')[0]! expect(dateField.text()).toContain('(Europe/Berlin)') // The formatted date part is locale-dependent but should contain the year expect(dateField.text()).toContain('2026') From fd9175925e3679fa04393707fa5b03659ed14b18 Mon Sep 17 00:00:00 2001 From: nitrix Date: Fri, 6 Mar 2026 22:54:41 +0100 Subject: [PATCH 7/7] Use vue-tsc --build in frontend hook to match CI The hook used --noEmit which is less strict than CI's --build, causing type errors to slip through. Co-Authored-By: Claude Opus 4.6 --- .claude/hooks/frontend-check.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/hooks/frontend-check.sh b/.claude/hooks/frontend-check.sh index 92606af..43a3002 100755 --- a/.claude/hooks/frontend-check.sh +++ b/.claude/hooks/frontend-check.sh @@ -16,7 +16,7 @@ cd "$CLAUDE_PROJECT_DIR/frontend" ERRORS="" # Type-check -if OUTPUT=$(npx vue-tsc --noEmit 2>&1); then +if OUTPUT=$(npm run type-check 2>&1); then : else ERRORS+="Type-check failed:\n$OUTPUT\n\n"