48 Commits

Author SHA1 Message Date
752d153cd4 Merge pull request 'Add organizer-only attendee list (011)' (#20) from 011-view-attendee-list into master
All checks were successful
CI / backend-test (push) Successful in 2m5s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m10s
CI / build-and-publish (push) Successful in 1m2s
2026-03-08 18:37:47 +01:00
763811fce6 Add organizer-only attendee list to event detail view (011)
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m11s
CI / build-and-publish (push) Has been skipped
New GET /events/{token}/attendees endpoint returns attendee names when
a valid organizer token is provided (403 otherwise). The frontend
conditionally renders the list below the attendee count for organizers,
silently degrading for visitors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:34:27 +01:00
d7ed28e036 Merge pull request 'Add event list temporal grouping (010)' (#19) from 010-event-list-grouping into master
All checks were successful
CI / backend-test (push) Successful in 1m2s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m9s
CI / build-and-publish (push) Successful in 1m1s
2026-03-08 17:30:35 +01:00
a52d0cd1d3 Add temporal grouping to event list (Today/This Week/Next Week/Later/Past)
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m9s
CI / build-and-publish (push) Has been skipped
Group events into five temporal sections with section headers, date subheaders,
and context-aware time display (clock time for upcoming, relative for past).
Includes new useEventGrouping composable, SectionHeader and DateSubheader
components, full unit and E2E test coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:26:58 +01:00
373f3671f6 Merge pull request 'Redesign event detail view with full-screen layout' (#18) from redesign-event-detail-view into master
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 22s
CI / frontend-e2e (push) Successful in 1m5s
CI / build-and-publish (push) Has been skipped
2026-03-08 16:51:09 +01:00
8f78c6cd45 Redesign event detail view: full-screen layout with hero image
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m5s
CI / build-and-publish (push) Has been skipped
Replace card-based event detail view with full-screen gradient layout.
Add hero image with gradient overlay, icon-based meta rows, and
"About" section. Content renders directly on the gradient background
with white text for an app-native feel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 16:47:29 +01:00
fe291e36e4 Merge pull request 'Add event list feature (009-list-events)' (#17) from 009-list-events into master
All checks were successful
CI / backend-test (push) Successful in 56s
CI / frontend-test (push) Successful in 22s
CI / frontend-e2e (push) Successful in 1m3s
CI / build-and-publish (push) Successful in 59s
2026-03-08 15:58:04 +01:00
e56998b17c Add event list feature (009-list-events)
All checks were successful
CI / backend-test (push) Successful in 57s
CI / frontend-test (push) Successful in 22s
CI / frontend-e2e (push) Successful in 1m4s
CI / build-and-publish (push) Has been skipped
Enable users to see all their saved events on the home screen, sorted
by date with upcoming events first. Key capabilities:

- EventCard with title, relative time display, and organizer/attendee
  role badge
- Sortable EventList with past-event visual distinction (faded style)
- Empty state when no events are stored
- Swipe-to-delete gesture with confirmation dialog
- Floating action button for quick event creation
- Rename router param :token → :eventToken across all views
- useRelativeTime composable (Intl.RelativeTimeFormat)
- useEventStorage: add validation, removeEvent(), reactive versioning
- Full E2E and unit test coverage for all new components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:53:55 +01:00
1b3eafa8d1 Remove unimplemented specs (009-026) and consolidate ideas into ideen.md
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 22s
CI / frontend-e2e (push) Successful in 56s
CI / build-and-publish (push) Has been skipped
Move feature summaries for 18 unimplemented specs into
.specify/memory/ideen.md before deleting the full spec files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:27:28 +01:00
061d507825 Use GitHub-hosted mirror for gitea-release-action
All checks were successful
CI / backend-test (push) Successful in 56s
CI / frontend-test (push) Successful in 21s
CI / frontend-e2e (push) Successful in 55s
CI / build-and-publish (push) Successful in 10s
The Gitea runner cannot reach gitea.com (IPv6 timeout), so switch to
the akkuman/gitea-release-action mirror hosted on GitHub.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:15:41 +01:00
d79a19ca15 Add Gitea release creation to CI pipeline
Some checks failed
CI / backend-test (push) Successful in 56s
CI / frontend-test (push) Successful in 21s
CI / frontend-e2e (push) Successful in 57s
CI / build-and-publish (push) Failing after 34s
Generate changelog from commits between tags and create a Gitea release
using the official gitea-release-action after publishing container images.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:08:49 +01:00
2da36058ae Merge pull request 'Add RSVP feature: submit RSVP, block on expired events' (#16) from 008-rsvp into master
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 22s
CI / frontend-e2e (push) Successful in 56s
CI / build-and-publish (push) Successful in 1m4s
2026-03-08 13:39:35 +01:00
90bfd12bf3 Validate expiryDate is strictly after eventDate and harden rejection tests
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 22s
CI / frontend-e2e (push) Successful in 56s
CI / build-and-publish (push) Has been skipped
Adds ExpiryDateBeforeEventException (400) when expiryDate <= eventDate,
asserts DB row count unchanged after every rejection in integration tests,
and replaces all hardcoded dates in EventServiceTest with TODAY-relative
expressions derived from the fixed Clock.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:22:10 +01:00
4d6df8d16b Block RSVPs on expired events with 409 Conflict and inject Clock into RsvpService
Adds expiry check to RsvpService using an injected Clock for testability,
handles EventExpiredException in GlobalExceptionHandler as 409 Conflict,
and adds unit + integration tests using relative dates from a fixed clock.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:00:30 +01:00
be1c5062a2 Add RSVP frontend: bottom sheet form, RsvpBar, and localStorage persistence
Introduces BottomSheet and RsvpBar components, integrates the RSVP
submission flow into EventDetailView, extends useEventStorage with
saveRsvp/getRsvp, and adds unit tests plus an E2E spec for the RSVP
workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:47:53 +01:00
d9136481d8 Run mvnw verify instead of test in stop hook to include SpotBugs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:05:09 +01:00
e248a2ee06 Add ArchUnit rule: web adapter must not depend on outbound ports
Prevents future regressions where controllers bypass the application layer
and access repositories directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:05:00 +01:00
fc77248c38 Extract CountAttendeesByEventUseCase to decouple controller from repository
The EventController was directly accessing RsvpRepository (an outbound port)
to count attendees, bypassing the application layer. Introduce a dedicated
inbound port and implement it in RsvpService. Remove the now-unused Clock
dependency from RsvpService.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:04:51 +01:00
a625e34fe4 Add RSVP creation endpoint with typed tokens and attendee count
Introduce typed token value objects (EventToken, OrganizerToken,
RsvpToken) and refactor all existing Event code to use them.

Add POST /events/{token}/rsvps endpoint that persists an RSVP and
returns an rsvpToken. Populate attendeeCount in GET /events/{token}
from a real count query instead of hardcoded 0.

Includes: OpenAPI spec, Liquibase migration (rsvps table with
ON DELETE CASCADE), domain model, hexagonal ports/adapters,
service layer, and full test coverage (unit + integration).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:49:49 +01:00
4828d06aba Add 008-rsvp feature spec and design artifacts
Spec, research decisions, implementation plan, data model,
API contract, and task breakdown for the RSVP feature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:48:00 +01:00
cac2903807 Merge pull request 'Update dependency eslint to v10.0.3' (#15) from renovate/eslint-monorepo into master
All checks were successful
CI / backend-test (push) Successful in 56s
CI / frontend-test (push) Successful in 21s
CI / frontend-e2e (push) Successful in 58s
CI / build-and-publish (push) Has been skipped
Reviewed-on: #15
2026-03-07 00:07:17 +01:00
Renovate Bot
210118bf9a Update dependency eslint to v10.0.3
All checks were successful
CI / backend-test (push) Successful in 54s
CI / frontend-test (push) Successful in 21s
CI / frontend-e2e (push) Successful in 52s
CI / build-and-publish (push) Has been skipped
2026-03-06 23:02:21 +00:00
9a78ebd9b0 Add merge-pr skill for Gitea PR + CI workflow
All checks were successful
CI / backend-test (push) Successful in 54s
CI / frontend-test (push) Successful in 21s
CI / frontend-e2e (push) Successful in 53s
CI / build-and-publish (push) Has been skipped
Encodes the workflow for creating PRs, monitoring CI status via
Actions API (cross-referencing head SHA), and merging when green.

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

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

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

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

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

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

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

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

View File

@@ -16,7 +16,7 @@ cd "$CLAUDE_PROJECT_DIR/frontend"
ERRORS=""
# Type-check
if OUTPUT=$(npx vue-tsc --noEmit 2>&1); then
if OUTPUT=$(npm run type-check 2>&1); then
:
else
ERRORS+="Type-check failed:\n$OUTPUT\n\n"

View File

@@ -26,7 +26,7 @@ PASSED=""
# Run backend tests if Java sources changed
if [[ -n "$HAS_BACKEND" ]]; then
if OUTPUT=$(cd backend && ./mvnw test -q 2>&1); then
if OUTPUT=$(cd backend && ./mvnw verify -q 2>&1); then
PASSED+="✓ Backend tests passed. "
else
# Filter: only [ERROR] lines, skip Maven boilerplate

View File

@@ -0,0 +1,94 @@
---
name: merge-pr
description: Create a Gitea pull request, monitor CI pipeline status, and merge when green. Use this skill when the user asks to "create a PR", "merge the PR", "ship it", "make it ready to merge", or when you need to open a pull request and wait for CI before merging. Also use when asked to check CI/PR status on Gitea.
---
# Merge PR
Create a pull request on Gitea, monitor the CI pipeline via the Actions API, and merge once all jobs pass.
## Why this skill exists
The Gitea MCP pull request API does not return CI status directly. To know if a PR is ready to merge, you must cross-reference the PR's `head.sha` with the Actions runs API, find the matching run, and check job conclusions. This skill encodes that workflow so it doesn't have to be rediscovered.
## Prerequisites
The Gitea MCP tools must be available. The key tools are:
- `mcp__gitea__pull_request_write` (method: `create`, `merge`)
- `mcp__gitea__pull_request_read` (method: `get`)
- `mcp__gitea__actions_run_read` (methods: `list_runs`, `list_run_jobs`)
If these tools are not yet loaded, use ToolSearch to discover and load them before proceeding.
## Workflow
### 1. Create the PR
Use `mcp__gitea__pull_request_write` with method `create`. Include a clear title, body with summary and test plan, head branch, and base branch (usually `master`).
Save the returned `head.sha` — you need it to find the CI run.
### 2. Find the CI run for the PR
The Actions API has no direct "get CI status for PR" call. Instead:
```
mcp__gitea__actions_run_read(method: "list_runs", owner, repo, perPage: 5)
```
Find the run whose `head_sha` matches the PR's `head.sha`. This is the CI run triggered by the push that the PR points to. If the branch was force-pushed or new commits were added, always match against the latest `head.sha` from a fresh `get` on the PR.
### 3. Monitor job status
Once you have the run ID:
```
mcp__gitea__actions_run_read(method: "list_run_jobs", owner, repo, run_id: <id>)
```
This returns all jobs with their `status` (queued/in_progress/completed) and `conclusion` (success/failure/skipped/null).
Present a status table to the user:
| Job | Status |
|-----|--------|
| backend-test | success |
| frontend-test | in_progress |
| frontend-e2e | queued |
If jobs are still running, wait ~30 seconds and check again. Don't poll in a tight loop.
### 4. Handle failures
If any job has `conclusion: failure`:
- Use `mcp__gitea__actions_run_read` with method `get_job_log_preview` to fetch the failing job's log
- Report the failure to the user with relevant log output
- Do NOT attempt to merge
### 5. Merge when green
Once all jobs show `conclusion: success` (or `skipped` for conditional jobs like `build-and-publish`):
```
mcp__gitea__pull_request_write(
method: "merge",
owner, repo,
index: <pr_number>,
merge_style: "merge",
delete_branch: true
)
```
Ask the user for confirmation before merging. They may want to review the PR in the web UI first.
### 6. Post-merge cleanup
After a successful merge, suggest:
- `git checkout master && git pull origin master`
- `git branch -d <feature-branch>` (local cleanup)
- Tagging a release if appropriate (see `/release` skill)
## Abbreviated flow
When the user just wants a quick status check (e.g. "how's the PR?"), skip straight to steps 2-3: find the run by SHA, show the job status table.

View File

@@ -7,7 +7,7 @@ jobs:
backend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up JDK 25
uses: actions/setup-java@v5
@@ -21,10 +21,10 @@ jobs:
frontend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Node 24
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 24
@@ -49,10 +49,10 @@ jobs:
frontend-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Node 24
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 24
@@ -66,7 +66,7 @@ jobs:
run: cd frontend && npm run test:e2e
- name: Upload Playwright report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: playwright-report
@@ -78,7 +78,9 @@ jobs:
if: startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, '.')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Parse SemVer tag
id: semver
@@ -114,3 +116,22 @@ jobs:
docker push "${IMAGE}:${{ steps.semver.outputs.minor }}"
docker push "${IMAGE}:${{ steps.semver.outputs.major }}"
docker push "${IMAGE}:latest"
- name: Generate changelog
id: changelog
run: |
PREV_TAG=$(git tag --sort=-v:refname | sed -n '2p')
if [ -z "$PREV_TAG" ]; then
git log --oneline --no-merges > RELEASE_NOTES.md
else
git log --oneline --no-merges "${PREV_TAG}..HEAD" > RELEASE_NOTES.md
fi
echo "Container image: \`${IMAGE}:${{ steps.semver.outputs.full }}\`" >> RELEASE_NOTES.md
- name: Create Gitea release
uses: akkuman/gitea-release-action@v1
with:
tag_name: ${{ github.ref_name }}
name: v${{ steps.semver.outputs.full }}
body_path: RELEASE_NOTES.md
token: ${{ github.token }}

3
.gitignore vendored
View File

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

View File

@@ -107,8 +107,10 @@ Accessibility is a baseline requirement, not an afterthought.
rationale. Never rewrite or delete the original decision.
- The visual design system in `.specify/memory/design-system.md` is authoritative. All
frontend implementation MUST follow it.
- Research reports go to `docs/agents/research/`, implementation plans to
`docs/agents/plan/`.
- Feature specs, research, and plans live in `specs/NNN-feature-name/`
(spec-kit format). Cross-cutting research goes to
`.specify/memory/research/`, cross-cutting plans to
`.specify/memory/plans/`.
- Conversation and brainstorming in German; code, comments, commits, and
documentation in English.
- Documentation lives in the README. No wiki, no elaborate docs site.

View File

@@ -33,12 +33,16 @@ 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:
* Organisator kann einstellen, ob Attendee-Namensliste öffentlich auf der Event-Seite sichtbar ist (default: nur für Organisator). Wenn öffentlich, muss im RSVP-Bottom-Sheet eine Warnung angezeigt werden, dass der Name öffentlich sichtbar sein wird.
* 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)
* QR Code generieren (z.B. für Plakate/Flyer)
* Ablaufdatum als Pflichtfeld, nach dem alle gespeicherten Daten gelöscht werden
* Übersichtsliste im LocalStorage: Alle Events die man zugesagt oder gemerkt hat (vgl. spliit)
* RSVP editieren: Gast kann seine bestehende Zusage bearbeiten (Name ändern via PUT mit rsvpToken) oder zurückziehen (DELETE mit rsvpToken). Bottom Sheet öffnet sich im Edit-Mode mit pre-filled Name + "Zusage zurückziehen"-Button. Später ergänzen: "Absagen und merken" (Kombination mit 011-bookmark-event). Ausgelagert aus 008-rsvp um den Scope klein zu halten.
* Organizer-Gästeliste: Namensliste der Zusagen nur für Organisator sichtbar (über Organizer-Link). Gehört thematisch zu 009-guest-list, nicht zu 008-rsvp.
* Sicherheit/Missbrauchsschutz:
* Nicht-erratbare Event-Tokens (z.B. UUIDs)
* Event-Erstellung ist offen, kein Login/Passwort/Invite-Code nötig
@@ -78,3 +82,113 @@ Die folgenden Punkte wurden in Diskussionen bereits geklärt und sind verbindlic
* Frontend: Vue 3 (mit Vite als Bundler, TypeScript, Vue Router)
* Architekturentscheidungen die NOCH NICHT getroffen wurden (hier darf nichts eigenmächtig entschieden werden!):
* (derzeit keine offenen Architekturentscheidungen)
## Nicht umgesetzte Feature-Ideen (ehemals Specs 009026)
### 009 Gästeliste
Organisator sieht alle RSVPs (Name, Status) und kann einzelne Einträge löschen.
* Nur mit gültigem Organizer-Token sichtbar
* Gäste ohne Token sehen keine Gästeliste
* Löschung serverseitig validiert
### 010 Event bearbeiten
Organisator kann Titel, Beschreibung, Datum, Ort und Ablaufdatum ändern.
* Formular vorausgefüllt mit aktuellen Werten
* Ablaufdatum muss in der Zukunft liegen
* Ohne Organizer-Token kein Edit-UI sichtbar
### 011 Event merken/bookmarken
Gäste können Events lokal merken, ohne RSVP abzugeben — rein clientseitig via localStorage.
* Kein Serverkontakt nötig
* Unabhängig vom RSVP-Status
* Auch bei abgelaufenen Events möglich
### 012 Lokale Event-Übersicht
Startseite (`/`) zeigt alle getrackten Events (erstellt, zugesagt, gemerkt) aus localStorage.
* Zeigt Titel, Datum, Beziehungstyp (Organisator/Gast/Gemerkt)
* Vergangene Events als "beendet" markiert
* Einträge können entfernt werden
### 013 Kalender-Export
.ics-Download (RFC 5545) mit Event-Details, optional webcal:// für Live-Updates.
* Stabile UID aus Event-Token (Re-Import aktualisiert statt dupliziert)
* Bei Absage: STATUS:CANCELLED im .ics
* Kein externer Kalenderservice kontaktiert
### 014 Änderungen hervorheben
Geänderte Felder werden visuell hervorgehoben, wenn der Gast seit der letzten Änderung nicht mehr auf der Seite war.
* Server trackt `last_edited_at` + geänderte Feldnamen
* Client speichert `last_seen_at` in localStorage
* Privacy-freundlich: kein serverseitiges Read-Tracking
### 015 Organisator-Updates
Organisator kann Textnachrichten im Event posten (Pinnwand-Stil).
* Chronologisch sortiert, löschbar durch Organisator
* Nach Ablauf kein Posting mehr möglich
* Ohne Organizer-Token kein Compose-UI
### 016 Gast-Benachrichtigungen
Badge/Indikator bei ungelesenen Organisator-Updates, rein clientseitig via localStorage.
* Eigener Timestamp `updates_last_seen_at` (getrennt von Feld-Änderungen)
* Kein Indikator beim ersten Besuch
* Kein serverseitiges Tracking (Privacy)
### 017 QR-Code
Event-Seite zeigt QR-Code mit der öffentlichen Event-URL.
* Serverseitig generiert (kein externer QR-Service)
* Download als SVG oder hochauflösendes PNG
* Auch bei abgelaufenen Events verfügbar
### 018 Datenlöschung
Automatische Löschung aller Event-Daten nach Ablaufdatum (Privacy-Garantie).
* Scheduled Job oder Lazy Cleanup bei Zugriff
* Löscht Event, RSVPs, Updates, Bilder, Metadaten
* Idempotent, kein PII im Log
### 019 Instanz-Limit
`MAX_ACTIVE_EVENTS` als Env-Variable begrenzt aktive Events für Self-Hoster.
* Nur nicht-abgelaufene Events zählen
* Unset/leer = unbegrenzt
* Serverseitige Durchsetzung bei Event-Erstellung
### 020 PWA
Web App Manifest + Service Worker für Installierbarkeit und Offline-Caching.
* Standalone-Modus ohne Browser-Chrome
* Icon + Name auf Home-Screen
* Alle Assets selbstgehostet
### 021 Farbthemen
Organisator wählt bei Erstellung ein vordefiniertes Farbthema für die Event-Seite.
* Nur auf der Gast-Seite angewendet (nicht global)
* Änderbar beim Bearbeiten
* Unabhängig von Dark/Light Mode
### 022 Headerbild
Organisator sucht Headerbild über integrierte Unsplash-Suche.
* Serverseitig geproxied (Client kontaktiert nie Unsplash)
* Bild lokal gespeichert + Unsplash-Attribution
* Feature deaktiviert wenn kein API-Key konfiguriert
### 023 Dark Mode
App erkennt `prefers-color-scheme` und bietet manuellen Toggle.
* Manuelle Auswahl in localStorage gespeichert
* Gilt für globales App-Chrome, nicht Event-Farbthemen
* Beide Modi WCAG AA konform
### 024 Event absagen
Organisator kann Event absagen (mit optionaler Nachricht, Einweg-Transition).
* RSVPs werden nach Absage abgelehnt
* Absage-Nachricht nachträglich editierbar
* Kann nicht rückgängig gemacht werden
### 025 Event löschen
Organisator löscht Event permanent und unwiderruflich.
* Entfernt alle zugehörigen Daten sofort
* localStorage-Eintrag wird entfernt, Redirect zu `/`
* Funktioniert in jedem Event-Status
### 026 404-Seite
Catch-all Route für ungültige Pfade mit "Seite nicht gefunden" und Link zur Startseite.
* Folgt dem Design System (Electric Dusk + Sora)
* WCAG AA konform
* Verhindert leere Seiten bei Fehlnavigation

View File

@@ -49,3 +49,12 @@ 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)
- TypeScript 5.9 (frontend only) + Vue 3, Vue Router 5 (existing — no additions) (010-event-list-grouping)
- localStorage via `useEventStorage.ts` composable (existing — no changes) (010-event-list-grouping)
## Recent Changes
- 007-view-event: Added Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript

View File

@@ -10,14 +10,14 @@ COPY backend/src/main/resources/openapi/api.yaml \
RUN npm run build
# Stage 2: Build backend with frontend assets baked in
FROM eclipse-temurin:25-jdk-alpine AS backend-build
FROM eclipse-temurin:25.0.2_10-jdk-alpine AS backend-build
WORKDIR /app/backend
COPY backend/ ./
COPY --from=frontend-build /app/frontend/dist src/main/resources/static/
RUN ./mvnw -B -DskipTests -Dcheckstyle.skip -Dspotbugs.skip package
# Stage 3: Runtime
FROM eclipse-temurin:25-jre-alpine
FROM eclipse-temurin:25.0.2_10-jre-alpine
WORKDIR /app
COPY --from=backend-build /app/backend/target/*.jar app.jar
EXPOSE 8080

View File

@@ -1,3 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.13/apache-maven-3.9.13-bin.zip

View File

@@ -179,7 +179,7 @@
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<version>3.6.0</version>
<version>3.6.1</version>
<executions>
<execution>
<id>add-openapi-sources</id>

View File

@@ -1,11 +1,31 @@
package de.fete.adapter.in.web;
import de.fete.adapter.in.web.api.EventsApi;
import de.fete.adapter.in.web.model.Attendee;
import de.fete.adapter.in.web.model.CreateEventRequest;
import de.fete.adapter.in.web.model.CreateEventResponse;
import de.fete.adapter.in.web.model.CreateRsvpRequest;
import de.fete.adapter.in.web.model.CreateRsvpResponse;
import de.fete.adapter.in.web.model.GetAttendeesResponse;
import de.fete.adapter.in.web.model.GetEventResponse;
import de.fete.application.service.EventNotFoundException;
import de.fete.application.service.InvalidTimezoneException;
import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
import de.fete.domain.model.Rsvp;
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.in.CreateRsvpUseCase;
import de.fete.domain.port.in.GetAttendeesUseCase;
import de.fete.domain.port.in.GetEventUseCase;
import java.time.Clock;
import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
@@ -15,19 +35,38 @@ import org.springframework.web.bind.annotation.RestController;
public class EventController implements EventsApi {
private final CreateEventUseCase createEventUseCase;
private final GetEventUseCase getEventUseCase;
private final CreateRsvpUseCase createRsvpUseCase;
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
private final GetAttendeesUseCase getAttendeesUseCase;
private final Clock clock;
/** Creates a new controller with the given use case. */
public EventController(CreateEventUseCase createEventUseCase) {
/** Creates a new controller with the given use cases and clock. */
public EventController(
CreateEventUseCase createEventUseCase,
GetEventUseCase getEventUseCase,
CreateRsvpUseCase createRsvpUseCase,
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
GetAttendeesUseCase getAttendeesUseCase,
Clock clock) {
this.createEventUseCase = createEventUseCase;
this.getEventUseCase = getEventUseCase;
this.createRsvpUseCase = createRsvpUseCase;
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
this.getAttendeesUseCase = getAttendeesUseCase;
this.clock = clock;
}
@Override
public ResponseEntity<CreateEventResponse> createEvent(
CreateEventRequest request) {
ZoneId zoneId = parseTimezone(request.getTimezone());
var command = new CreateEventCommand(
request.getTitle(),
request.getDescription(),
request.getDateTime(),
zoneId,
request.getLocation(),
request.getExpiryDate()
);
@@ -35,12 +74,74 @@ public class EventController implements EventsApi {
Event event = createEventUseCase.createEvent(command);
var response = new CreateEventResponse();
response.setEventToken(event.getEventToken());
response.setOrganizerToken(event.getOrganizerToken());
response.setEventToken(event.getEventToken().value());
response.setOrganizerToken(event.getOrganizerToken().value());
response.setTitle(event.getTitle());
response.setDateTime(event.getDateTime());
response.setTimezone(event.getTimezone().getId());
response.setExpiryDate(event.getExpiryDate());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@Override
public ResponseEntity<GetEventResponse> getEvent(UUID token) {
var eventToken = new de.fete.domain.model.EventToken(token);
Event event = getEventUseCase.getByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(token));
var response = new GetEventResponse();
response.setEventToken(event.getEventToken().value());
response.setTitle(event.getTitle());
response.setDescription(event.getDescription());
response.setDateTime(event.getDateTime());
response.setTimezone(event.getTimezone().getId());
response.setLocation(event.getLocation());
response.setAttendeeCount(
(int) countAttendeesByEventUseCase.countByEvent(eventToken));
response.setExpired(
event.getExpiryDate().isBefore(LocalDate.now(clock)));
return ResponseEntity.ok(response);
}
@Override
public ResponseEntity<GetAttendeesResponse> getAttendees(
UUID token, UUID organizerToken) {
var eventToken = new EventToken(token);
var orgToken = new OrganizerToken(organizerToken);
List<String> names = getAttendeesUseCase
.getAttendeeNames(eventToken, orgToken);
var attendees = names.stream()
.map(name -> new Attendee().name(name))
.toList();
var response = new GetAttendeesResponse();
response.setAttendees(attendees);
return ResponseEntity.ok(response);
}
@Override
public ResponseEntity<CreateRsvpResponse> createRsvp(
UUID token, CreateRsvpRequest createRsvpRequest) {
var eventToken = new EventToken(token);
Rsvp rsvp = createRsvpUseCase.createRsvp(eventToken, createRsvpRequest.getName());
var response = new CreateRsvpResponse();
response.setRsvpToken(rsvp.getRsvpToken().value());
response.setName(rsvp.getName());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
private static ZoneId parseTimezone(String timezone) {
try {
return ZoneId.of(timezone);
} catch (DateTimeException e) {
throw new InvalidTimezoneException(timezone);
}
}
}

View File

@@ -1,6 +1,11 @@
package de.fete.adapter.in.web;
import de.fete.application.service.EventExpiredException;
import de.fete.application.service.EventNotFoundException;
import de.fete.application.service.ExpiryDateBeforeEventException;
import de.fete.application.service.ExpiryDateInPastException;
import de.fete.application.service.InvalidOrganizerTokenException;
import de.fete.application.service.InvalidTimezoneException;
import java.net.URI;
import java.util.List;
import java.util.Map;
@@ -44,6 +49,19 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
return handleExceptionInternal(ex, problemDetail, headers, status, request);
}
/** Handles expiry date before event date. */
@ExceptionHandler(ExpiryDateBeforeEventException.class)
public ResponseEntity<ProblemDetail> handleExpiryDateBeforeEvent(
ExpiryDateBeforeEventException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, ex.getMessage());
problemDetail.setTitle("Invalid Expiry Date");
problemDetail.setType(URI.create("urn:problem-type:expiry-date-before-event"));
return ResponseEntity.badRequest()
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Handles expiry date validation failures. */
@ExceptionHandler(ExpiryDateInPastException.class)
public ResponseEntity<ProblemDetail> handleExpiryDateInPast(
@@ -57,6 +75,58 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
.body(problemDetail);
}
/** Handles RSVP on expired event. */
@ExceptionHandler(EventExpiredException.class)
public ResponseEntity<ProblemDetail> handleEventExpired(
EventExpiredException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, ex.getMessage());
problemDetail.setTitle("Event Expired");
problemDetail.setType(URI.create("urn:problem-type:event-expired"));
return ResponseEntity.status(HttpStatus.CONFLICT)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Handles invalid organizer token. */
@ExceptionHandler(InvalidOrganizerTokenException.class)
public ResponseEntity<ProblemDetail> handleInvalidOrganizerToken(
InvalidOrganizerTokenException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.FORBIDDEN, ex.getMessage());
problemDetail.setTitle("Forbidden");
problemDetail.setType(URI.create("urn:problem-type:invalid-organizer-token"));
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Handles event not found. */
@ExceptionHandler(EventNotFoundException.class)
public ResponseEntity<ProblemDetail> handleEventNotFound(
EventNotFoundException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problemDetail.setTitle("Event Not Found");
problemDetail.setType(URI.create("urn:problem-type:event-not-found"));
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Handles invalid timezone. */
@ExceptionHandler(InvalidTimezoneException.class)
public ResponseEntity<ProblemDetail> handleInvalidTimezone(
InvalidTimezoneException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, ex.getMessage());
problemDetail.setTitle("Invalid Timezone");
problemDetail.setType(URI.create("urn:problem-type:invalid-timezone"));
return ResponseEntity.badRequest()
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Catches all unhandled exceptions. */
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleAll(Exception ex) {

View File

@@ -34,6 +34,9 @@ public class EventJpaEntity {
@Column(name = "date_time", nullable = false)
private OffsetDateTime dateTime;
@Column(nullable = false, length = 64)
private String timezone;
@Column(length = 500)
private String location;
@@ -103,6 +106,16 @@ public class EventJpaEntity {
this.dateTime = dateTime;
}
/** Returns the IANA timezone name. */
public String getTimezone() {
return timezone;
}
/** Sets the IANA timezone name. */
public void setTimezone(String timezone) {
this.timezone = timezone;
}
/** Returns the event location. */
public String getLocation() {
return location;

View File

@@ -1,9 +1,11 @@
package de.fete.adapter.out.persistence;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
import de.fete.domain.port.out.EventRepository;
import java.time.ZoneId;
import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Repository;
/** Persistence adapter implementing the EventRepository outbound port. */
@@ -25,18 +27,19 @@ public class EventPersistenceAdapter implements EventRepository {
}
@Override
public Optional<Event> findByEventToken(UUID eventToken) {
return jpaRepository.findByEventToken(eventToken).map(this::toDomain);
public Optional<Event> findByEventToken(EventToken eventToken) {
return jpaRepository.findByEventToken(eventToken.value()).map(this::toDomain);
}
private EventJpaEntity toEntity(Event event) {
var entity = new EventJpaEntity();
entity.setId(event.getId());
entity.setEventToken(event.getEventToken());
entity.setOrganizerToken(event.getOrganizerToken());
entity.setEventToken(event.getEventToken().value());
entity.setOrganizerToken(event.getOrganizerToken().value());
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());
@@ -46,11 +49,12 @@ public class EventPersistenceAdapter implements EventRepository {
private Event toDomain(EventJpaEntity entity) {
var event = new Event();
event.setId(entity.getId());
event.setEventToken(entity.getEventToken());
event.setOrganizerToken(entity.getOrganizerToken());
event.setEventToken(new EventToken(entity.getEventToken()));
event.setOrganizerToken(new OrganizerToken(entity.getOrganizerToken()));
event.setTitle(entity.getTitle());
event.setDescription(entity.getDescription());
event.setDateTime(entity.getDateTime());
event.setTimezone(ZoneId.of(entity.getTimezone()));
event.setLocation(entity.getLocation());
event.setExpiryDate(entity.getExpiryDate());
event.setCreatedAt(entity.getCreatedAt());

View File

@@ -0,0 +1,68 @@
package de.fete.adapter.out.persistence;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.UUID;
/** JPA entity mapping to the rsvps table. */
@Entity
@Table(name = "rsvps")
public class RsvpJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "rsvp_token", nullable = false, unique = true)
private UUID rsvpToken;
@Column(name = "event_id", nullable = false)
private Long eventId;
@Column(nullable = false, length = 100)
private String name;
/** Returns the internal database ID. */
public Long getId() {
return id;
}
/** Sets the internal database ID. */
public void setId(Long id) {
this.id = id;
}
/** Returns the RSVP token. */
public UUID getRsvpToken() {
return rsvpToken;
}
/** Sets the RSVP token. */
public void setRsvpToken(UUID rsvpToken) {
this.rsvpToken = rsvpToken;
}
/** Returns the event ID. */
public Long getEventId() {
return eventId;
}
/** Sets the event ID. */
public void setEventId(Long eventId) {
this.eventId = eventId;
}
/** Returns the guest's display name. */
public String getName() {
return name;
}
/** Sets the guest's display name. */
public void setName(String name) {
this.name = name;
}
}

View File

@@ -0,0 +1,17 @@
package de.fete.adapter.out.persistence;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
/** Spring Data JPA repository for RSVP entities. */
public interface RsvpJpaRepository extends JpaRepository<RsvpJpaEntity, Long> {
/** Finds an RSVP by its token. */
java.util.Optional<RsvpJpaEntity> findByRsvpToken(UUID rsvpToken);
/** Counts RSVPs for the given event. */
long countByEventId(Long eventId);
/** Finds all RSVPs for the given event, ordered by ID ascending. */
java.util.List<RsvpJpaEntity> findAllByEventIdOrderByIdAsc(Long eventId);
}

View File

@@ -0,0 +1,56 @@
package de.fete.adapter.out.persistence;
import de.fete.domain.model.Rsvp;
import de.fete.domain.model.RsvpToken;
import de.fete.domain.port.out.RsvpRepository;
import java.util.List;
import org.springframework.stereotype.Repository;
/** Persistence adapter implementing the RsvpRepository outbound port. */
@Repository
public class RsvpPersistenceAdapter implements RsvpRepository {
private final RsvpJpaRepository jpaRepository;
/** Creates a new adapter with the given JPA repository. */
public RsvpPersistenceAdapter(RsvpJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Rsvp save(Rsvp rsvp) {
RsvpJpaEntity entity = toEntity(rsvp);
RsvpJpaEntity saved = jpaRepository.save(entity);
return toDomain(saved);
}
@Override
public long countByEventId(Long eventId) {
return jpaRepository.countByEventId(eventId);
}
@Override
public List<Rsvp> findByEventId(Long eventId) {
return jpaRepository.findAllByEventIdOrderByIdAsc(eventId).stream()
.map(this::toDomain)
.toList();
}
private RsvpJpaEntity toEntity(Rsvp rsvp) {
var entity = new RsvpJpaEntity();
entity.setId(rsvp.getId());
entity.setRsvpToken(rsvp.getRsvpToken().value());
entity.setEventId(rsvp.getEventId());
entity.setName(rsvp.getName());
return entity;
}
private Rsvp toDomain(RsvpJpaEntity entity) {
var rsvp = new Rsvp();
rsvp.setId(entity.getId());
rsvp.setRsvpToken(new RsvpToken(entity.getRsvpToken()));
rsvp.setEventId(entity.getEventId());
rsvp.setName(entity.getName());
return rsvp;
}
}

View File

@@ -0,0 +1,12 @@
package de.fete.application.service;
import java.util.UUID;
/** Thrown when an RSVP is attempted on an expired event. */
public class EventExpiredException extends RuntimeException {
/** Creates a new exception for the given event token. */
public EventExpiredException(UUID eventToken) {
super("Event has expired: " + eventToken);
}
}

View File

@@ -0,0 +1,12 @@
package de.fete.application.service;
import java.util.UUID;
/** Thrown when an event cannot be found by its token. */
public class EventNotFoundException extends RuntimeException {
/** Creates a new exception for the given event token. */
public EventNotFoundException(UUID eventToken) {
super("Event not found: " + eventToken);
}
}

View File

@@ -2,17 +2,20 @@ package de.fete.application.service;
import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
import de.fete.domain.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.UUID;
import java.util.Optional;
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;
@@ -29,16 +32,26 @@ public class EventService implements CreateEventUseCase {
throw new ExpiryDateInPastException(command.expiryDate());
}
if (!command.expiryDate().isAfter(command.dateTime().toLocalDate())) {
throw new ExpiryDateBeforeEventException(command.expiryDate(), command.dateTime());
}
var event = new Event();
event.setEventToken(UUID.randomUUID());
event.setOrganizerToken(UUID.randomUUID());
event.setEventToken(EventToken.generate());
event.setOrganizerToken(OrganizerToken.generate());
event.setTitle(command.title());
event.setDescription(command.description());
event.setDateTime(command.dateTime());
event.setTimezone(command.timezone());
event.setLocation(command.location());
event.setExpiryDate(command.expiryDate());
event.setCreatedAt(OffsetDateTime.now(clock));
return eventRepository.save(event);
}
@Override
public Optional<Event> getByEventToken(EventToken eventToken) {
return eventRepository.findByEventToken(eventToken);
}
}

View File

@@ -0,0 +1,13 @@
package de.fete.application.service;
import java.time.LocalDate;
import java.time.OffsetDateTime;
/** Thrown when an event's expiry date is not after the event date. */
public class ExpiryDateBeforeEventException extends RuntimeException {
/** Creates a new exception for the given dates. */
public ExpiryDateBeforeEventException(LocalDate expiryDate, OffsetDateTime dateTime) {
super("Expiry date " + expiryDate + " must be after event date " + dateTime.toLocalDate());
}
}

View File

@@ -0,0 +1,10 @@
package de.fete.application.service;
/** Thrown when an invalid organizer token is provided. */
public class InvalidOrganizerTokenException extends RuntimeException {
/** Creates a new exception for an invalid organizer token. */
public InvalidOrganizerTokenException() {
super("Invalid organizer token.");
}
}

View File

@@ -0,0 +1,10 @@
package de.fete.application.service;
/** Thrown when an invalid IANA timezone ID is provided. */
public class InvalidTimezoneException extends RuntimeException {
/** Creates a new exception for the given invalid timezone. */
public InvalidTimezoneException(String timezone) {
super("Invalid IANA timezone: " + timezone);
}
}

View File

@@ -0,0 +1,74 @@
package de.fete.application.service;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
import de.fete.domain.model.Rsvp;
import de.fete.domain.model.RsvpToken;
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
import de.fete.domain.port.in.CreateRsvpUseCase;
import de.fete.domain.port.in.GetAttendeesUseCase;
import de.fete.domain.port.out.EventRepository;
import de.fete.domain.port.out.RsvpRepository;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import org.springframework.stereotype.Service;
/** Application service implementing RSVP operations. */
@Service
public class RsvpService
implements CreateRsvpUseCase, CountAttendeesByEventUseCase, GetAttendeesUseCase {
private final EventRepository eventRepository;
private final RsvpRepository rsvpRepository;
private final Clock clock;
/** Creates a new RsvpService. */
public RsvpService(
EventRepository eventRepository,
RsvpRepository rsvpRepository,
Clock clock) {
this.eventRepository = eventRepository;
this.rsvpRepository = rsvpRepository;
this.clock = clock;
}
@Override
public Rsvp createRsvp(EventToken eventToken, String name) {
Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
if (!event.getExpiryDate().isAfter(LocalDate.now(clock))) {
throw new EventExpiredException(eventToken.value());
}
var rsvp = new Rsvp();
rsvp.setRsvpToken(RsvpToken.generate());
rsvp.setEventId(event.getId());
rsvp.setName(name.strip());
return rsvpRepository.save(rsvp);
}
@Override
public long countByEvent(EventToken eventToken) {
Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
return rsvpRepository.countByEventId(event.getId());
}
@Override
public List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken) {
Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
if (!event.getOrganizerToken().equals(organizerToken)) {
throw new InvalidOrganizerTokenException();
}
return rsvpRepository.findByEventId(event.getId()).stream()
.map(Rsvp::getName)
.toList();
}
}

View File

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

View File

@@ -2,17 +2,18 @@ package de.fete.domain.model;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.UUID;
import java.time.ZoneId;
/** Domain entity representing an event. */
public class Event {
private Long id;
private UUID eventToken;
private UUID organizerToken;
private EventToken eventToken;
private OrganizerToken organizerToken;
private String title;
private String description;
private OffsetDateTime dateTime;
private ZoneId timezone;
private String location;
private LocalDate expiryDate;
private OffsetDateTime createdAt;
@@ -27,23 +28,23 @@ public class Event {
this.id = id;
}
/** Returns the public event token (UUID). */
public UUID getEventToken() {
/** Returns the public event token. */
public EventToken getEventToken() {
return eventToken;
}
/** Sets the public event token. */
public void setEventToken(UUID eventToken) {
public void setEventToken(EventToken eventToken) {
this.eventToken = eventToken;
}
/** Returns the secret organizer token (UUID). */
public UUID getOrganizerToken() {
/** Returns the secret organizer token. */
public OrganizerToken getOrganizerToken() {
return organizerToken;
}
/** Sets the secret organizer token. */
public void setOrganizerToken(UUID organizerToken) {
public void setOrganizerToken(OrganizerToken organizerToken) {
this.organizerToken = organizerToken;
}
@@ -77,6 +78,16 @@ public class Event {
this.dateTime = dateTime;
}
/** Returns the IANA timezone. */
public ZoneId getTimezone() {
return timezone;
}
/** Sets the IANA timezone. */
public void setTimezone(ZoneId timezone) {
this.timezone = timezone;
}
/** Returns the event location. */
public String getLocation() {
return location;

View File

@@ -0,0 +1,18 @@
package de.fete.domain.model;
import java.util.Objects;
import java.util.UUID;
/** Type-safe wrapper for the public event token. */
public record EventToken(UUID value) {
/** Validates that the token value is not null. */
public EventToken {
Objects.requireNonNull(value, "eventToken must not be null");
}
/** Generates a new random event token. */
public static EventToken generate() {
return new EventToken(UUID.randomUUID());
}
}

View File

@@ -0,0 +1,18 @@
package de.fete.domain.model;
import java.util.Objects;
import java.util.UUID;
/** Type-safe wrapper for the secret organizer token. */
public record OrganizerToken(UUID value) {
/** Validates that the token value is not null. */
public OrganizerToken {
Objects.requireNonNull(value, "organizerToken must not be null");
}
/** Generates a new random organizer token. */
public static OrganizerToken generate() {
return new OrganizerToken(UUID.randomUUID());
}
}

View File

@@ -0,0 +1,50 @@
package de.fete.domain.model;
/** Domain entity representing an RSVP. */
public class Rsvp {
private Long id;
private RsvpToken rsvpToken;
private Long eventId;
private String name;
/** Returns the internal database ID. */
public Long getId() {
return id;
}
/** Sets the internal database ID. */
public void setId(Long id) {
this.id = id;
}
/** Returns the RSVP token. */
public RsvpToken getRsvpToken() {
return rsvpToken;
}
/** Sets the RSVP token. */
public void setRsvpToken(RsvpToken rsvpToken) {
this.rsvpToken = rsvpToken;
}
/** Returns the event ID this RSVP belongs to. */
public Long getEventId() {
return eventId;
}
/** Sets the event ID. */
public void setEventId(Long eventId) {
this.eventId = eventId;
}
/** Returns the guest's display name. */
public String getName() {
return name;
}
/** Sets the guest's display name. */
public void setName(String name) {
this.name = name;
}
}

View File

@@ -0,0 +1,18 @@
package de.fete.domain.model;
import java.util.Objects;
import java.util.UUID;
/** Type-safe wrapper for the RSVP token. */
public record RsvpToken(UUID value) {
/** Validates that the token value is not null. */
public RsvpToken {
Objects.requireNonNull(value, "rsvpToken must not be null");
}
/** Generates a new random RSVP token. */
public static RsvpToken generate() {
return new RsvpToken(UUID.randomUUID());
}
}

View File

@@ -0,0 +1,10 @@
package de.fete.domain.port.in;
import de.fete.domain.model.EventToken;
/** Inbound port for counting attendees of an event. */
public interface CountAttendeesByEventUseCase {
/** Counts the number of confirmed attendees for the given event. */
long countByEvent(EventToken eventToken);
}

View File

@@ -0,0 +1,11 @@
package de.fete.domain.port.in;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.Rsvp;
/** Inbound port for creating a new RSVP. */
public interface CreateRsvpUseCase {
/** Creates an RSVP for the given event and guest name. */
Rsvp createRsvp(EventToken eventToken, String name);
}

View File

@@ -0,0 +1,12 @@
package de.fete.domain.port.in;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
import java.util.List;
/** Inbound port for retrieving attendee names of an event. */
public interface GetAttendeesUseCase {
/** Returns attendee names ordered by RSVP submission time. */
List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken);
}

View File

@@ -0,0 +1,12 @@
package de.fete.domain.port.in;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import java.util.Optional;
/** Inbound port for retrieving a public event by its token. */
public interface GetEventUseCase {
/** Finds an event by its public event token. */
Optional<Event> getByEventToken(EventToken eventToken);
}

View File

@@ -1,8 +1,8 @@
package de.fete.domain.port.out;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import java.util.Optional;
import java.util.UUID;
/** Outbound port for persisting and retrieving events. */
public interface EventRepository {
@@ -11,5 +11,5 @@ public interface EventRepository {
Event save(Event event);
/** Finds an event by its public event token. */
Optional<Event> findByEventToken(UUID eventToken);
Optional<Event> findByEventToken(EventToken eventToken);
}

View File

@@ -0,0 +1,17 @@
package de.fete.domain.port.out;
import de.fete.domain.model.Rsvp;
import java.util.List;
/** Outbound port for persisting and querying RSVPs. */
public interface RsvpRepository {
/** Persists the given RSVP and returns it with generated fields populated. */
Rsvp save(Rsvp rsvp);
/** Counts the number of RSVPs for the given event. */
long countByEventId(Long eventId);
/** Finds all RSVPs for the given event, ordered by ID ascending. */
List<Rsvp> findByEventId(Long eventId);
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="002-add-timezone-column" author="fete">
<addColumn tableName="events">
<column name="timezone" type="varchar(64)" defaultValue="UTC">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="003-create-rsvps-table" author="fete">
<createTable tableName="rsvps">
<column name="id" type="bigserial" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="rsvp_token" type="uuid">
<constraints nullable="false" unique="true"/>
</column>
<column name="event_id" type="bigint">
<constraints nullable="false"
foreignKeyName="fk_rsvps_event_id"
references="events(id)"
deleteCascade="true"/>
</column>
<column name="name" type="varchar(100)">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="rsvps" indexName="idx_rsvps_event_id">
<column name="event_id"/>
</createIndex>
<createIndex tableName="rsvps" indexName="idx_rsvps_rsvp_token">
<column name="rsvp_token"/>
</createIndex>
</changeSet>
</databaseChangeLog>

View File

@@ -7,5 +7,7 @@
<include file="db/changelog/000-baseline.xml"/>
<include file="db/changelog/001-create-events-table.xml"/>
<include file="db/changelog/002-add-timezone-column.xml"/>
<include file="db/changelog/003-create-rsvps-table.xml"/>
</databaseChangeLog>

View File

@@ -37,6 +37,121 @@ paths:
schema:
$ref: "#/components/schemas/ValidationProblemDetail"
/events/{token}/rsvps:
post:
operationId: createRsvp
summary: Submit an RSVP for an event
tags:
- events
parameters:
- name: token
in: path
required: true
schema:
type: string
format: uuid
description: Public event token
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateRsvpRequest"
responses:
"201":
description: RSVP created successfully
content:
application/json:
schema:
$ref: "#/components/schemas/CreateRsvpResponse"
"400":
description: Validation failed (e.g. blank name)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ValidationProblemDetail"
"404":
description: Event not found
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"409":
description: Event has expired — RSVPs no longer accepted
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
/events/{token}/attendees:
get:
operationId: getAttendees
summary: Get attendee list for an event (organizer only)
tags:
- events
parameters:
- name: token
in: path
required: true
schema:
type: string
format: uuid
description: Public event token
- name: organizerToken
in: query
required: true
schema:
type: string
format: uuid
description: Organizer token for authorization
responses:
"200":
description: Attendee list
content:
application/json:
schema:
$ref: "#/components/schemas/GetAttendeesResponse"
"403":
description: Invalid organizer token
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"404":
description: Event not found
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
/events/{token}:
get:
operationId: getEvent
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 +159,7 @@ components:
required:
- title
- dateTime
- timezone
- expiryDate
properties:
title:
@@ -58,6 +174,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 +194,7 @@ components:
- organizerToken
- title
- dateTime
- timezone
- expiryDate
properties:
eventToken:
@@ -93,11 +214,113 @@ 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
CreateRsvpRequest:
type: object
required:
- name
properties:
name:
type: string
minLength: 1
maxLength: 100
description: Guest's display name
example: "Max Mustermann"
CreateRsvpResponse:
type: object
required:
- rsvpToken
- name
properties:
rsvpToken:
type: string
format: uuid
description: Token identifying this RSVP (store client-side for future updates)
example: "d4e5f6a7-b8c9-0123-4567-890abcdef012"
name:
type: string
description: Guest's display name as stored
example: "Max Mustermann"
GetAttendeesResponse:
type: object
required:
- attendees
properties:
attendees:
type: array
items:
$ref: "#/components/schemas/Attendee"
example:
- name: "Alice"
- name: "Bob"
Attendee:
type: object
required:
- name
properties:
name:
type: string
minLength: 1
maxLength: 100
example: "Alice"
ProblemDetail:
type: object
properties:

View File

@@ -60,4 +60,9 @@ class HexagonalArchitectureTest {
static final ArchRule persistenceMustNotDependOnWeb = noClasses()
.that().resideInAPackage("de.fete.adapter.out.persistence..")
.should().dependOnClassesThat().resideInAPackage("de.fete.adapter.in.web..");
@ArchTest
static final ArchRule webAdapterMustNotDependOnOutboundPorts = noClasses()
.that().resideInAPackage("de.fete.adapter.in.web..")
.should().dependOnClassesThat().resideInAPackage("de.fete.domain.port.out..");
}

View File

@@ -1,12 +1,26 @@
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.in.web.model.CreateRsvpRequest;
import de.fete.adapter.in.web.model.CreateRsvpResponse;
import de.fete.adapter.out.persistence.EventJpaEntity;
import de.fete.adapter.out.persistence.EventJpaRepository;
import de.fete.adapter.out.persistence.RsvpJpaEntity;
import de.fete.adapter.out.persistence.RsvpJpaRepository;
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,158 +37,483 @@ class EventControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private EventJpaRepository jpaRepository;
@Autowired
private RsvpJpaRepository rsvpJpaRepository;
// --- 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.of(2026, 6, 16));
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.of(2026, 6, 16));
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));
long countBefore = jpaRepository.count();
var request = new CreateEventRequest()
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Europe/Berlin")
.expiryDate(LocalDate.of(2026, 6, 16));
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"))
.andExpect(jsonPath("$.fieldErrors").isArray());
assertThat(jpaRepository.count()).isEqualTo(countBefore);
}
@Test
void createEventMissingDateTimeReturns400() throws Exception {
String body =
"""
{
"title": "No Date",
"expiryDate": "%s"
}
""".formatted(LocalDate.now().plusDays(30));
long countBefore = jpaRepository.count();
var request = new CreateEventRequest()
.title("No Date")
.timezone("Europe/Berlin")
.expiryDate(LocalDate.of(2026, 6, 16));
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());
assertThat(jpaRepository.count()).isEqualTo(countBefore);
}
@Test
void createEventMissingExpiryDateReturns400() throws Exception {
String body =
"""
{
"title": "No Expiry",
"dateTime": "2026-06-15T20:00:00+02:00"
}
""";
long countBefore = jpaRepository.count();
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());
assertThat(jpaRepository.count()).isEqualTo(countBefore);
}
@Test
void createEventExpiryDateInPastReturns400() throws Exception {
String body =
"""
{
"title": "Past Expiry",
"dateTime": "2026-06-15T20:00:00+02:00",
"expiryDate": "2025-01-01"
}
""";
long countBefore = jpaRepository.count();
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"));
assertThat(jpaRepository.count()).isEqualTo(countBefore);
}
@Test
void createEventExpiryDateTodayReturns400() throws Exception {
String body =
"""
{
"title": "Today Expiry",
"dateTime": "2026-06-15T20:00:00+02:00",
"expiryDate": "%s"
}
""".formatted(LocalDate.now());
long countBefore = jpaRepository.count();
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"));
assertThat(jpaRepository.count()).isEqualTo(countBefore);
}
@Test
void createEventExpiryDateBeforeEventDateReturns400() throws Exception {
long countBefore = jpaRepository.count();
var request = new CreateEventRequest()
.title("Bad Expiry")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Europe/Berlin")
.expiryDate(LocalDate.of(2026, 6, 10));
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:expiry-date-before-event"));
assertThat(jpaRepository.count()).isEqualTo(countBefore);
}
@Test
void createEventExpiryDateSameAsEventDateReturns400() throws Exception {
long countBefore = jpaRepository.count();
var request = new CreateEventRequest()
.title("Same Day Expiry")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Europe/Berlin")
.expiryDate(LocalDate.of(2026, 6, 15));
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:expiry-date-before-event"));
assertThat(jpaRepository.count()).isEqualTo(countBefore);
}
@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.of(2026, 6, 16));
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 {
long countBefore = jpaRepository.count();
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.of(2026, 6, 16));
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"));
assertThat(jpaRepository.count()).isEqualTo(countBefore);
}
// --- 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));
}
// --- RSVP tests ---
@Test
void createRsvpReturns201WithToken() throws Exception {
EventJpaEntity event = seedEvent(
"RSVP Event", "Join us!", "Europe/Berlin",
"Berlin", LocalDate.now().plusDays(30));
var request = new CreateRsvpRequest().name("Max Mustermann");
var result = mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.rsvpToken").isNotEmpty())
.andExpect(jsonPath("$.name").value("Max Mustermann"))
.andReturn();
var response = objectMapper.readValue(
result.getResponse().getContentAsString(), CreateRsvpResponse.class);
RsvpJpaEntity persisted = rsvpJpaRepository
.findByRsvpToken(response.getRsvpToken()).orElseThrow();
assertThat(persisted.getName()).isEqualTo("Max Mustermann");
assertThat(persisted.getEventId()).isEqualTo(event.getId());
}
@Test
void createRsvpWithBlankNameReturns400() throws Exception {
EventJpaEntity event = seedEvent(
"RSVP Event", null, "Europe/Berlin",
null, LocalDate.now().plusDays(30));
long countBefore = rsvpJpaRepository.count();
var request = new CreateRsvpRequest().name("");
mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"));
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
}
@Test
void attendeeCountIncreasesAfterRsvp() throws Exception {
EventJpaEntity event = seedEvent(
"Count Event", null, "Europe/Berlin",
null, LocalDate.now().plusDays(30));
mockMvc.perform(get("/api/events/" + event.getEventToken()))
.andExpect(jsonPath("$.attendeeCount").value(0));
var request = new CreateRsvpRequest().name("First Guest");
mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
mockMvc.perform(get("/api/events/" + event.getEventToken()))
.andExpect(jsonPath("$.attendeeCount").value(1));
}
@Test
void createRsvpForUnknownEventReturns404() throws Exception {
long countBefore = rsvpJpaRepository.count();
var request = new CreateRsvpRequest().name("Ghost");
mockMvc.perform(post("/api/events/" + UUID.randomUUID() + "/rsvps")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isNotFound())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
}
@Test
void createRsvpForExpiredEventReturns409() throws Exception {
EventJpaEntity event = seedEvent(
"Expired Party", null, "Europe/Berlin",
null, LocalDate.now().minusDays(1));
long countBefore = rsvpJpaRepository.count();
var request = new CreateRsvpRequest().name("Late Guest");
mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:event-expired"));
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
}
// --- GET /events/{token}/attendees tests ---
@Test
void getAttendeesReturnsNamesForOrganizer() throws Exception {
EventJpaEntity event = seedEvent(
"Party", null, "Europe/Berlin", null,
LocalDate.now().plusDays(30));
seedRsvp(event, "Alice");
seedRsvp(event, "Bob");
mockMvc.perform(get("/api/events/" + event.getEventToken()
+ "/attendees?organizerToken=" + event.getOrganizerToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.attendees").isArray())
.andExpect(jsonPath("$.attendees.length()").value(2))
.andExpect(jsonPath("$.attendees[0].name").value("Alice"))
.andExpect(jsonPath("$.attendees[1].name").value("Bob"));
}
@Test
void getAttendeesReturnsEmptyListWhenNoRsvps() throws Exception {
EventJpaEntity event = seedEvent(
"Empty Party", null, "Europe/Berlin", null,
LocalDate.now().plusDays(30));
mockMvc.perform(get("/api/events/" + event.getEventToken()
+ "/attendees?organizerToken=" + event.getOrganizerToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.attendees").isArray())
.andExpect(jsonPath("$.attendees.length()").value(0));
}
@Test
void getAttendeesReturns403ForInvalidOrganizerToken() throws Exception {
EventJpaEntity event = seedEvent(
"Secret Party", null, "Europe/Berlin", null,
LocalDate.now().plusDays(30));
mockMvc.perform(get("/api/events/" + event.getEventToken()
+ "/attendees?organizerToken=" + UUID.randomUUID()))
.andExpect(status().isForbidden())
.andExpect(content().contentTypeCompatibleWith(
"application/problem+json"));
}
@Test
void getAttendeesReturns404ForUnknownEvent() throws Exception {
mockMvc.perform(get("/api/events/" + UUID.randomUUID()
+ "/attendees?organizerToken=" + UUID.randomUUID()))
.andExpect(status().isNotFound())
.andExpect(content().contentTypeCompatibleWith(
"application/problem+json"));
}
private void seedRsvp(EventJpaEntity event, String name) {
var rsvp = new RsvpJpaEntity();
rsvp.setRsvpToken(UUID.randomUUID());
rsvp.setEventId(event.getId());
rsvp.setName(name);
rsvpJpaRepository.save(rsvp);
}
private EventJpaEntity seedEvent(
String title, String description, String timezone,
String location, LocalDate expiryDate) {
var entity = new EventJpaEntity();
entity.setEventToken(UUID.randomUUID());
entity.setOrganizerToken(UUID.randomUUID());
entity.setTitle(title);
entity.setDescription(description);
entity.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
entity.setTimezone(timezone);
entity.setLocation(location);
entity.setExpiryDate(expiryDate);
entity.setCreatedAt(OffsetDateTime.now());
return jpaRepository.save(entity);
}
}

View File

@@ -4,12 +4,14 @@ import static org.assertj.core.api.Assertions.assertThat;
import de.fete.TestcontainersConfig;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
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;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@@ -46,7 +48,7 @@ class EventPersistenceAdapterTest {
@Test
void findByUnknownEventTokenReturnsEmpty() {
Optional<Event> found = eventRepository.findByEventToken(UUID.randomUUID());
Optional<Event> found = eventRepository.findByEventToken(EventToken.generate());
assertThat(found).isEmpty();
}
@@ -60,11 +62,12 @@ class EventPersistenceAdapterTest {
OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC);
var event = new Event();
event.setEventToken(UUID.randomUUID());
event.setOrganizerToken(UUID.randomUUID());
event.setEventToken(EventToken.generate());
event.setOrganizerToken(OrganizerToken.generate());
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 +80,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());
@@ -84,11 +88,12 @@ class EventPersistenceAdapterTest {
private Event buildEvent() {
var event = new Event();
event.setEventToken(UUID.randomUUID());
event.setOrganizerToken(UUID.randomUUID());
event.setEventToken(EventToken.generate());
event.setOrganizerToken(OrganizerToken.generate());
event.setTitle("Test Event");
event.setDescription("Test description");
event.setDateTime(OffsetDateTime.now().plusDays(7));
event.setTimezone(ZoneId.of("Europe/Berlin"));
event.setLocation("Somewhere");
event.setExpiryDate(LocalDate.now().plusDays(30));
event.setCreatedAt(OffsetDateTime.now());

View File

@@ -9,13 +9,14 @@ import static org.mockito.Mockito.when;
import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.port.out.EventRepository;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -30,6 +31,7 @@ class EventServiceTest {
private static final Instant FIXED_INSTANT =
LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant();
private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE);
private static final LocalDate TODAY = LocalDate.ofInstant(FIXED_INSTANT, ZONE);
@Mock
private EventRepository eventRepository;
@@ -49,35 +51,21 @@ class EventServiceTest {
var command = new CreateEventCommand(
"Birthday Party",
"Come celebrate!",
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
TODAY.plusDays(90).atStartOfDay(ZONE).toOffsetDateTime(),
ZONE,
"Berlin",
LocalDate.of(2026, 7, 15)
TODAY.plusDays(120)
);
Event result = eventService.createEvent(command);
assertThat(result.getTitle()).isEqualTo("Birthday Party");
assertThat(result.getDescription()).isEqualTo("Come celebrate!");
assertThat(result.getTimezone()).isEqualTo(ZONE);
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());
assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE));
}
@Test
@@ -87,8 +75,8 @@ class EventServiceTest {
var command = new CreateEventCommand(
"Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
LocalDate.now(FIXED_CLOCK).plusDays(30)
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
TODAY.plusDays(11)
);
eventService.createEvent(command);
@@ -102,8 +90,8 @@ class EventServiceTest {
void expiryDateTodayThrowsException() {
var command = new CreateEventCommand(
"Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
LocalDate.now(FIXED_CLOCK)
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
TODAY
);
assertThatThrownBy(() -> eventService.createEvent(command))
@@ -114,8 +102,8 @@ class EventServiceTest {
void expiryDateInPastThrowsException() {
var command = new CreateEventCommand(
"Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
LocalDate.now(FIXED_CLOCK).minusDays(5)
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
TODAY.minusDays(5)
);
assertThatThrownBy(() -> eventService.createEvent(command))
@@ -129,12 +117,102 @@ class EventServiceTest {
var command = new CreateEventCommand(
"Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
LocalDate.now(FIXED_CLOCK).plusDays(1)
TODAY.plusDays(1).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
TODAY.plusDays(2)
);
Event result = eventService.createEvent(command);
assertThat(result.getExpiryDate()).isEqualTo(LocalDate.of(2026, 3, 6));
assertThat(result.getExpiryDate()).isEqualTo(TODAY.plusDays(2));
}
@Test
void expiryDateSameAsEventDateThrowsException() {
var command = new CreateEventCommand(
"Test", null,
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
ZONE, null,
TODAY.plusDays(10)
);
assertThatThrownBy(() -> eventService.createEvent(command))
.isInstanceOf(ExpiryDateBeforeEventException.class);
}
@Test
void expiryDateBeforeEventDateThrowsException() {
var command = new CreateEventCommand(
"Test", null,
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
ZONE, null,
TODAY.plusDays(5)
);
assertThatThrownBy(() -> eventService.createEvent(command))
.isInstanceOf(ExpiryDateBeforeEventException.class);
}
@Test
void expiryDateDayAfterEventDateSucceeds() {
when(eventRepository.save(any(Event.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
var command = new CreateEventCommand(
"Test", null,
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
ZONE, null,
TODAY.plusDays(11)
);
Event result = eventService.createEvent(command);
assertThat(result.getExpiryDate()).isEqualTo(TODAY.plusDays(11));
}
// --- GetEventUseCase tests (T004) ---
@Test
void getByEventTokenReturnsEvent() {
EventToken token = EventToken.generate();
var event = new Event();
event.setEventToken(token);
event.setTitle("Found Event");
when(eventRepository.findByEventToken(token))
.thenReturn(Optional.of(event));
Optional<Event> result = eventService.getByEventToken(token);
assertThat(result).isPresent();
assertThat(result.get().getTitle()).isEqualTo("Found Event");
}
@Test
void getByEventTokenReturnsEmptyForUnknownToken() {
EventToken token = EventToken.generate();
when(eventRepository.findByEventToken(token))
.thenReturn(Optional.empty());
Optional<Event> result = eventService.getByEventToken(token);
assertThat(result).isEmpty();
}
// --- Timezone validation tests (T006) ---
@Test
void createEventWithValidTimezoneSucceeds() {
when(eventRepository.save(any(Event.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
var command = new CreateEventCommand(
"Test", null,
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
ZoneId.of("America/New_York"), null,
TODAY.plusDays(11)
);
Event result = eventService.createEvent(command);
assertThat(result.getTimezone()).isEqualTo(ZoneId.of("America/New_York"));
}
}

View File

@@ -0,0 +1,206 @@
package de.fete.application.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
import de.fete.domain.model.Rsvp;
import de.fete.domain.model.RsvpToken;
import de.fete.domain.port.out.EventRepository;
import de.fete.domain.port.out.RsvpRepository;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class RsvpServiceTest {
private static final ZoneId ZONE = ZoneId.of("Europe/Berlin");
private static final Instant NOW = Instant.parse("2026-03-08T12:00:00Z");
private static final Clock FIXED_CLOCK = Clock.fixed(NOW, ZONE);
private static final LocalDate TODAY = LocalDate.ofInstant(NOW, ZONE);
@Mock
private EventRepository eventRepository;
@Mock
private RsvpRepository rsvpRepository;
private RsvpService rsvpService;
@BeforeEach
void setUp() {
rsvpService = new RsvpService(eventRepository, rsvpRepository, FIXED_CLOCK);
}
@Test
void createRsvpSucceedsForActiveEvent() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
when(rsvpRepository.save(any(Rsvp.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
Rsvp result = rsvpService.createRsvp(token, "Max Mustermann");
assertThat(result.getName()).isEqualTo("Max Mustermann");
assertThat(result.getRsvpToken()).isNotNull();
assertThat(result.getEventId()).isEqualTo(event.getId());
}
@Test
void createRsvpPersistsViaRepository() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
when(rsvpRepository.save(any(Rsvp.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
rsvpService.createRsvp(token, "Test Guest");
ArgumentCaptor<Rsvp> captor = ArgumentCaptor.forClass(Rsvp.class);
verify(rsvpRepository).save(captor.capture());
assertThat(captor.getValue().getName()).isEqualTo("Test Guest");
assertThat(captor.getValue().getEventId()).isEqualTo(event.getId());
}
@Test
void createRsvpThrowsWhenEventNotFound() {
EventToken token = EventToken.generate();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.empty());
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Guest"))
.isInstanceOf(EventNotFoundException.class);
}
@Test
void createRsvpTrimsName() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
when(rsvpRepository.save(any(Rsvp.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
Rsvp result = rsvpService.createRsvp(token, " Max ");
assertThat(result.getName()).isEqualTo("Max");
}
@Test
void createRsvpThrowsWhenEventExpired() {
var event = buildActiveEvent();
event.setExpiryDate(TODAY.minusDays(1));
EventToken token = event.getEventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
.isInstanceOf(EventExpiredException.class);
}
@Test
void createRsvpThrowsWhenEventExpiresToday() {
var event = buildActiveEvent();
event.setExpiryDate(TODAY);
EventToken token = event.getEventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
.isInstanceOf(EventExpiredException.class);
}
@Test
void getAttendeeNamesReturnsNamesInOrder() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
OrganizerToken orgToken = event.getOrganizerToken();
when(eventRepository.findByEventToken(token))
.thenReturn(Optional.of(event));
when(rsvpRepository.findByEventId(event.getId()))
.thenReturn(List.of(
buildRsvp(1L, "Alice"),
buildRsvp(2L, "Bob"),
buildRsvp(3L, "Charlie")));
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
assertThat(names).containsExactly("Alice", "Bob", "Charlie");
}
@Test
void getAttendeeNamesReturnsEmptyListWhenNoRsvps() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
OrganizerToken orgToken = event.getOrganizerToken();
when(eventRepository.findByEventToken(token))
.thenReturn(Optional.of(event));
when(rsvpRepository.findByEventId(event.getId()))
.thenReturn(List.of());
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
assertThat(names).isEmpty();
}
@Test
void getAttendeeNamesThrowsWhenEventNotFound() {
EventToken token = EventToken.generate();
OrganizerToken orgToken = OrganizerToken.generate();
when(eventRepository.findByEventToken(token))
.thenReturn(Optional.empty());
assertThatThrownBy(
() -> rsvpService.getAttendeeNames(token, orgToken))
.isInstanceOf(EventNotFoundException.class);
}
@Test
void getAttendeeNamesThrowsWhenOrganizerTokenInvalid() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
OrganizerToken wrongToken = OrganizerToken.generate();
when(eventRepository.findByEventToken(token))
.thenReturn(Optional.of(event));
assertThatThrownBy(
() -> rsvpService.getAttendeeNames(token, wrongToken))
.isInstanceOf(InvalidOrganizerTokenException.class);
}
private Rsvp buildRsvp(Long id, String name) {
var rsvp = new Rsvp();
rsvp.setId(id);
rsvp.setRsvpToken(RsvpToken.generate());
rsvp.setEventId(1L);
rsvp.setName(name);
return rsvp;
}
private Event buildActiveEvent() {
var event = new Event();
event.setId(1L);
event.setEventToken(EventToken.generate());
event.setOrganizerToken(OrganizerToken.generate());
event.setTitle("Test Event");
event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
event.setTimezone(ZONE);
event.setExpiryDate(TODAY.plusDays(30));
event.setCreatedAt(OffsetDateTime.now());
return event;
}
}

View File

@@ -12,7 +12,7 @@ test.describe('US-1: Create an event', () => {
await expect(page.getByText('Expiry date is required.')).toBeVisible()
})
test('creates an event and redirects to stub page', async ({ page }) => {
test('creates an event and redirects to event detail page', async ({ page }) => {
await page.goto('/create')
await page.getByLabel(/title/i).fill('Summer BBQ')
@@ -24,7 +24,6 @@ test.describe('US-1: Create an event', () => {
await page.getByRole('button', { name: /create event/i }).click()
await expect(page).toHaveURL(/\/events\/.+/)
await expect(page.getByText('Event created!')).toBeVisible()
})
test('stores event data in localStorage after creation', async ({ page }) => {

View File

@@ -0,0 +1,185 @@
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('US1: RSVP submission flow', () => {
test('submits RSVP, updates attendee count, and persists in localStorage', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json(fullEvent)
}),
http.post('*/api/events/:token/rsvps', () => {
return HttpResponse.json(
{ rsvpToken: 'd4e5f6a7-b8c9-0123-4567-890abcdef012', name: 'Max Mustermann' },
{ status: 201 },
)
}),
)
await page.goto(`/events/${fullEvent.eventToken}`)
// CTA is visible
const cta = page.getByRole('button', { name: "I'm attending" })
await expect(cta).toBeVisible()
// Open bottom sheet
await cta.click()
const dialog = page.getByRole('dialog', { name: 'RSVP' })
await expect(dialog).toBeVisible()
// Fill name and submit
await dialog.getByLabel('Your name').fill('Max Mustermann')
await dialog.getByRole('button', { name: 'Count me in' }).click()
// Bottom sheet closes, status bar appears
await expect(dialog).not.toBeVisible()
await expect(page.getByText("You're attending!")).toBeVisible()
await expect(cta).not.toBeVisible()
// Attendee count incremented
await expect(page.getByText('13')).toBeVisible()
// Verify localStorage
const stored = await page.evaluate(() => {
const raw = localStorage.getItem('fete:events')
return raw ? JSON.parse(raw) : null
})
expect(stored).toEqual(
expect.arrayContaining([
expect.objectContaining({
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
rsvpToken: 'd4e5f6a7-b8c9-0123-4567-890abcdef012',
rsvpName: 'Max Mustermann',
}),
]),
)
})
test('shows validation error when name is empty', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json(fullEvent)
}),
)
await page.goto(`/events/${fullEvent.eventToken}`)
await page.getByRole('button', { name: "I'm attending" }).click()
const dialog = page.getByRole('dialog', { name: 'RSVP' })
await dialog.getByRole('button', { name: 'Count me in' }).click()
await expect(page.getByText('Please enter your name.')).toBeVisible()
})
test('restores RSVP status from localStorage on page load', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json(fullEvent)
}),
)
// Pre-seed localStorage
await page.goto('/')
await page.evaluate(() => {
localStorage.setItem(
'fete:events',
JSON.stringify([
{
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
title: 'Summer BBQ',
dateTime: '2026-03-15T20:00:00+01:00',
expiryDate: '',
rsvpToken: 'existing-rsvp-token',
rsvpName: 'Anna',
},
]),
)
})
await page.goto(`/events/${fullEvent.eventToken}`)
// Status bar should show, not CTA
await expect(page.getByText("You're attending!")).toBeVisible()
await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible()
})
test('shows error when server is unreachable during RSVP', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json(fullEvent)
}),
http.post('*/api/events/:token/rsvps', () => {
return HttpResponse.json(
{ type: 'about:blank', title: 'Bad Request', status: 400 },
{ status: 400, headers: { 'Content-Type': 'application/problem+json' } },
)
}),
)
await page.goto(`/events/${fullEvent.eventToken}`)
await page.getByRole('button', { name: "I'm attending" }).click()
const dialog = page.getByRole('dialog', { name: 'RSVP' })
await dialog.getByLabel('Your name').fill('Max')
await dialog.getByRole('button', { name: 'Count me in' }).click()
await expect(page.getByText('Could not submit RSVP. Please try again.')).toBeVisible()
})
test('does not show RSVP bar for organizer', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json(fullEvent)
}),
)
// Pre-seed localStorage with organizer token
await page.goto('/')
await page.evaluate(() => {
localStorage.setItem(
'fete:events',
JSON.stringify([
{
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
organizerToken: 'org-token-123',
title: 'Summer BBQ',
dateTime: '2026-03-15T20:00:00+01:00',
expiryDate: '',
},
]),
)
})
await page.goto(`/events/${fullEvent.eventToken}`)
// Event content should load
await expect(page.getByText('Summer BBQ')).toBeVisible()
// But no RSVP bar
await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible()
await expect(page.getByText("You're attending!")).not.toBeVisible()
})
test('does not show RSVP bar on 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()
await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible()
})
})

View File

@@ -0,0 +1,127 @@
import { http, HttpResponse } from 'msw'
import { test, expect } from './msw-setup'
const fullEvent = {
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
title: 'Summer BBQ',
description: 'Bring your own drinks!',
dateTime: '2026-03-15T20:00:00+01:00',
timezone: 'Europe/Berlin',
location: 'Central Park, NYC',
attendeeCount: 12,
expired: false,
}
test.describe('US-1: View event details', () => {
test('displays all event fields for a valid event', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json(fullEvent)
}),
)
await page.goto(`/events/${fullEvent.eventToken}`)
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
await expect(page.getByText('Bring your own drinks!')).toBeVisible()
await expect(page.getByText('Central Park, NYC')).toBeVisible()
await expect(page.getByText('12')).toBeVisible()
await expect(page.getByText('Europe/Berlin')).toBeVisible()
await expect(page.getByText('2026')).toBeVisible()
})
test('does not load external resources', async ({ page, network }) => {
const externalRequests: string[] = []
page.on('request', (req) => {
const url = new URL(req.url())
if (!['localhost', '127.0.0.1'].includes(url.hostname)) {
externalRequests.push(req.url())
}
})
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json(fullEvent)
}),
)
await page.goto(`/events/${fullEvent.eventToken}`)
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
expect(externalRequests).toEqual([])
})
})
test.describe('US-2: View expired event', () => {
test('shows "event has ended" banner for expired event', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json({ ...fullEvent, expired: true })
}),
)
await page.goto(`/events/${fullEvent.eventToken}`)
await expect(page.getByText('This event has ended.')).toBeVisible()
})
})
test.describe('US-4: Event not found', () => {
test('shows "event not found" for unknown token', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json(
{ type: 'urn:problem-type:event-not-found', title: 'Event Not Found', status: 404, detail: 'Event not found.' },
{ status: 404, headers: { 'Content-Type': 'application/problem+json' } },
)
}),
)
await page.goto('/events/00000000-0000-0000-0000-000000000000')
await expect(page.getByText('Event not found.')).toBeVisible()
// No event data visible
await expect(page.locator('.detail__title')).not.toBeVisible()
})
})
test.describe('Server error', () => {
test('shows error message and retry button on 500', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json(
{ type: 'about:blank', title: 'Internal Server Error', status: 500, detail: 'An unexpected error occurred.' },
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } },
)
}),
)
await page.goto(`/events/${fullEvent.eventToken}`)
await expect(page.getByText('Something went wrong.')).toBeVisible()
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible()
})
test('retry button re-fetches the event', async ({ page, network }) => {
let callCount = 0
network.use(
http.get('*/api/events/:token', () => {
callCount++
if (callCount === 1) {
return HttpResponse.json(
{ type: 'about:blank', title: 'Error', status: 500 },
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } },
)
}
return HttpResponse.json(fullEvent)
}),
)
await page.goto(`/events/${fullEvent.eventToken}`)
await expect(page.getByText('Something went wrong.')).toBeVisible()
await page.getByRole('button', { name: 'Retry' }).click()
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
})
})

