52 Commits

Author SHA1 Message Date
Renovate Bot
a44b938f08 Update oxlint monorepo
All checks were successful
CI / backend-test (push) Successful in 57s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m12s
CI / build-and-publish (push) Has been skipped
2026-03-12 16:03:22 +00:00
e5b71f8fb8 Merge pull request 'Update oxlint monorepo' (#28) from renovate/oxlint-monorepo into master
All checks were successful
CI / backend-test (push) Successful in 55s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m9s
CI / build-and-publish (push) Has been skipped
Reviewed-on: #28
2026-03-12 10:10:22 +01:00
Renovate Bot
60649ae4de Update oxlint monorepo
All checks were successful
CI / backend-test (push) Successful in 56s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m10s
CI / build-and-publish (push) Has been skipped
2026-03-12 07:01:59 +00:00
e90aefae15 Merge pull request 'Update dependency oxlint to ~1.53.0' (#27) from renovate/oxlint-monorepo into master
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m12s
CI / build-and-publish (push) Has been skipped
Reviewed-on: #27
2026-03-11 14:02:07 +01:00
Renovate Bot
622932418d Update dependency oxlint to ~1.53.0
All checks were successful
CI / backend-test (push) Successful in 57s
CI / frontend-test (push) Successful in 24s
CI / frontend-e2e (push) Successful in 1m11s
CI / build-and-publish (push) Has been skipped
2026-03-11 06:02:47 +00:00
a1855ff8d6 Merge pull request 'Auto-delete expired events via daily scheduled job' (#26) from 013-auto-delete-expired into master
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) Successful in 1m8s
2026-03-09 22:01:29 +01:00
4bfaee685c Auto-delete expired events via daily scheduled cleanup job
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m10s
CI / build-and-publish (push) Has been skipped
Adds a Spring @Scheduled job (daily at 03:00) that deletes all events
whose expiry_date is before CURRENT_DATE using a native SQL DELETE.
RSVPs are cascade-deleted via the existing FK constraint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:58:35 +01:00
2a6a658df9 Merge pull request 'Make expiryDate an internal concern, auto-set to event date + 7 days' (#25) from auto-expiry-date into master
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m9s
CI / build-and-publish (push) Has been skipped
2026-03-09 21:33:43 +01:00
37d378ca59 Merge pull request 'Update dependency @vitest/eslint-plugin to v1.6.10' (#22) from renovate/vitest-eslint-plugin-1.x-lockfile into master
Some checks failed
CI / backend-test (push) Successful in 58s
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
CI / frontend-test (push) Has been cancelled
Reviewed-on: #22
2026-03-09 21:30:28 +01:00
0441ca0c33 Make expiryDate an internal concern, auto-set to event date + 7 days
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m12s
CI / build-and-publish (push) Has been skipped
The expiry date is no longer user-facing: it is removed from the API
(request and response) and the frontend. The backend now automatically
calculates it as the event date plus 7 days. The expired banner and
RSVP-bar filtering by expired status are also removed from the UI,
since expiry is purely an internal data-retention mechanism.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:29:12 +01:00
Renovate Bot
e6711b33d4 Update dependency @vitest/eslint-plugin to v1.6.10
All checks were successful
CI / backend-test (push) Successful in 1m0s
CI / frontend-test (push) Successful in 30s
CI / frontend-e2e (push) Successful in 1m11s
CI / build-and-publish (push) Has been skipped
2026-03-09 20:02:48 +00:00
6b3a06a72c Add OG banner and mobile screenshots to README
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 36s
CI / frontend-e2e (push) Successful in 1m28s
CI / build-and-publish (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:00:41 +01:00
448e801ca3 Merge pull request 'Add Open Graph and Twitter Card meta-tags for link previews' (#24) from 012-link-preview into master
All checks were successful
CI / backend-test (push) Successful in 1m0s
CI / frontend-test (push) Successful in 34s
CI / frontend-e2e (push) Successful in 1m13s
CI / build-and-publish (push) Successful in 1m0s
2026-03-09 20:30:10 +01:00
751201617d Add Open Graph and Twitter Card meta-tags for link previews
All checks were successful
CI / backend-test (push) Successful in 1m9s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m12s
CI / build-and-publish (push) Has been skipped
Replace PathResourceResolver SPA fallback with SpaController that
injects OG/Twitter meta-tags into cached index.html template.
Event pages get event-specific tags (title, date, location),
all other pages get generic fete branding. Includes og-image.png
brand asset and forward-headers-strategy for proxy support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:25:39 +01:00
fa34223c10 Add tada emoji as SVG favicon
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 24s
CI / frontend-e2e (push) Successful in 1m12s
CI / build-and-publish (push) Successful in 1m2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 19:26:38 +01:00
e6ea9405a6 Merge pull request 'Apply glassmorphism design system across all UI surfaces' (#23) from glassmorphism-event-cards into master
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 24s
CI / frontend-e2e (push) Successful in 1m10s
CI / build-and-publish (push) Successful in 1m9s
2026-03-09 19:11:52 +01:00
32f96e4c6f Replace hardcoded color values with glass design tokens
All checks were successful
CI / backend-test (push) Successful in 1m0s
CI / frontend-test (push) Successful in 25s
CI / frontend-e2e (push) Successful in 1m13s
CI / build-and-publish (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 19:07:43 +01:00
e6c4a21f65 Apply glassmorphism to ConfirmDialog overlay and surface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 19:00:39 +01:00
831ffc071a Apply glassmorphism to BottomSheet and RSVP bar status
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:57:30 +01:00
5dd7cb3fb8 Add animated glow border to RSVP CTA button
Wrap the "I'm attending" button with animated glow-border and
glass-inner styling. Update test selectors for new structure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:51:21 +01:00
64816558c1 Apply glass utility class to form fields and buttons
Use .glass class on form fields and buttons on gradient backgrounds.
Buttons get gradient glow border via background-clip trick. Solid
white fallback preserved for BottomSheet context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:47:57 +01:00
019ead7be3 Extract glass system into shared CSS utilities and design tokens
Centralize all hardcoded rgba color values into CSS custom properties
and extract glass/glow styles into reusable utility classes (.glass,
.glass-inner, .glow-border, .glow-border--animated) in main.css.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:35:36 +01:00
29974704d0 Apply glassmorphism to meta icon boxes on event detail view
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:22:39 +01:00
877c869a22 Restyle FAB with glass effect and static glow border
Replace solid orange FAB with glassmorphism inner and a conic
gradient border (pink-purple-indigo) with subtle glow halo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:20:50 +01:00
a9743025a7 Fix hero image transition on event detail page
Replace hard-edged color overlay with CSS mask-image fade-out and
increase hero height to 420px for a seamless blend into the aurora
mesh background.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:13:57 +01:00
9f82275c63 Replace linear gradient background with aurora mesh gradient
Use layered radial gradients on a dark base (#1B1730) with
backdrop blur for an organic, aurora-like background effect
that better complements the glassmorphism event cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:01:46 +01:00
e203ecf687 Apply glassmorphism styling to event cards on list view
Replace solid white event cards with glass-effect cards featuring
backdrop blur, semi-transparent gradient backgrounds, and light
borders that blend with the Electric Dusk gradient background.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 17:50:20 +01:00
aa3ea04bfc Merge pull request 'Update dependency vue to v3.5.30' (#21) from renovate/vue-monorepo into master
All checks were successful
CI / backend-test (push) Successful in 57s
CI / frontend-test (push) Successful in 24s
CI / frontend-e2e (push) Successful in 1m13s
CI / build-and-publish (push) Has been skipped
Reviewed-on: #21
2026-03-09 17:25:42 +01:00
Renovate Bot
27ca8ab4b8 Update dependency vue to v3.5.30
All checks were successful
CI / backend-test (push) Successful in 1m2s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m10s
CI / build-and-publish (push) Has been skipped
2026-03-09 11:17:01 +00:00
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
156 changed files with 9597 additions and 2236 deletions

View File

@@ -26,7 +26,7 @@ PASSED=""
# Run backend tests if Java sources changed # Run backend tests if Java sources changed
if [[ -n "$HAS_BACKEND" ]]; then 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. " PASSED+="✓ Backend tests passed. "
else else
# Filter: only [ERROR] lines, skip Maven boilerplate # 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

@@ -79,6 +79,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Parse SemVer tag - name: Parse SemVer tag
id: semver id: semver
@@ -114,3 +116,22 @@ jobs:
docker push "${IMAGE}:${{ steps.semver.outputs.minor }}" docker push "${IMAGE}:${{ steps.semver.outputs.minor }}"
docker push "${IMAGE}:${{ steps.semver.outputs.major }}" docker push "${IMAGE}:${{ steps.semver.outputs.major }}"
docker push "${IMAGE}:latest" 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 }}

View File

@@ -33,6 +33,7 @@ Person erstellt via App eine Veranstaltung und schickt seine Freunden irgendwie
* Updaten der Veranstaltung * Updaten der Veranstaltung
* Einsicht angemeldete Gäste, kann bei Bedarf Einträge entfernen * Einsicht angemeldete Gäste, kann bei Bedarf Einträge entfernen
* Featureideen: * 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. * 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 * 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 * Änderungen zum ursprünglichen Inhalt (z.b. geändertes datum/ort) werden iwi hervorgehoben
@@ -40,6 +41,8 @@ Person erstellt via App eine Veranstaltung und schickt seine Freunden irgendwie
* QR Code generieren (z.B. für Plakate/Flyer) * QR Code generieren (z.B. für Plakate/Flyer)
* Ablaufdatum als Pflichtfeld, nach dem alle gespeicherten Daten gelöscht werden * Ablaufdatum als Pflichtfeld, nach dem alle gespeicherten Daten gelöscht werden
* Übersichtsliste im LocalStorage: Alle Events die man zugesagt oder gemerkt hat (vgl. spliit) * Ü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: * Sicherheit/Missbrauchsschutz:
* Nicht-erratbare Event-Tokens (z.B. UUIDs) * Nicht-erratbare Event-Tokens (z.B. UUIDs)
* Event-Erstellung ist offen, kein Login/Passwort/Invite-Code nötig * Event-Erstellung ist offen, kein Login/Passwort/Invite-Code nötig
@@ -79,3 +82,113 @@ Die folgenden Punkte wurden in Diskussionen bereits geklärt und sind verbindlic
* Frontend: Vue 3 (mit Vite als Bundler, TypeScript, Vue Router) * Frontend: Vue 3 (mit Vite als Bundler, TypeScript, Vue Router)
* Architekturentscheidungen die NOCH NICHT getroffen wurden (hier darf nichts eigenmächtig entschieden werden!): * Architekturentscheidungen die NOCH NICHT getroffen wurden (hier darf nichts eigenmächtig entschieden werden!):
* (derzeit keine offenen Architekturentscheidungen) * (derzeit keine offenen Architekturentscheidungen)
## Nicht umgesetzte Feature-Ideen (ehemals Specs 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

@@ -0,0 +1,37 @@
# Modern UI Effects Research (2025-2026)
## Liquid Glass (Apple WWDC 2025)
Evolved glassmorphism with directional lighting. Three-layer approach: highlight, shadow, illumination.
- `backdrop-filter: blur(20px) saturate(1.5)` — higher saturation than basic glass
- `inset 0 1px 0 rgba(255,255,255,0.15)` — top highlight (light direction)
- `inset 0 -1px 0 rgba(0,0,0,0.1)` — bottom shadow
- Outer drop shadow for depth: `0 8px 32px rgba(0,0,0,0.3)`
- Advanced: SVG `feTurbulence` + `feSpecularLighting` for refraction (Chromium only)
- Browser support: `backdrop-filter` ~88%, Firefox since v103
## Aurora / Gradient Mesh Backgrounds
Stacked animated radial gradients simulating northern lights. Pairs well with glass cards on dark backgrounds.
- Multiple `radial-gradient(ellipse ...)` layers with partial opacity
- Animated via `background-position` shift (GPU-friendly)
- `@property` rule enables direct gradient color animation (broad support since 2024)
- Best for ambient background movement, not for content areas
## Animated Glow Borders
Rotating `conic-gradient` borders with blur halo. Striking on dark backgrounds.
- Outer wrapper with `conic-gradient(from var(--angle), color1, color2, color3, color1)`
- `::before` pseudo with `filter: blur(12px)` and `opacity: 0.5` for glow halo
- `@property --angle` trick to animate custom property inside `conic-gradient`
- Use sparingly — best for single highlight elements (FAB, CTA), not all cards
## Modern Neumorphism (2025-2026 revision)
Subtler than the original trend. Higher contrast, less extreme extrusion, combined with accent colors.
- Light and dark shadow pair: `6px 6px 12px rgba(0,0,0,0.5)` + `-6px -6px 12px rgba(60,50,80,0.15)`
- `border: 1px solid rgba(255,255,255,0.05)` for definition
- Works on dark backgrounds with slightly lighter "uplift" shadow direction
- Better suited for interactive elements (buttons, toggles) than content cards
## Sources
- Apple Liquid Glass CSS: dev.to/gruszdev, dev.to/kevinbism, css-tricks.com, kube.io
- Aurora: dev.to/oobleck, daltonwalsh.com, github.com/mattnewdavid
- Glow borders: frontendmasters.com (Kevin Powell), docode.co.in
- Trends overview: medium.com/design-bootcamp, index.dev, bighuman.com

View File

@@ -53,6 +53,10 @@ The following skills are available and should be used for their respective purpo
## Active Technologies ## Active Technologies
- Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript (007-view-event) - Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript (007-view-event)
- PostgreSQL (JPA via Spring Data, Liquibase migrations) (007-view-event) - PostgreSQL (JPA via Spring Data, Liquibase migrations) (007-view-event)
- TypeScript 5.9 (frontend only) + Vue 3, Vue Router 5 (existing — no additions) (010-event-list-grouping)
- localStorage via `useEventStorage.ts` composable (existing — no changes) (010-event-list-grouping)
- Java 25, Spring Boot 3.5.x + Spring Scheduling (`@Scheduled`), Spring Data JPA (for native query) (013-auto-delete-expired)
- PostgreSQL (existing, Liquibase migrations) (013-auto-delete-expired)
## Recent Changes ## Recent Changes
- 007-view-event: Added Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript - 007-view-event: Added Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript

View File

@@ -1,6 +1,25 @@
# fete <p align="center">
<img src="frontend/public/og-image.png" alt="fete" width="100%" />
</p>
A privacy-focused, self-hostable web app for event announcements and RSVPs. An alternative to Facebook Events or Telegram groups — reduced to the essentials. <p align="center">
<strong>Privacy-focused, self-hostable event announcements and RSVPs.</strong><br>
An alternative to Facebook Events or Telegram groups — reduced to the essentials.
</p>
<p align="center">
<img src="docs/screenshots/01-create-event.png" alt="Create Event" width="230" />
&nbsp;&nbsp;&nbsp;
<img src="docs/screenshots/02-event-detail.png" alt="Event Detail" width="230" />
&nbsp;&nbsp;&nbsp;
<img src="docs/screenshots/03-rsvp.png" alt="RSVP" width="230" />
</p>
<p align="center">
<sub>Create events &middot; Share with guests &middot; Collect RSVPs</sub>
</p>
---
## What it does ## What it does

View File

@@ -7,4 +7,8 @@
<Match> <Match>
<Package name="de.fete.adapter.in.web.model"/> <Package name="de.fete.adapter.in.web.model"/>
</Match> </Match>
<!-- Constructor-injected Spring beans storing interfaces/proxies are not a real exposure risk -->
<Match>
<Bug pattern="EI_EXPOSE_REP2"/>
</Match>
</FindBugsFilter> </FindBugsFilter>

View File

@@ -2,9 +2,11 @@ package de.fete;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
/** Spring Boot entry point for the fete application. */ /** Spring Boot entry point for the fete application. */
@SpringBootApplication @SpringBootApplication
@EnableScheduling
public class FeteApplication { public class FeteApplication {
/** Starts the application. */ /** Starts the application. */

View File

@@ -1,19 +1,28 @@
package de.fete.adapter.in.web; package de.fete.adapter.in.web;
import de.fete.adapter.in.web.api.EventsApi; import de.fete.adapter.in.web.api.EventsApi;
import de.fete.adapter.in.web.model.Attendee;
import de.fete.adapter.in.web.model.CreateEventRequest; import de.fete.adapter.in.web.model.CreateEventRequest;
import de.fete.adapter.in.web.model.CreateEventResponse; import de.fete.adapter.in.web.model.CreateEventResponse;
import de.fete.adapter.in.web.model.CreateRsvpRequest;
import de.fete.adapter.in.web.model.CreateRsvpResponse;
import de.fete.adapter.in.web.model.GetAttendeesResponse;
import de.fete.adapter.in.web.model.GetEventResponse; import de.fete.adapter.in.web.model.GetEventResponse;
import de.fete.application.service.EventNotFoundException; import de.fete.application.service.EventNotFoundException;
import de.fete.application.service.InvalidTimezoneException; import de.fete.application.service.InvalidTimezoneException;
import de.fete.domain.model.CreateEventCommand; import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event; import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.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.CreateEventUseCase;
import de.fete.domain.port.in.CreateRsvpUseCase;
import de.fete.domain.port.in.GetAttendeesUseCase;
import de.fete.domain.port.in.GetEventUseCase; import de.fete.domain.port.in.GetEventUseCase;
import java.time.Clock;
import java.time.DateTimeException; import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -25,16 +34,22 @@ public class EventController implements EventsApi {
private final CreateEventUseCase createEventUseCase; private final CreateEventUseCase createEventUseCase;
private final GetEventUseCase getEventUseCase; private final GetEventUseCase getEventUseCase;
private final Clock clock; private final CreateRsvpUseCase createRsvpUseCase;
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
private final GetAttendeesUseCase getAttendeesUseCase;
/** Creates a new controller with the given use cases and clock. */ /** Creates a new controller with the given use cases. */
public EventController( public EventController(
CreateEventUseCase createEventUseCase, CreateEventUseCase createEventUseCase,
GetEventUseCase getEventUseCase, GetEventUseCase getEventUseCase,
Clock clock) { CreateRsvpUseCase createRsvpUseCase,
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
GetAttendeesUseCase getAttendeesUseCase) {
this.createEventUseCase = createEventUseCase; this.createEventUseCase = createEventUseCase;
this.getEventUseCase = getEventUseCase; this.getEventUseCase = getEventUseCase;
this.clock = clock; this.createRsvpUseCase = createRsvpUseCase;
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
this.getAttendeesUseCase = getAttendeesUseCase;
} }
@Override @Override
@@ -47,42 +62,72 @@ public class EventController implements EventsApi {
request.getDescription(), request.getDescription(),
request.getDateTime(), request.getDateTime(),
zoneId, zoneId,
request.getLocation(), request.getLocation()
request.getExpiryDate()
); );
Event event = createEventUseCase.createEvent(command); Event event = createEventUseCase.createEvent(command);
var response = new CreateEventResponse(); var response = new CreateEventResponse();
response.setEventToken(event.getEventToken()); response.setEventToken(event.getEventToken().value());
response.setOrganizerToken(event.getOrganizerToken()); response.setOrganizerToken(event.getOrganizerToken().value());
response.setTitle(event.getTitle()); response.setTitle(event.getTitle());
response.setDateTime(event.getDateTime()); response.setDateTime(event.getDateTime());
response.setTimezone(event.getTimezone().getId()); response.setTimezone(event.getTimezone().getId());
response.setExpiryDate(event.getExpiryDate());
return ResponseEntity.status(HttpStatus.CREATED).body(response); return ResponseEntity.status(HttpStatus.CREATED).body(response);
} }
@Override @Override
public ResponseEntity<GetEventResponse> getEvent(UUID token) { public ResponseEntity<GetEventResponse> getEvent(UUID token) {
Event event = getEventUseCase.getByEventToken(token) var eventToken = new de.fete.domain.model.EventToken(token);
Event event = getEventUseCase.getByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(token)); .orElseThrow(() -> new EventNotFoundException(token));
var response = new GetEventResponse(); var response = new GetEventResponse();
response.setEventToken(event.getEventToken()); response.setEventToken(event.getEventToken().value());
response.setTitle(event.getTitle()); response.setTitle(event.getTitle());
response.setDescription(event.getDescription()); response.setDescription(event.getDescription());
response.setDateTime(event.getDateTime()); response.setDateTime(event.getDateTime());
response.setTimezone(event.getTimezone().getId()); response.setTimezone(event.getTimezone().getId());
response.setLocation(event.getLocation()); response.setLocation(event.getLocation());
response.setAttendeeCount(0); response.setAttendeeCount(
response.setExpired( (int) countAttendeesByEventUseCase.countByEvent(eventToken));
event.getExpiryDate().isBefore(LocalDate.now(clock)));
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
@Override
public ResponseEntity<GetAttendeesResponse> getAttendees(
UUID token, UUID organizerToken) {
var eventToken = new EventToken(token);
var orgToken = new OrganizerToken(organizerToken);
List<String> names = getAttendeesUseCase
.getAttendeeNames(eventToken, orgToken);
var attendees = names.stream()
.map(name -> new Attendee().name(name))
.toList();
var response = new GetAttendeesResponse();
response.setAttendees(attendees);
return ResponseEntity.ok(response);
}
@Override
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) { private static ZoneId parseTimezone(String timezone) {
try { try {
return ZoneId.of(timezone); return ZoneId.of(timezone);

View File

@@ -1,7 +1,10 @@
package de.fete.adapter.in.web; package de.fete.adapter.in.web;
import de.fete.application.service.EventExpiredException;
import de.fete.application.service.EventNotFoundException; import de.fete.application.service.EventNotFoundException;
import de.fete.application.service.ExpiryDateBeforeEventException;
import de.fete.application.service.ExpiryDateInPastException; import de.fete.application.service.ExpiryDateInPastException;
import de.fete.application.service.InvalidOrganizerTokenException;
import de.fete.application.service.InvalidTimezoneException; import de.fete.application.service.InvalidTimezoneException;
import java.net.URI; import java.net.URI;
import java.util.List; import java.util.List;
@@ -46,6 +49,19 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
return handleExceptionInternal(ex, problemDetail, headers, status, request); 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. */ /** Handles expiry date validation failures. */
@ExceptionHandler(ExpiryDateInPastException.class) @ExceptionHandler(ExpiryDateInPastException.class)
public ResponseEntity<ProblemDetail> handleExpiryDateInPast( public ResponseEntity<ProblemDetail> handleExpiryDateInPast(
@@ -59,6 +75,32 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
.body(problemDetail); .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. */ /** Handles event not found. */
@ExceptionHandler(EventNotFoundException.class) @ExceptionHandler(EventNotFoundException.class)
public ResponseEntity<ProblemDetail> handleEventNotFound( public ResponseEntity<ProblemDetail> handleEventNotFound(

View File

@@ -0,0 +1,188 @@
package de.fete.adapter.in.web;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.port.in.GetEventUseCase;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
/** Serves the SPA index.html with injected Open Graph and Twitter Card meta-tags. */
@Controller
public class SpaController {
private static final String PLACEHOLDER = "<!-- OG_META_TAGS -->";
private static final int MAX_TITLE_LENGTH = 70;
private static final int MAX_DESCRIPTION_LENGTH = 200;
private static final String GENERIC_TITLE = "fete";
private static final String GENERIC_DESCRIPTION =
"Privacy-focused event planning. Create and share events without accounts.";
private static final DateTimeFormatter DATE_FORMAT =
DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy 'at' h:mm a", Locale.ENGLISH);
private final GetEventUseCase getEventUseCase;
private String htmlTemplate;
/** Creates a new SpaController. */
public SpaController(GetEventUseCase getEventUseCase) {
this.getEventUseCase = getEventUseCase;
}
/** Loads and caches the index.html template at startup. */
@PostConstruct
void loadTemplate() throws IOException {
var resource = new ClassPathResource("/static/index.html");
if (resource.exists()) {
htmlTemplate = resource.getContentAsString(StandardCharsets.UTF_8);
}
}
/** Serves SPA HTML with generic meta-tags for non-event routes. */
@GetMapping(
value = {"/", "/create", "/events"},
produces = MediaType.TEXT_HTML_VALUE
)
@ResponseBody
public String serveGenericPage(HttpServletRequest request) {
if (htmlTemplate == null) {
return "";
}
String baseUrl = getBaseUrl(request);
return htmlTemplate.replace(PLACEHOLDER, renderTags(buildGenericMeta(baseUrl)));
}
/** Serves SPA HTML with event-specific meta-tags. */
@GetMapping(
value = "/events/{token}",
produces = MediaType.TEXT_HTML_VALUE
)
@ResponseBody
public String serveEventPage(@PathVariable String token,
HttpServletRequest request) {
if (htmlTemplate == null) {
return "";
}
String baseUrl = getBaseUrl(request);
Map<String, String> meta = resolveEventMeta(token, baseUrl);
return htmlTemplate.replace(PLACEHOLDER, renderTags(meta));
}
// --- Meta-tag composition ---
private Map<String, String> buildEventMeta(Event event, String baseUrl) {
var tags = new LinkedHashMap<String, String>();
String title = truncateTitle(event.getTitle());
String description = formatDescription(event);
tags.put("og:title", title);
tags.put("og:description", description);
tags.put("og:url", baseUrl + "/events/" + event.getEventToken().value());
tags.put("og:type", "website");
tags.put("og:site_name", GENERIC_TITLE);
tags.put("og:image", baseUrl + "/og-image.png");
tags.put("twitter:card", "summary");
tags.put("twitter:title", title);
tags.put("twitter:description", description);
return tags;
}
private Map<String, String> buildGenericMeta(String baseUrl) {
var tags = new LinkedHashMap<String, String>();
tags.put("og:title", GENERIC_TITLE);
tags.put("og:description", GENERIC_DESCRIPTION);
tags.put("og:url", baseUrl);
tags.put("og:type", "website");
tags.put("og:site_name", GENERIC_TITLE);
tags.put("og:image", baseUrl + "/og-image.png");
tags.put("twitter:card", "summary");
tags.put("twitter:title", GENERIC_TITLE);
tags.put("twitter:description", GENERIC_DESCRIPTION);
return tags;
}
private Map<String, String> resolveEventMeta(String token, String baseUrl) {
try {
UUID uuid = UUID.fromString(token);
Optional<Event> event =
getEventUseCase.getByEventToken(new EventToken(uuid));
if (event.isPresent()) {
return buildEventMeta(event.get(), baseUrl);
}
} catch (IllegalArgumentException ignored) {
// Invalid UUID — fall back to generic
}
return buildGenericMeta(baseUrl);
}
// --- Description formatting ---
private String truncateTitle(String title) {
if (title.length() <= MAX_TITLE_LENGTH) {
return title;
}
return title.substring(0, MAX_TITLE_LENGTH - 3) + "...";
}
private String formatDescription(Event event) {
ZonedDateTime zoned = event.getDateTime().atZoneSameInstant(event.getTimezone());
var sb = new StringBuilder();
sb.append("📅 ").append(zoned.format(DATE_FORMAT));
if (event.getLocation() != null && !event.getLocation().isBlank()) {
sb.append(" · 📍 ").append(event.getLocation());
}
if (event.getDescription() != null && !event.getDescription().isBlank()) {
sb.append("").append(event.getDescription());
}
String result = sb.toString();
if (result.length() > MAX_DESCRIPTION_LENGTH) {
return result.substring(0, MAX_DESCRIPTION_LENGTH - 3) + "...";
}
return result;
}
// --- HTML rendering ---
private String renderTags(Map<String, String> tags) {
var sb = new StringBuilder();
for (var entry : tags.entrySet()) {
String key = entry.getKey();
String value = escapeHtml(entry.getValue());
String attr = key.startsWith("twitter:") ? "name" : "property";
sb.append("<meta ").append(attr).append("=\"").append(key)
.append("\" content=\"").append(value).append("\">\n");
}
return sb.toString().stripTrailing();
}
private String escapeHtml(String input) {
return input
.replace("&", "&amp;")
.replace("\"", "&quot;")
.replace("<", "&lt;")
.replace(">", "&gt;");
}
private String getBaseUrl(HttpServletRequest request) {
return ServletUriComponentsBuilder.fromRequestUri(request)
.replacePath("")
.build()
.toUriString();
}
}

View File

@@ -3,10 +3,17 @@ package de.fete.adapter.out.persistence;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
/** Spring Data JPA repository for event entities. */ /** Spring Data JPA repository for event entities. */
public interface EventJpaRepository extends JpaRepository<EventJpaEntity, Long> { public interface EventJpaRepository extends JpaRepository<EventJpaEntity, Long> {
/** Finds an event by its public event token. */ /** Finds an event by its public event token. */
Optional<EventJpaEntity> findByEventToken(UUID eventToken); Optional<EventJpaEntity> findByEventToken(UUID eventToken);
/** Deletes all events whose expiry date is before today. Returns the number of deleted rows. */
@Modifying
@Query(value = "DELETE FROM events WHERE expiry_date < CURRENT_DATE", nativeQuery = true)
int deleteExpired();
} }

View File

@@ -1,10 +1,11 @@
package de.fete.adapter.out.persistence; package de.fete.adapter.out.persistence;
import de.fete.domain.model.Event; 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 de.fete.domain.port.out.EventRepository;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
/** Persistence adapter implementing the EventRepository outbound port. */ /** Persistence adapter implementing the EventRepository outbound port. */
@@ -26,15 +27,20 @@ public class EventPersistenceAdapter implements EventRepository {
} }
@Override @Override
public Optional<Event> findByEventToken(UUID eventToken) { public Optional<Event> findByEventToken(EventToken eventToken) {
return jpaRepository.findByEventToken(eventToken).map(this::toDomain); return jpaRepository.findByEventToken(eventToken.value()).map(this::toDomain);
}
@Override
public int deleteExpired() {
return jpaRepository.deleteExpired();
} }
private EventJpaEntity toEntity(Event event) { private EventJpaEntity toEntity(Event event) {
var entity = new EventJpaEntity(); var entity = new EventJpaEntity();
entity.setId(event.getId()); entity.setId(event.getId());
entity.setEventToken(event.getEventToken()); entity.setEventToken(event.getEventToken().value());
entity.setOrganizerToken(event.getOrganizerToken()); entity.setOrganizerToken(event.getOrganizerToken().value());
entity.setTitle(event.getTitle()); entity.setTitle(event.getTitle());
entity.setDescription(event.getDescription()); entity.setDescription(event.getDescription());
entity.setDateTime(event.getDateTime()); entity.setDateTime(event.getDateTime());
@@ -48,8 +54,8 @@ public class EventPersistenceAdapter implements EventRepository {
private Event toDomain(EventJpaEntity entity) { private Event toDomain(EventJpaEntity entity) {
var event = new Event(); var event = new Event();
event.setId(entity.getId()); event.setId(entity.getId());
event.setEventToken(entity.getEventToken()); event.setEventToken(new EventToken(entity.getEventToken()));
event.setOrganizerToken(entity.getOrganizerToken()); event.setOrganizerToken(new OrganizerToken(entity.getOrganizerToken()));
event.setTitle(entity.getTitle()); event.setTitle(entity.getTitle());
event.setDescription(entity.getDescription()); event.setDescription(entity.getDescription());
event.setDateTime(entity.getDateTime()); event.setDateTime(entity.getDateTime());

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

@@ -2,6 +2,8 @@ package de.fete.application.service;
import de.fete.domain.model.CreateEventCommand; import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event; import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
import de.fete.domain.port.in.CreateEventUseCase; import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.in.GetEventUseCase; import de.fete.domain.port.in.GetEventUseCase;
import de.fete.domain.port.out.EventRepository; import de.fete.domain.port.out.EventRepository;
@@ -9,13 +11,14 @@ import java.time.Clock;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/** Application service implementing event creation and retrieval. */ /** Application service implementing event creation and retrieval. */
@Service @Service
public class EventService implements CreateEventUseCase, GetEventUseCase { public class EventService implements CreateEventUseCase, GetEventUseCase {
private static final int EXPIRY_DAYS_AFTER_EVENT = 7;
private final EventRepository eventRepository; private final EventRepository eventRepository;
private final Clock clock; private final Clock clock;
@@ -27,26 +30,24 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
@Override @Override
public Event createEvent(CreateEventCommand command) { public Event createEvent(CreateEventCommand command) {
if (!command.expiryDate().isAfter(LocalDate.now(clock))) { LocalDate expiryDate = command.dateTime().toLocalDate().plusDays(EXPIRY_DAYS_AFTER_EVENT);
throw new ExpiryDateInPastException(command.expiryDate());
}
var event = new Event(); var event = new Event();
event.setEventToken(UUID.randomUUID()); event.setEventToken(EventToken.generate());
event.setOrganizerToken(UUID.randomUUID()); event.setOrganizerToken(OrganizerToken.generate());
event.setTitle(command.title()); event.setTitle(command.title());
event.setDescription(command.description()); event.setDescription(command.description());
event.setDateTime(command.dateTime()); event.setDateTime(command.dateTime());
event.setTimezone(command.timezone()); event.setTimezone(command.timezone());
event.setLocation(command.location()); event.setLocation(command.location());
event.setExpiryDate(command.expiryDate()); event.setExpiryDate(expiryDate);
event.setCreatedAt(OffsetDateTime.now(clock)); event.setCreatedAt(OffsetDateTime.now(clock));
return eventRepository.save(event); return eventRepository.save(event);
} }
@Override @Override
public Optional<Event> getByEventToken(UUID eventToken) { public Optional<Event> getByEventToken(EventToken eventToken) {
return eventRepository.findByEventToken(eventToken); return eventRepository.findByEventToken(eventToken);
} }
} }

View File

@@ -0,0 +1,30 @@
package de.fete.application.service;
import de.fete.domain.port.out.EventRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/** Scheduled job that deletes events whose expiry date is in the past. */
@Component
public class ExpiredEventCleanupJob {
private static final Logger log = LoggerFactory.getLogger(ExpiredEventCleanupJob.class);
private final EventRepository eventRepository;
/** Creates a new cleanup job with the given event repository. */
public ExpiredEventCleanupJob(EventRepository eventRepository) {
this.eventRepository = eventRepository;
}
/** Runs daily at 03:00 and deletes all expired events. */
@Scheduled(cron = "0 0 3 * * *")
@Transactional
public void deleteExpiredEvents() {
int deleted = eventRepository.deleteExpired();
log.info("Expired event cleanup: deleted {} event(s)", deleted);
}
}

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,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

@@ -1,21 +1,17 @@
package de.fete.config; package de.fete.config;
import java.io.IOException;
import java.time.Clock; import java.time.Clock;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; import org.springframework.web.servlet.config.annotation.PathMatchConfigurer;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;
/** Configures API path prefix and SPA static resource serving. */ /** Configures API path prefix. Static resources served by default Spring Boot handler. */
@Configuration @Configuration
public class WebConfig implements WebMvcConfigurer { public class WebConfig implements WebMvcConfigurer {
/** Provides a system clock bean for time-dependent services. */
@Bean @Bean
Clock clock() { Clock clock() {
return Clock.systemDefaultZone(); return Clock.systemDefaultZone();
@@ -25,23 +21,4 @@ public class WebConfig implements WebMvcConfigurer {
public void configurePathMatch(PathMatchConfigurer configurer) { public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.addPathPrefix("/api", c -> c.isAnnotationPresent(RestController.class)); configurer.addPathPrefix("/api", c -> c.isAnnotationPresent(RestController.class));
} }
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/")
.resourceChain(true)
.addResolver(new PathResourceResolver() {
@Override
protected Resource getResource(String resourcePath,
Resource location) throws IOException {
Resource requested = location.createRelative(resourcePath);
if (requested.exists() && requested.isReadable()) {
return requested;
}
Resource index = new ClassPathResource("/static/index.html");
return (index.exists() && index.isReadable()) ? index : null;
}
});
}
} }

View File

@@ -10,6 +10,5 @@ public record CreateEventCommand(
String description, String description,
OffsetDateTime dateTime, OffsetDateTime dateTime,
ZoneId timezone, ZoneId timezone,
String location, String location
LocalDate expiryDate
) {} ) {}

View File

@@ -3,14 +3,13 @@ package de.fete.domain.model;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.UUID;
/** Domain entity representing an event. */ /** Domain entity representing an event. */
public class Event { public class Event {
private Long id; private Long id;
private UUID eventToken; private EventToken eventToken;
private UUID organizerToken; private OrganizerToken organizerToken;
private String title; private String title;
private String description; private String description;
private OffsetDateTime dateTime; private OffsetDateTime dateTime;
@@ -29,23 +28,23 @@ public class Event {
this.id = id; this.id = id;
} }
/** Returns the public event token (UUID). */ /** Returns the public event token. */
public UUID getEventToken() { public EventToken getEventToken() {
return eventToken; return eventToken;
} }
/** Sets the public event token. */ /** Sets the public event token. */
public void setEventToken(UUID eventToken) { public void setEventToken(EventToken eventToken) {
this.eventToken = eventToken; this.eventToken = eventToken;
} }
/** Returns the secret organizer token (UUID). */ /** Returns the secret organizer token. */
public UUID getOrganizerToken() { public OrganizerToken getOrganizerToken() {
return organizerToken; return organizerToken;
} }
/** Sets the secret organizer token. */ /** Sets the secret organizer token. */
public void setOrganizerToken(UUID organizerToken) { public void setOrganizerToken(OrganizerToken organizerToken) {
this.organizerToken = organizerToken; this.organizerToken = organizerToken;
} }

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

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

View File

@@ -1,8 +1,8 @@
package de.fete.domain.port.out; package de.fete.domain.port.out;
import de.fete.domain.model.Event; import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
/** Outbound port for persisting and retrieving events. */ /** Outbound port for persisting and retrieving events. */
public interface EventRepository { public interface EventRepository {
@@ -11,5 +11,8 @@ public interface EventRepository {
Event save(Event event); Event save(Event event);
/** Finds an event by its public event token. */ /** Finds an event by its public event token. */
Optional<Event> findByEventToken(UUID eventToken); Optional<Event> findByEventToken(EventToken eventToken);
/** Deletes all events whose expiry date is in the past. Returns the number of deleted events. */
int deleteExpired();
} }

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

@@ -7,6 +7,9 @@ spring.jpa.open-in-view=false
# Liquibase # Liquibase
spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml
# Proxy headers
server.forward-headers-strategy=framework
# Actuator # Actuator
management.endpoints.web.exposure.include=health management.endpoints.web.exposure.include=health
management.endpoint.health.show-details=never management.endpoint.health.show-details=never

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

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

View File

@@ -37,6 +37,93 @@ paths:
schema: schema:
$ref: "#/components/schemas/ValidationProblemDetail" $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}: /events/{token}:
get: get:
operationId: getEvent operationId: getEvent
@@ -73,7 +160,6 @@ components:
- title - title
- dateTime - dateTime
- timezone - timezone
- expiryDate
properties: properties:
title: title:
type: string type: string
@@ -94,11 +180,6 @@ components:
location: location:
type: string type: string
maxLength: 500 maxLength: 500
expiryDate:
type: string
format: date
description: Date after which event data is deleted. Must be in the future.
example: "2026-06-15"
CreateEventResponse: CreateEventResponse:
type: object type: object
@@ -108,7 +189,6 @@ components:
- title - title
- dateTime - dateTime
- timezone - timezone
- expiryDate
properties: properties:
eventToken: eventToken:
type: string type: string
@@ -131,10 +211,6 @@ components:
type: string type: string
description: IANA timezone of the organizer description: IANA timezone of the organizer
example: "Europe/Berlin" example: "Europe/Berlin"
expiryDate:
type: string
format: date
example: "2026-06-15"
GetEventResponse: GetEventResponse:
type: object type: object
@@ -144,7 +220,6 @@ components:
- dateTime - dateTime
- timezone - timezone
- attendeeCount - attendeeCount
- expired
properties: properties:
eventToken: eventToken:
type: string type: string
@@ -177,10 +252,58 @@ components:
minimum: 0 minimum: 0
description: Number of confirmed attendees (attending=true) description: Number of confirmed attendees (attending=true)
example: 12 example: 12
expired:
type: boolean CreateRsvpRequest:
description: Whether the event's expiry date has passed type: object
example: false 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: ProblemDetail:
type: object type: object

View File

@@ -60,4 +60,9 @@ class HexagonalArchitectureTest {
static final ArchRule persistenceMustNotDependOnWeb = noClasses() static final ArchRule persistenceMustNotDependOnWeb = noClasses()
.that().resideInAPackage("de.fete.adapter.out.persistence..") .that().resideInAPackage("de.fete.adapter.out.persistence..")
.should().dependOnClassesThat().resideInAPackage("de.fete.adapter.in.web.."); .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

@@ -11,8 +11,12 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import de.fete.TestcontainersConfig; import de.fete.TestcontainersConfig;
import de.fete.adapter.in.web.model.CreateEventRequest; import de.fete.adapter.in.web.model.CreateEventRequest;
import de.fete.adapter.in.web.model.CreateEventResponse; import de.fete.adapter.in.web.model.CreateEventResponse;
import de.fete.adapter.in.web.model.CreateRsvpRequest;
import de.fete.adapter.in.web.model.CreateRsvpResponse;
import de.fete.adapter.out.persistence.EventJpaEntity; import de.fete.adapter.out.persistence.EventJpaEntity;
import de.fete.adapter.out.persistence.EventJpaRepository; 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.LocalDate;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
@@ -39,6 +43,9 @@ class EventControllerIntegrationTest {
@Autowired @Autowired
private EventJpaRepository jpaRepository; private EventJpaRepository jpaRepository;
@Autowired
private RsvpJpaRepository rsvpJpaRepository;
// --- Create Event tests --- // --- Create Event tests ---
@Test @Test
@@ -48,8 +55,7 @@ class EventControllerIntegrationTest {
.description("Come celebrate!") .description("Come celebrate!")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Europe/Berlin") .timezone("Europe/Berlin")
.location("Berlin") .location("Berlin");
.expiryDate(LocalDate.now().plusDays(30));
var result = mockMvc.perform(post("/api/events") var result = mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -60,7 +66,6 @@ class EventControllerIntegrationTest {
.andExpect(jsonPath("$.title").value("Birthday Party")) .andExpect(jsonPath("$.title").value("Birthday Party"))
.andExpect(jsonPath("$.timezone").value("Europe/Berlin")) .andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
.andExpect(jsonPath("$.dateTime").isNotEmpty()) .andExpect(jsonPath("$.dateTime").isNotEmpty())
.andExpect(jsonPath("$.expiryDate").isNotEmpty())
.andReturn(); .andReturn();
var response = objectMapper.readValue( var response = objectMapper.readValue(
@@ -72,7 +77,7 @@ class EventControllerIntegrationTest {
assertThat(persisted.getDescription()).isEqualTo("Come celebrate!"); assertThat(persisted.getDescription()).isEqualTo("Come celebrate!");
assertThat(persisted.getTimezone()).isEqualTo("Europe/Berlin"); assertThat(persisted.getTimezone()).isEqualTo("Europe/Berlin");
assertThat(persisted.getLocation()).isEqualTo("Berlin"); assertThat(persisted.getLocation()).isEqualTo("Berlin");
assertThat(persisted.getExpiryDate()).isEqualTo(request.getExpiryDate()); assertThat(persisted.getExpiryDate()).isEqualTo(LocalDate.of(2026, 6, 22));
assertThat(persisted.getDateTime().toInstant()) assertThat(persisted.getDateTime().toInstant())
.isEqualTo(request.getDateTime().toInstant()); .isEqualTo(request.getDateTime().toInstant());
assertThat(persisted.getOrganizerToken()).isNotNull(); assertThat(persisted.getOrganizerToken()).isNotNull();
@@ -84,8 +89,7 @@ class EventControllerIntegrationTest {
var request = new CreateEventRequest() var request = new CreateEventRequest()
.title("Minimal Event") .title("Minimal Event")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("UTC") .timezone("UTC");
.expiryDate(LocalDate.now().plusDays(30));
var result = mockMvc.perform(post("/api/events") var result = mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -108,39 +112,9 @@ class EventControllerIntegrationTest {
@Test @Test
void createEventMissingTitleReturns400() throws Exception { void createEventMissingTitleReturns400() throws Exception {
long countBefore = jpaRepository.count();
var request = new CreateEventRequest() var request = new CreateEventRequest()
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Europe/Berlin")
.expiryDate(LocalDate.now().plusDays(30));
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.title").value("Validation Failed"))
.andExpect(jsonPath("$.fieldErrors").isArray());
}
@Test
void createEventMissingDateTimeReturns400() throws Exception {
var request = new CreateEventRequest()
.title("No Date")
.timezone("Europe/Berlin")
.expiryDate(LocalDate.now().plusDays(30));
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.fieldErrors").isArray());
}
@Test
void createEventMissingExpiryDateReturns400() throws Exception {
var request = new CreateEventRequest()
.title("No Expiry")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Europe/Berlin"); .timezone("Europe/Berlin");
@@ -149,39 +123,28 @@ class EventControllerIntegrationTest {
.content(objectMapper.writeValueAsString(request))) .content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.title").value("Validation Failed"))
.andExpect(jsonPath("$.fieldErrors").isArray()); .andExpect(jsonPath("$.fieldErrors").isArray());
assertThat(jpaRepository.count()).isEqualTo(countBefore);
} }
@Test @Test
void createEventExpiryDateInPastReturns400() throws Exception { void createEventMissingDateTimeReturns400() throws Exception {
long countBefore = jpaRepository.count();
var request = new CreateEventRequest() var request = new CreateEventRequest()
.title("Past Expiry") .title("No Date")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) .timezone("Europe/Berlin");
.timezone("Europe/Berlin")
.expiryDate(LocalDate.of(2025, 1, 1));
mockMvc.perform(post("/api/events") mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))) .content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past")); .andExpect(jsonPath("$.fieldErrors").isArray());
}
@Test assertThat(jpaRepository.count()).isEqualTo(countBefore);
void createEventExpiryDateTodayReturns400() throws Exception {
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(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
} }
@Test @Test
@@ -189,8 +152,7 @@ class EventControllerIntegrationTest {
var request = new CreateEventRequest() var request = new CreateEventRequest()
.title("") .title("")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Europe/Berlin") .timezone("Europe/Berlin");
.expiryDate(LocalDate.now().plusDays(30));
mockMvc.perform(post("/api/events") mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -201,11 +163,12 @@ class EventControllerIntegrationTest {
@Test @Test
void createEventWithInvalidTimezoneReturns400() throws Exception { void createEventWithInvalidTimezoneReturns400() throws Exception {
long countBefore = jpaRepository.count();
var request = new CreateEventRequest() var request = new CreateEventRequest()
.title("Bad TZ") .title("Bad TZ")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Not/A/Zone") .timezone("Not/A/Zone");
.expiryDate(LocalDate.now().plusDays(30));
mockMvc.perform(post("/api/events") mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -213,6 +176,8 @@ class EventControllerIntegrationTest {
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:invalid-timezone")); .andExpect(jsonPath("$.type").value("urn:problem-type:invalid-timezone"));
assertThat(jpaRepository.count()).isEqualTo(countBefore);
} }
// --- GET /events/{token} tests --- // --- GET /events/{token} tests ---
@@ -231,7 +196,6 @@ class EventControllerIntegrationTest {
.andExpect(jsonPath("$.timezone").value("Europe/Berlin")) .andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
.andExpect(jsonPath("$.location").value("Central Park")) .andExpect(jsonPath("$.location").value("Central Park"))
.andExpect(jsonPath("$.attendeeCount").value(0)) .andExpect(jsonPath("$.attendeeCount").value(0))
.andExpect(jsonPath("$.expired").value(false))
.andExpect(jsonPath("$.dateTime").isNotEmpty()); .andExpect(jsonPath("$.dateTime").isNotEmpty());
} }
@@ -256,16 +220,166 @@ class EventControllerIntegrationTest {
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found")); .andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
} }
@Test // --- RSVP tests ---
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())) @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(status().isOk())
.andExpect(jsonPath("$.title").value("Past Event")) .andExpect(jsonPath("$.attendees").isArray())
.andExpect(jsonPath("$.expired").value(true)); .andExpect(jsonPath("$.attendees.length()").value(2))
.andExpect(jsonPath("$.attendees[0].name").value("Alice"))
.andExpect(jsonPath("$.attendees[1].name").value("Bob"));
}
@Test
void getAttendeesReturnsEmptyListWhenNoRsvps() throws Exception {
EventJpaEntity event = seedEvent(
"Empty Party", null, "Europe/Berlin", null,
LocalDate.now().plusDays(30));
mockMvc.perform(get("/api/events/" + event.getEventToken()
+ "/attendees?organizerToken=" + event.getOrganizerToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.attendees").isArray())
.andExpect(jsonPath("$.attendees.length()").value(0));
}
@Test
void getAttendeesReturns403ForInvalidOrganizerToken() throws Exception {
EventJpaEntity event = seedEvent(
"Secret Party", null, "Europe/Berlin", null,
LocalDate.now().plusDays(30));
mockMvc.perform(get("/api/events/" + event.getEventToken()
+ "/attendees?organizerToken=" + UUID.randomUUID()))
.andExpect(status().isForbidden())
.andExpect(content().contentTypeCompatibleWith(
"application/problem+json"));
}
@Test
void getAttendeesReturns404ForUnknownEvent() throws Exception {
mockMvc.perform(get("/api/events/" + UUID.randomUUID()
+ "/attendees?organizerToken=" + UUID.randomUUID()))
.andExpect(status().isNotFound())
.andExpect(content().contentTypeCompatibleWith(
"application/problem+json"));
}
private void seedRsvp(EventJpaEntity event, String name) {
var rsvp = new RsvpJpaEntity();
rsvp.setRsvpToken(UUID.randomUUID());
rsvp.setEventId(event.getId());
rsvp.setName(name);
rsvpJpaRepository.save(rsvp);
} }
private EventJpaEntity seedEvent( private EventJpaEntity seedEvent(

View File

@@ -0,0 +1,281 @@
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.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import de.fete.TestcontainersConfig;
import de.fete.adapter.out.persistence.EventJpaEntity;
import de.fete.adapter.out.persistence.EventJpaRepository;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfig.class)
class SpaControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private EventJpaRepository eventJpaRepository;
// --- Phase 2: Base functionality ---
@Test
void rootServesHtml() throws Exception {
mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML));
}
@Test
void rootHtmlDoesNotContainPlaceholder() throws Exception {
String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).doesNotContain("<!-- OG_META_TAGS -->");
}
@Test
void createRouteServesHtml() throws Exception {
mockMvc.perform(get("/create").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML));
}
@Test
void eventsRouteServesHtml() throws Exception {
mockMvc.perform(get("/events").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML));
}
// --- Phase 4 (US2): Generic OG meta-tags ---
@Test
void rootContainsGenericOgTitle() throws Exception {
String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:title");
assertThat(html).contains("content=\"fete\"");
}
@Test
void createRouteContainsGenericOgDescription() throws Exception {
String html = mockMvc.perform(get("/create").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:description");
assertThat(html).contains("Privacy-focused event planning");
}
@Test
void unknownRouteReturns404() throws Exception {
mockMvc.perform(get("/unknown/path").accept(MediaType.TEXT_HTML))
.andExpect(status().isNotFound());
}
// --- Phase 5 (US3): Twitter Card meta-tags ---
@Test
void eventRouteContainsTwitterCardTags() throws Exception {
EventJpaEntity event = seedEvent(
"Twitter Test", "Testing cards",
"Europe/Berlin", "Berlin", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("twitter:card");
assertThat(html).contains("twitter:title");
assertThat(html).contains("twitter:description");
}
@Test
void genericRouteContainsTwitterCardTags() throws Exception {
String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("twitter:card");
assertThat(html).contains("content=\"summary\"");
}
// --- Phase 3 (US1): Event-specific OG meta-tags ---
@Test
void eventRouteContainsEventSpecificOgTitle() throws Exception {
EventJpaEntity event = seedEvent(
"Birthday Party", "Come celebrate!",
"Europe/Berlin", "Berlin", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:title");
assertThat(html).contains("Birthday Party");
}
@Test
void eventRouteContainsOgDescription() throws Exception {
EventJpaEntity event = seedEvent(
"BBQ", "Bring drinks!",
"Europe/Berlin", "Central Park", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:description");
assertThat(html).contains("Central Park");
assertThat(html).contains("Bring drinks!");
}
@Test
void eventRouteContainsOgUrl() throws Exception {
EventJpaEntity event = seedEvent(
"Party", null,
"Europe/Berlin", null, LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:url");
assertThat(html).contains("/events/" + event.getEventToken());
}
@Test
void eventRouteContainsOgImage() throws Exception {
EventJpaEntity event = seedEvent(
"Party", null,
"Europe/Berlin", null, LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:image");
assertThat(html).contains("/og-image.png");
}
@Test
void unknownEventTokenFallsBackToGenericMeta() throws Exception {
String html = mockMvc.perform(
get("/events/" + UUID.randomUUID()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:title");
assertThat(html).contains("content=\"fete\"");
}
// --- HTML escaping ---
@Test
void specialCharactersAreHtmlEscaped() throws Exception {
EventJpaEntity event = seedEvent(
"Tom & Jerry's \"Party\"", "Fun <times> & more",
"Europe/Berlin", "O'Brien's Pub", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("Tom &amp; Jerry");
assertThat(html).contains("&amp; more");
assertThat(html).contains("&lt;times&gt;");
assertThat(html).doesNotContain("content=\"Tom & Jerry");
}
// --- Title truncation ---
@Test
void longTitleIsTruncatedTo70Chars() throws Exception {
String longTitle = "A".repeat(80);
EventJpaEntity event = seedEvent(
longTitle, "Desc",
"Europe/Berlin", null, LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("A".repeat(67) + "...");
assertThat(html).doesNotContain("A".repeat(68));
}
// --- Description formatting ---
@Test
void eventWithoutLocationOmitsPinEmoji() throws Exception {
EventJpaEntity event = seedEvent(
"Online Meetup", "Virtual gathering",
"Europe/Berlin", null, LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).doesNotContain("📍");
}
@Test
void eventWithoutDescriptionOmitsDash() throws Exception {
EventJpaEntity event = seedEvent(
"Silent Event", null,
"Europe/Berlin", "Berlin", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("📅");
assertThat(html).contains("Berlin");
assertThat(html).doesNotContain("");
}
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 eventJpaRepository.save(entity);
}
}

View File

@@ -0,0 +1,81 @@
package de.fete.adapter.out.persistence;
import static org.assertj.core.api.Assertions.assertThat;
import de.fete.TestcontainersConfig;
import de.fete.domain.model.Event;
import de.fete.domain.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 org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.transaction.annotation.Transactional;
@SpringBootTest
@Import(TestcontainersConfig.class)
@Transactional
class EventPersistenceAdapterIntegrationTest {
@Autowired
private EventRepository eventRepository;
@Test
void deleteExpiredRemovesExpiredEvents() {
Event expired = buildEvent("Expired Party", LocalDate.now().minusDays(1));
eventRepository.save(expired);
int deleted = eventRepository.deleteExpired();
assertThat(deleted).isGreaterThanOrEqualTo(1);
}
@Test
void deleteExpiredKeepsNonExpiredEvents() {
Event future = buildEvent("Future Party", LocalDate.now().plusDays(30));
Event saved = eventRepository.save(future);
eventRepository.deleteExpired();
assertThat(eventRepository.findByEventToken(saved.getEventToken())).isPresent();
}
@Test
void deleteExpiredKeepsEventsExpiringToday() {
Event today = buildEvent("Today Party", LocalDate.now());
Event saved = eventRepository.save(today);
eventRepository.deleteExpired();
assertThat(eventRepository.findByEventToken(saved.getEventToken())).isPresent();
}
@Test
void deleteExpiredReturnsZeroWhenNoneExpired() {
// Only save a future event
buildEvent("Future Only", LocalDate.now().plusDays(60));
int deleted = eventRepository.deleteExpired();
assertThat(deleted).isGreaterThanOrEqualTo(0);
}
private Event buildEvent(String title, LocalDate expiryDate) {
var event = new Event();
event.setEventToken(EventToken.generate());
event.setOrganizerToken(OrganizerToken.generate());
event.setTitle(title);
event.setDescription("Test description");
event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
event.setTimezone(ZoneId.of("Europe/Berlin"));
event.setLocation("Test Location");
event.setExpiryDate(expiryDate);
event.setCreatedAt(OffsetDateTime.now());
return event;
}
}

View File

@@ -4,13 +4,14 @@ import static org.assertj.core.api.Assertions.assertThat;
import de.fete.TestcontainersConfig; import de.fete.TestcontainersConfig;
import de.fete.domain.model.Event; 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 de.fete.domain.port.out.EventRepository;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
@@ -47,7 +48,7 @@ class EventPersistenceAdapterTest {
@Test @Test
void findByUnknownEventTokenReturnsEmpty() { void findByUnknownEventTokenReturnsEmpty() {
Optional<Event> found = eventRepository.findByEventToken(UUID.randomUUID()); Optional<Event> found = eventRepository.findByEventToken(EventToken.generate());
assertThat(found).isEmpty(); assertThat(found).isEmpty();
} }
@@ -61,8 +62,8 @@ class EventPersistenceAdapterTest {
OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC); OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC);
var event = new Event(); var event = new Event();
event.setEventToken(UUID.randomUUID()); event.setEventToken(EventToken.generate());
event.setOrganizerToken(UUID.randomUUID()); event.setOrganizerToken(OrganizerToken.generate());
event.setTitle("Full Event"); event.setTitle("Full Event");
event.setDescription("A detailed description"); event.setDescription("A detailed description");
event.setDateTime(dateTime); event.setDateTime(dateTime);
@@ -87,8 +88,8 @@ class EventPersistenceAdapterTest {
private Event buildEvent() { private Event buildEvent() {
var event = new Event(); var event = new Event();
event.setEventToken(UUID.randomUUID()); event.setEventToken(EventToken.generate());
event.setOrganizerToken(UUID.randomUUID()); event.setOrganizerToken(OrganizerToken.generate());
event.setTitle("Test Event"); event.setTitle("Test Event");
event.setDescription("Test description"); event.setDescription("Test description");
event.setDateTime(OffsetDateTime.now().plusDays(7)); event.setDateTime(OffsetDateTime.now().plusDays(7));

View File

@@ -1,7 +1,6 @@
package de.fete.application.service; package de.fete.application.service;
import static org.assertj.core.api.Assertions.assertThat; 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.ArgumentMatchers.any;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -9,15 +8,14 @@ import static org.mockito.Mockito.when;
import de.fete.domain.model.CreateEventCommand; import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event; import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.port.out.EventRepository; import de.fete.domain.port.out.EventRepository;
import java.time.Clock; import java.time.Clock;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -32,6 +30,7 @@ class EventServiceTest {
private static final Instant FIXED_INSTANT = private static final Instant FIXED_INSTANT =
LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant(); LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant();
private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE); private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE);
private static final LocalDate TODAY = LocalDate.ofInstant(FIXED_INSTANT, ZONE);
@Mock @Mock
private EventRepository eventRepository; private EventRepository eventRepository;
@@ -51,21 +50,20 @@ class EventServiceTest {
var command = new CreateEventCommand( var command = new CreateEventCommand(
"Birthday Party", "Birthday Party",
"Come celebrate!", "Come celebrate!",
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)), TODAY.plusDays(90).atStartOfDay(ZONE).toOffsetDateTime(),
ZoneId.of("Europe/Berlin"), ZONE,
"Berlin", "Berlin"
LocalDate.of(2026, 7, 15)
); );
Event result = eventService.createEvent(command); Event result = eventService.createEvent(command);
assertThat(result.getTitle()).isEqualTo("Birthday Party"); assertThat(result.getTitle()).isEqualTo("Birthday Party");
assertThat(result.getDescription()).isEqualTo("Come celebrate!"); assertThat(result.getDescription()).isEqualTo("Come celebrate!");
assertThat(result.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin")); assertThat(result.getTimezone()).isEqualTo(ZONE);
assertThat(result.getLocation()).isEqualTo("Berlin"); assertThat(result.getLocation()).isEqualTo("Berlin");
assertThat(result.getEventToken()).isNotNull(); assertThat(result.getEventToken()).isNotNull();
assertThat(result.getOrganizerToken()).isNotNull(); assertThat(result.getOrganizerToken()).isNotNull();
assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.now(FIXED_CLOCK)); assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE));
} }
@Test @Test
@@ -75,8 +73,7 @@ class EventServiceTest {
var command = new CreateEventCommand( var command = new CreateEventCommand(
"Test", null, "Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null, TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null
LocalDate.now(FIXED_CLOCK).plusDays(30)
); );
eventService.createEvent(command); eventService.createEvent(command);
@@ -87,50 +84,26 @@ class EventServiceTest {
} }
@Test @Test
void expiryDateTodayThrowsException() { void expiryDateIsEventDatePlusSevenDays() {
var command = new CreateEventCommand(
"Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null,
LocalDate.now(FIXED_CLOCK)
);
assertThatThrownBy(() -> eventService.createEvent(command))
.isInstanceOf(ExpiryDateInPastException.class);
}
@Test
void expiryDateInPastThrowsException() {
var command = new CreateEventCommand(
"Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null,
LocalDate.now(FIXED_CLOCK).minusDays(5)
);
assertThatThrownBy(() -> eventService.createEvent(command))
.isInstanceOf(ExpiryDateInPastException.class);
}
@Test
void expiryDateTomorrowSucceeds() {
when(eventRepository.save(any(Event.class))) when(eventRepository.save(any(Event.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); .thenAnswer(invocation -> invocation.getArgument(0));
var eventDate = TODAY.plusDays(10);
var command = new CreateEventCommand( var command = new CreateEventCommand(
"Test", null, "Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null, eventDate.atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null
LocalDate.now(FIXED_CLOCK).plusDays(1)
); );
Event result = eventService.createEvent(command); Event result = eventService.createEvent(command);
assertThat(result.getExpiryDate()).isEqualTo(LocalDate.of(2026, 3, 6)); assertThat(result.getExpiryDate()).isEqualTo(eventDate.plusDays(7));
} }
// --- GetEventUseCase tests (T004) --- // --- GetEventUseCase tests (T004) ---
@Test @Test
void getByEventTokenReturnsEvent() { void getByEventTokenReturnsEvent() {
UUID token = UUID.randomUUID(); EventToken token = EventToken.generate();
var event = new Event(); var event = new Event();
event.setEventToken(token); event.setEventToken(token);
event.setTitle("Found Event"); event.setTitle("Found Event");
@@ -145,7 +118,7 @@ class EventServiceTest {
@Test @Test
void getByEventTokenReturnsEmptyForUnknownToken() { void getByEventTokenReturnsEmptyForUnknownToken() {
UUID token = UUID.randomUUID(); EventToken token = EventToken.generate();
when(eventRepository.findByEventToken(token)) when(eventRepository.findByEventToken(token))
.thenReturn(Optional.empty()); .thenReturn(Optional.empty());
@@ -163,9 +136,8 @@ class EventServiceTest {
var command = new CreateEventCommand( var command = new CreateEventCommand(
"Test", null, "Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
ZoneId.of("America/New_York"), null, ZoneId.of("America/New_York"), null
LocalDate.now(FIXED_CLOCK).plusDays(30)
); );
Event result = eventService.createEvent(command); Event result = eventService.createEvent(command);

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

@@ -29,8 +29,10 @@ class WebConfigTest {
@Test @Test
void apiPrefixNotAccessibleWithoutIt() throws Exception { void apiPrefixNotAccessibleWithoutIt() throws Exception {
// /events without /api prefix should not resolve to the API endpoint // /events without /api prefix should not resolve to the REST API endpoint;
mockMvc.perform(get("/events")) // it is served by SpaController as HTML instead
.andExpect(status().isNotFound()); mockMvc.perform(get("/events")
.accept("text/html"))
.andExpect(status().isOk());
} }
} }

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<!-- OG_META_TAGS -->
<title>fete</title>
</head>
<body>
<div id="app"></div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

View File

@@ -9,7 +9,6 @@ test.describe('US-1: Create an event', () => {
await expect(page.getByText('Title is required.')).toBeVisible() await expect(page.getByText('Title is required.')).toBeVisible()
await expect(page.getByText('Date and time are required.')).toBeVisible() await expect(page.getByText('Date and time are required.')).toBeVisible()
await expect(page.getByText('Expiry date is required.')).toBeVisible()
}) })
test('creates an event and redirects to event detail page', async ({ page }) => { test('creates an event and redirects to event detail page', async ({ page }) => {
@@ -19,7 +18,6 @@ test.describe('US-1: Create an event', () => {
await page.getByLabel(/description/i).fill('Bring your own drinks') await page.getByLabel(/description/i).fill('Bring your own drinks')
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00') await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
await page.getByLabel(/location/i).fill('Central Park') await page.getByLabel(/location/i).fill('Central Park')
await page.getByLabel(/expiry/i).fill('2026-06-15')
await page.getByRole('button', { name: /create event/i }).click() await page.getByRole('button', { name: /create event/i }).click()
@@ -31,7 +29,6 @@ test.describe('US-1: Create an event', () => {
await page.getByLabel(/title/i).fill('Summer BBQ') await page.getByLabel(/title/i).fill('Summer BBQ')
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00') await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
await page.getByLabel(/expiry/i).fill('2026-06-15')
await page.getByRole('button', { name: /create event/i }).click() await page.getByRole('button', { name: /create event/i }).click()
await expect(page).toHaveURL(/\/events\/.+/) await expect(page).toHaveURL(/\/events\/.+/)
@@ -59,7 +56,6 @@ test.describe('US-1: Create an event', () => {
await page.goto('/create') await page.goto('/create')
await page.getByLabel(/title/i).fill('Test') await page.getByLabel(/title/i).fill('Test')
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00') await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
await page.getByLabel(/expiry/i).fill('2026-06-15')
await page.getByRole('button', { name: /create event/i }).click() await page.getByRole('button', { name: /create event/i }).click()

View File

@@ -0,0 +1,172 @@
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,
}
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()
})
})

View File

@@ -9,7 +9,6 @@ const fullEvent = {
timezone: 'Europe/Berlin', timezone: 'Europe/Berlin',
location: 'Central Park, NYC', location: 'Central Park, NYC',
attendeeCount: 12, attendeeCount: 12,
expired: false,
} }
test.describe('US-1: View event details', () => { test.describe('US-1: View event details', () => {
@@ -52,20 +51,6 @@ test.describe('US-1: View event details', () => {
}) })
}) })
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.describe('US-4: Event not found', () => {
test('shows "event not found" for unknown token', async ({ page, network }) => { test('shows "event not found" for unknown token', async ({ page, network }) => {
network.use( network.use(

View File

@@ -0,0 +1,368 @@
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',
organizerToken: 'org-token-1',
}
const futureEvent2: StoredEvent = {
eventToken: 'future-bbb',
title: 'Team Meeting',
dateTime: '2027-01-10T09:00:00Z',
rsvpToken: 'rsvp-token-1',
rsvpName: 'Alice',
}
const pastEvent: StoredEvent = {
eventToken: 'past-ccc',
title: 'New Year Party',
dateTime: '2025-01-01T00: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,
})
}),
)
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(),
}
const laterEvent: StoredEvent = {
eventToken: 'later-1',
title: 'Future Conference',
dateTime: new Date(now.getFullYear() + 1, 0, 15, 10, 0, 0).toISOString(),
}
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(),
}
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(),
}
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,
})
}),
)
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

@@ -3,6 +3,8 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<!-- OG_META_TAGS -->
<title>fete</title> <title>fete</title>
</head> </head>
<body> <body>

View File

@@ -26,14 +26,14 @@
"@vue/tsconfig": "^0.9.0", "@vue/tsconfig": "^0.9.0",
"eslint": "^10.0.2", "eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.51.0", "eslint-plugin-oxlint": "~1.54.0",
"eslint-plugin-vue": "~10.8.0", "eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"msw": "^2.12.10", "msw": "^2.12.10",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
"openapi-typescript": "^7.13.0", "openapi-typescript": "^7.13.0",
"oxlint": "~1.51.0", "oxlint": "~1.55.0",
"prettier": "3.8.1", "prettier": "3.8.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
@@ -1201,15 +1201,15 @@
} }
}, },
"node_modules/@eslint/config-array": { "node_modules/@eslint/config-array": {
"version": "0.23.2", "version": "0.23.3",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
"integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@eslint/object-schema": "^3.0.2", "@eslint/object-schema": "^3.0.3",
"debug": "^4.3.1", "debug": "^4.3.1",
"minimatch": "^10.2.1" "minimatch": "^10.2.4"
}, },
"engines": { "engines": {
"node": "^20.19.0 || ^22.13.0 || >=24" "node": "^20.19.0 || ^22.13.0 || >=24"
@@ -1229,9 +1229,9 @@
} }
}, },
"node_modules/@eslint/core": { "node_modules/@eslint/core": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
"integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -1242,9 +1242,9 @@
} }
}, },
"node_modules/@eslint/object-schema": { "node_modules/@eslint/object-schema": {
"version": "3.0.2", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
"integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@@ -1252,13 +1252,13 @@
} }
}, },
"node_modules/@eslint/plugin-kit": { "node_modules/@eslint/plugin-kit": {
"version": "0.6.0", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
"integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@eslint/core": "^1.1.0", "@eslint/core": "^1.1.1",
"levn": "^0.4.1" "levn": "^0.4.1"
}, },
"engines": { "engines": {
@@ -1727,9 +1727,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@oxlint/binding-android-arm-eabi": { "node_modules/@oxlint/binding-android-arm-eabi": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.55.0.tgz",
"integrity": "sha512-jJYIqbx4sX+suIxWstc4P7SzhEwb4ArWA2KVrmEuu9vH2i0qM6QIHz/ehmbGE4/2fZbpuMuBzTl7UkfNoqiSgw==", "integrity": "sha512-NhvgAhncTSOhRahQSCnkK/4YIGPjTmhPurQQ2dwt2IvwCMTvZRW5vF2K10UBOxFve4GZDMw6LtXZdC2qeuYIVQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1744,9 +1744,9 @@
} }
}, },
"node_modules/@oxlint/binding-android-arm64": { "node_modules/@oxlint/binding-android-arm64": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.55.0.tgz",
"integrity": "sha512-GtXyBCcH4ti98YdiMNCrpBNGitx87EjEWxevnyhcBK12k/Vu4EzSB45rzSC4fGFUD6sQgeaxItRCEEWeVwPafw==", "integrity": "sha512-P9iWRh+Ugqhg+D7rkc7boHX8o3H2h7YPcZHQIgvVBgnua5tk4LR2L+IBlreZs58/95cd2x3/004p5VsQM9z4SA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1761,9 +1761,9 @@
} }
}, },
"node_modules/@oxlint/binding-darwin-arm64": { "node_modules/@oxlint/binding-darwin-arm64": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.55.0.tgz",
"integrity": "sha512-3QJbeYaMHn6Bh2XeBXuITSsbnIctyTjvHf5nRjKYrT9pPeErNIpp5VDEeAXC0CZSwSVTsc8WOSDwgrAI24JolQ==", "integrity": "sha512-esakkJIt7WFAhT30P/Qzn96ehFpzdZ1mNuzpOb8SCW7lI4oB8VsyQnkSHREM671jfpuBb/o2ppzBCx5l0jpgMA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1778,9 +1778,9 @@
} }
}, },
"node_modules/@oxlint/binding-darwin-x64": { "node_modules/@oxlint/binding-darwin-x64": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.55.0.tgz",
"integrity": "sha512-NzErhMaTEN1cY0E8C5APy74lw5VwsNfJfVPBMWPVQLqAbO0k4FFLjvHURvkUL+Y18Wu+8Vs1kbqPh2hjXYA4pg==", "integrity": "sha512-xDMFRCCAEK9fOH6As2z8ELsC+VDGSFRHwIKVSilw+xhgLwTDFu37rtmRbmUlx8rRGS6cWKQPTc47AVxAZEVVPQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1795,9 +1795,9 @@
} }
}, },
"node_modules/@oxlint/binding-freebsd-x64": { "node_modules/@oxlint/binding-freebsd-x64": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.55.0.tgz",
"integrity": "sha512-msAIh3vPAoKoHlOE/oe6Q5C/n9umypv/k81lED82ibrJotn+3YG2Qp1kiR8o/Dg5iOEU97c6tl0utxcyFenpFw==", "integrity": "sha512-mYZqnwUD7ALCRxGenyLd1uuG+rHCL+OTT6S8FcAbVm/ZT2AZMGjvibp3F6k1SKOb2aeqFATmwRykrE41Q0GWVw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1812,9 +1812,9 @@
} }
}, },
"node_modules/@oxlint/binding-linux-arm-gnueabihf": { "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.55.0.tgz",
"integrity": "sha512-CqQPcvqYyMe9ZBot2stjGogEzk1z8gGAngIX7srSzrzexmXixwVxBdFZyxTVM0CjGfDeV+Ru0w25/WNjlMM2Hw==", "integrity": "sha512-LcX6RYcF9vL9ESGwJW3yyIZ/d/ouzdOKXxCdey1q0XJOW1asrHsIg5MmyKdEBR4plQx+shvYeQne7AzW5f3T1w==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1829,9 +1829,9 @@
} }
}, },
"node_modules/@oxlint/binding-linux-arm-musleabihf": { "node_modules/@oxlint/binding-linux-arm-musleabihf": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.55.0.tgz",
"integrity": "sha512-dstrlYQgZMnyOssxSbolGCge/sDbko12N/35RBNuqLpoPbft2aeBidBAb0dvQlyBd9RJ6u8D4o4Eh8Un6iTgyQ==", "integrity": "sha512-C+8GS1rPtK+dI7mJFkqoRBkDuqbrNihnyYQsJPS9ez+8zF9JzfvU19lawqt4l/Y23o5uQswE/DORa8aiXUih3w==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1846,9 +1846,9 @@
} }
}, },
"node_modules/@oxlint/binding-linux-arm64-gnu": { "node_modules/@oxlint/binding-linux-arm64-gnu": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.55.0.tgz",
"integrity": "sha512-QEjUpXO7d35rP1/raLGGbAsBLLGZIzV3ZbeSjqWlD3oRnxpRIZ6iL4o51XQHkconn3uKssc+1VKdtHJ81BBhDA==", "integrity": "sha512-ErLE4XbmcCopA4/CIDiH6J1IAaDOMnf/KSx/aFObs4/OjAAM3sFKWGZ57pNOMxhhyBdcmcXwYymph9GwcpcqgQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1863,9 +1863,9 @@
} }
}, },
"node_modules/@oxlint/binding-linux-arm64-musl": { "node_modules/@oxlint/binding-linux-arm64-musl": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.55.0.tgz",
"integrity": "sha512-YSJua5irtG4DoMAjUapDTPhkQLHhBIY0G9JqlZS6/SZPzqDkPku/1GdWs0D6h/wyx0Iz31lNCfIaWKBQhzP0wQ==", "integrity": "sha512-/kp65avi6zZfqEng56TTuhiy3P/3pgklKIdf38yvYeJ9/PgEeRA2A2AqKAKbZBNAqUzrzHhz9jF6j/PZvhJzTQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1880,9 +1880,9 @@
} }
}, },
"node_modules/@oxlint/binding-linux-ppc64-gnu": { "node_modules/@oxlint/binding-linux-ppc64-gnu": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.55.0.tgz",
"integrity": "sha512-7L4Wj2IEUNDETKssB9IDYt16T6WlF+X2jgC/hBq3diGHda9vJLpAgb09+D3quFq7TdkFtI7hwz/jmuQmQFPc1Q==", "integrity": "sha512-A6pTdXwcEEwL/nmz0eUJ6WxmxcoIS+97GbH96gikAyre3s5deC7sts38ZVVowjS2QQFuSWkpA4ZmQC0jZSNvJQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -1897,9 +1897,9 @@
} }
}, },
"node_modules/@oxlint/binding-linux-riscv64-gnu": { "node_modules/@oxlint/binding-linux-riscv64-gnu": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.55.0.tgz",
"integrity": "sha512-cBUHqtOXy76G41lOB401qpFoKx1xq17qYkhWrLSM7eEjiHM9sOtYqpr6ZdqCnN9s6ZpzudX4EkeHOFH2E9q0vA==", "integrity": "sha512-clj0lnIN+V52G9tdtZl0LbdTSurnZ1NZj92Je5X4lC7gP5jiCSW+Y/oiDiSauBAD4wrHt2S7nN3pA0zfKYK/6Q==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1914,9 +1914,9 @@
} }
}, },
"node_modules/@oxlint/binding-linux-riscv64-musl": { "node_modules/@oxlint/binding-linux-riscv64-musl": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.55.0.tgz",
"integrity": "sha512-WKbg8CysgZcHfZX0ixQFBRSBvFZUHa3SBnEjHY2FVYt2nbNJEjzTxA3ZR5wMU0NOCNKIAFUFvAh5/XJKPRJuJg==", "integrity": "sha512-NNu08pllN5x/O94/sgR3DA8lbrGBnTHsINZZR0hcav1sj79ksTiKKm1mRzvZvacwQ0hUnGinFo+JO75ok2PxYg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1931,9 +1931,9 @@
} }
}, },
"node_modules/@oxlint/binding-linux-s390x-gnu": { "node_modules/@oxlint/binding-linux-s390x-gnu": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.55.0.tgz",
"integrity": "sha512-N1QRUvJTxqXNSu35YOufdjsAVmKVx5bkrggOWAhTWBc3J4qjcBwr1IfyLh/6YCg8sYRSR1GraldS9jUgJL/U4A==", "integrity": "sha512-BvfQz3PRlWZRoEZ17dZCqgQsMRdpzGZomJkVATwCIGhHVVeHJMQdmdXPSjcT1DCNUrOjXnVyj1RGDj5+/Je2+Q==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -1948,9 +1948,9 @@
} }
}, },
"node_modules/@oxlint/binding-linux-x64-gnu": { "node_modules/@oxlint/binding-linux-x64-gnu": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.55.0.tgz",
"integrity": "sha512-e0Mz0DizsCoqNIjeOg6OUKe8JKJWZ5zZlwsd05Bmr51Jo3AOL4UJnPvwKumr4BBtBrDZkCmOLhCvDGm95nJM2g==", "integrity": "sha512-ngSOoFCSBMKVQd24H8zkbcBNc7EHhjnF1sv3mC9NNXQ/4rRjI/4Dj9+9XoDZeFEkF1SX1COSBXF1b2Pr9rqdEw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1965,9 +1965,9 @@
} }
}, },
"node_modules/@oxlint/binding-linux-x64-musl": { "node_modules/@oxlint/binding-linux-x64-musl": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.55.0.tgz",
"integrity": "sha512-wD8HGTWhYBKXvRDvoBVB1y+fEYV01samhWQSy1Zkxq2vpezvMnjaFKRuiP6tBNITLGuffbNDEXOwcAhJ3gI5Ug==", "integrity": "sha512-BDpP7W8GlaG7BR6QjGZAleYzxoyKc/D24spZIF2mB3XsfALQJJT/OBmP8YpeTb1rveFSBHzl8T7l0aqwkWNdGA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1982,9 +1982,9 @@
} }
}, },
"node_modules/@oxlint/binding-openharmony-arm64": { "node_modules/@oxlint/binding-openharmony-arm64": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.55.0.tgz",
"integrity": "sha512-5NSwQ2hDEJ0GPXqikjWtwzgAQCsS7P9aLMNenjjKa+gknN3lTCwwwERsT6lKXSirfU3jLjexA2XQvQALh5h27w==", "integrity": "sha512-PS6GFvmde/pc3fCA2Srt51glr8Lcxhpf6WIBFfLphndjRrD34NEcses4TSxQrEcxYo6qVywGfylM0ZhSCF2gGA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1999,9 +1999,9 @@
} }
}, },
"node_modules/@oxlint/binding-win32-arm64-msvc": { "node_modules/@oxlint/binding-win32-arm64-msvc": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.55.0.tgz",
"integrity": "sha512-JEZyah1M0RHMw8d+jjSSJmSmO8sABA1J1RtrHYujGPeCkYg1NeH0TGuClpe2h5QtioRTaF57y/TZfn/2IFV6fA==", "integrity": "sha512-P6JcLJGs/q1UOvDLzN8otd9JsH4tsuuPDv+p7aHqHM3PrKmYdmUvkNj4K327PTd35AYcznOCN+l4ZOaq76QzSw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2016,9 +2016,9 @@
} }
}, },
"node_modules/@oxlint/binding-win32-ia32-msvc": { "node_modules/@oxlint/binding-win32-ia32-msvc": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.55.0.tgz",
"integrity": "sha512-q3cEoKH6kwjz/WRyHwSf0nlD2F5Qw536kCXvmlSu+kaShzgrA0ojmh45CA81qL+7udfCaZL2SdKCZlLiGBVFlg==", "integrity": "sha512-gzkk4zE2zsE+WmRxFOiAZHpCpUNDFytEakqNXoNHW+PnYEOTPKDdW6nrzgSeTbGKVPXNAKQnRnMgrh7+n3Xueg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -2033,9 +2033,9 @@
} }
}, },
"node_modules/@oxlint/binding-win32-x64-msvc": { "node_modules/@oxlint/binding-win32-x64-msvc": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.55.0.tgz",
"integrity": "sha512-Q14+fOGb9T28nWF/0EUsYqERiRA7cl1oy4TJrGmLaqhm+aO2cV+JttboHI3CbdeMCAyDI1+NoSlrM7Melhp/cw==", "integrity": "sha512-ZFALNow2/og75gvYzNP7qe+rREQ5xunktwA+lgykoozHZ6hw9bqg4fn5j2UvG4gIn1FXqrZHkOAXuPf5+GOYTQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2947,9 +2947,9 @@
} }
}, },
"node_modules/@vitest/eslint-plugin": { "node_modules/@vitest/eslint-plugin": {
"version": "1.6.9", "version": "1.6.10",
"resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.9.tgz", "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.10.tgz",
"integrity": "sha512-9WfPx1OwJ19QLCSRLkqVO7//1WcWnK3fE/3fJhKMAmDe8+9G4rB47xCNIIeCq3FdEzkIoLTfDlwDlPBaUTMhow==", "integrity": "sha512-/cOf+mTu4HBJIYHTETo8/OFCSZv3T2p+KfGnouzKfjK063cWLZp0TzvK7EU5B3eFG7ypUNtw6l+jK+SA+p1g8g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3204,13 +3204,13 @@
} }
}, },
"node_modules/@vue/compiler-core": { "node_modules/@vue/compiler-core": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz",
"integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==", "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.0", "@babel/parser": "^7.29.0",
"@vue/shared": "3.5.29", "@vue/shared": "3.5.30",
"entities": "^7.0.1", "entities": "^7.0.1",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
@@ -3229,40 +3229,40 @@
} }
}, },
"node_modules/@vue/compiler-dom": { "node_modules/@vue/compiler-dom": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz",
"integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==", "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-core": "3.5.29", "@vue/compiler-core": "3.5.30",
"@vue/shared": "3.5.29" "@vue/shared": "3.5.30"
} }
}, },
"node_modules/@vue/compiler-sfc": { "node_modules/@vue/compiler-sfc": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz",
"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==", "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.0", "@babel/parser": "^7.29.0",
"@vue/compiler-core": "3.5.29", "@vue/compiler-core": "3.5.30",
"@vue/compiler-dom": "3.5.29", "@vue/compiler-dom": "3.5.30",
"@vue/compiler-ssr": "3.5.29", "@vue/compiler-ssr": "3.5.30",
"@vue/shared": "3.5.29", "@vue/shared": "3.5.30",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"postcss": "^8.5.6", "postcss": "^8.5.8",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
} }
}, },
"node_modules/@vue/compiler-ssr": { "node_modules/@vue/compiler-ssr": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz",
"integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==", "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.29", "@vue/compiler-dom": "3.5.30",
"@vue/shared": "3.5.29" "@vue/shared": "3.5.30"
} }
}, },
"node_modules/@vue/devtools-api": { "node_modules/@vue/devtools-api": {
@@ -3362,53 +3362,53 @@
} }
}, },
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz",
"integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==", "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/shared": "3.5.29" "@vue/shared": "3.5.30"
} }
}, },
"node_modules/@vue/runtime-core": { "node_modules/@vue/runtime-core": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz",
"integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==", "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.29", "@vue/reactivity": "3.5.30",
"@vue/shared": "3.5.29" "@vue/shared": "3.5.30"
} }
}, },
"node_modules/@vue/runtime-dom": { "node_modules/@vue/runtime-dom": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz",
"integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==", "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.29", "@vue/reactivity": "3.5.30",
"@vue/runtime-core": "3.5.29", "@vue/runtime-core": "3.5.30",
"@vue/shared": "3.5.29", "@vue/shared": "3.5.30",
"csstype": "^3.2.3" "csstype": "^3.2.3"
} }
}, },
"node_modules/@vue/server-renderer": { "node_modules/@vue/server-renderer": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz",
"integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==", "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-ssr": "3.5.29", "@vue/compiler-ssr": "3.5.30",
"@vue/shared": "3.5.29" "@vue/shared": "3.5.30"
}, },
"peerDependencies": { "peerDependencies": {
"vue": "3.5.29" "vue": "3.5.30"
} }
}, },
"node_modules/@vue/shared": { "node_modules/@vue/shared": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz",
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vue/test-utils": { "node_modules/@vue/test-utils": {
@@ -4304,18 +4304,18 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "10.0.2", "version": "10.0.3",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz",
"integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2", "@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/config-helpers": "^0.5.2",
"@eslint/core": "^1.1.0", "@eslint/core": "^1.1.1",
"@eslint/plugin-kit": "^0.6.0", "@eslint/plugin-kit": "^0.6.1",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2", "@humanwhocodes/retry": "^0.4.2",
@@ -4324,7 +4324,7 @@
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
"debug": "^4.3.2", "debug": "^4.3.2",
"escape-string-regexp": "^4.0.0", "escape-string-regexp": "^4.0.0",
"eslint-scope": "^9.1.1", "eslint-scope": "^9.1.2",
"eslint-visitor-keys": "^5.0.1", "eslint-visitor-keys": "^5.0.1",
"espree": "^11.1.1", "espree": "^11.1.1",
"esquery": "^1.7.0", "esquery": "^1.7.0",
@@ -4337,7 +4337,7 @@
"imurmurhash": "^0.1.4", "imurmurhash": "^0.1.4",
"is-glob": "^4.0.0", "is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1", "json-stable-stringify-without-jsonify": "^1.0.1",
"minimatch": "^10.2.1", "minimatch": "^10.2.4",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"optionator": "^0.9.3" "optionator": "^0.9.3"
}, },
@@ -4376,9 +4376,9 @@
} }
}, },
"node_modules/eslint-plugin-oxlint": { "node_modules/eslint-plugin-oxlint": {
"version": "1.51.0", "version": "1.54.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-oxlint/-/eslint-plugin-oxlint-1.51.0.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-oxlint/-/eslint-plugin-oxlint-1.54.0.tgz",
"integrity": "sha512-lct8LD1AxfHF1PcsuK6mFYals+zX0mx/WP2G4i16h0iR8jpT3xCfGTmTNwXiImcevzGIiJ/VDBgQ7t0B9z2Jeg==", "integrity": "sha512-bWcHxjvdcFNkPsSRMSBJYVSz4lFA+ZfztejRwp5c2sWRxkDHfkyGQzgus/4Qw75hBZID56Tilf/zzV1znsMr+w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4418,9 +4418,9 @@
} }
}, },
"node_modules/eslint-scope": { "node_modules/eslint-scope": {
"version": "9.1.1", "version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
@@ -5776,9 +5776,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/oxlint": { "node_modules/oxlint": {
"version": "1.51.0", "version": "1.55.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.51.0.tgz", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.55.0.tgz",
"integrity": "sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ==", "integrity": "sha512-T+FjepiyWpaZMhekqRpH8Z3I4vNM610p6w+Vjfqgj5TZUxHXl7N8N5IPvmOU8U4XdTRxqtNNTh9Y4hLtr7yvFg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -5791,25 +5791,25 @@
"url": "https://github.com/sponsors/Boshen" "url": "https://github.com/sponsors/Boshen"
}, },
"optionalDependencies": { "optionalDependencies": {
"@oxlint/binding-android-arm-eabi": "1.51.0", "@oxlint/binding-android-arm-eabi": "1.55.0",
"@oxlint/binding-android-arm64": "1.51.0", "@oxlint/binding-android-arm64": "1.55.0",
"@oxlint/binding-darwin-arm64": "1.51.0", "@oxlint/binding-darwin-arm64": "1.55.0",
"@oxlint/binding-darwin-x64": "1.51.0", "@oxlint/binding-darwin-x64": "1.55.0",
"@oxlint/binding-freebsd-x64": "1.51.0", "@oxlint/binding-freebsd-x64": "1.55.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.51.0", "@oxlint/binding-linux-arm-gnueabihf": "1.55.0",
"@oxlint/binding-linux-arm-musleabihf": "1.51.0", "@oxlint/binding-linux-arm-musleabihf": "1.55.0",
"@oxlint/binding-linux-arm64-gnu": "1.51.0", "@oxlint/binding-linux-arm64-gnu": "1.55.0",
"@oxlint/binding-linux-arm64-musl": "1.51.0", "@oxlint/binding-linux-arm64-musl": "1.55.0",
"@oxlint/binding-linux-ppc64-gnu": "1.51.0", "@oxlint/binding-linux-ppc64-gnu": "1.55.0",
"@oxlint/binding-linux-riscv64-gnu": "1.51.0", "@oxlint/binding-linux-riscv64-gnu": "1.55.0",
"@oxlint/binding-linux-riscv64-musl": "1.51.0", "@oxlint/binding-linux-riscv64-musl": "1.55.0",
"@oxlint/binding-linux-s390x-gnu": "1.51.0", "@oxlint/binding-linux-s390x-gnu": "1.55.0",
"@oxlint/binding-linux-x64-gnu": "1.51.0", "@oxlint/binding-linux-x64-gnu": "1.55.0",
"@oxlint/binding-linux-x64-musl": "1.51.0", "@oxlint/binding-linux-x64-musl": "1.55.0",
"@oxlint/binding-openharmony-arm64": "1.51.0", "@oxlint/binding-openharmony-arm64": "1.55.0",
"@oxlint/binding-win32-arm64-msvc": "1.51.0", "@oxlint/binding-win32-arm64-msvc": "1.55.0",
"@oxlint/binding-win32-ia32-msvc": "1.51.0", "@oxlint/binding-win32-ia32-msvc": "1.55.0",
"@oxlint/binding-win32-x64-msvc": "1.51.0" "@oxlint/binding-win32-x64-msvc": "1.55.0"
}, },
"peerDependencies": { "peerDependencies": {
"oxlint-tsgolint": ">=0.15.0" "oxlint-tsgolint": ">=0.15.0"
@@ -7319,16 +7319,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vue": { "node_modules/vue": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.29", "@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.29", "@vue/compiler-sfc": "3.5.30",
"@vue/runtime-dom": "3.5.29", "@vue/runtime-dom": "3.5.30",
"@vue/server-renderer": "3.5.29", "@vue/server-renderer": "3.5.30",
"@vue/shared": "3.5.29" "@vue/shared": "3.5.30"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "*" "typescript": "*"

View File

@@ -38,14 +38,14 @@
"@vue/tsconfig": "^0.9.0", "@vue/tsconfig": "^0.9.0",
"eslint": "^10.0.2", "eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.51.0", "eslint-plugin-oxlint": "~1.54.0",
"eslint-plugin-vue": "~10.8.0", "eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"msw": "^2.12.10", "msw": "^2.12.10",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
"openapi-typescript": "^7.13.0", "openapi-typescript": "^7.13.0",
"oxlint": "~1.51.0", "oxlint": "~1.55.0",
"prettier": "3.8.1", "prettier": "3.8.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<text y="0.9em" font-size="80" x="50%" text-anchor="middle">🎉</text>
</svg>

After

Width:  |  Height:  |  Size: 144 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -16,6 +16,26 @@
--color-text-on-gradient: #ffffff; --color-text-on-gradient: #ffffff;
--color-surface: #fff5f8; --color-surface: #fff5f8;
--color-card: #ffffff; --color-card: #ffffff;
--color-dark-base: #1B1730;
/* Glass system */
--color-glass: rgba(255, 255, 255, 0.1);
--color-glass-strong: rgba(255, 255, 255, 0.15);
--color-glass-subtle: rgba(255, 255, 255, 0.05);
--color-glass-border: rgba(255, 255, 255, 0.18);
--color-glass-border-hover: rgba(255, 255, 255, 0.3);
--color-glass-hover: rgba(255, 255, 255, 0.18);
--color-glass-inner: rgba(27, 23, 48, 0.55);
--color-glass-overlay: rgba(27, 23, 48, 0.4);
/* Text on gradient (opacity variants) */
--color-text-muted: rgba(255, 255, 255, 0.5);
--color-text-secondary: rgba(255, 255, 255, 0.7);
--color-text-soft: rgba(255, 255, 255, 0.85);
--color-text-bright: rgba(255, 255, 255, 0.9);
/* Glow border */
--gradient-glow: conic-gradient(from 135deg, var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end), var(--color-gradient-start));
/* Gradient */ /* Gradient */
--gradient-primary: linear-gradient(135deg, #f06292 0%, #ab47bc 50%, #5c6bc0 100%); --gradient-primary: linear-gradient(135deg, #f06292 0%, #ab47bc 50%, #5c6bc0 100%);
@@ -33,7 +53,7 @@
--radius-button: 14px; --radius-button: 14px;
/* Shadows */ /* Shadows */
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1); --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.12);
--shadow-button: 0 2px 8px rgba(0, 0, 0, 0.15); --shadow-button: 0 2px 8px rgba(0, 0, 0, 0.15);
/* Layout */ /* Layout */
@@ -60,7 +80,22 @@ html {
body { body {
min-height: 100vh; min-height: 100vh;
background: var(--gradient-primary); background-color: var(--color-dark-base);
position: relative;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-color: var(--color-dark-base);
background-image:
radial-gradient(at 70% 20%, rgba(240, 98, 146, 0.55) 0px, transparent 50%),
radial-gradient(at 25% 50%, rgba(171, 71, 188, 0.5) 0px, transparent 55%),
radial-gradient(at 80% 70%, rgba(92, 107, 192, 0.55) 0px, transparent 50%),
radial-gradient(at 35% 85%, rgba(255, 112, 67, 0.3) 0px, transparent 40%);
filter: blur(80px);
z-index: -1;
} }
#app { #app {
@@ -82,28 +117,35 @@ body {
/* Card-style form fields */ /* Card-style form fields */
.form-field { .form-field {
background: var(--color-card); background: var(--color-card);
border: none; border: 1px solid #e0e0e0;
border-radius: var(--radius-card); border-radius: var(--radius-card);
padding: var(--spacing-md) var(--spacing-md); padding: var(--spacing-md) var(--spacing-md);
box-shadow: var(--shadow-card);
width: 100%; width: 100%;
font-family: inherit; font-family: inherit;
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 400; font-weight: 400;
color: var(--color-text); color: var(--color-text);
outline: none; outline: none;
transition: box-shadow 0.2s ease; transition: border-color 0.2s ease;
}
.form-field.glass {
color: var(--color-text-on-gradient);
} }
.form-field:focus { .form-field:focus {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18); border-color: var(--color-glass-border-hover);
} }
.form-field::placeholder { .form-field::placeholder {
color: #999; color: var(--color-text-muted);
font-weight: 400; font-weight: 400;
} }
.form-field.glass::placeholder {
color: var(--color-text-muted);
}
textarea.form-field { textarea.form-field {
resize: vertical; resize: vertical;
min-height: 5rem; min-height: 5rem;
@@ -128,22 +170,29 @@ textarea.form-field {
display: block; display: block;
width: 100%; width: 100%;
padding: var(--spacing-md) var(--spacing-lg); padding: var(--spacing-md) var(--spacing-lg);
background: var(--color-accent); background: var(--color-card);
color: var(--color-text); color: var(--color-text);
border: none; border: 1px solid #e0e0e0;
border-radius: var(--radius-button); border-radius: var(--radius-button);
font-family: inherit; font-family: inherit;
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
box-shadow: var(--shadow-button); transition: border-color 0.2s ease, transform 0.1s ease;
transition: opacity 0.2s ease, transform 0.1s ease;
text-align: center; text-align: center;
text-decoration: none; text-decoration: none;
} }
.btn-primary.glass {
color: var(--color-text-on-gradient);
border: 2px solid transparent;
background:
linear-gradient(var(--color-glass-inner), var(--color-glass-inner)) padding-box,
var(--gradient-glow) border-box;
}
.btn-primary:hover { .btn-primary:hover {
opacity: 0.92; border-color: var(--color-glass-border-hover);
} }
.btn-primary:active { .btn-primary:active {
@@ -176,6 +225,68 @@ textarea.form-field {
100% { background-position: -200% 0; } 100% { background-position: -200% 0; }
} }
/* ── Glass System ── */
/* Glass surface: passive containers on gradient (cards, icon boxes) */
.glass {
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
border: 1px solid var(--color-glass-border);
box-shadow: var(--shadow-card);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.glass:hover:not(input):not(textarea):not(.btn-primary) {
background: var(--color-glass-hover);
border-color: var(--color-glass-border-hover);
}
/* Glass interactive inner: dark translucent fill for interactive elements (FAB, CTA) */
.glass-inner {
background: var(--color-glass-inner);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
/* Glow border: conic gradient wrapper with halo (static) */
.glow-border {
background: var(--gradient-glow);
padding: 2px;
position: relative;
}
.glow-border::before {
content: '';
position: absolute;
inset: -4px;
border-radius: inherit;
background: var(--gradient-glow);
filter: blur(8px);
opacity: 0.3;
z-index: -1;
}
/* Glow border animated variant */
@property --glow-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
.glow-border--animated {
background: conic-gradient(from var(--glow-angle), var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end), var(--color-gradient-start));
animation: glow-rotate 4s linear infinite;
}
.glow-border--animated::before {
background: conic-gradient(from var(--glow-angle), var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end), var(--color-gradient-start));
animation: glow-rotate 4s linear infinite;
}
@keyframes glow-rotate {
to { --glow-angle: 360deg; }
}
/* Utility */ /* Utility */
.text-center { .text-center {
text-align: center; text-align: center;
@@ -192,3 +303,34 @@ textarea.form-field {
white-space: nowrap; white-space: nowrap;
border: 0; border: 0;
} }
/* Bottom sheet form */
.sheet-title {
font-size: 1.2rem;
font-weight: 700;
color: var(--color-text-on-gradient);
}
.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-on-gradient);
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: var(--color-text-muted);
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: var(--color-text-soft);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attendee-list__empty {
font-size: 0.9rem;
color: var(--color-text-muted);
font-style: italic;
}
</style>

View File

@@ -0,0 +1,100 @@
<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: var(--color-glass-overlay);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 100;
}
.sheet {
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
border: 1px solid var(--color-glass-border);
border-bottom: none;
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
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: var(--color-glass-border-hover);
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,155 @@
<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: var(--color-glass-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: var(--spacing-lg);
}
.confirm-dialog {
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
border: 1px solid var(--color-glass-border);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-radius: var(--radius-card);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
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-on-gradient);
}
.confirm-dialog__message {
font-size: 0.9rem;
font-weight: 400;
color: var(--color-text-soft);
}
.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: var(--color-glass);
border: 1px solid var(--color-glass-border);
color: var(--color-text-on-gradient);
}
.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,58 @@
<template>
<RouterLink to="/create" class="fab glow-border" aria-label="Create event">
<span class="fab__inner glass-inner">
<span class="fab__icon" aria-hidden="true">+</span>
</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%;
color: var(--color-text-on-gradient);
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
z-index: 100;
transition: transform 0.15s ease;
}
.fab__inner {
width: 100%;
height: 100%;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.fab:hover {
transform: scale(1.08);
}
.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: var(--color-text-soft);
margin: 0;
padding: var(--spacing-xs) 0;
}
</style>