View File

@@ -0,0 +1,377 @@
import { test, expect } from './msw-setup'
import type { StoredEvent } from '../src/composables/useEventStorage'
const STORAGE_KEY = 'fete:events'
const futureEvent1: StoredEvent = {
eventToken: 'future-aaa',
title: 'Summer BBQ',
dateTime: '2027-06-15T18:00:00Z',
expiryDate: '2027-06-16T00:00:00Z',
organizerToken: 'org-token-1',
}
const futureEvent2: StoredEvent = {
eventToken: 'future-bbb',
title: 'Team Meeting',
dateTime: '2027-01-10T09:00:00Z',
expiryDate: '2027-01-11T00:00:00Z',
rsvpToken: 'rsvp-token-1',
rsvpName: 'Alice',
}
const pastEvent: StoredEvent = {
eventToken: 'past-ccc',
title: 'New Year Party',
dateTime: '2025-01-01T00:00:00Z',
expiryDate: '2025-01-02T00:00:00Z',
}
function seedEvents(events: StoredEvent[]): string {
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
}
test.describe('US2: Empty State', () => {
test('shows empty state when no events are stored', async ({ page }) => {
await page.goto('/')
await expect(page.getByText('No events yet')).toBeVisible()
await expect(page.getByRole('link', { name: /Create Event/ })).toBeVisible()
})
test('empty state links to create page', async ({ page }) => {
await page.goto('/')
const link = page.getByRole('link', { name: /Create Event/ })
await expect(link).toHaveAttribute('href', '/create')
})
test('empty state is hidden when events exist', async ({ page }) => {
await page.addInitScript(seedEvents([futureEvent1]))
await page.goto('/')
await expect(page.getByText('No events yet')).not.toBeVisible()
})
})
test.describe('US4: Past Events Appear Faded', () => {
test('past events have the faded modifier class', async ({ page }) => {
await page.addInitScript(seedEvents([futureEvent1, pastEvent]))
await page.goto('/')
const cards = page.locator('.event-card')
await expect(cards).toHaveCount(2)
// Future event should NOT have past class
const futureCard = cards.filter({ hasText: 'Summer BBQ' })
await expect(futureCard).not.toHaveClass(/event-card--past/)
// Past event should have past class
const pastCard = cards.filter({ hasText: 'New Year Party' })
await expect(pastCard).toHaveClass(/event-card--past/)
})
test('past events remain clickable', async ({ page, network }) => {
await page.addInitScript(seedEvents([pastEvent]))
const { http, HttpResponse } = await import('msw')
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json({
eventToken: pastEvent.eventToken,
title: pastEvent.title,
dateTime: pastEvent.dateTime,
description: '',
location: '',
timezone: 'UTC',
attendeeCount: 0,
expired: true,
})
}),
)
await page.goto('/')
await page.getByText('New Year Party').click()
await expect(page).toHaveURL(`/events/${pastEvent.eventToken}`)
})
})
test.describe('US3: Remove Event from List', () => {
test('delete icon triggers confirmation dialog, confirm removes event', async ({ page }) => {
await page.addInitScript(seedEvents([futureEvent1, futureEvent2]))
await page.goto('/')
// Both events visible
await expect(page.getByText('Summer BBQ')).toBeVisible()
await expect(page.getByText('Team Meeting')).toBeVisible()
// Click delete on Summer BBQ
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
// Confirmation dialog appears
await expect(page.getByText('Remove event?')).toBeVisible()
// Confirm removal
await page.getByRole('button', { name: 'Remove', exact: true }).click()
// Event is gone, other remains
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
await expect(page.getByText('Team Meeting')).toBeVisible()
})
test('cancel keeps the event in the list', async ({ page }) => {
await page.addInitScript(seedEvents([futureEvent1]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await expect(page.getByText('Remove event?')).toBeVisible()
// Cancel
await page.getByRole('button', { name: 'Cancel' }).click()
// Dialog gone, event still there
await expect(page.getByText('Remove event?')).not.toBeVisible()
await expect(page.getByText('Summer BBQ')).toBeVisible()
})
})
test.describe('US5: Visual Distinction for Event Roles', () => {
test('shows organizer badge for events with organizerToken', async ({ page }) => {
await page.addInitScript(seedEvents([futureEvent1]))
await page.goto('/')
const card = page.locator('.event-card').filter({ hasText: 'Summer BBQ' })
const badge = card.locator('.event-card__badge')
await expect(badge).toBeVisible()
await expect(badge).toHaveText('Organizer')
await expect(badge).toHaveClass(/event-card__badge--organizer/)
})
test('shows attendee badge for events with rsvpToken only', async ({ page }) => {
await page.addInitScript(seedEvents([futureEvent2]))
await page.goto('/')
const card = page.locator('.event-card').filter({ hasText: 'Team Meeting' })
const badge = card.locator('.event-card__badge')
await expect(badge).toBeVisible()
await expect(badge).toHaveText('Attendee')
await expect(badge).toHaveClass(/event-card__badge--attendee/)
})
test('shows no badge for events without organizerToken or rsvpToken', async ({ page }) => {
await page.addInitScript(seedEvents([pastEvent]))
await page.goto('/')
const card = page.locator('.event-card').filter({ hasText: 'New Year Party' })
await expect(card.locator('.event-card__badge')).toHaveCount(0)
})
})
test.describe('FAB: Create Event Button', () => {
test('FAB is visible when events exist', async ({ page }) => {
await page.addInitScript(seedEvents([futureEvent1]))
await page.goto('/')
const fab = page.getByRole('link', { name: 'Create event' })
await expect(fab).toBeVisible()
})
test('FAB navigates to create page', async ({ page }) => {
await page.addInitScript(seedEvents([futureEvent1]))
await page.goto('/')
const fab = page.getByRole('link', { name: 'Create event' })
await expect(fab).toHaveAttribute('href', '/create')
})
test('FAB is not visible on empty state (empty state has its own CTA)', async ({ page }) => {
await page.goto('/')
await expect(page.locator('.fab')).toHaveCount(0)
})
})
test.describe('Temporal Grouping: Section Headers', () => {
test('events are distributed under correct section headers', async ({ page }) => {
// Use dates relative to "now" to ensure correct section assignment
const now = new Date()
const todayEvent: StoredEvent = {
eventToken: 'today-1',
title: 'Today Standup',
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0, 0).toISOString(),
expiryDate: '',
}
const laterEvent: StoredEvent = {
eventToken: 'later-1',
title: 'Future Conference',
dateTime: new Date(now.getFullYear() + 1, 0, 15, 10, 0, 0).toISOString(),
expiryDate: '',
}
await page.addInitScript(seedEvents([todayEvent, laterEvent, pastEvent]))
await page.goto('/')
// Verify section headers appear
await expect(page.getByRole('heading', { name: 'Today', level: 2 })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Later', level: 2 })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Past', level: 2 })).toBeVisible()
// Events are in the correct sections
const sections = page.locator('.event-section')
const todaySection = sections.filter({ has: page.getByRole('heading', { name: 'Today', level: 2 }) })
await expect(todaySection.getByText('Today Standup')).toBeVisible()
const laterSection = sections.filter({ has: page.getByRole('heading', { name: 'Later', level: 2 }) })
await expect(laterSection.getByText('Future Conference')).toBeVisible()
const pastSection = sections.filter({ has: page.getByRole('heading', { name: 'Past', level: 2 }) })
await expect(pastSection.getByText('New Year Party')).toBeVisible()
})
test('empty sections are not rendered', async ({ page }) => {
// Only a past event — no Today, This Week, or Later sections
await page.addInitScript(seedEvents([pastEvent]))
await page.goto('/')
await expect(page.getByRole('heading', { name: 'Past', level: 2 })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Today', level: 2 })).toHaveCount(0)
await expect(page.getByRole('heading', { name: 'This Week', level: 2 })).toHaveCount(0)
await expect(page.getByRole('heading', { name: 'Next Week', level: 2 })).toHaveCount(0)
await expect(page.getByRole('heading', { name: 'Later', level: 2 })).toHaveCount(0)
})
test('Today section header has emphasis CSS class', async ({ page }) => {
const now = new Date()
const todayEvent: StoredEvent = {
eventToken: 'today-emph',
title: 'Emphasis Test',
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 20, 0, 0).toISOString(),
expiryDate: '',
}
await page.addInitScript(seedEvents([todayEvent]))
await page.goto('/')
const todayHeader = page.getByRole('heading', { name: 'Today', level: 2 })
await expect(todayHeader).toHaveClass(/section-header--emphasized/)
})
})
test.describe('Temporal Grouping: Date Subheaders', () => {
test('no date subheader in Today section', async ({ page }) => {
const now = new Date()
const todayEvent: StoredEvent = {
eventToken: 'today-sub',
title: 'No Subheader Test',
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 19, 0, 0).toISOString(),
expiryDate: '',
}
await page.addInitScript(seedEvents([todayEvent]))
await page.goto('/')
const todaySection = page.locator('.event-section').filter({
has: page.getByRole('heading', { name: 'Today', level: 2 }),
})
await expect(todaySection.locator('.date-subheader')).toHaveCount(0)
})
test('date subheaders appear in Later section', async ({ page }) => {
await page.addInitScript(seedEvents([futureEvent1, futureEvent2]))
await page.goto('/')
const laterSection = page.locator('.event-section').filter({
has: page.getByRole('heading', { name: 'Later', level: 2 }),
})
// Both future events are on different dates, so expect subheaders
const subheaders = laterSection.locator('.date-subheader')
await expect(subheaders).toHaveCount(2)
})
test('date subheaders appear in Past section', async ({ page }) => {
await page.addInitScript(seedEvents([pastEvent]))
await page.goto('/')
const pastSection = page.locator('.event-section').filter({
has: page.getByRole('heading', { name: 'Past', level: 2 }),
})
await expect(pastSection.locator('.date-subheader')).toHaveCount(1)
})
})
test.describe('Temporal Grouping: Time Display', () => {
test('future event cards show clock time', async ({ page }) => {
await page.addInitScript(seedEvents([futureEvent1]))
await page.goto('/')
const timeLabel = page.locator('.event-card__time')
const text = await timeLabel.first().textContent()
// Should show clock time (e.g., "18:00" or "6:00 PM"), not relative time
expect(text).toMatch(/\d{1,2}[:.]\d{2}/)
})
test('past event cards show relative time', async ({ page }) => {
await page.addInitScript(seedEvents([pastEvent]))
await page.goto('/')
const timeLabel = page.locator('.event-card__time')
const text = await timeLabel.first().textContent()
// Should show relative time like "X years ago" or "last year"
expect(text).toMatch(/ago|last|yesterday/)
})
})
test.describe('US1: View My Events', () => {
test('displays all stored events with title and relative time', async ({ page }) => {
await page.addInitScript(seedEvents([futureEvent1, futureEvent2, pastEvent]))
await page.goto('/')
await expect(page.getByText('Summer BBQ')).toBeVisible()
await expect(page.getByText('Team Meeting')).toBeVisible()
await expect(page.getByText('New Year Party')).toBeVisible()
})
test('events are sorted: upcoming ascending, then past', async ({ page }) => {
await page.addInitScript(seedEvents([futureEvent1, futureEvent2, pastEvent]))
await page.goto('/')
const titles = page.locator('.event-card__title')
await expect(titles).toHaveCount(3)
// Team Meeting (Jan 2027) before Summer BBQ (Jun 2027), then past event
await expect(titles.nth(0)).toHaveText('Team Meeting')
await expect(titles.nth(1)).toHaveText('Summer BBQ')
await expect(titles.nth(2)).toHaveText('New Year Party')
})
test('clicking an event navigates to its detail page', async ({ page, network }) => {
await page.addInitScript(seedEvents([futureEvent1]))
// Mock the event detail API so navigation doesn't fail
const { http, HttpResponse } = await import('msw')
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json({
eventToken: futureEvent1.eventToken,
title: futureEvent1.title,
dateTime: futureEvent1.dateTime,
description: '',
location: '',
timezone: 'UTC',
attendeeCount: 0,
expired: false,
})
}),
)
await page.goto('/')
await page.getByText('Summer BBQ').click()
await expect(page).toHaveURL(`/events/${futureEvent1.eventToken}`)
})
test('each event shows a relative time label', async ({ page }) => {
await page.addInitScript(seedEvents([futureEvent1]))
await page.goto('/')
// The relative time element should exist and contain text (exact value depends on current time)
const timeLabel = page.locator('.event-card__time')
await expect(timeLabel).toHaveCount(1)
await expect(timeLabel.first()).not.toBeEmpty()
})
})

View File

@@ -0,0 +1,99 @@
import { http, HttpResponse } from 'msw'
import { test, expect } from './msw-setup'
const eventToken = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
const organizerToken = 'f9e8d7c6-b5a4-3210-fedc-ba9876543210'
const fullEvent = {
eventToken,
title: 'Summer BBQ',
description: 'Bring your own drinks!',
dateTime: '2026-03-15T20:00:00+01:00',
timezone: 'Europe/Berlin',
location: 'Central Park, NYC',
attendeeCount: 3,
expired: false,
}
const attendeesResponse = {
attendees: [
{ name: 'Alice' },
{ name: 'Bob' },
{ name: 'Charlie' },
],
}
test.describe('US-1: View attendee list as organizer', () => {
test('organizer sees attendee names', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json(fullEvent)
}),
http.get('*/api/events/:token/attendees', () => {
return HttpResponse.json(attendeesResponse)
}),
)
// Set organizer token in localStorage before navigating
await page.goto('/')
await page.evaluate(
([et, ot]) => {
localStorage.setItem(
'fete:events',
JSON.stringify([{ eventToken: et, organizerToken: ot, title: 'Summer BBQ', dateTime: '2026-03-15T20:00:00+01:00', expiryDate: '' }]),
)
},
[eventToken, organizerToken],
)
await page.goto(`/events/${eventToken}`)
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
await expect(page.getByText('3 Attendees')).toBeVisible()
await expect(page.getByText('Alice')).toBeVisible()
await expect(page.getByText('Bob')).toBeVisible()
await expect(page.getByText('Charlie')).toBeVisible()
})
test('visitor does not see attendee list', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json(fullEvent)
}),
)
await page.goto(`/events/${eventToken}`)
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
await expect(page.getByText('3 going')).toBeVisible()
await expect(page.locator('.attendee-list')).not.toBeVisible()
})
test('organizer sees empty state when no attendees', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json({ ...fullEvent, attendeeCount: 0 })
}),
http.get('*/api/events/:token/attendees', () => {
return HttpResponse.json({ attendees: [] })
}),
)
await page.goto('/')
await page.evaluate(
([et, ot]) => {
localStorage.setItem(
'fete:events',
JSON.stringify([{ eventToken: et, organizerToken: ot, title: 'Summer BBQ', dateTime: '2026-03-15T20:00:00+01:00', expiryDate: '' }]),
)
},
[eventToken, organizerToken],
)
await page.goto(`/events/${eventToken}`)
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
await expect(page.getByText('0 Attendees')).toBeVisible()
await expect(page.getByText('No attendees yet.')).toBeVisible()
})
})