View File

@@ -0,0 +1,62 @@
<template>
<div class="empty-state">
<p class="empty-state__message">No events yet.<br />Create your first one!</p>
<RouterLink to="/create" class="empty-state__cta glow-border glow-border--animated">
<span class="empty-state__cta-inner glass-inner">Create Event</span>
</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;
width: 100%;
border-radius: var(--radius-button);
text-decoration: none;
transition: transform 0.1s ease;
}
.empty-state__cta-inner {
display: block;
width: 100%;
padding: var(--spacing-md) var(--spacing-lg);
border-radius: calc(var(--radius-button) - 2px);
font-family: inherit;
font-size: 1rem;
font-weight: 700;
color: var(--color-text-on-gradient);
text-align: center;
}
.empty-state__cta:hover {
transform: scale(1.02);
}
.empty-state__cta:active {
transform: scale(0.98);
}
.empty-state__cta:focus-visible {
outline: 2px solid #fff;
outline-offset: 3px;
}
</style>

View File

@@ -0,0 +1,180 @@
<template>
<div
class="event-card glass"
: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;
border-radius: var(--radius-card);
padding: var(--spacing-md) var(--spacing-lg);
gap: var(--spacing-sm);
transition: background 0.2s ease, border-color 0.2s ease;
}
.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-on-gradient);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-card__time {
font-size: 0.8rem;
font-weight: 400;
color: var(--color-text-secondary);
}
.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: var(--color-text-on-gradient);
}
.event-card__badge--attendee {
background: var(--color-glass-strong);
color: var(--color-text-bright);
}
.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: var(--color-text-muted);
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,104 @@
<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 -->
<div v-else class="rsvp-bar__cta glow-border glow-border--animated">
<button class="rsvp-bar__cta-inner glass-inner" type="button" @click="$emit('open')">
I'm attending
</button>
</div>
</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%;
border-radius: var(--radius-button);
transition: transform 0.1s ease;
}
.rsvp-bar__cta:hover {
transform: scale(1.02);
}
.rsvp-bar__cta:active {
transform: scale(0.98);
}
.rsvp-bar__cta-inner {
display: block;
width: 100%;
padding: var(--spacing-md) var(--spacing-lg);
border-radius: calc(var(--radius-button) - 2px);
font-family: inherit;
font-size: 1rem;
font-weight: 700;
color: var(--color-text-on-gradient);
text-align: center;
border: none;
cursor: pointer;
}
.rsvp-bar__status {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
border: 1px solid var(--color-glass-border);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
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-on-gradient);
}
.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-on-gradient);
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' },
{ eventToken: 'later-1', title: 'Later Event', dateTime: '2027-06-15T10:00:00' },
{ eventToken: 'today-1', title: 'Today Event', dateTime: '2026-03-11T18:00:00' },
{ eventToken: 'week-1', title: 'This Week Event', dateTime: '2026-03-13T10:00:00' },
{ eventToken: 'nextweek-1', title: 'Next Week Event', dateTime: '2026-03-16T10:00:00' },
]
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-inner').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-inner').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,157 @@
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',
...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