View File

@@ -23,17 +23,17 @@
"@vitest/eslint-plugin": "^1.6.9",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"@vue/tsconfig": "^0.9.0",
"eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.50.0",
"eslint-plugin-oxlint": "~1.51.0",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1",
"jsdom": "^28.1.0",
"msw": "^2.12.10",
"npm-run-all2": "^8.0.4",
"openapi-typescript": "^7.13.0",
"oxlint": "~1.50.0",
"oxlint": "~1.51.0",
"prettier": "3.8.1",
"typescript": "~5.9.3",
"vite": "^7.3.1",
@@ -1201,15 +1201,15 @@
}
},
"node_modules/@eslint/config-array": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz",
"integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==",
"version": "0.23.3",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
"integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/object-schema": "^3.0.2",
"@eslint/object-schema": "^3.0.3",
"debug": "^4.3.1",
"minimatch": "^10.2.1"
"minimatch": "^10.2.4"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
@@ -1229,9 +1229,9 @@
}
},
"node_modules/@eslint/core": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz",
"integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
"integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -1242,9 +1242,9 @@
}
},
"node_modules/@eslint/object-schema": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz",
"integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
"integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -1252,13 +1252,13 @@
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz",
"integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==",
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
"integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^1.1.0",
"@eslint/core": "^1.1.1",
"levn": "^0.4.1"
},
"engines": {
@@ -1727,9 +1727,9 @@
"license": "MIT"
},
"node_modules/@oxlint/binding-android-arm-eabi": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.50.0.tgz",
"integrity": "sha512-G7MRGk/6NCe+L8ntonRdZP7IkBfEpiZ/he3buLK6JkLgMHgJShXZ+BeOwADmspXez7U7F7L1Anf4xLSkLHiGTg==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.51.0.tgz",
"integrity": "sha512-jJYIqbx4sX+suIxWstc4P7SzhEwb4ArWA2KVrmEuu9vH2i0qM6QIHz/ehmbGE4/2fZbpuMuBzTl7UkfNoqiSgw==",
"cpu": [
"arm"
],
@@ -1744,9 +1744,9 @@
}
},
"node_modules/@oxlint/binding-android-arm64": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.50.0.tgz",
"integrity": "sha512-GeSuMoJWCVpovJi/e3xDSNgjeR8WEZ6MCXL6EtPiCIM2NTzv7LbflARINTXTJy2oFBYyvdf/l2PwHzYo6EdXvg==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.51.0.tgz",
"integrity": "sha512-GtXyBCcH4ti98YdiMNCrpBNGitx87EjEWxevnyhcBK12k/Vu4EzSB45rzSC4fGFUD6sQgeaxItRCEEWeVwPafw==",
"cpu": [
"arm64"
],
@@ -1761,9 +1761,9 @@
}
},
"node_modules/@oxlint/binding-darwin-arm64": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.50.0.tgz",
"integrity": "sha512-w3SY5YtxGnxCHPJ8Twl3KmS9oja1gERYk3AMoZ7Hv8P43ZtB6HVfs02TxvarxfL214Tm3uzvc2vn+DhtUNeKnw==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.51.0.tgz",
"integrity": "sha512-3QJbeYaMHn6Bh2XeBXuITSsbnIctyTjvHf5nRjKYrT9pPeErNIpp5VDEeAXC0CZSwSVTsc8WOSDwgrAI24JolQ==",
"cpu": [
"arm64"
],
@@ -1778,9 +1778,9 @@
}
},
"node_modules/@oxlint/binding-darwin-x64": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.50.0.tgz",
"integrity": "sha512-hNfogDqy7tvmllXKBSlHo6k5x7dhTUVOHbMSE15CCAcXzmqf5883aPvBYPOq9AE7DpDUQUZ1kVE22YbiGW+tuw==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.51.0.tgz",
"integrity": "sha512-NzErhMaTEN1cY0E8C5APy74lw5VwsNfJfVPBMWPVQLqAbO0k4FFLjvHURvkUL+Y18Wu+8Vs1kbqPh2hjXYA4pg==",
"cpu": [
"x64"
],
@@ -1795,9 +1795,9 @@
}
},
"node_modules/@oxlint/binding-freebsd-x64": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.50.0.tgz",
"integrity": "sha512-ykZevOWEyu0nsxolA911ucxpEv0ahw8jfEeGWOwwb/VPoE4xoexuTOAiPNlWZNJqANlJl7yp8OyzCtXTUAxotw==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.51.0.tgz",
"integrity": "sha512-msAIh3vPAoKoHlOE/oe6Q5C/n9umypv/k81lED82ibrJotn+3YG2Qp1kiR8o/Dg5iOEU97c6tl0utxcyFenpFw==",
"cpu": [
"x64"
],
@@ -1812,9 +1812,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.50.0.tgz",
"integrity": "sha512-hif3iDk7vo5GGJ4OLCCZAf2vjnU9FztGw4L0MbQL0M2iY9LKFtDMMiQAHmkF0PQGQMVbTYtPdXCLKVgdkiqWXQ==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.51.0.tgz",
"integrity": "sha512-CqQPcvqYyMe9ZBot2stjGogEzk1z8gGAngIX7srSzrzexmXixwVxBdFZyxTVM0CjGfDeV+Ru0w25/WNjlMM2Hw==",
"cpu": [
"arm"
],
@@ -1829,9 +1829,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.50.0.tgz",
"integrity": "sha512-dVp9iSssiGAnTNey2Ruf6xUaQhdnvcFOJyRWd/mu5o2jVbFK15E5fbWGeFRfmuobu5QXuROtFga44+7DOS3PLg==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.51.0.tgz",
"integrity": "sha512-dstrlYQgZMnyOssxSbolGCge/sDbko12N/35RBNuqLpoPbft2aeBidBAb0dvQlyBd9RJ6u8D4o4Eh8Un6iTgyQ==",
"cpu": [
"arm"
],
@@ -1846,9 +1846,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm64-gnu": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.50.0.tgz",
"integrity": "sha512-1cT7yz2HA910CKA9NkH1ZJo50vTtmND2fkoW1oyiSb0j6WvNtJ0Wx2zoySfXWc/c+7HFoqRK5AbEoL41LOn9oA==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.51.0.tgz",
"integrity": "sha512-QEjUpXO7d35rP1/raLGGbAsBLLGZIzV3ZbeSjqWlD3oRnxpRIZ6iL4o51XQHkconn3uKssc+1VKdtHJ81BBhDA==",
"cpu": [
"arm64"
],
@@ -1863,9 +1863,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm64-musl": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.50.0.tgz",
"integrity": "sha512-++B3k/HEPFVlj89cOz8kWfQccMZB/aWL9AhsW7jPIkG++63Mpwb2cE9XOEsd0PATbIan78k2Gky+09uWM1d/gQ==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.51.0.tgz",
"integrity": "sha512-YSJua5irtG4DoMAjUapDTPhkQLHhBIY0G9JqlZS6/SZPzqDkPku/1GdWs0D6h/wyx0Iz31lNCfIaWKBQhzP0wQ==",
"cpu": [
"arm64"
],
@@ -1880,9 +1880,9 @@
}
},
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.50.0.tgz",
"integrity": "sha512-Z9b/KpFMkx66w3gVBqjIC1AJBTZAGoI9+U+K5L4QM0CB/G0JSNC1es9b3Y0Vcrlvtdn8A+IQTkYjd/Q0uCSaZw==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.51.0.tgz",
"integrity": "sha512-7L4Wj2IEUNDETKssB9IDYt16T6WlF+X2jgC/hBq3diGHda9vJLpAgb09+D3quFq7TdkFtI7hwz/jmuQmQFPc1Q==",
"cpu": [
"ppc64"
],
@@ -1897,9 +1897,9 @@
}
},
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.50.0.tgz",
"integrity": "sha512-jvmuIw8wRSohsQlFNIST5uUwkEtEJmOQYr33bf/K2FrFPXHhM4KqGekI3ShYJemFS/gARVacQFgBzzJKCAyJjg==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.51.0.tgz",
"integrity": "sha512-cBUHqtOXy76G41lOB401qpFoKx1xq17qYkhWrLSM7eEjiHM9sOtYqpr6ZdqCnN9s6ZpzudX4EkeHOFH2E9q0vA==",
"cpu": [
"riscv64"
],
@@ -1914,9 +1914,9 @@
}
},
"node_modules/@oxlint/binding-linux-riscv64-musl": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.50.0.tgz",
"integrity": "sha512-x+UrN47oYNh90nmAAyql8eQaaRpHbDPu5guasDg10+OpszUQ3/1+1J6zFMmV4xfIEgTcUXG/oI5fxJhF4eWCNA==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.51.0.tgz",
"integrity": "sha512-WKbg8CysgZcHfZX0ixQFBRSBvFZUHa3SBnEjHY2FVYt2nbNJEjzTxA3ZR5wMU0NOCNKIAFUFvAh5/XJKPRJuJg==",
"cpu": [
"riscv64"
],
@@ -1931,9 +1931,9 @@
}
},
"node_modules/@oxlint/binding-linux-s390x-gnu": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.50.0.tgz",
"integrity": "sha512-i/JLi2ljLUIVfekMj4ISmdt+Hn11wzYUdRRrkVUYsCWw7zAy5xV7X9iA+KMyM156LTFympa7s3oKBjuCLoTAUQ==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.51.0.tgz",
"integrity": "sha512-N1QRUvJTxqXNSu35YOufdjsAVmKVx5bkrggOWAhTWBc3J4qjcBwr1IfyLh/6YCg8sYRSR1GraldS9jUgJL/U4A==",
"cpu": [
"s390x"
],
@@ -1948,9 +1948,9 @@
}
},
"node_modules/@oxlint/binding-linux-x64-gnu": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.50.0.tgz",
"integrity": "sha512-/C7brhn6c6UUPccgSPCcpLQXcp+xKIW/3sji/5VZ8/OItL3tQ2U7KalHz887UxxSQeEOmd1kY6lrpuwFnmNqOA==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.51.0.tgz",
"integrity": "sha512-e0Mz0DizsCoqNIjeOg6OUKe8JKJWZ5zZlwsd05Bmr51Jo3AOL4UJnPvwKumr4BBtBrDZkCmOLhCvDGm95nJM2g==",
"cpu": [
"x64"
],
@@ -1965,9 +1965,9 @@
}
},
"node_modules/@oxlint/binding-linux-x64-musl": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.50.0.tgz",
"integrity": "sha512-oDR1f+bGOYU8LfgtEW8XtotWGB63ghtcxk5Jm6IDTCk++rTA/IRMsjOid2iMd+1bW+nP9Mdsmcdc7VbPD3+iyQ==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.51.0.tgz",
"integrity": "sha512-wD8HGTWhYBKXvRDvoBVB1y+fEYV01samhWQSy1Zkxq2vpezvMnjaFKRuiP6tBNITLGuffbNDEXOwcAhJ3gI5Ug==",
"cpu": [
"x64"
],
@@ -1982,9 +1982,9 @@
}
},
"node_modules/@oxlint/binding-openharmony-arm64": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.50.0.tgz",
"integrity": "sha512-4CmRGPp5UpvXyu4jjP9Tey/SrXDQLRvZXm4pb4vdZBxAzbFZkCyh0KyRy4txld/kZKTJlW4TO8N1JKrNEk+mWw==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.51.0.tgz",
"integrity": "sha512-5NSwQ2hDEJ0GPXqikjWtwzgAQCsS7P9aLMNenjjKa+gknN3lTCwwwERsT6lKXSirfU3jLjexA2XQvQALh5h27w==",
"cpu": [
"arm64"
],
@@ -1999,9 +1999,9 @@
}
},
"node_modules/@oxlint/binding-win32-arm64-msvc": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.50.0.tgz",
"integrity": "sha512-Fq0M6vsGcFsSfeuWAACDhd5KJrO85ckbEfe1EGuBj+KPyJz7KeWte2fSFrFGmNKNXyhEMyx4tbgxiWRujBM2KQ==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.51.0.tgz",
"integrity": "sha512-JEZyah1M0RHMw8d+jjSSJmSmO8sABA1J1RtrHYujGPeCkYg1NeH0TGuClpe2h5QtioRTaF57y/TZfn/2IFV6fA==",
"cpu": [
"arm64"
],
@@ -2016,9 +2016,9 @@
}
},
"node_modules/@oxlint/binding-win32-ia32-msvc": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.50.0.tgz",
"integrity": "sha512-qTdWR9KwY/vxJGhHVIZG2eBOhidOQvOwzDxnX+jhW/zIVacal1nAhR8GLkiywW8BIFDkQKXo/zOfT+/DY+ns/w==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.51.0.tgz",
"integrity": "sha512-q3cEoKH6kwjz/WRyHwSf0nlD2F5Qw536kCXvmlSu+kaShzgrA0ojmh45CA81qL+7udfCaZL2SdKCZlLiGBVFlg==",
"cpu": [
"ia32"
],
@@ -2033,9 +2033,9 @@
}
},
"node_modules/@oxlint/binding-win32-x64-msvc": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.50.0.tgz",
"integrity": "sha512-682t7npLC4G2Ca+iNlI9fhAKTcFPYYXJjwoa88H4q+u5HHHlsnL/gHULapX3iqp+A8FIJbgdylL5KMYo2LaluQ==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.51.0.tgz",
"integrity": "sha512-Q14+fOGb9T28nWF/0EUsYqERiRA7cl1oy4TJrGmLaqhm+aO2cV+JttboHI3CbdeMCAyDI1+NoSlrM7Melhp/cw==",
"cpu": [
"x64"
],
@@ -3423,9 +3423,9 @@
}
},
"node_modules/@vue/tsconfig": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.8.1.tgz",
"integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==",
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.9.0.tgz",
"integrity": "sha512-RP+v9Cpbsk1ZVXltCHHkYBr7+624x6gcijJXVjIcsYk7JXqvIpRtMwU2ARLvWDhmy9ffdFYxhsfJnPztADBohQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -4304,18 +4304,18 @@
}
},
"node_modules/eslint": {
"version": "10.0.2",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz",
"integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==",
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz",
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.2",
"@eslint/config-array": "^0.23.3",
"@eslint/config-helpers": "^0.5.2",
"@eslint/core": "^1.1.0",
"@eslint/plugin-kit": "^0.6.0",
"@eslint/core": "^1.1.1",
"@eslint/plugin-kit": "^0.6.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
@@ -4324,7 +4324,7 @@
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^9.1.1",
"eslint-scope": "^9.1.2",
"eslint-visitor-keys": "^5.0.1",
"espree": "^11.1.1",
"esquery": "^1.7.0",
@@ -4337,7 +4337,7 @@
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"minimatch": "^10.2.1",
"minimatch": "^10.2.4",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
@@ -4376,9 +4376,9 @@
}
},
"node_modules/eslint-plugin-oxlint": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-oxlint/-/eslint-plugin-oxlint-1.50.0.tgz",
"integrity": "sha512-QAxeFeUHuekmLkuRLdzHH8Z0JvC7482OaQ3jlUMdEd0gcS6m+MYHei3Favoew9DdvTQT7yHxrm7BL0iXoenb6w==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-oxlint/-/eslint-plugin-oxlint-1.51.0.tgz",
"integrity": "sha512-lct8LD1AxfHF1PcsuK6mFYals+zX0mx/WP2G4i16h0iR8jpT3xCfGTmTNwXiImcevzGIiJ/VDBgQ7t0B9z2Jeg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4418,9 +4418,9 @@
}
},
"node_modules/eslint-scope": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz",
"integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==",
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@@ -5776,9 +5776,9 @@
"license": "MIT"
},
"node_modules/oxlint": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.50.0.tgz",
"integrity": "sha512-iSJ4IZEICBma8cZX7kxIIz9PzsYLF2FaLAYN6RKu7VwRVKdu7RIgpP99bTZaGl//Yao7fsaGZLSEo5xBrI5ReQ==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.51.0.tgz",
"integrity": "sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ==",
"dev": true,
"license": "MIT",
"bin": {
@@ -5791,28 +5791,28 @@
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxlint/binding-android-arm-eabi": "1.50.0",
"@oxlint/binding-android-arm64": "1.50.0",
"@oxlint/binding-darwin-arm64": "1.50.0",
"@oxlint/binding-darwin-x64": "1.50.0",
"@oxlint/binding-freebsd-x64": "1.50.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.50.0",
"@oxlint/binding-linux-arm-musleabihf": "1.50.0",
"@oxlint/binding-linux-arm64-gnu": "1.50.0",
"@oxlint/binding-linux-arm64-musl": "1.50.0",
"@oxlint/binding-linux-ppc64-gnu": "1.50.0",
"@oxlint/binding-linux-riscv64-gnu": "1.50.0",
"@oxlint/binding-linux-riscv64-musl": "1.50.0",
"@oxlint/binding-linux-s390x-gnu": "1.50.0",
"@oxlint/binding-linux-x64-gnu": "1.50.0",
"@oxlint/binding-linux-x64-musl": "1.50.0",
"@oxlint/binding-openharmony-arm64": "1.50.0",
"@oxlint/binding-win32-arm64-msvc": "1.50.0",
"@oxlint/binding-win32-ia32-msvc": "1.50.0",
"@oxlint/binding-win32-x64-msvc": "1.50.0"
"@oxlint/binding-android-arm-eabi": "1.51.0",
"@oxlint/binding-android-arm64": "1.51.0",
"@oxlint/binding-darwin-arm64": "1.51.0",
"@oxlint/binding-darwin-x64": "1.51.0",
"@oxlint/binding-freebsd-x64": "1.51.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.51.0",
"@oxlint/binding-linux-arm-musleabihf": "1.51.0",
"@oxlint/binding-linux-arm64-gnu": "1.51.0",
"@oxlint/binding-linux-arm64-musl": "1.51.0",
"@oxlint/binding-linux-ppc64-gnu": "1.51.0",
"@oxlint/binding-linux-riscv64-gnu": "1.51.0",
"@oxlint/binding-linux-riscv64-musl": "1.51.0",
"@oxlint/binding-linux-s390x-gnu": "1.51.0",
"@oxlint/binding-linux-x64-gnu": "1.51.0",
"@oxlint/binding-linux-x64-musl": "1.51.0",
"@oxlint/binding-openharmony-arm64": "1.51.0",
"@oxlint/binding-win32-arm64-msvc": "1.51.0",
"@oxlint/binding-win32-ia32-msvc": "1.51.0",
"@oxlint/binding-win32-x64-msvc": "1.51.0"
},
"peerDependencies": {
"oxlint-tsgolint": ">=0.14.1"
"oxlint-tsgolint": ">=0.15.0"
},
"peerDependenciesMeta": {
"oxlint-tsgolint": {

View File

@@ -35,17 +35,17 @@
"@vitest/eslint-plugin": "^1.6.9",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"@vue/tsconfig": "^0.9.0",
"eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.50.0",
"eslint-plugin-oxlint": "~1.51.0",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1",
"jsdom": "^28.1.0",
"msw": "^2.12.10",
"npm-run-all2": "^8.0.4",
"openapi-typescript": "^7.13.0",
"oxlint": "~1.50.0",
"oxlint": "~1.51.0",
"prettier": "3.8.1",
"typescript": "~5.9.3",
"vite": "^7.3.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -163,6 +163,19 @@ textarea.form-field {
padding-left: 0.25rem;
}
/* Skeleton shimmer loading state */
.skeleton {
background: linear-gradient(90deg, var(--color-card) 25%, #e0e0e0 50%, var(--color-card) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-card);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Utility */
.text-center {
text-align: center;
@@ -179,3 +192,34 @@ textarea.form-field {
white-space: nowrap;
border: 0;
}
/* Bottom sheet form */
.sheet-title {
font-size: 1.2rem;
font-weight: 700;
color: var(--color-text);
}
.rsvp-form {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.rsvp-form__label {
font-size: 0.85rem;
font-weight: 700;
color: var(--color-text);
padding-left: 0.25rem;
}
.rsvp-form__field-error {
color: #d32f2f;
font-size: 0.875rem;
font-weight: 600;
padding-left: 0.25rem;
}
.rsvp-form__error {
text-align: center;
}

View File

@@ -0,0 +1,59 @@
<template>
<section class="attendee-list">
<h3 class="attendee-list__heading">
{{ attendees.length === 1 ? '1 Attendee' : `${attendees.length} Attendees` }}
</h3>
<ul v-if="attendees.length > 0" class="attendee-list__items">
<li v-for="(name, index) in attendees" :key="index" class="attendee-list__item">
{{ name }}
</li>
</ul>
<p v-else class="attendee-list__empty">No attendees yet.</p>
</section>
</template>
<script setup lang="ts">
defineProps<{
attendees: string[]
}>()
</script>
<style scoped>
.attendee-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.attendee-list__heading {
font-size: 0.75rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.attendee-list__items {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.attendee-list__item {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.85);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attendee-list__empty {
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<Teleport to="body">
<Transition name="sheet">
<div v-if="open" class="sheet-backdrop" @click.self="$emit('close')" @keydown.escape="$emit('close')">
<div class="sheet" role="dialog" aria-modal="true" :aria-label="label" ref="sheetEl" tabindex="-1">
<div class="sheet__handle" aria-hidden="true" />
<slot />
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
defineProps<{
open: boolean
label: string
}>()
defineEmits<{
close: []
}>()
const sheetEl = ref<HTMLElement | null>(null)
watch(
() => sheetEl.value,
async (el) => {
if (el) {
await nextTick()
const firstInput = el.querySelector<HTMLElement>('input, textarea, button[type="submit"]')
if (firstInput) {
firstInput.focus()
} else {
el.focus()
}
}
},
)
</script>
<style scoped>
.sheet-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 100;
}
.sheet {
background: var(--color-card);
border-radius: 20px 20px 0 0;
padding: var(--spacing-lg) var(--spacing-xl) var(--spacing-2xl);
width: 100%;
max-width: var(--content-max-width);
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
outline: none;
}
.sheet__handle {
width: 36px;
height: 4px;
background: #ccc;
border-radius: 2px;
align-self: center;
flex-shrink: 0;
}
/* Transition */
.sheet-enter-active,
.sheet-leave-active {
transition: opacity 0.25s ease;
}
.sheet-enter-active .sheet,
.sheet-leave-active .sheet {
transition: transform 0.25s ease;
}
.sheet-enter-from,
.sheet-leave-to {
opacity: 0;
}
.sheet-enter-from .sheet,
.sheet-leave-to .sheet {
transform: translateY(100%);
}
</style>

View File

@@ -0,0 +1,151 @@
<template>
<Teleport to="body">
<Transition name="confirm-dialog">
<div v-if="open" class="confirm-dialog__overlay" @click.self="$emit('cancel')">
<div
class="confirm-dialog"
role="alertdialog"
aria-modal="true"
:aria-label="title"
@keydown.escape="$emit('cancel')"
>
<p class="confirm-dialog__title">{{ title }}</p>
<p class="confirm-dialog__message">{{ message }}</p>
<div class="confirm-dialog__actions">
<button
ref="cancelBtn"
class="confirm-dialog__btn confirm-dialog__btn--cancel"
type="button"
@click="$emit('cancel')"
>
{{ cancelLabel }}
</button>
<button
class="confirm-dialog__btn confirm-dialog__btn--confirm"
type="button"
@click="$emit('confirm')"
>
{{ confirmLabel }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
const props = withDefaults(
defineProps<{
open: boolean
title?: string
message?: string
confirmLabel?: string
cancelLabel?: string
}>(),
{
title: 'Are you sure?',
message: '',
confirmLabel: 'Remove',
cancelLabel: 'Cancel',
},
)
defineEmits<{
confirm: []
cancel: []
}>()
const cancelBtn = ref<HTMLButtonElement | null>(null)
watch(
() => props.open,
async (isOpen) => {
if (isOpen) {
await nextTick()
cancelBtn.value?.focus()
}
},
)
</script>
<style scoped>
.confirm-dialog__overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: var(--spacing-lg);
}
.confirm-dialog {
background: var(--color-card);
border-radius: var(--radius-card);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
padding: var(--spacing-xl);
max-width: 320px;
width: 100%;
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.confirm-dialog__title {
font-size: 1.05rem;
font-weight: 700;
color: var(--color-text);
}
.confirm-dialog__message {
font-size: 0.9rem;
font-weight: 400;
color: #666;
}
.confirm-dialog__actions {
display: flex;
gap: var(--spacing-sm);
justify-content: flex-end;
margin-top: var(--spacing-xs);
}
.confirm-dialog__btn {
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius-button);
font-family: inherit;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s ease;
}
.confirm-dialog__btn:hover {
opacity: 0.85;
}
.confirm-dialog__btn--cancel {
background: #e8e8e8;
color: #555;
}
.confirm-dialog__btn--confirm {
background: #d32f2f;
color: #fff;
}
.confirm-dialog-enter-active,
.confirm-dialog-leave-active {
transition: opacity 0.15s ease;
}
.confirm-dialog-enter-from,
.confirm-dialog-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<RouterLink to="/create" class="fab" aria-label="Create event">
<span class="fab__icon" aria-hidden="true">+</span>
</RouterLink>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>
<style scoped>
.fab {
position: fixed;
bottom: calc(1.2rem + env(safe-area-inset-bottom));
right: 1.2rem;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--color-accent);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
text-decoration: none;
z-index: 100;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.fab:hover {
transform: scale(1.08);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
}
.fab:active {
transform: scale(0.95);
}
.fab:focus-visible {
outline: 2px solid #fff;
outline-offset: 3px;
}
.fab__icon {
font-size: 1.8rem;
font-weight: 300;
line-height: 1;
}
</style>

View File

@@ -0,0 +1,19 @@
<template>
<h3 class="date-subheader">{{ label }}</h3>
</template>
<script setup lang="ts">
defineProps<{
label: string
}>()
</script>
<style scoped>
.date-subheader {
font-size: 0.85rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
margin: 0;
padding: var(--spacing-xs) 0;
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<div class="empty-state">
<p class="empty-state__message">No events yet.<br />Create your first one!</p>
<RouterLink to="/create" class="btn-primary empty-state__cta">+ Create Event</RouterLink>
</div>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>
<style scoped>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-lg);
}
.empty-state__message {
font-size: 1rem;
font-weight: 400;
color: var(--color-text-on-gradient);
opacity: 0.9;
text-align: center;
}
.empty-state__cta {
max-width: 280px;
}
</style>

View File

@@ -0,0 +1,181 @@
<template>
<div
class="event-card"
:class="{ 'event-card--past': isPast, 'event-card--swiping': isSwiping }"
:style="swipeStyle"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<RouterLink :to="`/events/${eventToken}`" class="event-card__link">
<span class="event-card__title">{{ title }}</span>
<span class="event-card__time">{{ displayTime }}</span>
</RouterLink>
<span v-if="eventRole" class="event-card__badge" :class="`event-card__badge--${eventRole}`">
{{ eventRole === 'organizer' ? 'Organizer' : 'Attendee' }}
</span>
<button
class="event-card__delete"
type="button"
:aria-label="`Remove ${title}`"
@click.stop="$emit('delete', eventToken)"
>
×
</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { RouterLink } from 'vue-router'
const props = defineProps<{
eventToken: string
title: string
relativeTime: string
isPast: boolean
eventRole?: 'organizer' | 'attendee'
timeDisplayMode?: 'clock' | 'relative'
dateTime?: string
}>()
const emit = defineEmits<{
delete: [eventToken: string]
}>()
const displayTime = computed(() => {
if (props.timeDisplayMode === 'clock' && props.dateTime) {
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(new Date(props.dateTime))
}
return props.relativeTime
})
const SWIPE_THRESHOLD = 80
const startX = ref(0)
const deltaX = ref(0)
const isSwiping = ref(false)
const swipeStyle = computed(() => {
if (deltaX.value === 0) return {}
return { transform: `translateX(${deltaX.value}px)` }
})
function onTouchStart(e: TouchEvent) {
const touch = e.touches[0]
if (!touch) return
startX.value = touch.clientX
deltaX.value = 0
isSwiping.value = false
}
function onTouchMove(e: TouchEvent) {
const touch = e.touches[0]
if (!touch) return
const diff = touch.clientX - startX.value
// Only allow leftward swipe
if (diff < 0) {
deltaX.value = diff
isSwiping.value = true
}
}
function onTouchEnd() {
if (deltaX.value < -SWIPE_THRESHOLD) {
emit('delete', props.eventToken)
}
deltaX.value = 0
isSwiping.value = false
}
</script>
<style scoped>
.event-card {
display: flex;
align-items: center;
background: var(--color-card);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
padding: var(--spacing-md) var(--spacing-lg);
gap: var(--spacing-sm);
}
.event-card--past {
opacity: 0.6;
filter: saturate(0.5);
}
.event-card:not(.event-card--swiping) {
transition: opacity 0.2s ease, filter 0.2s ease, transform 0.2s ease;
}
.event-card__link {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.15rem;
text-decoration: none;
color: inherit;
min-width: 0;
}
.event-card__title {
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-card__time {
font-size: 0.8rem;
font-weight: 400;
color: #888;
}
.event-card__badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.15rem 0.5rem;
border-radius: 999px;
white-space: nowrap;
flex-shrink: 0;
}
.event-card__badge--organizer {
background: var(--color-accent);
color: #fff;
}
.event-card__badge--attendee {
background: #e0e0e0;
color: #555;
}
.event-card__delete {
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
font-size: 1.2rem;
color: #bbb;
cursor: pointer;
border-radius: 50%;
transition: color 0.15s ease, background 0.15s ease;
}
.event-card__delete:hover {
color: #d32f2f;
background: rgba(211, 47, 47, 0.08);
}
.event-card__delete:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div class="event-list">
<section
v-for="section in groupedSections"
:key="section.key"
:aria-label="section.label"
class="event-section"
>
<SectionHeader :label="section.label" :emphasized="section.emphasized" />
<div role="list">
<template v-for="group in section.dateGroups" :key="group.dateKey">
<DateSubheader v-if="group.showSubheader" :label="group.label" />
<div v-for="event in group.events" :key="event.eventToken" role="listitem">
<EventCard
:event-token="event.eventToken"
:title="event.title"
:relative-time="formatRelativeTime(event.dateTime)"
:is-past="section.key === 'past'"
:event-role="getRole(event)"
:time-display-mode="section.key === 'past' ? 'relative' : 'clock'"
:date-time="event.dateTime"
@delete="requestDelete"
/>
</div>
</template>
</div>
</section>
<ConfirmDialog
:open="!!pendingDeleteToken"
title="Remove event?"
message="This event will be removed from your list."
confirm-label="Remove"
cancel-label="Cancel"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useEventStorage, isValidStoredEvent } from '../composables/useEventStorage'
import { useEventGrouping } from '../composables/useEventGrouping'
import { formatRelativeTime } from '../composables/useRelativeTime'
import EventCard from './EventCard.vue'
import SectionHeader from './SectionHeader.vue'
import DateSubheader from './DateSubheader.vue'
import ConfirmDialog from './ConfirmDialog.vue'
import type { StoredEvent } from '../composables/useEventStorage'
const { getStoredEvents, removeEvent } = useEventStorage()
const pendingDeleteToken = ref<string | null>(null)
function requestDelete(eventToken: string) {
pendingDeleteToken.value = eventToken
}
function confirmDelete() {
if (pendingDeleteToken.value) {
removeEvent(pendingDeleteToken.value)
}
pendingDeleteToken.value = null
}
function cancelDelete() {
pendingDeleteToken.value = null
}
function getRole(event: StoredEvent): 'organizer' | 'attendee' | undefined {
if (event.organizerToken) return 'organizer'
if (event.rsvpToken) return 'attendee'
return undefined
}
const groupedSections = computed(() => {
const valid = getStoredEvents().filter(isValidStoredEvent)
return useEventGrouping(valid)
})
</script>
<style scoped>
.event-list {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.event-section [role="list"] {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<div class="rsvp-bar">
<div class="rsvp-bar__inner">
<!-- Status state: already RSVPed -->
<div v-if="hasRsvp" class="rsvp-bar__status">
<span class="rsvp-bar__check" aria-hidden="true"></span>
<span class="rsvp-bar__text">You're attending!</span>
</div>
<!-- CTA state: no RSVP yet -->
<button v-else class="btn-primary rsvp-bar__cta" type="button" @click="$emit('open')">
I'm attending
</button>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
hasRsvp?: boolean
}>()
defineEmits<{
open: []
}>()
</script>
<style scoped>
.rsvp-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
z-index: 50;
padding: var(--spacing-md) var(--content-padding);
padding-bottom: calc(var(--spacing-md) + env(safe-area-inset-bottom, 0px));
}
.rsvp-bar__inner {
width: 100%;
max-width: var(--content-max-width);
}
.rsvp-bar__cta {
width: 100%;
}
.rsvp-bar__status {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
background: var(--color-card);
border-radius: var(--radius-card);
padding: var(--spacing-md) var(--spacing-lg);
box-shadow: var(--shadow-card);
font-weight: 600;
font-size: 0.95rem;
color: var(--color-text);
}
.rsvp-bar__check {
color: #4caf50;
font-size: 1.1rem;
font-weight: 700;
}
.rsvp-bar__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,27 @@
<template>
<h2 class="section-header" :class="{ 'section-header--emphasized': emphasized }">
{{ label }}
</h2>
</template>
<script setup lang="ts">
defineProps<{
label: string
emphasized?: boolean
}>()
</script>
<style scoped>
.section-header {
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
margin: 0;
padding: var(--spacing-sm) 0;
}
.section-header--emphasized {
font-size: 1.1rem;
font-weight: 800;
}
</style>

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import AttendeeList from '../AttendeeList.vue'
describe('AttendeeList', () => {
it('renders attendee names as list items', () => {
const wrapper = mount(AttendeeList, {
props: { attendees: ['Alice', 'Bob', 'Charlie'] },
})
const items = wrapper.findAll('.attendee-list__item')
expect(items).toHaveLength(3)
expect(items[0]!.text()).toBe('Alice')
expect(items[1]!.text()).toBe('Bob')
expect(items[2]!.text()).toBe('Charlie')
})
it('shows empty state message when no attendees', () => {
const wrapper = mount(AttendeeList, {
props: { attendees: [] },
})
expect(wrapper.find('.attendee-list__empty').text()).toBe('No attendees yet.')
expect(wrapper.find('.attendee-list__items').exists()).toBe(false)
})
it('shows plural count heading for multiple attendees', () => {
const wrapper = mount(AttendeeList, {
props: { attendees: ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'] },
})
expect(wrapper.find('.attendee-list__heading').text()).toBe('5 Attendees')
})
it('shows singular count heading for one attendee', () => {
const wrapper = mount(AttendeeList, {
props: { attendees: ['Alice'] },
})
expect(wrapper.find('.attendee-list__heading').text()).toBe('1 Attendee')
})
it('shows zero count heading for no attendees', () => {
const wrapper = mount(AttendeeList, {
props: { attendees: [] },
})
expect(wrapper.find('.attendee-list__heading').text()).toBe('0 Attendees')
})
})

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import BottomSheet from '../BottomSheet.vue'
function mountSheet(open = true) {
return mount(BottomSheet, {
props: { open, label: 'Test Sheet' },
slots: { default: '<p>Sheet content</p>' },
attachTo: document.body,
})
}
describe('BottomSheet', () => {
it('renders slot content when open', () => {
const wrapper = mountSheet(true)
expect(document.body.textContent).toContain('Sheet content')
wrapper.unmount()
})
it('does not render content when closed', () => {
const wrapper = mountSheet(false)
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
wrapper.unmount()
})
it('has aria-modal and aria-label on the dialog', () => {
const wrapper = mountSheet(true)
const dialog = document.body.querySelector('[role="dialog"]')!
expect(dialog.getAttribute('aria-modal')).toBe('true')
expect(dialog.getAttribute('aria-label')).toBe('Test Sheet')
wrapper.unmount()
})
it('emits close when backdrop is clicked', async () => {
const wrapper = mountSheet(true)
const backdrop = document.body.querySelector('.sheet-backdrop')! as HTMLElement
await backdrop.click()
// Vue test utils tracks emitted events on the wrapper
expect(wrapper.emitted('close')).toBeTruthy()
wrapper.unmount()
})
it('emits close on Escape key', async () => {
const wrapper = mountSheet(true)
const backdrop = document.body.querySelector('.sheet-backdrop')! as HTMLElement
backdrop.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await wrapper.vm.$nextTick()
expect(wrapper.emitted('close')).toBeTruthy()
wrapper.unmount()
})
})

View File

@@ -0,0 +1,111 @@
import { describe, it, expect, afterEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import ConfirmDialog from '../ConfirmDialog.vue'
let wrapper: VueWrapper
function mountDialog(props: Record<string, unknown> = {}) {
wrapper = mount(ConfirmDialog, {
props: {
open: true,
...props,
},
attachTo: document.body,
})
return wrapper
}
function dialog() {
return document.body.querySelector('.confirm-dialog')
}
function overlay() {
return document.body.querySelector('.confirm-dialog__overlay')
}
afterEach(() => {
wrapper?.unmount()
})
describe('ConfirmDialog', () => {
it('renders when open is true', () => {
mountDialog()
expect(dialog()).not.toBeNull()
})
it('does not render when open is false', () => {
mountDialog({ open: false })
expect(dialog()).toBeNull()
})
it('displays default title', () => {
mountDialog()
expect(dialog()!.querySelector('.confirm-dialog__title')!.textContent).toBe('Are you sure?')
})
it('displays custom title and message', () => {
mountDialog({
title: 'Remove event?',
message: 'This cannot be undone.',
})
expect(dialog()!.querySelector('.confirm-dialog__title')!.textContent).toBe('Remove event?')
expect(dialog()!.querySelector('.confirm-dialog__message')!.textContent).toBe('This cannot be undone.')
})
it('displays custom button labels', () => {
mountDialog({
confirmLabel: 'Delete',
cancelLabel: 'Keep',
})
const buttons = dialog()!.querySelectorAll('.confirm-dialog__btn')
expect(buttons[0]!.textContent!.trim()).toBe('Keep')
expect(buttons[1]!.textContent!.trim()).toBe('Delete')
})
it('emits confirm when confirm button is clicked', async () => {
mountDialog()
const btn = dialog()!.querySelector('.confirm-dialog__btn--confirm') as HTMLElement
btn.click()
await wrapper.vm.$nextTick()
expect(wrapper.emitted('confirm')).toHaveLength(1)
})
it('emits cancel when cancel button is clicked', async () => {
mountDialog()
const btn = dialog()!.querySelector('.confirm-dialog__btn--cancel') as HTMLElement
btn.click()
await wrapper.vm.$nextTick()
expect(wrapper.emitted('cancel')).toHaveLength(1)
})
it('emits cancel when overlay is clicked', async () => {
mountDialog()
const el = overlay() as HTMLElement
el.click()
await wrapper.vm.$nextTick()
expect(wrapper.emitted('cancel')).toHaveLength(1)
})
it('emits cancel when Escape key is pressed', async () => {
mountDialog()
const el = dialog() as HTMLElement
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await wrapper.vm.$nextTick()
expect(wrapper.emitted('cancel')).toHaveLength(1)
})
it('focuses cancel button when opened', async () => {
mountDialog({ open: false })
await wrapper.setProps({ open: true })
await wrapper.vm.$nextTick()
const cancelBtn = dialog()!.querySelector('.confirm-dialog__btn--cancel')
expect(document.activeElement).toBe(cancelBtn)
})
it('has alertdialog role and aria-modal', () => {
mountDialog()
const el = dialog() as HTMLElement
expect(el.getAttribute('role')).toBe('alertdialog')
expect(el.getAttribute('aria-modal')).toBe('true')
})
})

View File

@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import DateSubheader from '../DateSubheader.vue'
describe('DateSubheader', () => {
it('renders the date label as an h3', () => {
const wrapper = mount(DateSubheader, { props: { label: 'Wed, 12 Mar' } })
const h3 = wrapper.find('h3')
expect(h3.exists()).toBe(true)
expect(h3.text()).toBe('Wed, 12 Mar')
})
it('applies the date-subheader class', () => {
const wrapper = mount(DateSubheader, { props: { label: 'Fri, 14 Mar' } })
expect(wrapper.find('.date-subheader').exists()).toBe(true)
})
})

View File

@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import EmptyState from '../EmptyState.vue'
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/create', name: 'create', component: { template: '<div />' } },
],
})
function mountEmptyState() {
return mount(EmptyState, {
global: {
plugins: [router],
},
})
}
describe('EmptyState', () => {
it('renders an inviting message', () => {
const wrapper = mountEmptyState()
expect(wrapper.text()).toContain('No events yet')
})
it('renders a Create Event link', () => {
const wrapper = mountEmptyState()
const link = wrapper.find('a')
expect(link.exists()).toBe(true)
expect(link.text()).toContain('Create Event')
expect(link.attributes('href')).toBe('/create')
})
})

View File

@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import EventCard from '../EventCard.vue'
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/events/:eventToken', name: 'event', component: { template: '<div />' } },
],
})
function mountCard(props: Record<string, unknown> = {}) {
return mount(EventCard, {
props: {
eventToken: 'abc-123',
title: 'Birthday Party',
relativeTime: 'in 3 days',
isPast: false,
...props,
},
global: {
plugins: [router],
},
})
}
describe('EventCard', () => {
it('renders the event title', () => {
const wrapper = mountCard()
expect(wrapper.text()).toContain('Birthday Party')
})
it('renders relative time', () => {
const wrapper = mountCard({ relativeTime: 'yesterday' })
expect(wrapper.text()).toContain('yesterday')
})
it('links to the event detail page', () => {
const wrapper = mountCard({ eventToken: 'xyz-789' })
const link = wrapper.find('a')
expect(link.attributes('href')).toBe('/events/xyz-789')
})
it('applies past modifier class when isPast is true', () => {
const wrapper = mountCard({ isPast: true })
expect(wrapper.find('.event-card--past').exists()).toBe(true)
})
it('does not apply past modifier class when isPast is false', () => {
const wrapper = mountCard({ isPast: false })
expect(wrapper.find('.event-card--past').exists()).toBe(false)
})
it('renders organizer badge when eventRole is organizer', () => {
const wrapper = mountCard({ eventRole: 'organizer' })
expect(wrapper.text()).toContain('Organizer')
})
it('renders attendee badge when eventRole is attendee', () => {
const wrapper = mountCard({ eventRole: 'attendee' })
expect(wrapper.text()).toContain('Attendee')
})
it('renders no badge when eventRole is undefined', () => {
const wrapper = mountCard({ eventRole: undefined })
expect(wrapper.find('.event-card__badge').exists()).toBe(false)
})
it('emits delete event with eventToken when delete button is clicked', async () => {
const wrapper = mountCard({ eventToken: 'abc-123' })
await wrapper.find('.event-card__delete').trigger('click')
expect(wrapper.emitted('delete')).toEqual([['abc-123']])
})
it('displays clock time when timeDisplayMode is clock', () => {
const wrapper = mountCard({
timeDisplayMode: 'clock',
dateTime: '2026-03-11T18:30:00',
})
const timeText = wrapper.find('.event-card__time').text()
// Locale-dependent: could be "18:30" or "06:30 PM"
expect(timeText).toMatch(/(?:18.30|6.30\s*PM)/i)
})
it('displays relative time when timeDisplayMode is relative', () => {
const wrapper = mountCard({
relativeTime: '3 days ago',
timeDisplayMode: 'relative',
dateTime: '2026-03-08T10:00:00',
})
expect(wrapper.find('.event-card__time').text()).toBe('3 days ago')
})
it('falls back to relativeTime when timeDisplayMode is not set', () => {
const wrapper = mountCard({ relativeTime: 'in 3 days' })
expect(wrapper.find('.event-card__time').text()).toBe('in 3 days')
})
})

View File

@@ -0,0 +1,140 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import EventList from '../EventList.vue'
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/events/:eventToken', name: 'event', component: { template: '<div />' } },
],
})
// Fixed "now": Wednesday, 2026-03-11 12:00
const NOW = new Date(2026, 2, 11, 12, 0, 0)
const mockEvents = [
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2026-03-01T10:00:00', expiryDate: '' },
{ eventToken: 'later-1', title: 'Later Event', dateTime: '2027-06-15T10:00:00', expiryDate: '' },
{ eventToken: 'today-1', title: 'Today Event', dateTime: '2026-03-11T18:00:00', expiryDate: '' },
{ eventToken: 'week-1', title: 'This Week Event', dateTime: '2026-03-13T10:00:00', expiryDate: '' },
{ eventToken: 'nextweek-1', title: 'Next Week Event', dateTime: '2026-03-16T10:00:00', expiryDate: '' },
]
vi.mock('../../composables/useEventStorage', () => ({
isValidStoredEvent: (e: unknown) => {
if (typeof e !== 'object' || e === null) return false
const obj = e as Record<string, unknown>
return typeof obj.eventToken === 'string' && obj.eventToken.length > 0
&& typeof obj.title === 'string' && obj.title.length > 0
&& typeof obj.dateTime === 'string' && obj.dateTime.length > 0
},
useEventStorage: () => ({
getStoredEvents: () => mockEvents,
removeEvent: vi.fn(),
}),
}))
vi.mock('../../composables/useRelativeTime', () => ({
formatRelativeTime: (dateTime: string) => {
if (dateTime.includes('03-01')) return '10 days ago'
if (dateTime.includes('06-15')) return 'in 1 year'
if (dateTime.includes('03-11')) return 'in 6 hours'
if (dateTime.includes('03-13')) return 'in 2 days'
if (dateTime.includes('03-16')) return 'in 5 days'
return 'sometime'
},
}))
function mountList() {
return mount(EventList, {
global: { plugins: [router] },
})
}
describe('EventList', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
afterEach(() => {
vi.useRealTimers()
})
it('renders section headers for each non-empty section', () => {
const wrapper = mountList()
const headers = wrapper.findAll('.section-header')
expect(headers).toHaveLength(5)
expect(headers[0]!.text()).toBe('Today')
expect(headers[1]!.text()).toBe('This Week')
expect(headers[2]!.text()).toBe('Next Week')
expect(headers[3]!.text()).toBe('Later')
expect(headers[4]!.text()).toBe('Past')
})
it('renders events within their correct sections', () => {
const wrapper = mountList()
const sections = wrapper.findAll('.event-section')
expect(sections).toHaveLength(5)
expect(sections[0]!.text()).toContain('Today Event')
expect(sections[1]!.text()).toContain('This Week Event')
expect(sections[2]!.text()).toContain('Next Week Event')
expect(sections[3]!.text()).toContain('Later Event')
expect(sections[4]!.text()).toContain('Past Event')
})
it('renders all valid events as cards', () => {
const wrapper = mountList()
const cards = wrapper.findAll('.event-card')
expect(cards).toHaveLength(5)
})
it('marks past events with isPast class', () => {
const wrapper = mountList()
const pastSection = wrapper.findAll('.event-section')[4]!
const pastCards = pastSection.findAll('.event-card')
expect(pastCards).toHaveLength(1)
expect(pastCards[0]!.classes()).toContain('event-card--past')
})
it('does not mark non-past events with isPast class', () => {
const wrapper = mountList()
const todaySection = wrapper.findAll('.event-section')[0]!
const cards = todaySection.findAll('.event-card')
expect(cards[0]!.classes()).not.toContain('event-card--past')
})
it('sections have aria-label attributes', () => {
const wrapper = mountList()
const sections = wrapper.findAll('section')
expect(sections[0]!.attributes('aria-label')).toBe('Today')
expect(sections[1]!.attributes('aria-label')).toBe('This Week')
expect(sections[2]!.attributes('aria-label')).toBe('Next Week')
expect(sections[3]!.attributes('aria-label')).toBe('Later')
expect(sections[4]!.attributes('aria-label')).toBe('Past')
})
it('does not render date subheader in "Today" section', () => {
const wrapper = mountList()
const todaySection = wrapper.findAll('.event-section')[0]!
expect(todaySection.find('.date-subheader').exists()).toBe(false)
})
it('renders date subheaders in non-today sections', () => {
const wrapper = mountList()
const thisWeekSection = wrapper.findAll('.event-section')[1]!
expect(thisWeekSection.find('.date-subheader').exists()).toBe(true)
const nextWeekSection = wrapper.findAll('.event-section')[2]!
expect(nextWeekSection.find('.date-subheader').exists()).toBe(true)
const laterSection = wrapper.findAll('.event-section')[3]!
expect(laterSection.find('.date-subheader').exists()).toBe(true)
const pastSection = wrapper.findAll('.event-section')[4]!
expect(pastSection.find('.date-subheader').exists()).toBe(true)
})
})