@@ -43,7 +43,6 @@ describe('useEventStorage', () => {
organizerToken: 'org-456', organizerToken: 'org-456',
title: 'Birthday', title: 'Birthday',
dateTime: '2026-06-15T20:00:00+02:00', dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
}) })
const events = getStoredEvents() const events = getStoredEvents()
@@ -61,7 +60,6 @@ describe('useEventStorage', () => {
organizerToken: 'org-456', organizerToken: 'org-456',
title: 'Test', title: 'Test',
dateTime: '2026-06-15T20:00:00+02:00', dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
}) })
expect(getOrganizerToken('abc-123')).toBe('org-456') expect(getOrganizerToken('abc-123')).toBe('org-456')
@@ -79,14 +77,12 @@ describe('useEventStorage', () => {
eventToken: 'event-1', eventToken: 'event-1',
title: 'First', title: 'First',
dateTime: '2026-06-15T20:00:00+02:00', dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
}) })
saveCreatedEvent({ saveCreatedEvent({
eventToken: 'event-2', eventToken: 'event-2',
title: 'Second', title: 'Second',
dateTime: '2026-07-15T20:00:00+02:00', dateTime: '2026-07-15T20:00:00+02:00',
expiryDate: '2026-08-15',
}) })
const events = getStoredEvents() const events = getStoredEvents()
@@ -102,18 +98,174 @@ describe('useEventStorage', () => {
eventToken: 'abc-123', eventToken: 'abc-123',
title: 'Old Title', title: 'Old Title',
dateTime: '2026-06-15T20:00:00+02:00', dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
}) })
saveCreatedEvent({ saveCreatedEvent({
eventToken: 'abc-123', eventToken: 'abc-123',
title: 'New Title', title: 'New Title',
dateTime: '2026-06-15T20:00:00+02:00', dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
}) })
const events = getStoredEvents() const events = getStoredEvents()
expect(events).toHaveLength(1) expect(events).toHaveLength(1)
expect(events[0]!.title).toBe('New Title') 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',
})
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',
})
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',
})
saveCreatedEvent({
eventToken: 'event-2',
title: 'Second',
dateTime: '2026-07-15T20:00:00+02:00',
})
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',
})
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',
}),
).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