View File

@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import RsvpBar from '../RsvpBar.vue'
describe('RsvpBar', () => {
it('renders CTA button when hasRsvp is false', () => {
const wrapper = mount(RsvpBar)
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending")
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(false)
})
it('renders status text when hasRsvp is true', () => {
const wrapper = mount(RsvpBar, { props: { hasRsvp: true } })
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(true)
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
})
it('emits open when CTA button is clicked', async () => {
const wrapper = mount(RsvpBar)
await wrapper.find('.rsvp-bar__cta').trigger('click')
expect(wrapper.emitted('open')).toHaveLength(1)
})
it('does not render CTA button when hasRsvp is true', () => {
const wrapper = mount(RsvpBar, { props: { hasRsvp: true } })
expect(wrapper.find('button').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import SectionHeader from '../SectionHeader.vue'
describe('SectionHeader', () => {
it('renders the section label as an h2', () => {
const wrapper = mount(SectionHeader, { props: { label: 'Today' } })
const h2 = wrapper.find('h2')
expect(h2.exists()).toBe(true)
expect(h2.text()).toBe('Today')
})
it('does not apply emphasized class by default', () => {
const wrapper = mount(SectionHeader, { props: { label: 'Later' } })
expect(wrapper.find('.section-header--emphasized').exists()).toBe(false)
})
it('applies emphasized class when emphasized prop is true', () => {
const wrapper = mount(SectionHeader, { props: { label: 'Today', emphasized: true } })
expect(wrapper.find('.section-header--emphasized').exists()).toBe(true)
})
it('does not apply emphasized class when emphasized prop is false', () => {
const wrapper = mount(SectionHeader, { props: { label: 'Past', emphasized: false } })
expect(wrapper.find('.section-header--emphasized').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useEventGrouping } from '../../composables/useEventGrouping'
import type { StoredEvent } from '../../composables/useEventStorage'
function makeEvent(overrides: Partial<StoredEvent> & { dateTime: string }): StoredEvent {
return {
eventToken: `evt-${Math.random().toString(36).slice(2, 8)}`,
title: 'Test Event',
expiryDate: '',
...overrides,
}
}
describe('useEventGrouping', () => {
// Fixed "now": Wednesday, 2026-03-11 12:00 local
const NOW = new Date(2026, 2, 11, 12, 0, 0)
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
afterEach(() => {
vi.useRealTimers()
})
it('returns empty array when no events', () => {
const sections = useEventGrouping([], NOW)
expect(sections).toEqual([])
})
it('classifies a today event into "today" section', () => {
const event = makeEvent({ dateTime: '2026-03-11T18:30:00' })
const sections = useEventGrouping([event], NOW)
expect(sections).toHaveLength(1)
expect(sections[0]!.key).toBe('today')
expect(sections[0]!.label).toBe('Today')
expect(sections[0]!.dateGroups[0]!.events).toHaveLength(1)
})
it('classifies events into all five sections', () => {
const events = [
makeEvent({ title: 'Today', dateTime: '2026-03-11T10:00:00' }),
makeEvent({ title: 'This Week', dateTime: '2026-03-13T10:00:00' }), // Friday (same week)
makeEvent({ title: 'Next Week', dateTime: '2026-03-16T10:00:00' }), // Monday next week
makeEvent({ title: 'Later', dateTime: '2026-03-30T10:00:00' }), // far future
makeEvent({ title: 'Past', dateTime: '2026-03-09T10:00:00' }), // Monday (past)
]
const sections = useEventGrouping(events, NOW)
expect(sections).toHaveLength(5)
expect(sections[0]!.key).toBe('today')
expect(sections[1]!.key).toBe('thisWeek')
expect(sections[2]!.key).toBe('nextWeek')
expect(sections[3]!.key).toBe('later')
expect(sections[4]!.key).toBe('past')
})
it('omits empty sections', () => {
const events = [
makeEvent({ title: 'Today', dateTime: '2026-03-11T10:00:00' }),
makeEvent({ title: 'Past', dateTime: '2026-03-01T10:00:00' }),
]
const sections = useEventGrouping(events, NOW)
expect(sections).toHaveLength(2)
expect(sections[0]!.key).toBe('today')
expect(sections[1]!.key).toBe('past')
})
it('sorts upcoming events ascending by time', () => {
const events = [
makeEvent({ title: 'Later', dateTime: '2026-03-11T20:00:00' }),
makeEvent({ title: 'Earlier', dateTime: '2026-03-11T08:00:00' }),
]
const sections = useEventGrouping(events, NOW)
const todayEvents = sections[0]!.dateGroups[0]!.events
expect(todayEvents[0]!.title).toBe('Earlier')
expect(todayEvents[1]!.title).toBe('Later')
})
it('sorts past events descending by time (most recent first)', () => {
const events = [
makeEvent({ title: 'Older', dateTime: '2026-03-01T10:00:00' }),
makeEvent({ title: 'Newer', dateTime: '2026-03-09T10:00:00' }),
]
const sections = useEventGrouping(events, NOW)
const pastEvents = sections[0]!.dateGroups
expect(pastEvents[0]!.events[0]!.title).toBe('Newer')
expect(pastEvents[1]!.events[0]!.title).toBe('Older')
})
it('groups events by date within a section', () => {
const events = [
makeEvent({ title: 'Fri AM', dateTime: '2026-03-13T09:00:00' }),
makeEvent({ title: 'Fri PM', dateTime: '2026-03-13T18:00:00' }),
makeEvent({ title: 'Sat', dateTime: '2026-03-14T12:00:00' }),
]
const sections = useEventGrouping(events, NOW)
expect(sections[0]!.key).toBe('thisWeek')
const dateGroups = sections[0]!.dateGroups
expect(dateGroups).toHaveLength(2) // Friday and Saturday
expect(dateGroups[0]!.events).toHaveLength(2) // Two Friday events
expect(dateGroups[1]!.events).toHaveLength(1) // One Saturday event
})
it('sets showSubheader=false for "today" section', () => {
const event = makeEvent({ dateTime: '2026-03-11T18:00:00' })
const sections = useEventGrouping([event], NOW)
expect(sections[0]!.dateGroups[0]!.showSubheader).toBe(false)
})
it('sets showSubheader=true for non-today sections', () => {
const events = [
makeEvent({ dateTime: '2026-03-13T10:00:00' }), // thisWeek
makeEvent({ dateTime: '2026-03-30T10:00:00' }), // later (beyond next week)
makeEvent({ dateTime: '2026-03-01T10:00:00' }), // past
]
const sections = useEventGrouping(events, NOW)
for (const section of sections) {
for (const group of section.dateGroups) {
expect(group.showSubheader).toBe(true)
}
}
})
it('sets emphasized=true only for "today" section', () => {
const events = [
makeEvent({ dateTime: '2026-03-11T18:00:00' }),
makeEvent({ dateTime: '2026-03-30T10:00:00' }),
]
const sections = useEventGrouping(events, NOW)
expect(sections[0]!.emphasized).toBe(true) // today
expect(sections[1]!.emphasized).toBe(false) // later
})
it('on Sunday, tomorrow (Monday) goes to "nextWeek" not "thisWeek"', () => {
// Sunday 2026-03-15
const sunday = new Date(2026, 2, 15, 12, 0, 0)
const mondayEvent = makeEvent({ title: 'Monday', dateTime: '2026-03-16T10:00:00' })
const sections = useEventGrouping([mondayEvent], sunday)
expect(sections).toHaveLength(1)
expect(sections[0]!.key).toBe('nextWeek')
})
it('on Sunday, today events still appear under "today"', () => {
const sunday = new Date(2026, 2, 15, 12, 0, 0)
const todayEvent = makeEvent({ dateTime: '2026-03-15T18:00:00' })
const sections = useEventGrouping([todayEvent], sunday)
expect(sections[0]!.key).toBe('today')
})
it('dateGroup labels are formatted via Intl', () => {
const event = makeEvent({ dateTime: '2026-03-13T10:00:00' }) // Friday
const sections = useEventGrouping([event], NOW)
const label = sections[0]!.dateGroups[0]!.label
// The exact format depends on locale, but should contain the day number
expect(label).toContain('13')
})
})

View File

@@ -116,4 +116,168 @@ describe('useEventStorage', () => {
expect(events).toHaveLength(1)
expect(events[0]!.title).toBe('New Title')
})
it('saves and retrieves RSVP for an existing event', () => {
const { saveCreatedEvent, saveRsvp, getRsvp } = useEventStorage()
saveCreatedEvent({
eventToken: 'abc-123',
title: 'Birthday',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
saveRsvp('abc-123', 'rsvp-token-1', 'Max', 'Birthday', '2026-06-15T20:00:00+02:00')
const rsvp = getRsvp('abc-123')
expect(rsvp).toEqual({ rsvpToken: 'rsvp-token-1', rsvpName: 'Max' })
})
it('saves RSVP for a new event (not previously stored)', () => {
const { saveRsvp, getRsvp, getStoredEvents } = useEventStorage()
saveRsvp('new-event', 'rsvp-token-2', 'Anna', 'Party', '2026-08-01T18:00:00+02:00')
const rsvp = getRsvp('new-event')
expect(rsvp).toEqual({ rsvpToken: 'rsvp-token-2', rsvpName: 'Anna' })
const events = getStoredEvents()
expect(events).toHaveLength(1)
expect(events[0]!.eventToken).toBe('new-event')
expect(events[0]!.title).toBe('Party')
})
it('returns undefined RSVP for event without RSVP', () => {
const { saveCreatedEvent, getRsvp } = useEventStorage()
saveCreatedEvent({
eventToken: 'abc-123',
title: 'Test',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
expect(getRsvp('abc-123')).toBeUndefined()
})
it('returns undefined RSVP for unknown event', () => {
const { getRsvp } = useEventStorage()
expect(getRsvp('unknown')).toBeUndefined()
})
it('removes an event by token', () => {
const { saveCreatedEvent, getStoredEvents, removeEvent } = useEventStorage()
saveCreatedEvent({
eventToken: 'event-1',
title: 'First',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
saveCreatedEvent({
eventToken: 'event-2',
title: 'Second',
dateTime: '2026-07-15T20:00:00+02:00',
expiryDate: '2026-08-15',
})
removeEvent('event-1')
const events = getStoredEvents()
expect(events).toHaveLength(1)
expect(events[0]!.eventToken).toBe('event-2')
})
it('removeEvent does nothing for unknown token', () => {
const { saveCreatedEvent, getStoredEvents, removeEvent } = useEventStorage()
saveCreatedEvent({
eventToken: 'event-1',
title: 'First',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
removeEvent('nonexistent')
expect(getStoredEvents()).toHaveLength(1)
})
})
describe('isValidStoredEvent', () => {
// Import directly since it's an exported function
let isValidStoredEvent: (e: unknown) => boolean
beforeEach(async () => {
const mod = await import('../useEventStorage')
isValidStoredEvent = mod.isValidStoredEvent
})
it('returns true for a valid event', () => {
expect(
isValidStoredEvent({
eventToken: 'abc-123',
title: 'Birthday',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
}),
).toBe(true)
})
it('returns false for null', () => {
expect(isValidStoredEvent(null)).toBe(false)
})
it('returns false for non-object', () => {
expect(isValidStoredEvent('string')).toBe(false)
})
it('returns false when eventToken is missing', () => {
expect(
isValidStoredEvent({
title: 'Birthday',
dateTime: '2026-06-15T20:00:00+02:00',
}),
).toBe(false)
})
it('returns false when eventToken is empty', () => {
expect(
isValidStoredEvent({
eventToken: '',
title: 'Birthday',
dateTime: '2026-06-15T20:00:00+02:00',
}),
).toBe(false)
})
it('returns false when title is missing', () => {
expect(
isValidStoredEvent({
eventToken: 'abc-123',
dateTime: '2026-06-15T20:00:00+02:00',
}),
).toBe(false)
})
it('returns false when dateTime is invalid', () => {
expect(
isValidStoredEvent({
eventToken: 'abc-123',
title: 'Birthday',
dateTime: 'not-a-date',
}),
).toBe(false)
})
it('returns false when dateTime is empty', () => {
expect(
isValidStoredEvent({
eventToken: 'abc-123',
title: 'Birthday',
dateTime: '',
}),
).toBe(false)
})
})

View File

@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest'
import { formatRelativeTime } from '../useRelativeTime'
describe('formatRelativeTime', () => {
const now = new Date('2026-06-15T12:00:00Z')
it('formats seconds ago', () => {
const result = formatRelativeTime('2026-06-15T11:59:30Z', now)
expect(result).toMatch(/30 seconds ago/)
})
it('formats minutes ago', () => {
const result = formatRelativeTime('2026-06-15T11:55:00Z', now)
expect(result).toMatch(/5 minutes ago/)
})
it('formats hours ago', () => {
const result = formatRelativeTime('2026-06-15T09:00:00Z', now)
expect(result).toMatch(/3 hours ago/)
})
it('formats days ago', () => {
const result = formatRelativeTime('2026-06-13T12:00:00Z', now)
expect(result).toMatch(/2 days ago/)
})
it('formats weeks ago', () => {
const result = formatRelativeTime('2026-06-01T12:00:00Z', now)
expect(result).toMatch(/2 weeks ago/)
})
it('formats months ago', () => {
const result = formatRelativeTime('2026-03-15T12:00:00Z', now)
expect(result).toMatch(/3 months ago/)
})
it('formats years ago', () => {
const result = formatRelativeTime('2024-06-15T12:00:00Z', now)
expect(result).toMatch(/2 years ago/)
})
it('formats future seconds', () => {
const result = formatRelativeTime('2026-06-15T12:00:30Z', now)
expect(result).toMatch(/in 30 seconds/)
})
it('formats future days', () => {
const result = formatRelativeTime('2026-06-18T12:00:00Z', now)
expect(result).toMatch(/in 3 days/)
})
it('formats future months', () => {
const result = formatRelativeTime('2026-09-15T12:00:00Z', now)
expect(result).toMatch(/in 3 months/)
})
it('formats "now" for zero difference', () => {
const result = formatRelativeTime('2026-06-15T12:00:00Z', now)
// Intl.RelativeTimeFormat with numeric: 'auto' returns "now" for 0 seconds
expect(result).toMatch(/now/)
})
it('formats yesterday', () => {
const result = formatRelativeTime('2026-06-14T12:00:00Z', now)
expect(result).toMatch(/yesterday|1 day ago/)
})
it('formats tomorrow', () => {
const result = formatRelativeTime('2026-06-16T12:00:00Z', now)
expect(result).toMatch(/tomorrow|in 1 day/)
})
})

View File

@@ -0,0 +1,149 @@
import type { StoredEvent } from './useEventStorage'
export type SectionKey = 'today' | 'thisWeek' | 'nextWeek' | 'later' | 'past'
export interface DateGroup {
dateKey: string
label: string
events: StoredEvent[]
showSubheader: boolean
}
export interface EventSection {
key: SectionKey
label: string
dateGroups: DateGroup[]
emphasized: boolean
}
const SECTION_ORDER: SectionKey[] = ['today', 'thisWeek', 'nextWeek', 'later', 'past']
const SECTION_LABELS: Record<SectionKey, string> = {
today: 'Today',
thisWeek: 'This Week',
nextWeek: 'Next Week',
later: 'Later',
past: 'Past',
}
function startOfDay(date: Date): Date {
const d = new Date(date)
d.setHours(0, 0, 0, 0)
return d
}
function endOfDay(date: Date): Date {
const d = new Date(date)
d.setHours(23, 59, 59, 999)
return d
}
function endOfWeek(date: Date): Date {
const d = new Date(date)
const dayOfWeek = d.getDay() // 0=Sun, 1=Mon, ..., 6=Sat
// ISO week: Monday is first day. End of week = Sunday.
// If today is Sunday (0), end of week is today.
// Otherwise, days until Sunday = 7 - dayOfWeek
const daysUntilSunday = dayOfWeek === 0 ? 0 : 7 - dayOfWeek
d.setDate(d.getDate() + daysUntilSunday)
return endOfDay(d)
}
function endOfNextWeek(date: Date): Date {
const thisWeekEnd = endOfWeek(date)
const d = new Date(thisWeekEnd)
d.setDate(d.getDate() + 7)
return endOfDay(d)
}
function toDateKey(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function formatDateLabel(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
weekday: 'short',
day: 'numeric',
month: 'short',
}).format(date)
}
function classifyEvent(eventDate: Date, todayStart: Date, todayEnd: Date, weekEnd: Date, nextWeekEnd: Date): SectionKey {
if (eventDate < todayStart) return 'past'
if (eventDate <= todayEnd) return 'today'
if (eventDate <= weekEnd) return 'thisWeek'
if (eventDate <= nextWeekEnd) return 'nextWeek'
return 'later'
}
export function useEventGrouping(events: StoredEvent[], now: Date = new Date()): EventSection[] {
const todayStart = startOfDay(now)
const todayEnd = endOfDay(now)
const weekEnd = endOfWeek(now)
const nextWeekEnd = endOfNextWeek(now)
// Classify events into sections
const buckets: Record<SectionKey, StoredEvent[]> = {
today: [],
thisWeek: [],
nextWeek: [],
later: [],
past: [],
}
for (const event of events) {
const eventDate = new Date(event.dateTime)
const section = classifyEvent(eventDate, todayStart, todayEnd, weekEnd, nextWeekEnd)
buckets[section].push(event)
}
// Build sections
const sections: EventSection[] = []
for (const key of SECTION_ORDER) {
const sectionEvents = buckets[key]
if (sectionEvents.length === 0) continue
// Sort events
const ascending = key !== 'past'
sectionEvents.sort((a, b) => {
const diff = new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime()
return ascending ? diff : -diff
})
// Group by date
const dateGroupMap = new Map<string, StoredEvent[]>()
for (const event of sectionEvents) {
const dateKey = toDateKey(new Date(event.dateTime))
const group = dateGroupMap.get(dateKey)
if (group) {
group.push(event)
} else {
dateGroupMap.set(dateKey, [event])
}
}
// Convert to DateGroup array (order preserved from sorted events)
const dateGroups: DateGroup[] = []
for (const [dateKey, groupEvents] of dateGroupMap) {
dateGroups.push({
dateKey,
label: formatDateLabel(new Date(groupEvents[0]!.dateTime)),
events: groupEvents,
showSubheader: key !== 'today',
})
}
sections.push({
key,
label: SECTION_LABELS[key],
dateGroups,
emphasized: key === 'today',
})
}
return sections
}

View File

@@ -4,10 +4,30 @@ export interface StoredEvent {
title: string
dateTime: string
expiryDate: string
rsvpToken?: string
rsvpName?: string
}
import { ref } from 'vue'
const STORAGE_KEY = 'fete:events'
const version = ref(0)
export function isValidStoredEvent(e: unknown): e is StoredEvent {
if (typeof e !== 'object' || e === null) return false
const obj = e as Record<string, unknown>
return (
typeof obj.eventToken === 'string' &&
obj.eventToken.length > 0 &&
typeof obj.title === 'string' &&
obj.title.length > 0 &&
typeof obj.dateTime === 'string' &&
obj.dateTime.length > 0 &&
!isNaN(new Date(obj.dateTime).getTime())
)
}
function readEvents(): StoredEvent[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
@@ -19,6 +39,7 @@ function readEvents(): StoredEvent[] {
function writeEvents(events: StoredEvent[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(events))
version.value++
}
export function useEventStorage() {
@@ -29,6 +50,7 @@ export function useEventStorage() {
}
function getStoredEvents(): StoredEvent[] {
void version.value
return readEvents()
}
@@ -37,5 +59,30 @@ export function useEventStorage() {
return event?.organizerToken
}
return { saveCreatedEvent, getStoredEvents, getOrganizerToken }
function saveRsvp(eventToken: string, rsvpToken: string, rsvpName: string, title: string, dateTime: string): void {
const events = readEvents()
const existing = events.find((e) => e.eventToken === eventToken)
if (existing) {
existing.rsvpToken = rsvpToken
existing.rsvpName = rsvpName
} else {
events.push({ eventToken, title, dateTime, expiryDate: '', rsvpToken, rsvpName })
}
writeEvents(events)
}
function getRsvp(eventToken: string): { rsvpToken: string; rsvpName: string } | undefined {
const event = readEvents().find((e) => e.eventToken === eventToken)
if (event?.rsvpToken && event?.rsvpName) {
return { rsvpToken: event.rsvpToken, rsvpName: event.rsvpName }
}
return undefined
}
function removeEvent(eventToken: string): void {
const events = readEvents().filter((e) => e.eventToken !== eventToken)
writeEvents(events)
}
return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp, removeEvent }
}

View File

@@ -0,0 +1,23 @@
const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
['year', 365 * 24 * 60 * 60],
['month', 30 * 24 * 60 * 60],
['week', 7 * 24 * 60 * 60],
['day', 24 * 60 * 60],
['hour', 60 * 60],
['minute', 60],
['second', 1],
]
export function formatRelativeTime(dateTime: string, now: Date = new Date()): string {
const target = new Date(dateTime)
const diffSeconds = Math.round((target.getTime() - now.getTime()) / 1000)
for (const [unit, secondsInUnit] of UNITS) {
if (Math.abs(diffSeconds) >= secondsInUnit) {
const value = Math.round(diffSeconds / secondsInUnit)
return new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }).format(value, unit)
}
}
return new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }).format(0, 'second')
}

View File

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

View File

@@ -184,6 +184,7 @@ async function handleSubmit() {
title: form.title.trim(),
description: form.description.trim() || undefined,
dateTime: dateTimeWithOffset,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
location: form.location.trim() || undefined,
expiryDate: form.expiryDate,
},
@@ -214,7 +215,7 @@ async function handleSubmit() {
expiryDate: data.expiryDate,
})
router.push({ name: 'event', params: { token: data.eventToken } })
router.push({ name: 'event', params: { eventToken: data.eventToken } })
}
} catch {
submitting.value = false

View File

@@ -0,0 +1,445 @@
<template>
<main class="detail">
<!-- Hero image with overlaid header -->
<div class="detail__hero">
<img
class="detail__hero-img"
src="@/assets/images/event-hero-placeholder.jpg"
alt=""
/>
<div class="detail__hero-overlay" />
<header class="detail__header">
<RouterLink to="/" class="detail__back" aria-label="Back to home">&larr;</RouterLink>
<span class="detail__brand">fete</span>
</header>
</div>
<div class="detail__body">
<!-- Loading state -->
<div v-if="state === 'loading'" class="detail__content" aria-busy="true" aria-label="Loading event details">
<div class="skeleton skeleton--title" />
<div class="skeleton skeleton--line" />
<div class="skeleton skeleton--line skeleton--short" />
<div class="skeleton skeleton--line" />
</div>
<!-- Loaded state -->
<div v-else-if="state === 'loaded' && event" class="detail__content">
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
This event has ended.
</div>
<h1 class="detail__title">{{ event.title }}</h1>
<dl class="detail__meta">
<div class="detail__meta-item">
<dt class="detail__meta-icon" aria-label="Date and time">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</dt>
<dd class="detail__meta-text">{{ formattedDateTime }}</dd>
</div>
<div v-if="event.location" class="detail__meta-item">
<dt class="detail__meta-icon" aria-label="Location">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
</dt>
<dd class="detail__meta-text">{{ event.location }}</dd>
</div>
<div class="detail__meta-item">
<dt class="detail__meta-icon" aria-label="Attendees">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</dt>
<dd class="detail__meta-text">{{ event.attendeeCount }} going</dd>
</div>
</dl>
<AttendeeList v-if="isOrganizer && attendeeNames !== null" :attendees="attendeeNames" />
<div v-if="event.description" class="detail__section">
<h2 class="detail__section-title">About</h2>
<p class="detail__description">{{ event.description }}</p>
</div>
</div>
<!-- Not found state -->
<div v-else-if="state === 'not-found'" class="detail__content detail__content--center" role="status">
<p class="detail__message">Event not found.</p>
</div>
<!-- Error state -->
<div v-else-if="state === 'error'" class="detail__content detail__content--center" role="alert">
<p class="detail__message">Something went wrong.</p>
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
</div>
</div>
<!-- RSVP bar (only for loaded, non-expired events) -->
<RsvpBar
v-if="state === 'loaded' && event && !event.expired && !isOrganizer"
:has-rsvp="!!rsvpName"
@open="sheetOpen = true"
/>
<!-- RSVP bottom sheet -->
<BottomSheet :open="sheetOpen" label="RSVP" @close="sheetOpen = false">
<h2 class="sheet-title">RSVP</h2>
<form class="rsvp-form" @submit.prevent="submitRsvp" novalidate>
<div class="form-group">
<label class="rsvp-form__label" for="rsvp-name">Your name</label>
<input
id="rsvp-name"
v-model.trim="nameInput"
class="form-field"
type="text"
placeholder="e.g. Max Mustermann"
maxlength="100"
required
@input="nameError = ''"
/>
<span v-if="nameError" class="rsvp-form__field-error" role="alert">{{ nameError }}</span>
</div>
<button class="btn-primary" type="submit" :disabled="submitting">
{{ submitting ? 'Sending…' : "Count me in" }}
</button>
<p v-if="submitError" class="rsvp-form__field-error rsvp-form__error" role="alert">{{ submitError }}</p>
</form>
</BottomSheet>
</main>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { api } from '@/api/client'
import { useEventStorage } from '@/composables/useEventStorage'
import AttendeeList from '@/components/AttendeeList.vue'
import BottomSheet from '@/components/BottomSheet.vue'
import RsvpBar from '@/components/RsvpBar.vue'
import type { components } from '@/api/schema'
type GetEventResponse = components['schemas']['GetEventResponse']
type State = 'loading' | 'loaded' | 'not-found' | 'error'
const route = useRoute()
const { saveRsvp, getRsvp, getOrganizerToken } = useEventStorage()
const state = ref<State>('loading')
const event = ref<GetEventResponse | null>(null)
// RSVP state
const sheetOpen = ref(false)
const nameInput = ref('')
const nameError = ref('')
const submitError = ref('')
const submitting = ref(false)
const rsvpName = ref<string | undefined>(undefined)
const isOrganizer = ref(false)
const attendeeNames = ref<string[] | null>(null)
const formattedDateTime = computed(() => {
if (!event.value) return ''
const formatted = new Intl.DateTimeFormat(undefined, {
dateStyle: 'long',
timeStyle: 'short',
}).format(new Date(event.value.dateTime))
return `${formatted} (${event.value.timezone})`
})
async function fetchEvent() {
state.value = 'loading'
event.value = null
try {
const { data, error, response } = await api.GET('/events/{token}', {
params: { path: { token: route.params.eventToken as string } },
})
if (error) {
state.value = response.status === 404 ? 'not-found' : 'error'
return
}
event.value = data!
state.value = 'loaded'
// Check if current user is the organizer
const orgToken = getOrganizerToken(event.value.eventToken)
isOrganizer.value = !!orgToken
// Fetch attendee list for organizer
if (orgToken) {
fetchAttendees(event.value.eventToken, orgToken)
}
// Restore RSVP status from localStorage
const stored = getRsvp(event.value.eventToken)
if (stored) {
rsvpName.value = stored.rsvpName
}
} catch {
state.value = 'error'
}
}
async function submitRsvp() {
nameError.value = ''
submitError.value = ''
if (!nameInput.value) {
nameError.value = 'Please enter your name.'
return
}
if (nameInput.value.length > 100) {
nameError.value = 'Name must be 100 characters or fewer.'
return
}
submitting.value = true
try {
const { data, error } = await api.POST('/events/{token}/rsvps', {
params: { path: { token: route.params.eventToken as string } },
body: { name: nameInput.value },
})
if (error) {
submitError.value = 'Could not submit RSVP. Please try again.'
return
}
// Persist RSVP in localStorage
saveRsvp(
event.value!.eventToken,
data!.rsvpToken,
data!.name,
event.value!.title,
event.value!.dateTime,
)
// Update UI
rsvpName.value = data!.name
event.value!.attendeeCount += 1
sheetOpen.value = false
nameInput.value = ''
} catch {
submitError.value = 'Could not submit RSVP. Please try again.'
} finally {
submitting.value = false
}
}
async function fetchAttendees(eventToken: string, organizerToken: string) {
try {
const { data, error } = await api.GET('/events/{token}/attendees', {
params: {
path: { token: eventToken },
query: { organizerToken },
},
})
if (!error) {
attendeeNames.value = data!.attendees.map((a) => a.name)
}
} catch {
// Silently degrade — don't show attendee list
}
}
onMounted(fetchEvent)
</script>
<style scoped>
.detail {
display: flex;
flex-direction: column;
/* Break out of .app-container constraints */
width: 100dvw;
flex: 1;
position: relative;
left: 50%;
transform: translateX(-50%);
margin: calc(-1 * var(--content-padding)) 0;
overflow-x: hidden;
}
/* Hero image section */
.detail__hero {
position: relative;
width: 100%;
height: 260px;
overflow: hidden;
flex-shrink: 0;
}
.detail__hero-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.detail__hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.4) 0%,
transparent 50%,
var(--color-gradient-start) 100%
);
}
.detail__header {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-lg) var(--content-padding);
padding-top: env(safe-area-inset-top, var(--spacing-lg));
z-index: 1;
}
.detail__back {
color: var(--color-text-on-gradient);
font-size: 1.5rem;
text-decoration: none;
line-height: 1;
}
.detail__brand {
font-size: 1.3rem;
font-weight: 700;
color: var(--color-text-on-gradient);
}
.detail__body {
flex: 1;
padding: var(--spacing-lg) var(--content-padding);
padding-bottom: 6rem;
}
.detail__content {
display: flex;
flex-direction: column;
gap: var(--spacing-2xl);
max-width: var(--content-max-width);
margin: 0 auto;
}
.detail__content--center {
align-items: center;
text-align: center;
padding-top: 4rem;
}
/* Title */
.detail__title {
font-size: 2rem;
font-weight: 800;
color: var(--color-text-on-gradient);
word-break: break-word;
line-height: 1.2;
letter-spacing: -0.02em;
}
/* Meta rows: icon + text */
.detail__meta {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.detail__meta-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.detail__meta-icon {
flex-shrink: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.15);
border-radius: 10px;
color: var(--color-text-on-gradient);
}
.detail__meta-text {
font-size: 0.9rem;
color: var(--color-text-on-gradient);
word-break: break-word;
}
/* About section */
.detail__section {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.detail__section-title {
font-size: 0.75rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.detail__description {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.85);
line-height: 1.6;
word-break: break-word;
}
/* Expired banner */
.detail__banner {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: 10px;
font-weight: 600;
font-size: 0.85rem;
text-align: center;
}
.detail__banner--expired {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(4px);
}
/* Error / not-found message */
.detail__message {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-text-on-gradient);
}
/* Skeleton shimmer on gradient */
.skeleton {
background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.25) 50%, rgba(255, 255, 255, 0.1) 75%);
background-size: 200% 100%;
}
.skeleton--title {
height: 2rem;
width: 70%;
border-radius: 8px;
}
.skeleton--line {
height: 1rem;
width: 85%;
border-radius: 6px;
}
.skeleton--short {
width: 45%;
}
</style>

View File

@@ -27,7 +27,7 @@ const route = useRoute()
const copyState = ref<'idle' | 'copied' | 'failed'>('idle')
const eventUrl = computed(() => {
return window.location.origin + '/events/' + route.params.token
return window.location.origin + '/events/' + route.params.eventToken
})
const copyLabel = computed(() => {

View File

@@ -1,13 +1,26 @@
<template>
<main class="home">
<h1 class="home__title">fete</h1>
<p class="home__subtitle">No events yet.<br />Create your first one!</p>
<RouterLink to="/create" class="btn-primary home__cta">+ Create Event</RouterLink>
<template v-if="events.length > 0">
<EventList />
<CreateEventFab />
</template>
<template v-else>
<EmptyState />
</template>
</main>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { computed } from 'vue'
import { useEventStorage, isValidStoredEvent } from '../composables/useEventStorage'
import EventList from '../components/EventList.vue'
import EmptyState from '../components/EmptyState.vue'
import CreateEventFab from '../components/CreateEventFab.vue'
const { getStoredEvents } = useEventStorage()
const events = computed(() => getStoredEvents().filter(isValidStoredEvent))
</script>
<style scoped>
@@ -15,27 +28,15 @@ import { RouterLink } from 'vue-router'
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-lg);
text-align: center;
padding-top: var(--spacing-lg);
}
.home__title {
font-size: 2rem;
font-weight: 800;
color: var(--color-text-on-gradient);
text-align: center;
}
.home__subtitle {
font-size: 1rem;
font-weight: 400;
color: var(--color-text-on-gradient);
opacity: 0.9;
}
.home__cta {
margin-top: var(--spacing-md);
max-width: 280px;
}
</style>

View File

@@ -14,6 +14,8 @@ vi.mock('@/composables/useEventStorage', () => ({
saveCreatedEvent: vi.fn(),
getStoredEvents: vi.fn(() => []),
getOrganizerToken: vi.fn(),
saveRsvp: vi.fn(),
getRsvp: vi.fn(),
})),
}))
@@ -23,7 +25,7 @@ function createTestRouter() {
routes: [
{ path: '/', name: 'home', component: { template: '<div />' } },
{ path: '/create', name: 'create-event', component: EventCreateView },
{ path: '/events/:token', name: 'event', component: { template: '<div />' } },
{ path: '/events/:eventToken', name: 'event', component: { template: '<div />' } },
],
})
}
@@ -165,6 +167,9 @@ describe('EventCreateView', () => {
saveCreatedEvent: mockSave,
getStoredEvents: vi.fn(() => []),
getOrganizerToken: vi.fn(),
saveRsvp: vi.fn(),
getRsvp: vi.fn(),
removeEvent: vi.fn(),
})
vi.mocked(api.POST).mockResolvedValueOnce({
@@ -173,6 +178,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,
@@ -216,7 +222,7 @@ describe('EventCreateView', () => {
expect(pushSpy).toHaveBeenCalledWith({
name: 'event',
params: { token: 'abc-123' },
params: { eventToken: 'abc-123' },
})
})

View File

@@ -0,0 +1,404 @@
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(),
POST: vi.fn(),
},
}))
const mockSaveRsvp = vi.fn()
const mockGetRsvp = vi.fn()
const mockGetOrganizerToken = vi.fn()
vi.mock('@/composables/useEventStorage', () => ({
useEventStorage: vi.fn(() => ({
saveCreatedEvent: vi.fn(),
getStoredEvents: vi.fn(() => []),
getOrganizerToken: mockGetOrganizerToken,
saveRsvp: mockSaveRsvp,
getRsvp: mockGetRsvp,
removeEvent: vi.fn(),
})),
}))
function createTestRouter(_token?: string) {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div />' } },
{ path: '/events/:eventToken', 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] },
attachTo: document.body,
})
}
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,
}
function mockLoadedEvent(eventOverrides = {}) {
vi.mocked(api.GET).mockResolvedValue({
data: { ...fullEvent, ...eventOverrides },
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
}
beforeEach(() => {
vi.restoreAllMocks()
mockGetRsvp.mockReturnValue(undefined)
mockGetOrganizerToken.mockReturnValue(undefined)
})
describe('EventDetailView', () => {
// 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)
wrapper.unmount()
})
// Loaded state — all fields
it('renders all event fields when loaded', async () => {
mockLoadedEvent()
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')
wrapper.unmount()
})
// Loaded state — locale-formatted date/time
it('formats date/time with Intl.DateTimeFormat and timezone', async () => {
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
const dateField = wrapper.findAll('.detail__meta-text')[0]!
expect(dateField.text()).toContain('(Europe/Berlin)')
expect(dateField.text()).toContain('2026')
wrapper.unmount()
})
// Loaded state — optional fields absent
it('does not render description and location when absent', async () => {
mockLoadedEvent({ description: undefined, location: undefined, attendeeCount: 0 })
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.text()).not.toContain('Description')
expect(wrapper.text()).not.toContain('Location')
expect(wrapper.text()).toContain('0')
wrapper.unmount()
})
// Expired state
it('renders "event has ended" banner when expired', async () => {
mockLoadedEvent({ expired: true })
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.text()).toContain('This event has ended.')
expect(wrapper.find('.detail__banner--expired').exists()).toBe(true)
wrapper.unmount()
})
// No expired banner when not expired
it('does not render expired banner when event is active', async () => {
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.find('.detail__banner--expired').exists()).toBe(false)
wrapper.unmount()
})
// 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.')
expect(wrapper.find('.detail__title').exists()).toBe(false)
wrapper.unmount()
})
// 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')
wrapper.unmount()
})
// 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')
wrapper.unmount()
})
// RSVP bar
it('shows RSVP CTA bar on active event', async () => {
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending")
wrapper.unmount()
})
it('does not show RSVP bar for organizer', async () => {
mockGetOrganizerToken.mockReturnValue('org-token-123')
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.find('.rsvp-bar').exists()).toBe(false)
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
wrapper.unmount()
})
it('does not show RSVP bar on expired event', async () => {
mockLoadedEvent({ expired: true })
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
expect(wrapper.find('.rsvp-bar').exists()).toBe(false)
wrapper.unmount()
})
it('shows RSVP status bar when localStorage has RSVP', async () => {
mockGetRsvp.mockReturnValue({ rsvpToken: 'rsvp-1', rsvpName: 'Max' })
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(true)
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
wrapper.unmount()
})
// RSVP form submission
it('opens bottom sheet when CTA is clicked', async () => {
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
await wrapper.find('.rsvp-bar__cta').trigger('click')
await flushPromises()
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull()
wrapper.unmount()
})
it('shows validation error when submitting empty name', async () => {
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
await wrapper.find('.rsvp-bar__cta').trigger('click')
await flushPromises()
// Form is inside Teleport — find via document.body
const form = document.body.querySelector('.rsvp-form')! as HTMLFormElement
form.dispatchEvent(new Event('submit', { bubbles: true }))
await flushPromises()
expect(document.body.querySelector('.rsvp-form__field-error')?.textContent).toBe('Please enter your name.')
expect(vi.mocked(api.POST)).not.toHaveBeenCalled()
wrapper.unmount()
})
it('submits RSVP, saves to storage, and shows status', async () => {
mockLoadedEvent()
vi.mocked(api.POST).mockResolvedValue({
data: { rsvpToken: 'rsvp-token-1', name: 'Max' },
error: undefined,
response: new Response(null, { status: 201 }),
} as never)
const wrapper = await mountWithToken()
await flushPromises()
// Open sheet
await wrapper.find('.rsvp-bar__cta').trigger('click')
await flushPromises()
// Fill name via Teleported input
const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement
input.value = 'Max'
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
// Submit form
const form = document.body.querySelector('.rsvp-form')! as HTMLFormElement
form.dispatchEvent(new Event('submit', { bubbles: true }))
await flushPromises()
// Verify API call
expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events/{token}/rsvps', {
params: { path: { token: 'test-token' } },
body: { name: 'Max' },
})
// Verify storage
expect(mockSaveRsvp).toHaveBeenCalledWith(
'abc-123',
'rsvp-token-1',
'Max',
'Summer BBQ',
'2026-03-15T20:00:00+01:00',
)
// Verify UI switched to status
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
// Verify attendee count incremented
expect(wrapper.text()).toContain('13')
wrapper.unmount()
})
// Attendee list (organizer)
it('shows attendee list for organizer', async () => {
mockGetOrganizerToken.mockReturnValue('org-token-123')
mockLoadedEvent()
vi.mocked(api.GET)
.mockResolvedValueOnce({
data: fullEvent,
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
.mockResolvedValueOnce({
data: { attendees: [{ name: 'Alice' }, { name: 'Bob' }] },
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.find('.attendee-list').exists()).toBe(true)
expect(wrapper.text()).toContain('Alice')
expect(wrapper.text()).toContain('Bob')
expect(wrapper.find('.attendee-list__heading').text()).toBe('2 Attendees')
wrapper.unmount()
})
it('does not show attendee list for visitor', async () => {
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.find('.attendee-list').exists()).toBe(false)
wrapper.unmount()
})
it('shows error when RSVP submission fails', async () => {
mockLoadedEvent()
vi.mocked(api.POST).mockResolvedValue({
data: undefined,
error: { type: 'about:blank', title: 'Bad Request', status: 400 },
response: new Response(null, { status: 400 }),
} as never)
const wrapper = await mountWithToken()
await flushPromises()
await wrapper.find('.rsvp-bar__cta').trigger('click')
await flushPromises()
const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement
input.value = 'Max'
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
const form = document.body.querySelector('.rsvp-form')! as HTMLFormElement
form.dispatchEvent(new Event('submit', { bubbles: true }))
await flushPromises()
expect(document.body.textContent).toContain('Could not submit RSVP. Please try again.')
wrapper.unmount()
})
})

View File

@@ -8,7 +8,7 @@ function createTestRouter() {
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div />' } },
{ path: '/events/:token', name: 'event', component: EventStubView },
{ path: '/events/:eventToken', name: 'event', component: EventStubView },
],
})
}

View File

@@ -0,0 +1,94 @@
# OpenAPI contract addition for GET /events/{token}
# To be merged into backend/src/main/resources/openapi/api.yaml
paths:
/events/{token}:
get:
operationId: getEvent
summary: Get public event details by token
tags:
- events
parameters:
- name: token
in: path
required: true
schema:
type: string
format: uuid
description: Public event token
responses:
"200":
description: Event found
content:
application/json:
schema:
$ref: "#/components/schemas/GetEventResponse"
"404":
description: Event not found
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
components:
schemas:
GetEventResponse:
type: object
required:
- eventToken
- title
- dateTime
- timezone
- attendeeCount
- expired
properties:
eventToken:
type: string
format: uuid
description: Public event token
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
title:
type: string
description: Event title
example: "Summer BBQ"
description:
type: string
description: Event description (absent if not set)
example: "Bring your own drinks!"
dateTime:
type: string
format: date-time
description: Event date/time with organizer's UTC offset
example: "2026-03-15T20:00:00+01:00"
timezone:
type: string
description: IANA timezone name of the organizer
example: "Europe/Berlin"
location:
type: string
description: Event location (absent if not set)
example: "Central Park, NYC"
attendeeCount:
type: integer
minimum: 0
description: Number of confirmed attendees (attending=true)
example: 12
expired:
type: boolean
description: Whether the event's expiry date has passed
example: false
# Modification to existing CreateEventRequest — add timezone field
# CreateEventRequest (additions):
# timezone:
# type: string
# description: IANA timezone of the organizer
# example: "Europe/Berlin"
# (make required)
# Modification to existing CreateEventResponse — add timezone field
# CreateEventResponse (additions):
# timezone:
# type: string
# description: IANA timezone of the organizer
# example: "Europe/Berlin"