@@ -3,11 +3,30 @@ export interface StoredEvent {
organizerToken?: string organizerToken?: string
title: string title: string
dateTime: string dateTime: string
expiryDate: string rsvpToken?: string
rsvpName?: string
} }
import { ref } from 'vue'
const STORAGE_KEY = 'fete:events' const STORAGE_KEY = 'fete:events'
const version = ref(0)
export function isValidStoredEvent(e: unknown): e is StoredEvent {
if (typeof e !== 'object' || e === null) return false
const obj = e as Record<string, unknown>
return (
typeof obj.eventToken === 'string' &&
obj.eventToken.length > 0 &&
typeof obj.title === 'string' &&
obj.title.length > 0 &&
typeof obj.dateTime === 'string' &&
obj.dateTime.length > 0 &&
!isNaN(new Date(obj.dateTime).getTime())
)
}
function readEvents(): StoredEvent[] { function readEvents(): StoredEvent[] {
try { try {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY)
@@ -19,6 +38,7 @@ function readEvents(): StoredEvent[] {
function writeEvents(events: StoredEvent[]): void { function writeEvents(events: StoredEvent[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(events)) localStorage.setItem(STORAGE_KEY, JSON.stringify(events))
version.value++
} }
export function useEventStorage() { export function useEventStorage() {
@@ -29,6 +49,7 @@ export function useEventStorage() {
} }
function getStoredEvents(): StoredEvent[] { function getStoredEvents(): StoredEvent[] {
void version.value
return readEvents() return readEvents()
} }
@@ -37,5 +58,30 @@ export function useEventStorage() {
return event?.organizerToken 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, 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,7 +15,7 @@ const router = createRouter({
component: () => import('../views/EventCreateView.vue'), component: () => import('../views/EventCreateView.vue'),
}, },
{ {
path: '/events/:token', path: '/events/:eventToken',
name: 'event', name: 'event',
component: () => import('../views/EventDetailView.vue'), component: () => import('../views/EventDetailView.vue'),
}, },

View File

@@ -12,7 +12,7 @@
id="title" id="title"
v-model="form.title" v-model="form.title"
type="text" type="text"
class="form-field" class="form-field glass"
required required
maxlength="200" maxlength="200"
placeholder="What's the event?" placeholder="What's the event?"
@@ -27,7 +27,7 @@
<textarea <textarea
id="description" id="description"
v-model="form.description" v-model="form.description"
class="form-field" class="form-field glass"
maxlength="2000" maxlength="2000"
placeholder="Tell people more about it…" placeholder="Tell people more about it…"
:aria-invalid="!!errors.description" :aria-invalid="!!errors.description"
@@ -42,7 +42,7 @@
id="dateTime" id="dateTime"
v-model="form.dateTime" v-model="form.dateTime"
type="datetime-local" type="datetime-local"
class="form-field" class="form-field glass"
required required
:aria-invalid="!!errors.dateTime" :aria-invalid="!!errors.dateTime"
:aria-describedby="errors.dateTime ? 'dateTime-error' : undefined" :aria-describedby="errors.dateTime ? 'dateTime-error' : undefined"
@@ -56,7 +56,7 @@
id="location" id="location"
v-model="form.location" v-model="form.location"
type="text" type="text"
class="form-field" class="form-field glass"
maxlength="500" maxlength="500"
placeholder="Where is it?" placeholder="Where is it?"
:aria-invalid="!!errors.location" :aria-invalid="!!errors.location"
@@ -65,22 +65,7 @@
<span v-if="errors.location" id="location-error" class="field-error" role="alert">{{ errors.location }}</span> <span v-if="errors.location" id="location-error" class="field-error" role="alert">{{ errors.location }}</span>
</div> </div>
<div class="form-group"> <button type="submit" class="btn-primary glass" :disabled="submitting">
<label for="expiryDate" class="form-label">Expiry Date *</label>
<input
id="expiryDate"
v-model="form.expiryDate"
type="date"
class="form-field"
required
:min="tomorrow"
:aria-invalid="!!errors.expiryDate"
:aria-describedby="errors.expiryDate ? 'expiryDate-error' : undefined"
/>
<span v-if="errors.expiryDate" id="expiryDate-error" class="field-error" role="alert">{{ errors.expiryDate }}</span>
</div>
<button type="submit" class="btn-primary" :disabled="submitting">
{{ submitting ? 'Creating…' : 'Create Event' }} {{ submitting ? 'Creating…' : 'Create Event' }}
</button> </button>
@@ -90,7 +75,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, computed, watch } from 'vue' import { reactive, ref, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router' import { RouterLink, useRouter } from 'vue-router'
import { api } from '@/api/client' import { api } from '@/api/client'
import { useEventStorage } from '@/composables/useEventStorage' import { useEventStorage } from '@/composables/useEventStorage'
@@ -103,7 +88,6 @@ const form = reactive({
description: '', description: '',
dateTime: '', dateTime: '',
location: '', location: '',
expiryDate: '',
}) })
const errors = reactive({ const errors = reactive({
@@ -111,31 +95,22 @@ const errors = reactive({
description: '', description: '',
dateTime: '', dateTime: '',
location: '', location: '',
expiryDate: '',
}) })
const submitting = ref(false) const submitting = ref(false)
const serverError = ref('') const serverError = ref('')
const tomorrow = computed(() => {
const d = new Date()
d.setDate(d.getDate() + 1)
return d.toISOString().split('T')[0]
})
function clearErrors() { function clearErrors() {
errors.title = '' errors.title = ''
errors.description = '' errors.description = ''
errors.dateTime = '' errors.dateTime = ''
errors.location = '' errors.location = ''
errors.expiryDate = ''
serverError.value = '' serverError.value = ''
} }
// Clear individual field errors when the user types // Clear individual field errors when the user types
watch(() => form.title, () => { errors.title = ''; serverError.value = '' }) watch(() => form.title, () => { errors.title = ''; serverError.value = '' })
watch(() => form.dateTime, () => { errors.dateTime = ''; serverError.value = '' }) watch(() => form.dateTime, () => { errors.dateTime = ''; serverError.value = '' })
watch(() => form.expiryDate, () => { errors.expiryDate = ''; serverError.value = '' })
watch(() => form.description, () => { serverError.value = '' }) watch(() => form.description, () => { serverError.value = '' })
watch(() => form.location, () => { serverError.value = '' }) watch(() => form.location, () => { serverError.value = '' })
@@ -153,14 +128,6 @@ function validate(): boolean {
valid = false valid = false
} }
if (!form.expiryDate) {
errors.expiryDate = 'Expiry date is required.'
valid = false
} else if (form.expiryDate <= (new Date().toISOString().split('T')[0] ?? '')) {
errors.expiryDate = 'Expiry date must be in the future.'
valid = false
}
return valid return valid
} }
@@ -186,7 +153,6 @@ async function handleSubmit() {
dateTime: dateTimeWithOffset, dateTime: dateTimeWithOffset,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
location: form.location.trim() || undefined, location: form.location.trim() || undefined,
expiryDate: form.expiryDate,
}, },
}) })
@@ -212,10 +178,9 @@ async function handleSubmit() {
organizerToken: data.organizerToken, organizerToken: data.organizerToken,
title: data.title, title: data.title,
dateTime: data.dateTime, dateTime: data.dateTime,
expiryDate: data.expiryDate,
}) })
router.push({ name: 'event', params: { token: data.eventToken } }) router.push({ name: 'event', params: { eventToken: data.eventToken } })
} }
} catch { } catch {
submitting.value = false submitting.value = false

View File

@@ -1,12 +1,22 @@
<template> <template>
<main class="detail"> <main class="detail">
<!-- Hero image with overlaid header -->
<div class="detail__hero">
<img
class="detail__hero-img"
src="@/assets/images/event-hero-placeholder.jpg"
alt=""
/>
<div class="detail__hero-overlay" />
<header class="detail__header"> <header class="detail__header">
<RouterLink to="/" class="detail__back" aria-label="Back to home">&larr;</RouterLink> <RouterLink to="/" class="detail__back" aria-label="Back to home">&larr;</RouterLink>
<span class="detail__brand">fete</span> <span class="detail__brand">fete</span>
</header> </header>
</div>
<div class="detail__body">
<!-- Loading state --> <!-- Loading state -->
<div v-if="state === 'loading'" class="detail__card" aria-busy="true" aria-label="Loading event details"> <div v-if="state === 'loading'" class="detail__content" aria-busy="true" aria-label="Loading event details">
<div class="skeleton skeleton--title" /> <div class="skeleton skeleton--title" />
<div class="skeleton skeleton--line" /> <div class="skeleton skeleton--line" />
<div class="skeleton skeleton--line skeleton--short" /> <div class="skeleton skeleton--line skeleton--short" />
@@ -14,46 +24,85 @@
</div> </div>
<!-- Loaded state --> <!-- Loaded state -->
<div v-else-if="state === 'loaded' && event" class="detail__card"> <div v-else-if="state === 'loaded' && event" class="detail__content">
<h1 class="detail__title">{{ event.title }}</h1> <h1 class="detail__title">{{ event.title }}</h1>
<dl class="detail__fields"> <dl class="detail__meta">
<div class="detail__field"> <div class="detail__meta-item">
<dt class="detail__label">Date &amp; Time</dt> <dt class="detail__meta-icon glass" aria-label="Date and time">
<dd class="detail__value">{{ formattedDateTime }}</dd> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</dt>
<dd class="detail__meta-text">{{ formattedDateTime }}</dd>
</div> </div>
<div v-if="event.description" class="detail__field"> <div v-if="event.location" class="detail__meta-item">
<dt class="detail__label">Description</dt> <dt class="detail__meta-icon glass" aria-label="Location">
<dd class="detail__value">{{ event.description }}</dd> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
</dt>
<dd class="detail__meta-text">{{ event.location }}</dd>
</div> </div>
<div v-if="event.location" class="detail__field"> <div class="detail__meta-item">
<dt class="detail__label">Location</dt> <dt class="detail__meta-icon glass" aria-label="Attendees">
<dd class="detail__value">{{ event.location }}</dd> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div> </dt>
<dd class="detail__meta-text">{{ event.attendeeCount }} going</dd>
<div class="detail__field">
<dt class="detail__label">Attendees</dt>
<dd class="detail__value">{{ event.attendeeCount }}</dd>
</div> </div>
</dl> </dl>
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status"> <AttendeeList v-if="isOrganizer && attendeeNames !== null" :attendees="attendeeNames" />
This event has ended.
<div v-if="event.description" class="detail__section">
<h2 class="detail__section-title">About</h2>
<p class="detail__description">{{ event.description }}</p>
</div> </div>
</div> </div>
<!-- Not found state --> <!-- Not found state -->
<div v-else-if="state === 'not-found'" class="detail__card detail__card--center" role="status"> <div v-else-if="state === 'not-found'" class="detail__content detail__content--center" role="status">
<p class="detail__message">Event not found.</p> <p class="detail__message">Event not found.</p>
</div> </div>
<!-- Error state --> <!-- Error state -->
<div v-else-if="state === 'error'" class="detail__card detail__card--center" role="alert"> <div v-else-if="state === 'error'" class="detail__content detail__content--center" role="alert">
<p class="detail__message">Something went wrong.</p> <p class="detail__message">Something went wrong.</p>
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button> <button class="btn-primary glass" type="button" @click="fetchEvent">Retry</button>
</div> </div>
</div>
<!-- RSVP bar -->
<RsvpBar
v-if="state === 'loaded' && event && !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 glass"
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>
<div class="rsvp-form__submit glow-border glow-border--animated">
<button class="rsvp-form__submit-inner glass-inner" type="submit" :disabled="submitting">
{{ submitting ? 'Sending…' : "Count me in" }}
</button>
</div>
<p v-if="submitError" class="rsvp-form__field-error rsvp-form__error" role="alert">{{ submitError }}</p>
</form>
</BottomSheet>
</main> </main>
</template> </template>
@@ -61,15 +110,31 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { RouterLink, useRoute } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
import { api } from '@/api/client' import { api } from '@/api/client'
import { useEventStorage } from '@/composables/useEventStorage'
import AttendeeList from '@/components/AttendeeList.vue'
import BottomSheet from '@/components/BottomSheet.vue'
import RsvpBar from '@/components/RsvpBar.vue'
import type { components } from '@/api/schema' import type { components } from '@/api/schema'
type GetEventResponse = components['schemas']['GetEventResponse'] type GetEventResponse = components['schemas']['GetEventResponse']
type State = 'loading' | 'loaded' | 'not-found' | 'error' type State = 'loading' | 'loaded' | 'not-found' | 'error'
const route = useRoute() const route = useRoute()
const { saveRsvp, getRsvp, getOrganizerToken } = useEventStorage()
const state = ref<State>('loading') const state = ref<State>('loading')
const event = ref<GetEventResponse | null>(null) 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(() => { const formattedDateTime = computed(() => {
if (!event.value) return '' if (!event.value) return ''
const formatted = new Intl.DateTimeFormat(undefined, { const formatted = new Intl.DateTimeFormat(undefined, {
@@ -85,7 +150,7 @@ async function fetchEvent() {
try { try {
const { data, error, response } = await api.GET('/events/{token}', { const { data, error, response } = await api.GET('/events/{token}', {
params: { path: { token: route.params.token as string } }, params: { path: { token: route.params.eventToken as string } },
}) })
if (error) { if (error) {
@@ -95,11 +160,91 @@ async function fetchEvent() {
event.value = data! event.value = data!
state.value = 'loaded' 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 { } catch {
state.value = 'error' 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) onMounted(fetchEvent)
</script> </script>
@@ -107,14 +252,56 @@ onMounted(fetchEvent)
.detail { .detail {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-2xl); /* Break out of .app-container constraints */
padding-top: var(--spacing-lg); width: 100dvw;
flex: 1;
position: relative;
left: 50%;
transform: translateX(-50%);
margin: calc(-1 * var(--content-padding)) 0;
overflow-x: hidden;
}
/* Hero image section */
.detail__hero {
position: relative;
width: 100%;
height: 420px;
overflow: visible;
flex-shrink: 0;
}
.detail__hero-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
}
.detail__hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
var(--color-glass-overlay) 0%,
transparent 50%
);
} }
.detail__header { .detail__header {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-sm); gap: var(--spacing-sm);
padding: var(--spacing-lg) var(--content-padding);
padding-top: env(safe-area-inset-top, var(--spacing-lg));
z-index: 1;
} }
.detail__back { .detail__back {
@@ -130,85 +317,157 @@ onMounted(fetchEvent)
color: var(--color-text-on-gradient); color: var(--color-text-on-gradient);
} }
.detail__card { .detail__body {
background: var(--color-card); flex: 1;
border-radius: var(--radius-card); padding: var(--spacing-lg) var(--content-padding);
padding: var(--spacing-xl); padding-bottom: 6rem;
box-shadow: var(--shadow-card); }
.detail__content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-lg); gap: var(--spacing-2xl);
max-width: var(--content-max-width);
margin: 0 auto;
} }
.detail__card--center { .detail__content--center {
align-items: center; align-items: center;
text-align: center; text-align: center;
padding-top: 4rem;
} }
/* Title */
.detail__title { .detail__title {
font-size: 1.4rem; font-size: 2rem;
font-weight: 700; font-weight: 800;
color: var(--color-text); color: var(--color-text-on-gradient);
word-break: break-word; word-break: break-word;
line-height: 1.2;
letter-spacing: -0.02em;
} }
.detail__fields { /* Meta rows: icon + text */
.detail__meta {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-md); gap: var(--spacing-md);
} }
.detail__field { .detail__meta-item {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 0.15rem; gap: var(--spacing-sm);
} }
.detail__label { .detail__meta-icon {
font-size: 0.8rem; flex-shrink: 0;
font-weight: 700; width: 36px;
color: #888; height: 36px;
text-transform: uppercase; display: flex;
letter-spacing: 0.04em; align-items: center;
justify-content: center;
border-radius: 10px;
color: var(--color-text-on-gradient);
} }
.detail__value { .detail__meta-text {
font-size: 0.95rem; font-size: 0.9rem;
color: var(--color-text); color: var(--color-text-on-gradient);
word-break: break-word; word-break: break-word;
} }
/* About section */
.detail__section {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.detail__section-title {
font-size: 0.75rem;
font-weight: 700;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.detail__description {
font-size: 0.95rem;
color: var(--color-text-soft);
line-height: 1.6;
word-break: break-word;
}
/* Expired banner */
.detail__banner { .detail__banner {
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-card); border-radius: 10px;
font-weight: 600; font-weight: 600;
font-size: 0.9rem; font-size: 0.85rem;
text-align: center; text-align: center;
} }
.detail__banner--expired { /* Error / not-found message */
background: #fff3e0;
color: #e65100;
}
.detail__message { .detail__message {
font-size: 1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
color: var(--color-text); color: var(--color-text-on-gradient);
}
/* Skeleton shimmer on gradient */
.skeleton {
background: linear-gradient(90deg, var(--color-glass) 25%, var(--color-glass-hover) 50%, var(--color-glass) 75%);
background-size: 200% 100%;
} }
/* Skeleton sizes */
.skeleton--title { .skeleton--title {
height: 1.6rem; height: 2rem;
width: 60%; width: 70%;
border-radius: 8px;
} }
.skeleton--line { .skeleton--line {
height: 1rem; height: 1rem;
width: 80%; width: 85%;
border-radius: 6px;
} }
.skeleton--short { .skeleton--short {
width: 40%; width: 45%;
}
/* RSVP submit button (glow border wrapper) */
.rsvp-form__submit {
width: 100%;
border-radius: var(--radius-button);
transition: transform 0.1s ease;
}
.rsvp-form__submit:hover {
transform: scale(1.02);
}
.rsvp-form__submit:active {
transform: scale(0.98);
}
.rsvp-form__submit-inner {
display: block;
width: 100%;
padding: var(--spacing-md) var(--spacing-lg);
border-radius: calc(var(--radius-button) - 2px);
font-family: inherit;
font-size: 1rem;
font-weight: 700;
color: var(--color-text-on-gradient);
text-align: center;
border: none;
cursor: pointer;
}
.rsvp-form__submit-inner:disabled {
opacity: 0.6;
cursor: not-allowed;
} }
</style> </style>

View File

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

View File

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

View File

@@ -14,6 +14,8 @@ vi.mock('@/composables/useEventStorage', () => ({
saveCreatedEvent: vi.fn(), saveCreatedEvent: vi.fn(),
getStoredEvents: vi.fn(() => []), getStoredEvents: vi.fn(() => []),
getOrganizerToken: vi.fn(), getOrganizerToken: vi.fn(),
saveRsvp: vi.fn(),
getRsvp: vi.fn(),
})), })),
})) }))
@@ -23,7 +25,7 @@ function createTestRouter() {
routes: [ routes: [
{ path: '/', name: 'home', component: { template: '<div />' } }, { path: '/', name: 'home', component: { template: '<div />' } },
{ path: '/create', name: 'create-event', component: EventCreateView }, { path: '/create', name: 'create-event', component: EventCreateView },
{ path: '/events/:token', name: 'event', component: { template: '<div />' } }, { path: '/events/:eventToken', name: 'event', component: { template: '<div />' } },
], ],
}) })
} }
@@ -42,7 +44,6 @@ describe('EventCreateView', () => {
expect(wrapper.find('#description').exists()).toBe(true) expect(wrapper.find('#description').exists()).toBe(true)
expect(wrapper.find('#dateTime').exists()).toBe(true) expect(wrapper.find('#dateTime').exists()).toBe(true)
expect(wrapper.find('#location').exists()).toBe(true) expect(wrapper.find('#location').exists()).toBe(true)
expect(wrapper.find('#expiryDate').exists()).toBe(true)
}) })
it('has required attribute on required fields', async () => { it('has required attribute on required fields', async () => {
@@ -56,7 +57,6 @@ describe('EventCreateView', () => {
expect(wrapper.find('#title').attributes('required')).toBeDefined() expect(wrapper.find('#title').attributes('required')).toBeDefined()
expect(wrapper.find('#dateTime').attributes('required')).toBeDefined() expect(wrapper.find('#dateTime').attributes('required')).toBeDefined()
expect(wrapper.find('#expiryDate').attributes('required')).toBeDefined()
}) })
it('does not have required attribute on optional fields', async () => { it('does not have required attribute on optional fields', async () => {
@@ -100,7 +100,6 @@ describe('EventCreateView', () => {
// Fill required fields // Fill required fields
await wrapper.find('#title').setValue('My Event') await wrapper.find('#title').setValue('My Event')
await wrapper.find('#dateTime').setValue('2026-12-25T18:00') await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
await wrapper.find('#expiryDate').setValue('2026-12-24')
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
await flushPromises() await flushPromises()
@@ -125,7 +124,7 @@ describe('EventCreateView', () => {
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
const errorsBefore = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0) const errorsBefore = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0)
expect(errorsBefore.length).toBeGreaterThanOrEqual(3) expect(errorsBefore.length).toBeGreaterThanOrEqual(2)
// Type into title field // Type into title field
await wrapper.find('#title').setValue('My Event') await wrapper.find('#title').setValue('My Event')
@@ -136,9 +135,6 @@ describe('EventCreateView', () => {
const dateTimeError = wrapper.find('#dateTime').element.closest('.form-group')!.querySelector('[role="alert"]')! const dateTimeError = wrapper.find('#dateTime').element.closest('.form-group')!.querySelector('[role="alert"]')!
expect(dateTimeError.textContent).not.toBe('') expect(dateTimeError.textContent).not.toBe('')
const expiryError = wrapper.find('#expiryDate').element.closest('.form-group')!.querySelector('[role="alert"]')!
expect(expiryError.textContent).not.toBe('')
}) })
it('shows validation errors when submitting empty form', async () => { it('shows validation errors when submitting empty form', async () => {
@@ -154,7 +150,7 @@ describe('EventCreateView', () => {
const errorElements = wrapper.findAll('[role="alert"]') const errorElements = wrapper.findAll('[role="alert"]')
const errorTexts = errorElements.map((el) => el.text()).filter((t) => t.length > 0) const errorTexts = errorElements.map((el) => el.text()).filter((t) => t.length > 0)
expect(errorTexts.length).toBeGreaterThanOrEqual(3) expect(errorTexts.length).toBeGreaterThanOrEqual(2)
}) })
it('submits successfully, saves to storage, and navigates to event page', async () => { it('submits successfully, saves to storage, and navigates to event page', async () => {
@@ -165,6 +161,9 @@ describe('EventCreateView', () => {
saveCreatedEvent: mockSave, saveCreatedEvent: mockSave,
getStoredEvents: vi.fn(() => []), getStoredEvents: vi.fn(() => []),
getOrganizerToken: vi.fn(), getOrganizerToken: vi.fn(),
saveRsvp: vi.fn(),
getRsvp: vi.fn(),
removeEvent: vi.fn(),
}) })
vi.mocked(api.POST).mockResolvedValueOnce({ vi.mocked(api.POST).mockResolvedValueOnce({
@@ -174,7 +173,6 @@ describe('EventCreateView', () => {
title: 'Birthday Party', title: 'Birthday Party',
dateTime: '2026-12-25T18:00:00+01:00', dateTime: '2026-12-25T18:00:00+01:00',
timezone: 'Europe/Berlin', timezone: 'Europe/Berlin',
expiryDate: '2026-12-24',
}, },
error: undefined, error: undefined,
response: new Response(), response: new Response(),
@@ -193,7 +191,6 @@ describe('EventCreateView', () => {
await wrapper.find('#description').setValue('Come celebrate!') await wrapper.find('#description').setValue('Come celebrate!')
await wrapper.find('#dateTime').setValue('2026-12-25T18:00') await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
await wrapper.find('#location').setValue('Berlin') await wrapper.find('#location').setValue('Berlin')
await wrapper.find('#expiryDate').setValue('2026-12-24')
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
await flushPromises() await flushPromises()
@@ -203,7 +200,6 @@ describe('EventCreateView', () => {
title: 'Birthday Party', title: 'Birthday Party',
description: 'Come celebrate!', description: 'Come celebrate!',
location: 'Berlin', location: 'Berlin',
expiryDate: '2026-12-24',
}), }),
}) })
@@ -212,12 +208,11 @@ describe('EventCreateView', () => {
organizerToken: 'org-456', organizerToken: 'org-456',
title: 'Birthday Party', title: 'Birthday Party',
dateTime: '2026-12-25T18:00:00+01:00', dateTime: '2026-12-25T18:00:00+01:00',
expiryDate: '2026-12-24',
}) })
expect(pushSpy).toHaveBeenCalledWith({ expect(pushSpy).toHaveBeenCalledWith({
name: 'event', name: 'event',
params: { token: 'abc-123' }, params: { eventToken: 'abc-123' },
}) })
}) })
@@ -240,7 +235,6 @@ describe('EventCreateView', () => {
await wrapper.find('#title').setValue('Duplicate Event') await wrapper.find('#title').setValue('Duplicate Event')
await wrapper.find('#dateTime').setValue('2026-12-25T18:00') await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
await wrapper.find('#expiryDate').setValue('2026-12-24')
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
await flushPromises() await flushPromises()
@@ -251,6 +245,5 @@ describe('EventCreateView', () => {
// Other field errors should not be present // Other field errors should not be present
expect(wrapper.find('#dateTime-error').exists()).toBe(false) expect(wrapper.find('#dateTime-error').exists()).toBe(false)
expect(wrapper.find('#expiryDate-error').exists()).toBe(false)
}) })
}) })