View File

@@ -0,0 +1,56 @@
# Data Model: View Event Landing Page (007)
**Date**: 2026-03-06
## Entities
### Event (modified — adds `timezone` field)
| Field | Type | Required | Constraints | Notes |
|-----------------|------------------|----------|--------------------------|----------------------------------|
| id | Long | yes | BIGSERIAL, PK | Internal only, never exposed |
| eventToken | UUID | yes | UNIQUE, NOT NULL | Public identifier in URLs |
| organizerToken | UUID | yes | UNIQUE, NOT NULL | Secret, never in public API |
| title | String | yes | 1200 chars | |
| description | String | no | max 2000 chars | |
| dateTime | OffsetDateTime | yes | | Organizer's original offset |
| timezone | String | yes | IANA zone ID, max 64 | **NEW** — e.g. "Europe/Berlin" |
| location | String | no | max 500 chars | |
| expiryDate | LocalDate | yes | Must be future at create | Auto-deletion trigger |
| createdAt | OffsetDateTime | yes | Server-generated | |
**Validation rules**:
- `timezone` must be a valid IANA zone ID (`ZoneId.getAvailableZoneIds()`).
- `expiryDate` must be in the future at creation time (existing rule).
**State transitions**:
- Active → Expired: when `expiryDate < today` (computed, not stored).
- Active → Cancelled: future (US-18), adds `cancelledAt` + `cancellationMessage`.
### RSVP (future — not created in this feature)
Documented here for context only. Created when the RSVP feature (US-8+) is implemented.
| Field | Type | Required | Constraints |
|------------|---------|----------|------------------------------|
| id | Long | yes | BIGSERIAL, PK |
| eventId | Long | yes | FK → events.id |
| guestName | String | yes | 1100 chars |
| attending | Boolean | yes | true = attending |
| createdAt | OffsetDateTime | yes | Server-generated |
## Relationships
```
Event 1 ←── * RSVP (future)
```
## Type Mapping (full stack)
| Concept | Java | PostgreSQL | OpenAPI | TypeScript |
|--------------|-------------------|---------------|---------------------|------------|
| Event time | `OffsetDateTime` | `timestamptz` | `string` `date-time`| `string` |
| Timezone | `String` | `varchar(64)` | `string` | `string` |
| Expiry date | `LocalDate` | `date` | `string` `date` | `string` |
| Token | `UUID` | `uuid` | `string` `uuid` | `string` |
| Count | `int` | `integer` | `integer` | `number` |

View File

@@ -0,0 +1,89 @@
# Implementation Plan: View Event Landing Page
**Branch**: `007-view-event` | **Date**: 2026-03-06 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/007-view-event/spec.md`
## Summary
Add a public event detail page at `/events/:token` that displays event information (title, date/time with IANA timezone, description, location, attendee count) without requiring authentication. The page handles four states: loaded, expired ("event has ended"), not found (404), and server error (retry button). Loading uses skeleton-shimmer placeholders. Backend adds `GET /events/{token}` endpoint and a `timezone` field to the Event model (cross-cutting change to US-1).
## Technical Context
**Language/Version**: Java 25 (backend), TypeScript 5.9 (frontend)
**Primary Dependencies**: Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
**Storage**: PostgreSQL (JPA via Spring Data, Liquibase migrations)
**Testing**: JUnit (backend), Vitest (frontend unit), Playwright + MSW (frontend E2E)
**Target Platform**: Self-hosted web application (Docker)
**Project Type**: Web service + SPA
**Performance Goals**: N/A (single-user scale, self-hosted)
**Constraints**: No external resources (CDNs, fonts, tracking), WCAG AA, privacy-first
**Scale/Scope**: Single new view + one new API endpoint + one cross-cutting model change
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| I. Privacy by Design | PASS | No PII exposed. Only attendee count shown (not names). No external resources. No tracking. |
| II. Test-Driven Methodology | PASS | TDD enforced: backend unit tests, frontend unit tests, E2E tests per spec. |
| III. API-First Development | PASS | OpenAPI spec updated first. Types generated. Response schemas include `example:` fields. |
| IV. Simplicity & Quality | PASS | Minimal changes: one GET endpoint, one new view, one model field. `attendeeCount` returns 0 (no RSVP stub). Cancelled state deferred. |
| V. Dependency Discipline | PASS | No new dependencies. Skeleton shimmer is CSS-only. |
| VI. Accessibility | PASS | Semantic HTML, ARIA attributes, keyboard navigable, WCAG AA contrast via design system. |
**Post-Phase-1 re-check**: All gates still pass. The `timezone` field addition is a justified cross-cutting change documented in research.md R-1.
## Project Structure
### Documentation (this feature)
```text
specs/007-view-event/
├── plan.md # This file
├── spec.md # Feature specification
├── research.md # Phase 0: research decisions
├── data-model.md # Phase 1: entity definitions
├── quickstart.md # Phase 1: implementation overview
├── contracts/
│ └── get-event.yaml # Phase 1: GET endpoint contract
└── tasks.md # Phase 2: implementation tasks (via /speckit.tasks)
```
### Source Code (repository root)
```text
backend/
├── src/main/java/de/fete/
│ ├── domain/
│ │ ├── model/Event.java # Add timezone field
│ │ └── port/in/GetEventUseCase.java # NEW: inbound port
│ ├── application/service/EventService.java # Implement GetEventUseCase
│ ├── adapter/
│ │ ├── in/web/EventController.java # Implement getEvent()
│ │ └── out/persistence/
│ │ ├── EventJpaEntity.java # Add timezone column
│ │ └── EventPersistenceAdapter.java # Map timezone field
│ └── config/
├── src/main/resources/
│ ├── openapi/api.yaml # Add GET endpoint + timezone
│ └── db/changelog/ # Liquibase: add timezone column
└── src/test/java/de/fete/ # Unit + integration tests
frontend/
├── src/
│ ├── api/schema.d.ts # Regenerated from OpenAPI
│ ├── views/EventDetailView.vue # NEW: event detail page
│ ├── views/EventCreateView.vue # Add timezone to create request
│ ├── router/index.ts # Point /events/:token to EventDetailView
│ └── assets/main.css # Skeleton shimmer styles
├── e2e/
│ └── event-view.spec.ts # NEW: E2E tests for view event
└── src/__tests__/ # Unit tests for EventDetailView
```
**Structure Decision**: Existing web application structure (backend + frontend). No new packages or modules — extends existing hexagonal architecture with one new inbound port and one new frontend view.
## Complexity Tracking
No constitution violations. No entries needed.

View File

@@ -0,0 +1,39 @@
# Quickstart: View Event Landing Page (007)
## What this feature does
Adds a public event detail page at `/events/:token`. Guests open a shared link and see:
- Event title, date/time (with IANA timezone), description, location
- Count of confirmed attendees (no names)
- "Event has ended" state for expired events
- "Event not found" for invalid tokens
- Skeleton shimmer while loading
## Prerequisites
- US-1 (Create Event) is implemented — Event entity, JPA persistence, POST endpoint exist.
- No RSVP model yet — attendee count returns 0 until RSVP feature is built.
## Key changes
### Backend
1. **OpenAPI**: Add `GET /events/{token}` endpoint + `GetEventResponse` schema. Add `timezone` field to `CreateEventRequest`, `CreateEventResponse`, and `GetEventResponse`.
2. **Domain**: Add `timezone` (String) to `Event.java`.
3. **Persistence**: Add `timezone` column to `EventJpaEntity`, Liquibase migration.
4. **Use case**: New `GetEventUseCase` (inbound port) + implementation in `EventService`.
5. **Controller**: `EventController` implements `getEvent()` — maps to `GetEventResponse`, computes `expired` and `attendeeCount`.
### Frontend
1. **API types**: Regenerate `schema.d.ts` from updated OpenAPI spec.
2. **EventDetailView.vue**: New view component — fetches event by token, renders detail card.
3. **Router**: Replace `EventStubView` import at `/events/:token` with `EventDetailView`.
4. **States**: Loading (skeleton shimmer), loaded, expired, not-found, server-error (retry button).
5. **Create form**: Send `timezone` field (auto-detected via `Intl.DateTimeFormat`).
### Testing
- Backend: Unit tests for `GetEventUseCase`, controller tests for GET endpoint (200, 404).
- Frontend: Unit tests for EventDetailView (all states).
- E2E: Playwright tests with MSW mocks for all states (loaded, expired, not-found, error).

View File

@@ -0,0 +1,100 @@
# Research: View Event Landing Page (007)
**Date**: 2026-03-06 | **Status**: Complete
## R-1: Timezone Field (Cross-Cutting)
**Decision**: Add `timezone` String field (IANA zone ID) to Event entity, JPA entity, and OpenAPI schemas (both Create and Get).
**Rationale**: The spec requires displaying the IANA timezone name (e.g. "Europe/Berlin") alongside the event time. `OffsetDateTime` preserves the offset (e.g. `+01:00`) but loses the IANA zone name. Since Europe/Berlin and Africa/Lagos both use `+01:00`, the zone name must be stored separately.
**Alternatives considered**:
- Store `ZonedDateTime` instead of `OffsetDateTime` — rejected because `OffsetDateTime` is already the established type in the stack (see `datetime-best-practices.md`), and `ZonedDateTime` serialization is non-standard in JSON/OpenAPI.
- Derive timezone from offset — rejected because offset-to-zone mapping is ambiguous.
**Impact on US-1 (Create Event)**:
- `CreateEventRequest` gains a required `timezone` field (string, IANA zone ID).
- `CreateEventResponse` gains a `timezone` field.
- Frontend auto-detects via `Intl.DateTimeFormat().resolvedOptions().timeZone`.
- Backend validates against `java.time.ZoneId.getAvailableZoneIds()`.
- JPA: new `VARCHAR(64)` column `timezone` on `events` table.
- Liquibase changeset: add `timezone` column. Existing events without timezone get `UTC` as default (pre-launch, destructive migration acceptable).
## R-2: GET Endpoint Design
**Decision**: `GET /api/events/{token}` returns public event data. Uses the existing hexagonal architecture pattern.
**Rationale**: Follows the established pattern from `POST /events`. The event token is the public identifier — no auth required.
**Flow**:
1. `EventController` implements generated `EventsApi.getEvent()`.
2. New inbound port: `GetEventUseCase` with `getByEventToken(UUID): Optional<Event>`.
3. `EventService` implements the use case, delegates to `EventRepository.findByEventToken()` (already exists).
4. Controller maps domain `Event` to `GetEventResponse` DTO.
5. 404 returns `ProblemDetail` (RFC 9457) — no event data leaked.
**Alternatives considered**:
- Separate `/event/{token}` path (singular) — rejected because OpenAPI groups by resource; `/events/{token}` is RESTful convention.
- Note: Frontend route is `/event/:token` (spec clarification), but API path is `/api/events/{token}`. These are independent.
## R-3: Attendee Count Without RSVP Model
**Decision**: Include `attendeeCount` (integer) in the `GetEventResponse`. Return `0` until the RSVP feature (US-8+) is implemented.
**Rationale**: FR-001 requires attendee count display. The API contract should be stable from the start — consumers should not need to change when RSVP is added later. Returning `0` is correct (no RSVPs exist yet).
**Future hook**: When RSVP is implemented, `EventService` or a dedicated query will `COUNT(*) WHERE event_id = ? AND status = 'ATTENDING'`.
**Alternatives considered**:
- Omit `attendeeCount` until RSVP exists — rejected because it would require API consumers to handle the field's absence, then handle its presence later. Breaking change.
- Add a stub RSVP table now — rejected (YAGNI, violates Principle IV).
## R-4: Expired Event Detection
**Decision**: Server-side. The `GetEventResponse` includes a boolean `expired` field, computed by comparing `expiryDate` with the server's current date.
**Rationale**: Server is the source of truth for time. Client clocks may be wrong. The frontend uses this flag to toggle the "event has ended" state.
**Computation**: `event.getExpiryDate().isBefore(LocalDate.now(clock))` — uses the injected `Clock` bean (already exists for testability in `EventService`).
**Alternatives considered**:
- Client-side comparison — rejected because client clock may differ from server, leading to inconsistent behavior.
- Separate endpoint for status — rejected (over-engineering).
## R-5: URL Pattern
**Decision**: Frontend route stays at `/events/:token` (plural). API path is `/api/events/{token}`. Both use the plural RESTful convention consistently.
**Rationale**: `/events/:token` is the standard REST resource pattern (collection + identifier). The existing router already uses this path. Consistency between frontend route and API resource name reduces cognitive overhead.
**Impact**: No route change needed — the existing `/events/:token` route in the router is correct.
## R-6: Skeleton Shimmer Loading State
**Decision**: CSS-only shimmer animation using a gradient sweep. No additional dependencies.
**Rationale**: The spec requires skeleton-shimmer placeholders during API loading. A CSS-only approach is lightweight and matches the dependency discipline principle.
**Implementation pattern**:
```css
.skeleton {
background: linear-gradient(90deg, var(--color-card) 25%, #e0e0e0 50%, var(--color-card) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-card);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
```
Skeleton blocks match the approximate shape/size of the real content fields (title, date, location, etc.).
## R-7: Cancelled Event State (Deferred)
**Decision**: The `GetEventResponse` does NOT include cancellation fields yet. US-3 (view cancelled event) is explicitly deferred until US-18 (cancel event) is implemented.
**Rationale**: Spec says "[Deferred until US-18 is implemented]". Adding unused fields violates Principle IV (KISS).
**Future hook**: When US-18 lands, add `cancelled: boolean` and `cancellationMessage: string` to the response schema.

View File

@@ -9,18 +9,18 @@
### User Story 1 - View event details as guest (Priority: P1)
A guest receives a shared event link, opens it, and sees all relevant event information: title, description (if provided), date and time, location (if provided), and the list of confirmed attendees with a count.
A guest receives a shared event link, opens it, and sees all relevant event information: title, description (if provided), date and time, location (if provided), and the count of confirmed attendees.
**Why this priority**: Core value of the feature — without this, no other part of the event page is meaningful.
**Independent Test**: Can be fully tested by navigating to a valid event URL and verifying all event fields are displayed correctly, including attendee list and count.
**Independent Test**: Can be fully tested by navigating to a valid event URL and verifying all event fields are displayed correctly, including attendee count.
**Acceptance Scenarios**:
1. **Given** a valid event link, **When** a guest opens the URL, **Then** the page displays the event title, date and time, and attendee count.
2. **Given** a valid event link for an event with optional fields set, **When** a guest opens the URL, **Then** the description and location are also displayed.
3. **Given** a valid event link for an event with optional fields absent, **When** a guest opens the URL, **Then** only the required fields are shown — no placeholder text for missing optional fields.
4. **Given** a valid event with RSVPs, **When** a guest opens the event page, **Then** the names of all confirmed attendees ("attending") are listed and a total count is shown.
4. **Given** a valid event with RSVPs, **When** a guest opens the event page, **Then** only the total count of confirmed attendees is shown — individual names are NOT displayed to guests (names are only visible to the organizer via the organizer view).
5. **Given** an event page, **When** it is rendered, **Then** no external resources (CDNs, fonts, tracking scripts) are loaded — all assets are served from the app's own domain.
6. **Given** a guest with no account, **When** they open the event URL, **Then** the page loads without any login, account, or access code required.
@@ -70,9 +70,9 @@ A guest navigates to an event URL that no longer resolves — the event was dele
### Edge Cases
- What happens when the event has no attendees yet? — Attendee list is empty; count shows 0.
- What happens when the event has no attendees yet? — Count shows 0.
- What happens when the event has been cancelled after US-18 is implemented? — Renders cancelled state with optional message; RSVP hidden. [Deferred]
- What happens when the server is temporarily unavailable? — [NEEDS EXPANSION]
- What happens when the server is temporarily unavailable? — The page displays a generic, friendly error message with a manual "Retry" button. No automatic retry.
- How does the page behave when JavaScript is disabled? — Per Q-3 resolution: the app is a SPA; JavaScript-dependent rendering is acceptable.
## Requirements
@@ -81,7 +81,7 @@ A guest navigates to an event URL that no longer resolves — the event was dele
- **FR-001**: The event page MUST display: title, date and time, and attendee count for any valid event.
- **FR-002**: The event page MUST display description and location when those optional fields are set on the event.
- **FR-003**: The event page MUST list the names of all confirmed attendees (those who RSVPed "attending").
- **FR-003**: The public event page MUST display only the count of confirmed attendees. Individual attendee names MUST NOT be shown to guests — names are only visible to the organizer (organizer view, separate user story).
- **FR-004**: If the event's expiry date has passed, the page MUST render a clear "this event has ended" state and MUST NOT show any RSVP actions.
- **FR-005**: If the event has been cancelled (US-18), the page MUST display a "cancelled" state with the cancellation message (if provided) and MUST NOT show any RSVP actions. [Deferred until US-18 is implemented]
- **FR-006**: If the event token does not match any event on the server, the page MUST display a clear "event not found" message — no partial data or error traces.
@@ -90,15 +90,29 @@ A guest navigates to an event URL that no longer resolves — the event was dele
### Key Entities
- **Event**: Has a public event token (UUID in URL), title, optional description, date/time, optional location, expiry date, and optionally a cancelled state with message.
- **RSVP**: Has a guest name and attending status; confirmed attendees (status = attending) are listed on the public event page.
- **Event**: Has a public event token (UUID in URL), title, optional description, date/time (OffsetDateTime — displayed in the organizer's original timezone, no conversion to viewer timezone), IANA timezone name (e.g. `Europe/Berlin`, stored as separate field — required for human-readable timezone display), optional location, expiry date (LocalDate), and optionally a cancelled state with message. See `.specify/memory/research/datetime-best-practices.md` for full stack type mapping.
- **Note**: The IANA timezone requires a new `timezone` field on the Event entity and API schema. This impacts US-1 (Create Event) — the frontend must send the organizer's IANA zone ID alongside the OffsetDateTime.
- **RSVP**: Has a guest name and binary attending status (attending / not attending — no "maybe"). Only the count of confirmed attendees (status = attending) is exposed on the public event page. Individual names are visible only in the organizer view, sorted alphabetically by name.
## Success Criteria
### Measurable Outcomes
- **SC-001**: A guest who opens a valid event URL can see all set event fields (title, date/time, and any optional fields) without logging in.
- **SC-002**: The attendee list and count reflect all current server-side RSVPs with attending status.
- **SC-002**: The attendee count reflects all current server-side RSVPs with attending status. No individual names are exposed on the public event page.
- **SC-003**: An expired event URL renders the "ended" state — RSVP controls are absent from the DOM, not merely hidden via CSS.
- **SC-004**: An unknown event token URL renders a "not found" message — no event data, no server error details.
- **SC-005**: No network requests to external domains are made when loading the event page.
## Clarifications
### Session 2026-03-06
- Q: What should the event page display when the server is temporarily unavailable? → A: Generic friendly error state with a manual "Retry" button; no automatic retry.
- Q: How should date/time be displayed regarding timezones? → A: Organizer timezone preserved — display the time exactly as entered by the organizer (OffsetDateTime), no conversion to viewer's local timezone. The IANA timezone name (e.g. "Europe/Berlin") MUST be displayed alongside the time. Requires a new `timezone` field on Event entity/API (impacts US-1).
- Q: What is the URL pattern for event pages? → A: `/events/:token` (e.g. `/events/a1b2c3d4-...`). Plural, matching the RESTful API resource name.
- Q: Should guest names be visible to other guests on the public event page? → A: No. Only the attendee count is shown to guests. Individual names are exclusively visible to the organizer, sorted alphabetically.
- Q: How should the loading state look while the API call is in progress? → A: Skeleton-shimmer (placeholder blocks in field shape that shimmer until data arrives).
- Q: Should the event page include OpenGraph meta tags for link previews? → A: Out of scope for US-007. Separate user story — generic app-branding OG-tags only, no event data exposed to crawlers. Noted in `.specify/memory/ideen.md`.
- Q: Should date/time formatting adapt to the viewer's browser locale? → A: Yes, browser-locale-based via `Intl.DateTimeFormat` (e.g. DE: "15. März 2026, 20:00" / EN: "March 15, 2026, 8:00 PM").
- Q: Is RSVP status binary or are there more states (e.g. "maybe")? → A: Binary — attending or not attending. No "maybe" status. Count reflects only confirmed attendees.

View File

@@ -0,0 +1,225 @@
# Tasks: View Event Landing Page
**Input**: Design documents from `/specs/007-view-event/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/get-event.yaml
**Tests**: Included — constitution enforces Test-Driven Methodology (Principle II).
**Organization**: Tasks grouped by user story. US3 (cancelled event) is deferred until US-18.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US4)
- Exact file paths included in descriptions
---
## Phase 1: Setup (Cross-Cutting Schema Changes)
**Purpose**: OpenAPI contract update, database migration, and type generation — prerequisites for all backend and frontend work.
- [x] T001 Update OpenAPI spec: add `GET /events/{token}` endpoint, `GetEventResponse` schema, and `timezone` field to `CreateEventRequest`/`CreateEventResponse` in `backend/src/main/resources/openapi/api.yaml`
- [x] T002 [P] Add Liquibase changeset: `timezone VARCHAR(64) NOT NULL DEFAULT 'UTC'` column on `events` table in `backend/src/main/resources/db/changelog/`
- [x] T003 Regenerate frontend TypeScript types from updated OpenAPI spec in `frontend/src/api/schema.d.ts`
**Checkpoint**: OpenAPI contract finalized, DB schema ready, frontend types available.
---
## Phase 2: Foundational (Backend — Blocks All User Stories)
**Purpose**: Domain model update, new GET use case, controller endpoint, and backend tests. All user stories depend on this.
**CRITICAL**: No frontend user story work can begin until this phase is complete.
### Backend Tests (TDD — write first, verify they fail)
- [x] T004 [P] Backend unit tests for `GetEventUseCase`: test getByEventToken returns event, returns empty for unknown token, computes expired flag — in `backend/src/test/java/de/fete/`
- [x] T005 [P] Backend controller tests for `GET /events/{token}`: test 200 with full response, 200 with optional fields absent, 404 with ProblemDetail — in `backend/src/test/java/de/fete/`
- [x] T006 [P] Backend tests for timezone in Create Event flow: request validation (valid/invalid IANA zone), persistence round-trip — in `backend/src/test/java/de/fete/`
### Backend Implementation
- [x] T007 Add `timezone` field (String) to domain model in `backend/src/main/java/de/fete/domain/model/Event.java`
- [x] T008 [P] Add `timezone` column to JPA entity and update persistence mapping in `backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java` and `EventPersistenceAdapter.java`
- [x] T009 [P] Update Create Event flow to accept and validate `timezone` (must be valid IANA zone ID via `ZoneId.getAvailableZoneIds()`) in `backend/src/main/java/de/fete/application/service/EventService.java` and `EventController.java`
- [x] T010 Create `GetEventUseCase` inbound port with `getByEventToken(UUID): Optional<Event>` in `backend/src/main/java/de/fete/domain/port/in/GetEventUseCase.java`
- [x] T011 Implement `GetEventUseCase` in `backend/src/main/java/de/fete/application/service/EventService.java` — delegates to existing `findByEventToken()` repository method
- [x] T012 Implement `getEvent()` in `backend/src/main/java/de/fete/adapter/in/web/EventController.java` — maps domain Event to GetEventResponse, computes `expired` (expiryDate vs server clock) and `attendeeCount` (hardcoded 0)
**Checkpoint**: Backend complete — `GET /api/events/{token}` returns 200 or 404. All backend tests pass.
---
## Phase 3: User Story 1 — View Event Details as Guest (Priority: P1) MVP
**Goal**: A guest opens a shared event link and sees all event information: title, date/time with IANA timezone, description, location, attendee count. Loading shows skeleton shimmer.
**Independent Test**: Navigate to a valid event URL, verify all fields display correctly with locale-formatted date/time.
### Tests for User Story 1
- [x] T013 [P] [US1] Unit tests for EventDetailView loaded state: renders title, date/time (locale-formatted via `Intl.DateTimeFormat`), timezone, description, location, attendee count — in `frontend/src/__tests__/EventDetailView.spec.ts`
- [x] T014 [P] [US1] Unit test for EventDetailView loading state: renders skeleton shimmer placeholders — in `frontend/src/__tests__/EventDetailView.spec.ts`
### Implementation for User Story 1
- [x] T015 [P] [US1] Add skeleton shimmer CSS (CSS-only gradient animation, no dependencies) in `frontend/src/assets/main.css`
- [x] T016 [US1] Create `EventDetailView.vue` with loading (skeleton shimmer) and loaded states — fetches event via `openapi-fetch` GET `/events/{token}`, formats date/time with `Intl.DateTimeFormat` using browser locale — in `frontend/src/views/EventDetailView.vue`
- [x] T017 [US1] Update router to use `EventDetailView` for `/events/:token` route in `frontend/src/router/index.ts`
- [x] T018 [P] [US1] Update `EventCreateView.vue` to send `timezone` field (auto-detected via `Intl.DateTimeFormat().resolvedOptions().timeZone`) in `frontend/src/views/EventCreateView.vue`
- [x] T019 [US1] E2E test for loaded event: navigate to valid event URL, verify all fields displayed, verify no external resource requests — in `frontend/e2e/event-view.spec.ts`
**Checkpoint**: US1 complete — guest can view event details. Skeleton shimmer during loading. Date/time locale-formatted with timezone label.
---
## Phase 4: User Story 2 — View Expired Event (Priority: P2)
**Goal**: A guest opens a link to an expired event. The page shows event details plus a clear "event has ended" indicator. No RSVP actions shown.
**Independent Test**: Create an event with past expiry date, navigate to its URL, verify "event has ended" state renders and no RSVP controls are present.
**Dependencies**: Requires Phase 3 (EventDetailView exists).
### Tests for User Story 2
- [x] T020 [P] [US2] Unit test for EventDetailView expired state: renders "event has ended" indicator, RSVP controls absent from DOM — in `frontend/src/__tests__/EventDetailView.spec.ts`
### Implementation for User Story 2
- [x] T021 [US2] Add expired state rendering to `EventDetailView.vue`: show event details + "event has ended" banner when `expired === true`, no RSVP actions in DOM — in `frontend/src/views/EventDetailView.vue`
- [x] T022 [US2] E2E test for expired event: MSW returns event with `expired: true`, verify banner and absent RSVP controls — in `frontend/e2e/event-view.spec.ts`
**Checkpoint**: US2 complete — expired events clearly show "ended" state.
---
## Phase 5: User Story 4 — Event Not Found (Priority: P2)
**Goal**: A guest navigates to an invalid event URL. The page shows a clear "event not found" message — no partial data, no error traces.
**Independent Test**: Navigate to a URL with an unknown event token, verify "event not found" message renders.
**Dependencies**: Requires Phase 3 (EventDetailView exists). No dependency on US2.
### Tests for User Story 4
- [x] T023 [P] [US4] Unit test for EventDetailView not-found state: renders "event not found" message, no event data in DOM — in `frontend/src/__tests__/EventDetailView.spec.ts`
### Implementation for User Story 4
- [x] T024 [US4] Add not-found state rendering to `EventDetailView.vue`: show "event not found" message when API returns 404 — in `frontend/src/views/EventDetailView.vue`
- [x] T025 [US4] E2E test for event not found: MSW returns 404 ProblemDetail, verify message and no event data — in `frontend/e2e/event-view.spec.ts`
**Checkpoint**: US4 complete — invalid tokens show friendly not-found message.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Server error edge case, final validation, and cleanup.
- [x] T026 Add server error state with manual retry button to `EventDetailView.vue`: friendly error message + "Retry" button that re-fetches — in `frontend/src/views/EventDetailView.vue`
- [x] T027 [P] Unit test for server error + retry state in `frontend/src/__tests__/EventDetailView.spec.ts`
- [x] T028 [P] E2E test for server error: MSW returns 500, verify error message and retry button functionality — in `frontend/e2e/event-view.spec.ts`
- [x] T029 Run quickstart.md validation: verify all key changes listed in quickstart.md are implemented
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — start immediately
- **Foundational (Phase 2)**: Depends on T001 (OpenAPI spec) and T002 (migration) from Setup
- **US1 (Phase 3)**: Depends on Phase 2 completion (backend endpoint must exist)
- **US2 (Phase 4)**: Depends on Phase 3 (EventDetailView exists) — can parallelize with US4
- **US4 (Phase 5)**: Depends on Phase 3 (EventDetailView exists) — can parallelize with US2
- **Polish (Phase 6)**: Depends on Phase 3 minimum; ideally after US2 + US4
### User Story Dependencies
```
Phase 1 (Setup) ──► Phase 2 (Backend) ──► Phase 3 (US1/MVP)
┌────┴────┐
▼ ▼
Phase 4 Phase 5
(US2) (US4)
└────┬────┘
Phase 6 (Polish)
```
- **US1 (P1)**: Requires Phase 2 — no dependency on other stories
- **US2 (P2)**: Requires US1 (same component) — no dependency on US4
- **US4 (P2)**: Requires US1 (same component) — no dependency on US2
- **US3 (P2)**: DEFERRED until US-18 (cancel event) is implemented
### Within Each Phase
- Tests MUST be written and FAIL before implementation (TDD)
- Models/ports before services
- Services before controllers
- Backend before frontend (for the same endpoint)
### Parallel Opportunities
**Phase 1**: T002 (migration) can run in parallel with T001 (OpenAPI update)
**Phase 2**: T004, T005, T006 (tests) can run in parallel. T008, T009 can run in parallel after T007.
**Phase 3**: T013, T014 (unit tests) and T015 (CSS) can run in parallel. T018 (create form timezone) is independent.
**Phase 4 + 5**: US2 and US4 can be implemented in parallel (different UI states, same file but non-conflicting sections).
---
## Parallel Example: Phase 2 (Backend)
```bash
# Write all backend tests in parallel (TDD):
Task T004: "Unit tests for GetEventUseCase"
Task T005: "Controller tests for GET /events/{token}"
Task T006: "Tests for timezone in Create Event flow"
# Then implement in parallel where possible:
Task T008: "Add timezone to JPA entity + persistence" # parallel
Task T009: "Update Create Event flow for timezone" # parallel
# T010-T012 are sequential (port → service → controller)
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup (OpenAPI + migration + types)
2. Complete Phase 2: Backend (domain + use case + controller + tests)
3. Complete Phase 3: US1 (EventDetailView + router + tests)
4. **STOP and VALIDATE**: Guest can view event details via shared link
5. Deploy/demo if ready
### Incremental Delivery
1. Setup + Backend → Backend ready, API testable via curl
2. Add US1 → Guest can view events (MVP!)
3. Add US2 → Expired events show "ended" state
4. Add US4 → Invalid tokens show "not found"
5. Polish → Server error handling, final validation
### Deferred Work
- **US3 (Cancelled event)**: Blocked on US-18. No tasks generated. Will require adding `cancelled` + `cancellationMessage` to GetEventResponse and a new UI state.
---
## Notes
- [P] tasks = different files, no dependencies on incomplete tasks
- [Story] label maps task to specific user story for traceability
- `attendeeCount` returns 0 until RSVP feature (US-8+) is implemented (R-3)
- `expired` is computed server-side using injected Clock bean (R-4)
- Frontend route: `/events/:token` — API path: `/api/events/{token}` (R-5)
- Skeleton shimmer is CSS-only, no additional dependencies (R-6)
- Date/time formatted via `Intl.DateTimeFormat` with browser locale (spec clarification Q7)

View File

@@ -0,0 +1,79 @@
# OpenAPI contract addition for POST /events/{eventToken}/rsvps
# To be merged into backend/src/main/resources/openapi/api.yaml
paths:
/events/{eventToken}/rsvps:
post:
operationId: createRsvp
summary: Submit an RSVP for an event
tags:
- events
parameters:
- name: eventToken
in: path
required: true
schema:
type: string
format: uuid
description: Public event token
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateRsvpRequest"
responses:
"201":
description: RSVP created successfully
content:
application/json:
schema:
$ref: "#/components/schemas/CreateRsvpResponse"
"400":
description: Validation failed (e.g. blank name)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ValidationProblemDetail"
"404":
description: Event not found
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"409":
description: Event has expired — RSVPs no longer accepted
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
components:
schemas:
CreateRsvpRequest:
type: object
required:
- name
properties:
name:
type: string
minLength: 1
maxLength: 100
description: Guest's display name
example: "Max Mustermann"
CreateRsvpResponse:
type: object
required:
- rsvpToken
- name
properties:
rsvpToken:
type: string
format: uuid
description: Token identifying this RSVP (store client-side for future updates)
example: "d4e5f6a7-b8c9-0123-4567-890abcdef012"
name:
type: string
description: Guest's display name as stored
example: "Max Mustermann"

View File

@@ -0,0 +1,93 @@
# Data Model: RSVP to an Event (008)
**Date**: 2026-03-06
## Entities
### Rsvp (NEW)
| Field | Type | Required | Constraints | Notes |
|------------|----------------|----------|--------------------------------|------------------------------------|
| id | Long | yes | BIGSERIAL, PK | Internal only, never exposed |
| rsvpToken | RsvpToken | yes | UNIQUE, NOT NULL | Server-generated UUID, returned to client |
| eventId | Long | yes | FK -> events.id, NOT NULL | Which event this RSVP belongs to |
| name | String | yes | 1-100 chars, NOT NULL | Guest's display name |
**Notes**:
- No `attending` boolean — existence of an entry implies attendance (per spec).
- No `createdAt` — not required by the spec. Can be added later if needed (e.g. for guest list sorting in 009).
- Duplicates from different devices or cleared localStorage are accepted (privacy trade-off).
### Token Value Objects (NEW)
| Record | Field | Type | Notes |
|------------------|-------|------|-----------------------------------------------|
| `EventToken` | value | UUID | Immutable, non-null. Java record wrapping UUID |
| `OrganizerToken` | value | UUID | Immutable, non-null. Java record wrapping UUID |
| `RsvpToken` | value | UUID | Immutable, non-null. Java record wrapping UUID |
**Purpose**: Type-safe wrappers preventing mix-ups between the three token types at compile time. All generated server-side via `UUID.randomUUID()`. JPA entities continue to use raw `UUID` columns — mapping happens in the persistence adapters.
### Event (MODIFIED — token fields change type)
The Event domain model's `eventToken` and `organizerToken` fields change from raw `UUID` to their typed record wrappers. No database schema change — the JPA entity keeps raw `UUID` columns.
| Field | Old Type | New Type |
|-----------------|----------|------------------|
| eventToken | UUID | EventToken |
| organizerToken | UUID | OrganizerToken |
The `attendeeCount` was already added to the API response in 007-view-event — it now gets populated from a count query instead of returning 0.
### StoredEvent (frontend localStorage — modified)
| Field | Type | Required | Notes |
|----------------|--------|----------|------------------------------------|
| eventToken | string | yes | Existing |
| organizerToken | string | no | Existing (organizer flow) |
| title | string | yes | Existing |
| dateTime | string | yes | Existing |
| expiryDate | string | yes | Existing |
| rsvpToken | string | no | **NEW** — set after RSVP submission |
| rsvpName | string | no | **NEW** — guest's submitted name |
## Validation Rules
- `name`: required, 1-100 characters, trimmed. Blank or whitespace-only is rejected.
- `rsvpToken`: server-generated, never from client input on create.
- `eventId`: must reference an existing, non-expired event.
## Relationships
```
Event 1 <---- * Rsvp
| |
eventToken rsvpToken (unique)
(public) (returned to client)
```
## Type Mapping (full stack)
| Concept | Java | PostgreSQL | OpenAPI | TypeScript |
|--------------|-------------------|---------------|---------------------|------------|
| RSVP ID | `Long` | `bigserial` | N/A (not exposed) | N/A |
| RSVP Token | `RsvpToken` | `uuid` | `string` `uuid` | `string` |
| Event FK | `Long` | `bigint` | N/A (path param) | N/A |
| Guest name | `String` | `varchar(100)`| `string` | `string` |
| Attendee cnt | `long` | `count(*)` | `integer` | `number` |
## Database Migration
New Liquibase changeset `003-create-rsvps-table.xml`:
```sql
CREATE TABLE rsvps (
id BIGSERIAL PRIMARY KEY,
rsvp_token UUID NOT NULL UNIQUE,
event_id BIGINT NOT NULL REFERENCES events(id),
name VARCHAR(100) NOT NULL
);
CREATE INDEX idx_rsvps_event_id ON rsvps(event_id);
CREATE INDEX idx_rsvps_rsvp_token ON rsvps(rsvp_token);
```

114
specs/008-rsvp/plan.md Normal file
View File

@@ -0,0 +1,114 @@
# Implementation Plan: RSVP to an Event
**Branch**: `008-rsvp` | **Date**: 2026-03-06 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/008-rsvp/spec.md`
## Summary
Add RSVP functionality to the event detail page. Backend: new `POST /api/events/{eventToken}/rsvps` endpoint that persists an RSVP (guest name) and returns an `rsvpToken`. Populates the existing `attendeeCount` field with real data from a count query. Rejects RSVPs on expired events (409). Frontend: fullscreen event presentation with sticky bottom bar (RSVP CTA or status), bottom sheet with RSVP form (name + submit). localStorage stores rsvpToken and name per event. No account required.
## 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, no PII logging
**Scale/Scope**: New RSVP domain (model + service + controller + persistence), new frontend components (bottom sheet, sticky bar), modified event detail view
## 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 logged. Only guest-entered name stored. No IP logging. No tracking. Attendee names not exposed publicly (count only). Unprotected endpoint is a conscious privacy trade-off per spec. |
| II. Test-Driven Methodology | PASS | TDD enforced: backend unit + integration tests, frontend unit tests, E2E tests. Tests written before implementation. |
| III. API-First Development | PASS | OpenAPI spec updated first. New endpoint + schemas with `example:` fields. Types generated before implementation. |
| IV. Simplicity & Quality | PASS | Minimal scope: one POST endpoint, one domain entity, one bottom sheet component. No CAPTCHA, no rate limiting, no edit/withdraw (deferred). Cancelled event guard deferred to US-18. |
| V. Dependency Discipline | PASS | No new dependencies. Bottom sheet is CSS + Vue (~50 lines). No UI library. |
| VI. Accessibility | PASS | Bottom sheet uses dialog role + aria-modal. Focus trap. ESC to close. Keyboard navigable. WCAG AA contrast via design system. |
**Post-Phase-1 re-check**: All gates still pass. Three token value objects (`EventToken`, `OrganizerToken`, `RsvpToken`) introduced uniformly — justified by spec requirement for type-safe tokens. Refactoring existing Event model to use typed tokens is a mechanical change well-covered by existing tests.
## Project Structure
### Documentation (this feature)
```text
specs/008-rsvp/
├── plan.md # This file
├── spec.md # Feature specification
├── research.md # Phase 0: research decisions (R-1 through R-8)
├── data-model.md # Phase 1: Rsvp entity, RsvpToken value object
├── quickstart.md # Phase 1: implementation overview
├── contracts/
│ └── create-rsvp.yaml # Phase 1: POST 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 # MODIFIED: UUID → EventToken/OrganizerToken
│ │ │ ├── EventToken.java # NEW: typed token record
│ │ │ ├── OrganizerToken.java # NEW: typed token record
│ │ │ ├── Rsvp.java # NEW: RSVP domain entity
│ │ │ └── RsvpToken.java # NEW: typed token record
│ │ └── port/
│ │ ├── in/CreateRsvpUseCase.java # NEW: inbound port
│ │ └── out/RsvpRepository.java # NEW: outbound port
│ ├── application/service/
│ │ ├── EventService.java # MODIFIED: use typed tokens
│ │ └── RsvpService.java # NEW: RSVP business logic
│ ├── adapter/
│ │ ├── in/web/
│ │ │ ├── EventController.java # MODIFIED: typed tokens + attendee count + createRsvp()
│ │ │ └── GlobalExceptionHandler.java # MODIFIED: handle EventExpiredException
│ │ └── out/persistence/
│ │ ├── EventPersistenceAdapter.java # MODIFIED: map typed tokens
│ │ ├── RsvpJpaEntity.java # NEW: JPA entity
│ │ ├── RsvpJpaRepository.java # NEW: Spring Data interface
│ │ └── RsvpPersistenceAdapter.java # NEW: port implementation
├── src/main/resources/
│ ├── openapi/api.yaml # MODIFIED: add RSVP endpoint + schemas
│ └── db/changelog/
│ ├── db.changelog-master.xml # MODIFIED: include 003
│ └── 003-create-rsvps-table.xml # NEW: rsvps table
└── src/test/java/de/fete/
├── application/service/
│ ├── EventServiceTest.java # MODIFIED: use typed tokens
│ └── RsvpServiceTest.java # NEW: unit tests
└── adapter/in/web/
└── EventControllerIntegrationTest.java # MODIFIED: typed tokens + RSVP integration tests
frontend/
├── src/
│ ├── api/schema.d.ts # REGENERATED from OpenAPI
│ ├── components/
│ │ ├── BottomSheet.vue # NEW: reusable bottom sheet
│ │ └── RsvpBar.vue # NEW: sticky bottom bar
│ ├── views/EventDetailView.vue # MODIFIED: integrate RSVP bar + sheet
│ ├── composables/useEventStorage.ts # MODIFIED: add rsvpToken/rsvpName
│ └── assets/main.css # MODIFIED: bottom sheet + bar styles
├── src/views/__tests__/EventDetailView.spec.ts # MODIFIED: RSVP integration tests
├── src/components/__tests__/
│ ├── BottomSheet.spec.ts # NEW: unit tests
│ └── RsvpBar.spec.ts # NEW: unit tests
├── src/composables/__tests__/useEventStorage.spec.ts # MODIFIED: test new fields
└── e2e/
└── event-rsvp.spec.ts # NEW: E2E tests
```
**Structure Decision**: Extends the existing web application structure (backend + frontend). Adds a new RSVP domain following the same hexagonal architecture pattern established in 006-create-event and 007-view-event. Cross-cutting refactoring introduces typed token value objects (`EventToken`, `OrganizerToken`, `RsvpToken`) across all layers. Two new frontend components (`BottomSheet`, `RsvpBar`) are the first entries in `src/components/` — justified because they're reusable UI primitives, not view-specific markup.
## Complexity Tracking
No constitution violations. No entries needed.

View File

@@ -0,0 +1,58 @@
# Quickstart: RSVP to an Event (008)
## What this feature adds
1. **Backend**: New `POST /api/events/{eventToken}/rsvps` endpoint that accepts an RSVP (guest name) and returns an `rsvpToken`. Populates the existing `attendeeCount` field in `GET /events/{token}` with real data.
2. **Frontend**: Bottom sheet RSVP form on the event detail page. Sticky bottom bar with CTA (or status after RSVP). localStorage persistence of RSVP data.
## Implementation order
1. **Token value objects** — Create `EventToken`, `OrganizerToken`, `RsvpToken` records. Refactor `Event` domain model and all layers (service, controller, repository, persistence adapter, tests) to use typed tokens instead of raw UUID.
2. **OpenAPI spec** — Add `CreateRsvpRequest`, `CreateRsvpResponse`, and the `POST /events/{eventToken}/rsvps` endpoint.
3. **Liquibase migration** — Create `rsvps` table (003-create-rsvps-table.xml).
4. **Domain model**`Rsvp` entity using `RsvpToken`.
5. **Ports**`CreateRsvpUseCase` (in), `RsvpRepository` (out).
6. **Persistence adapter**`RsvpJpaEntity`, `RsvpJpaRepository`, `RsvpPersistenceAdapter`.
7. **Service**`RsvpService` implementing `CreateRsvpUseCase`.
8. **Controller** — Add `createRsvp()` to `EventController`.
9. **Attendee count** — Wire `RsvpRepository.countByEventId()` into the GET event flow.
10. **Frontend composable** — Extend `useEventStorage` with `rsvpToken`/`rsvpName`.
11. **Frontend UI** — Bottom sheet component, sticky bar, RSVP form.
12. **E2E tests** — RSVP submission, expired event guard, localStorage verification.
## Key files to touch
### Backend (new)
- `domain/model/EventToken.java`
- `domain/model/OrganizerToken.java`
- `domain/model/Rsvp.java`
- `domain/model/RsvpToken.java`
- `domain/port/in/CreateRsvpUseCase.java`
- `domain/port/out/RsvpRepository.java`
- `application/service/RsvpService.java`
- `adapter/out/persistence/RsvpJpaEntity.java`
- `adapter/out/persistence/RsvpJpaRepository.java`
- `adapter/out/persistence/RsvpPersistenceAdapter.java`
- `db/changelog/003-create-rsvps-table.xml`
### Backend (modified)
- `domain/model/Event.java` — UUID → EventToken/OrganizerToken
- `application/service/EventService.java` — use typed tokens
- `adapter/in/web/EventController.java` — typed tokens + wire attendee count + createRsvp()
- `adapter/in/web/GlobalExceptionHandler.java` — handle `EventExpiredException` (409)
- `adapter/out/persistence/EventPersistenceAdapter.java` — map typed tokens
- `domain/port/out/EventRepository.java` — typed token in signature
- `openapi/api.yaml` — new endpoint + schemas
- `db/changelog/db.changelog-master.xml` — include new migration
- All existing tests — update to use typed tokens
### Frontend (new)
- `src/components/BottomSheet.vue` — reusable bottom sheet
- `src/components/RsvpBar.vue` — sticky bottom bar (CTA or status)
- `e2e/event-rsvp.spec.ts` — E2E tests
### Frontend (modified)
- `src/views/EventDetailView.vue` — integrate RSVP bar + bottom sheet
- `src/composables/useEventStorage.ts` — add rsvpToken/rsvpName fields
- `src/api/schema.d.ts` — regenerated from OpenAPI

157
specs/008-rsvp/research.md Normal file
View File

@@ -0,0 +1,157 @@
# Research: RSVP to an Event (008)
**Date**: 2026-03-06 | **Status**: Complete
## R-1: RSVP Endpoint Design
**Decision**: `POST /api/events/{eventToken}/rsvps` creates an RSVP. Returns `201` with `rsvpToken` on success. Rejects with `409 Conflict` if event expired.
**Rationale**: RSVPs are a sub-resource of events — nesting under the event token is RESTful and groups operations logically. The event token in the path identifies the event; no separate event ID in the request body needed.
**Flow**:
1. `EventController` implements generated `EventsApi.createRsvp()` — same controller, same tag, sub-resource of events.
2. Controller resolves the event via `eventToken`, checks expiry.
3. New inbound port: `CreateRsvpUseCase` with `createRsvp(CreateRsvpCommand): Rsvp`.
4. `RsvpService` validates event exists + not expired, persists RSVP, returns domain model.
5. Controller maps to `CreateRsvpResponse` DTO (contains `rsvpToken`).
6. 404 if event not found, 409 if event expired, 400 for validation errors.
**Alternatives considered**:
- `POST /api/rsvps` with eventToken in body — rejected because RSVPs are always scoped to an event. Nested resource is cleaner.
- Separate `RsvpController` — rejected because the URL is under `/events/`, so it belongs in `EventController`. One controller per resource root (KISS).
- `PUT` instead of `POST` — rejected because the client doesn't know the rsvpToken before creation.
## R-2: Token Value Objects
**Decision**: Introduce all three token types as Java records wrapping `UUID`: `EventToken`, `OrganizerToken`, and `RsvpToken`. Refactor the existing `Event` domain model and all layers (service, controller, repository, persistence adapter) to use the typed tokens instead of raw `UUID`.
**Rationale**: The spec mandates typed token records. Introducing `RsvpToken` alone while leaving the other two as raw UUIDs would create an inconsistency in the domain model. All three tokens serve the same purpose (type-safe identification) and should be modeled uniformly. The cross-cutting refactoring touches existing code but is mechanical and well-covered by existing tests.
**Implementation pattern** (same for all three):
```java
package de.fete.domain.model;
public record EventToken(UUID value) {
public EventToken {
Objects.requireNonNull(value, "eventToken must not be null");
}
public static EventToken generate() {
return new EventToken(UUID.randomUUID());
}
}
```
```java
public record OrganizerToken(UUID value) { /* same pattern */ }
public record RsvpToken(UUID value) { /* same pattern */ }
```
**Impact on existing code**:
- `Event.java`: `UUID eventToken``EventToken eventToken`, `UUID organizerToken``OrganizerToken organizerToken`
- `EventService.java`: `UUID.randomUUID()``EventToken.generate()` / `OrganizerToken.generate()`
- `EventController.java`: unwrap tokens at API boundary (`token.value()`)
- `EventRepository.java` / `EventPersistenceAdapter.java`: map between domain tokens and raw UUIDs for JPA
- `EventJpaEntity.java`: stays with raw `UUID` columns (JPA mapping layer)
- All existing tests: update to use typed tokens
**Alternatives considered**:
- Use raw UUID everywhere — rejected because the spec explicitly requires typed records and they prevent mixing up token types at compile time.
- Introduce only `RsvpToken` now — rejected because it creates an inconsistency. All three should be uniform.
## R-3: Attendee Count Population
**Decision**: Populate `attendeeCount` in `GetEventResponse` by counting RSVP rows for the event. The count query lives in the `RsvpRepository` port and is called by `EventService` (or a query in `RsvpService` delegated to by `EventController`).
**Rationale**: The `attendeeCount` field already exists in the API contract (returns 0 today per R-3 in 007). Now it gets real data. Since an RSVP entry's existence implies attendance (no `attending` boolean), the count is simply `COUNT(*) WHERE event_id = ?`.
**Implementation approach**: Add `countByEventId(Long eventId)` to `RsvpRepository`. `EventService.getByEventToken()` returns the Event domain object; the controller queries the RSVP count separately and sets it on the response. This keeps Event and RSVP domains loosely coupled.
**Alternatives considered**:
- Store count on the Event entity (denormalized) — rejected because it introduces update anomalies and requires synchronization logic.
- Join query in EventRepository — rejected because it couples Event persistence to RSVP schema.
## R-4: Expired Event Guard
**Decision**: Both client and server enforce the expiry guard. Client hides the RSVP form when `expired === true` (from GetEventResponse). Server rejects `POST /rsvps` with `409 Conflict` when `event.expiryDate < today`.
**Rationale**: Defense in depth. The client check is UX (don't show a form that can't succeed). The server check is the authoritative guard (clients can be bypassed). Using `409 Conflict` rather than `400 Bad Request` because the request format is valid — it's the event state that prevents the operation.
**Alternatives considered**:
- Client-only guard — rejected because clients can be bypassed.
- `400 Bad Request` — rejected because the request body is valid; the conflict is with the event's state.
- `422 Unprocessable Entity` — acceptable but `409` better communicates "the resource state conflicts with this operation."
## R-5: localStorage Schema for RSVP
**Decision**: Extend the existing `fete:events` localStorage structure. Each `StoredEvent` entry gains optional `rsvpToken` and `rsvpName` fields.
**Rationale**: The existing `useEventStorage` composable already stores events by token. Adding RSVP data to the same entry avoids a second localStorage key and keeps event data co-located. The spec requires storing: rsvpToken, name, event token, event title, event date — the last three are already in `StoredEvent`.
**Schema change**:
```typescript
interface StoredEvent {
eventToken: string
organizerToken?: string // existing (for organizers)
title: string // existing
dateTime: string // existing
expiryDate: string // existing
rsvpToken?: string // NEW — present after RSVP
rsvpName?: string // NEW — guest's submitted name
}
```
**Alternatives considered**:
- Separate `fete:rsvps` localStorage key — rejected because it duplicates event metadata (title, date) and complicates lookups.
- IndexedDB — rejected (over-engineering for a few KBs of data).
## R-6: Bottom Sheet UI Pattern
**Decision**: Implement the bottom sheet as a Vue component using CSS transforms and transitions. No UI library dependency.
**Rationale**: The spec requires a bottom sheet for the RSVP form. A custom implementation using `transform: translateY()` with CSS transitions is lightweight, accessible, and avoids new dependencies (Principle V). The sheet slides up from the bottom on open and back down on close.
**Key implementation details**:
- Overlay backdrop (semi-transparent) with click-to-dismiss
- `<dialog>` element or ARIA `role="dialog"` with `aria-modal="true"`
- Focus trap inside the sheet (keyboard accessibility)
- ESC key to close
- Transition: `transform 0.3s ease-out`
- Mobile: full-width, max-height ~50vh. Desktop: full-width within the 480px column.
**Alternatives considered**:
- Modal/dialog instead of bottom sheet — rejected because bottom sheets are the mobile-native pattern for contextual actions.
- Headless UI library (e.g., @headlessui/vue) — rejected because it adds a dependency for a single component. Custom implementation is ~50 lines.
## R-7: Sticky Bottom Bar
**Decision**: The sticky bar is a `position: fixed` element at the bottom of the viewport, within the content column (max 480px). It contains either the RSVP CTA button or the RSVP status text.
**Rationale**: The spec defines two states for the bar:
1. **No RSVP**: Shows CTA button (accent color, "Ich bin dabei!" or similar)
2. **Has RSVP**: Shows status text ("Du kommst!" + edit hint, though edit is out of scope)
The bar state is determined by checking localStorage for an rsvpToken for the current event.
**CSS pattern**:
```css
.sticky-bar {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 480px; /* matches content column */
padding: 1rem 1.2rem;
/* glass-morphism or solid surface */
}
```
**Alternatives considered**:
- `position: sticky` at bottom of content — rejected because it only sticks within the scroll container, not the viewport. Fixed is needed for always-visible CTA.
## R-8: Cancelled Event Guard (Deferred)
**Decision**: FR-010 (cancelled event guard) is deferred per spec. The code will NOT check for cancellation status. This will be added when US-18 (cancel event) is implemented.
**Rationale**: No cancellation field exists on the Event model yet. Adding a guard for a non-existent state violates KISS (Principle IV).

Some files were not shown because too many files have changed in this diff Show More