View File

@@ -7,15 +7,31 @@ import { api } from '@/api/client'
vi.mock('@/api/client', () => ({ vi.mock('@/api/client', () => ({
api: { api: {
GET: vi.fn(), 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) { function createTestRouter(_token?: string) {
return createRouter({ return createRouter({
history: createMemoryHistory(), history: createMemoryHistory(),
routes: [ routes: [
{ path: '/', name: 'home', component: { template: '<div />' } }, { path: '/', name: 'home', component: { template: '<div />' } },
{ path: '/events/:token', name: 'event', component: EventDetailView }, { path: '/events/:eventToken', name: 'event', component: EventDetailView },
], ],
}) })
} }
@@ -26,6 +42,7 @@ async function mountWithToken(token = 'test-token') {
await router.isReady() await router.isReady()
return mount(EventDetailView, { return mount(EventDetailView, {
global: { plugins: [router] }, global: { plugins: [router] },
attachTo: document.body,
}) })
} }
@@ -37,15 +54,24 @@ const fullEvent = {
timezone: 'Europe/Berlin', timezone: 'Europe/Berlin',
location: 'Central Park, NYC', location: 'Central Park, NYC',
attendeeCount: 12, 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(() => { beforeEach(() => {
vi.restoreAllMocks() vi.restoreAllMocks()
mockGetRsvp.mockReturnValue(undefined)
mockGetOrganizerToken.mockReturnValue(undefined)
}) })
describe('EventDetailView', () => { describe('EventDetailView', () => {
// T014: Loading state // Loading state
it('renders skeleton shimmer placeholders while loading', async () => { it('renders skeleton shimmer placeholders while loading', async () => {
vi.mocked(api.GET).mockReturnValue(new Promise(() => {})) vi.mocked(api.GET).mockReturnValue(new Promise(() => {}))
@@ -53,15 +79,12 @@ describe('EventDetailView', () => {
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true) expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
expect(wrapper.findAll('.skeleton').length).toBeGreaterThanOrEqual(3) expect(wrapper.findAll('.skeleton').length).toBeGreaterThanOrEqual(3)
wrapper.unmount()
}) })
// T013: Loaded state — all fields // Loaded state — all fields
it('renders all event fields when loaded', async () => { it('renders all event fields when loaded', async () => {
vi.mocked(api.GET).mockResolvedValue({ mockLoadedEvent()
data: fullEvent,
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
const wrapper = await mountWithToken() const wrapper = await mountWithToken()
await flushPromises() await flushPromises()
@@ -71,37 +94,25 @@ describe('EventDetailView', () => {
expect(wrapper.text()).toContain('Central Park, NYC') expect(wrapper.text()).toContain('Central Park, NYC')
expect(wrapper.text()).toContain('12') expect(wrapper.text()).toContain('12')
expect(wrapper.text()).toContain('Europe/Berlin') expect(wrapper.text()).toContain('Europe/Berlin')
wrapper.unmount()
}) })
// T013: Loaded state — locale-formatted date/time // Loaded state — locale-formatted date/time
it('formats date/time with Intl.DateTimeFormat and timezone', async () => { it('formats date/time with Intl.DateTimeFormat and timezone', async () => {
vi.mocked(api.GET).mockResolvedValue({ mockLoadedEvent()
data: fullEvent,
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
const wrapper = await mountWithToken() const wrapper = await mountWithToken()
await flushPromises() await flushPromises()
const dateField = wrapper.findAll('.detail__value')[0]! const dateField = wrapper.findAll('.detail__meta-text')[0]!
expect(dateField.text()).toContain('(Europe/Berlin)') expect(dateField.text()).toContain('(Europe/Berlin)')
// The formatted date part is locale-dependent but should contain the year
expect(dateField.text()).toContain('2026') expect(dateField.text()).toContain('2026')
wrapper.unmount()
}) })
// T013: Loaded state — optional fields absent // Loaded state — optional fields absent
it('does not render description and location when absent', async () => { it('does not render description and location when absent', async () => {
vi.mocked(api.GET).mockResolvedValue({ mockLoadedEvent({ description: undefined, location: undefined, attendeeCount: 0 })
data: {
...fullEvent,
description: undefined,
location: undefined,
attendeeCount: 0,
},
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
const wrapper = await mountWithToken() const wrapper = await mountWithToken()
await flushPromises() await flushPromises()
@@ -109,38 +120,10 @@ describe('EventDetailView', () => {
expect(wrapper.text()).not.toContain('Description') expect(wrapper.text()).not.toContain('Description')
expect(wrapper.text()).not.toContain('Location') expect(wrapper.text()).not.toContain('Location')
expect(wrapper.text()).toContain('0') expect(wrapper.text()).toContain('0')
wrapper.unmount()
}) })
// T020 (US2): Expired state // Not found state
it('renders "event has ended" banner when expired', async () => {
vi.mocked(api.GET).mockResolvedValue({
data: { ...fullEvent, expired: true },
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.text()).toContain('This event has ended.')
expect(wrapper.find('.detail__banner--expired').exists()).toBe(true)
})
// T020 (US2): No expired banner when not expired
it('does not render expired banner when event is active', async () => {
vi.mocked(api.GET).mockResolvedValue({
data: fullEvent,
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.find('.detail__banner--expired').exists()).toBe(false)
})
// T023 (US4): Not found state
it('renders "event not found" when API returns 404', async () => { it('renders "event not found" when API returns 404', async () => {
vi.mocked(api.GET).mockResolvedValue({ vi.mocked(api.GET).mockResolvedValue({
data: undefined, data: undefined,
@@ -152,11 +135,11 @@ describe('EventDetailView', () => {
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('Event not found.') expect(wrapper.text()).toContain('Event not found.')
// No event data in DOM
expect(wrapper.find('.detail__title').exists()).toBe(false) expect(wrapper.find('.detail__title').exists()).toBe(false)
wrapper.unmount()
}) })
// T027: Server error + retry // Server error + retry
it('renders error state with retry button on server error', async () => { it('renders error state with retry button on server error', async () => {
vi.mocked(api.GET).mockResolvedValue({ vi.mocked(api.GET).mockResolvedValue({
data: undefined, data: undefined,
@@ -169,9 +152,10 @@ describe('EventDetailView', () => {
expect(wrapper.text()).toContain('Something went wrong.') expect(wrapper.text()).toContain('Something went wrong.')
expect(wrapper.find('button').text()).toBe('Retry') expect(wrapper.find('button').text()).toBe('Retry')
wrapper.unmount()
}) })
// T027: Retry button re-fetches // Retry button re-fetches
it('retry button triggers a new fetch', async () => { it('retry button triggers a new fetch', async () => {
vi.mocked(api.GET) vi.mocked(api.GET)
.mockResolvedValueOnce({ .mockResolvedValueOnce({
@@ -194,5 +178,192 @@ describe('EventDetailView', () => {
await flushPromises() await flushPromises()
expect(wrapper.find('.detail__title').text()).toBe('Summer BBQ') 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('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-inner').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-inner').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-inner').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-inner').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(), history: createMemoryHistory(),
routes: [ routes: [
{ path: '/', name: 'home', component: { template: '<div />' } }, { path: '/', name: 'home', component: { template: '<div />' } },
{ path: '/events/:token', name: 'event', component: EventStubView }, { path: '/events/:eventToken', name: 'event', component: EventStubView },
], ],
}) })
} }

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.

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