63 Commits

Author SHA1 Message Date
c51eacb261 Merge pull request 'Implement watch-event feature (017)' (#39) from 017-watch-event into master
All checks were successful
CI / backend-test (push) Successful in 56s
CI / frontend-test (push) Successful in 26s
CI / frontend-e2e (push) Successful in 1m30s
CI / build-and-publish (push) Has been skipped
2026-03-12 22:25:21 +01:00
c450849e4d Implement watch-event feature (017) with bookmark in RsvpBar
All checks were successful
CI / backend-test (push) Successful in 1m0s
CI / frontend-test (push) Successful in 27s
CI / frontend-e2e (push) Successful in 1m30s
CI / build-and-publish (push) Has been skipped
Add client-side watch/bookmark functionality: users can save events to
localStorage without RSVPing via a bookmark button next to the "I'm attending"
CTA. Watched events appear in the event list with a "Watching" label.
Bookmark is only visible for visitors (not attendees or organizers).

Includes spec, plan, research, tasks, unit tests, and E2E tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:20:57 +01:00
e01d5ee642 Merge pull request 'Implement cancel-event feature (016)' (#38) from 016-cancel-event into master
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 38s
CI / frontend-e2e (push) Successful in 1m25s
CI / build-and-publish (push) Successful in 1m31s
2026-03-12 20:42:38 +01:00
d333ab3d39 Refactor domain models to records and move exceptions to sub-package
All checks were successful
CI / backend-test (push) Successful in 1m1s
CI / frontend-test (push) Successful in 26s
CI / frontend-e2e (push) Successful in 1m25s
CI / build-and-publish (push) Has been skipped
- Convert Event and Rsvp from mutable POJOs to Java records
- Move all 8 exception classes to application.service.exception sub-package
- Add ArchUnit rule enforcing domain models must be records

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:09:27 +01:00
541017965f Implement cancel-event feature (016)
Add PATCH /events/{eventToken} endpoint for organizers to cancel events,
cancellation banner for visitors, and RSVP rejection on cancelled events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:52:22 +01:00
981920f004 Merge pull request 'Update dependency eslint-plugin-oxlint to ~1.55.0' (#36) from renovate/oxlint-monorepo into master
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 26s
CI / frontend-e2e (push) Successful in 1m22s
CI / build-and-publish (push) Has been skipped
Reviewed-on: #36
2026-03-12 19:07:29 +01:00
3908c89998 Add spec, plan, and tasks for 016-cancel-event feature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:03:57 +01:00
bf0f4ffb7f Merge pull request 'Rename path parameter {token} to {eventToken}' (#37) from 015-rename-token-to-eventToken into master
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 27s
CI / frontend-e2e (push) Successful in 1m23s
CI / build-and-publish (push) Successful in 1m12s
2026-03-12 18:11:09 +01:00
58043d1507 Rename path parameter {token} to {eventToken} in OpenAPI spec
All checks were successful
CI / backend-test (push) Successful in 57s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m18s
CI / build-and-publish (push) Has been skipped
Aligns the path parameter naming with the value object convention
used throughout the codebase (eventToken, rsvpToken, organizerToken).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:07:44 +01:00
Renovate Bot
264c4ec21f Update dependency eslint-plugin-oxlint to ~1.55.0
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 26s
CI / frontend-e2e (push) Successful in 1m21s
CI / build-and-publish (push) Has been skipped
2026-03-12 17:02:27 +00:00
6d7a55fdb3 Merge pull request 'Update dependency vite to v8' (#31) from renovate/vite-8.x into master
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 27s
CI / frontend-e2e (push) Successful in 1m21s
CI / build-and-publish (push) Has been skipped
Merge pull request 'Update dependency vite to v8' (#31)
2026-03-12 17:55:13 +01:00
a8aacf4ee9 Merge pull request 'Update dependency vitest to v4.1.0' (#33) from renovate/vitest-monorepo into master
Some checks failed
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
CI / backend-test (push) Has been cancelled
Merge pull request 'Update dependency vitest to v4.1.0' (#33)
2026-03-12 17:55:02 +01:00
0a404ecde3 Merge pull request 'Update dependency @vitejs/plugin-vue to v6.0.5' (#32) from renovate/vitejs-plugin-vue-6.x-lockfile into master
Some checks failed
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
CI / backend-test (push) Has been cancelled
Merge pull request 'Update dependency @vitejs/plugin-vue to v6.0.5' (#32)
2026-03-12 17:54:54 +01:00
01f9e3dac1 Merge pull request 'Update dependency @vitest/eslint-plugin to v1.6.11' (#34) from renovate/vitest-eslint-plugin-1.x-lockfile into master
Some checks failed
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
CI / backend-test (push) Has been cancelled
Merge pull request 'Update dependency @vitest/eslint-plugin to v1.6.11' (#34)
2026-03-12 17:54:47 +01:00
ad607afe83 Merge pull request 'Update oxlint monorepo' (#29) from renovate/oxlint-monorepo into master
Some checks failed
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
CI / backend-test (push) Has been cancelled
Merge pull request 'Update oxlint monorepo' (#29)
2026-03-12 17:54:40 +01:00
f0424223de Merge pull request 'Update dependency maven to v3.9.14' (#30) from renovate/maven-3.x into master
Some checks failed
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
CI / backend-test (push) Has been cancelled
Merge pull request 'Update dependency maven to v3.9.14' (#30)
2026-03-12 17:54:33 +01:00
7ab9068c14 Merge pull request 'Add cancel RSVP feature' (#35) from 014-cancel-rsvp 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 1m17s
CI / build-and-publish (push) Has been skipped
2026-03-12 17:49:34 +01:00
41bb17d5c9 Add cancel RSVP feature (backend DELETE endpoint + frontend UI)
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 24s
CI / frontend-e2e (push) Successful in 1m18s
CI / build-and-publish (push) Has been skipped
Allows guests to cancel their RSVP via a DELETE endpoint using their
guestToken. Frontend shows cancel button in RsvpBar and clears local
storage on success. Includes unit tests, integration tests, and E2E spec.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:45:37 +01:00
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
Renovate Bot
7477a953c5 Update dependency @vitest/eslint-plugin to v1.6.11
All checks were successful
CI / backend-test (push) Successful in 1m1s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m9s
CI / build-and-publish (push) Has been skipped
2026-03-12 16:02:57 +00:00
Renovate Bot
7fb296b47f Update dependency vitest to v4.1.0
All checks were successful
CI / backend-test (push) Successful in 1m0s
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 15:02:35 +00:00
Renovate Bot
8ab7d345c8 Update dependency @vitejs/plugin-vue to v6.0.5
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 15:02:20 +00:00
Renovate Bot
cf2139f229 Update dependency vite to v8
All checks were successful
CI / backend-test (push) Successful in 57s
CI / frontend-test (push) Successful in 27s
CI / frontend-e2e (push) Successful in 1m14s
CI / build-and-publish (push) Has been skipped
2026-03-12 14:02:26 +00:00
Renovate Bot
79f33d659c Update dependency maven to v3.9.14
All checks were successful
CI / backend-test (push) Successful in 55s
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 12:02:00 +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
178 changed files with 12337 additions and 2670 deletions

View File

@@ -79,6 +79,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Parse SemVer tag
id: semver
@@ -114,3 +116,22 @@ jobs:
docker push "${IMAGE}:${{ steps.semver.outputs.minor }}"
docker push "${IMAGE}:${{ steps.semver.outputs.major }}"
docker push "${IMAGE}:latest"
- name: Generate changelog
id: changelog
run: |
PREV_TAG=$(git tag --sort=-v:refname | sed -n '2p')
if [ -z "$PREV_TAG" ]; then
git log --oneline --no-merges > RELEASE_NOTES.md
else
git log --oneline --no-merges "${PREV_TAG}..HEAD" > RELEASE_NOTES.md
fi
echo "Container image: \`${IMAGE}:${{ steps.semver.outputs.full }}\`" >> RELEASE_NOTES.md
- name: Create Gitea release
uses: akkuman/gitea-release-action@v1
with:
tag_name: ${{ github.ref_name }}
name: v${{ steps.semver.outputs.full }}
body_path: RELEASE_NOTES.md
token: ${{ github.token }}

View File

@@ -82,3 +82,114 @@ Die folgenden Punkte wurden in Diskussionen bereits geklärt und sind verbindlic
* Frontend: Vue 3 (mit Vite als Bundler, TypeScript, Vue Router)
* Architekturentscheidungen die NOCH NICHT getroffen wurden (hier darf nichts eigenmächtig entschieden werden!):
* (derzeit keine offenen Architekturentscheidungen)
## Nicht umgesetzte Feature-Ideen (ehemals Specs 009026)
### 009 Gästeliste
Organisator sieht alle RSVPs (Name, Status) und kann einzelne Einträge löschen.
* Nur mit gültigem Organizer-Token sichtbar
* Gäste ohne Token sehen keine Gästeliste
* Löschung serverseitig validiert
### 010 Event bearbeiten
Organisator kann Titel, Beschreibung, Datum, Ort und Ablaufdatum ändern.
* Formular vorausgefüllt mit aktuellen Werten
* Ablaufdatum muss in der Zukunft liegen
* Ohne Organizer-Token kein Edit-UI sichtbar
### 011 Event merken/bookmarken
Gäste können Events lokal merken, ohne RSVP abzugeben — rein clientseitig via localStorage.
* Kein Serverkontakt nötig
* Unabhängig vom RSVP-Status
* Auch bei abgelaufenen Events möglich
### 012 Lokale Event-Übersicht
Startseite (`/`) zeigt alle getrackten Events (erstellt, zugesagt, gemerkt) aus localStorage.
* Zeigt Titel, Datum, Beziehungstyp (Organisator/Gast/Gemerkt)
* Vergangene Events als "beendet" markiert
* Einträge können entfernt werden
### 013 Kalender-Export
.ics-Download (RFC 5545) mit Event-Details, optional webcal:// für Live-Updates.
* Stabile UID aus Event-Token (Re-Import aktualisiert statt dupliziert)
* Bei Absage: STATUS:CANCELLED im .ics
* Kein externer Kalenderservice kontaktiert
### 014 Änderungen hervorheben
Geänderte Felder werden visuell hervorgehoben, wenn der Gast seit der letzten Änderung nicht mehr auf der Seite war.
* Server trackt `last_edited_at` + geänderte Feldnamen
* Client speichert `last_seen_at` in localStorage
* Privacy-freundlich: kein serverseitiges Read-Tracking
### 015 Organisator-Updates
Organisator kann Textnachrichten im Event posten (Pinnwand-Stil).
* Chronologisch sortiert, löschbar durch Organisator
* Nach Ablauf kein Posting mehr möglich
* Ohne Organizer-Token kein Compose-UI
### 016 Gast-Benachrichtigungen
Badge/Indikator bei ungelesenen Organisator-Updates, rein clientseitig via localStorage.
* Eigener Timestamp `updates_last_seen_at` (getrennt von Feld-Änderungen)
* Kein Indikator beim ersten Besuch
* Kein serverseitiges Tracking (Privacy)
### 017 QR-Code
Event-Seite zeigt QR-Code mit der öffentlichen Event-URL.
* Serverseitig generiert (kein externer QR-Service)
* Download als SVG oder hochauflösendes PNG
* Auch bei abgelaufenen Events verfügbar
### 018 Datenlöschung
Automatische Löschung aller Event-Daten nach Ablaufdatum (Privacy-Garantie).
* Scheduled Job oder Lazy Cleanup bei Zugriff
* Löscht Event, RSVPs, Updates, Bilder, Metadaten
* Idempotent, kein PII im Log
### 019 Instanz-Limit
`MAX_ACTIVE_EVENTS` als Env-Variable begrenzt aktive Events für Self-Hoster.
* Nur nicht-abgelaufene Events zählen
* Unset/leer = unbegrenzt
* Serverseitige Durchsetzung bei Event-Erstellung
### 020 PWA
Web App Manifest + Service Worker für Installierbarkeit und Offline-Caching.
* Standalone-Modus ohne Browser-Chrome
* Icon + Name auf Home-Screen
* Alle Assets selbstgehostet
### 021 Farbthemen
Organisator wählt bei Erstellung ein vordefiniertes Farbthema für die Event-Seite.
* Nur auf der Gast-Seite angewendet (nicht global)
* Änderbar beim Bearbeiten
* Unabhängig von Dark/Light Mode
### 022 Headerbild
Organisator sucht Headerbild über integrierte Unsplash-Suche.
* Serverseitig geproxied (Client kontaktiert nie Unsplash)
* Bild lokal gespeichert + Unsplash-Attribution
* Feature deaktiviert wenn kein API-Key konfiguriert
### 023 Dark Mode
App erkennt `prefers-color-scheme` und bietet manuellen Toggle.
* Manuelle Auswahl in localStorage gespeichert
* Gilt für globales App-Chrome, nicht Event-Farbthemen
* Beide Modi WCAG AA konform
### 024 Event absagen
Organisator kann Event absagen (mit optionaler Nachricht, Einweg-Transition).
* RSVPs werden nach Absage abgelehnt
* Absage-Nachricht nachträglich editierbar
* Kann nicht rückgängig gemacht werden
* Wenn Organisator Event auf der Eventlistenseite löscht, muss dabei das Event abgesagt werden (nicht nur lokal entfernen)
### 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

@@ -49,10 +49,3 @@ The following skills are available and should be used for their respective purpo
- The loop runner is `ralph.sh`. Each run lives in its own directory under `.ralph/`.
- Run directories contain: `instructions.md` (prompt), `chief-wiggum.md` (directives), `answers.md` (human answers), `questions.md` (Ralph's questions), `progress.txt` (iteration log), `meta.md` (metadata), `run.log` (execution log).
- Project specifications (user stories, setup tasks, personas, etc.) live in `specs/` (feature dirs) and `.specify/memory/` (cross-cutting docs).
## Active Technologies
- Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript (007-view-event)
- PostgreSQL (JPA via Spring Data, Liquibase migrations) (007-view-event)
## Recent Changes
- 007-view-event: Added Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript

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

View File

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

View File

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

View File

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

View File

@@ -1,25 +1,32 @@
package de.fete.adapter.in.web;
import de.fete.adapter.in.web.api.EventsApi;
import de.fete.adapter.in.web.model.Attendee;
import de.fete.adapter.in.web.model.CreateEventRequest;
import de.fete.adapter.in.web.model.CreateEventResponse;
import de.fete.adapter.in.web.model.CreateRsvpRequest;
import de.fete.adapter.in.web.model.CreateRsvpResponse;
import de.fete.adapter.in.web.model.GetAttendeesResponse;
import de.fete.adapter.in.web.model.GetEventResponse;
import de.fete.application.service.EventNotFoundException;
import de.fete.application.service.InvalidTimezoneException;
import de.fete.adapter.in.web.model.PatchEventRequest;
import de.fete.application.service.exception.EventNotFoundException;
import de.fete.application.service.exception.InvalidTimezoneException;
import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
import de.fete.domain.model.Rsvp;
import de.fete.domain.model.RsvpToken;
import de.fete.domain.port.in.CancelRsvpUseCase;
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.in.CreateRsvpUseCase;
import de.fete.domain.port.in.GetAttendeesUseCase;
import de.fete.domain.port.in.GetEventUseCase;
import java.time.Clock;
import de.fete.domain.port.in.UpdateEventUseCase;
import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -32,21 +39,27 @@ public class EventController implements EventsApi {
private final CreateEventUseCase createEventUseCase;
private final GetEventUseCase getEventUseCase;
private final CreateRsvpUseCase createRsvpUseCase;
private final CancelRsvpUseCase cancelRsvpUseCase;
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
private final Clock clock;
private final GetAttendeesUseCase getAttendeesUseCase;
private final UpdateEventUseCase updateEventUseCase;
/** Creates a new controller with the given use cases and clock. */
/** Creates a new controller with the given use cases. */
public EventController(
CreateEventUseCase createEventUseCase,
GetEventUseCase getEventUseCase,
CreateRsvpUseCase createRsvpUseCase,
CancelRsvpUseCase cancelRsvpUseCase,
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
Clock clock) {
GetAttendeesUseCase getAttendeesUseCase,
UpdateEventUseCase updateEventUseCase) {
this.createEventUseCase = createEventUseCase;
this.getEventUseCase = getEventUseCase;
this.createRsvpUseCase = createRsvpUseCase;
this.cancelRsvpUseCase = cancelRsvpUseCase;
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
this.clock = clock;
this.getAttendeesUseCase = getAttendeesUseCase;
this.updateEventUseCase = updateEventUseCase;
}
@Override
@@ -59,57 +72,91 @@ public class EventController implements EventsApi {
request.getDescription(),
request.getDateTime(),
zoneId,
request.getLocation(),
request.getExpiryDate()
request.getLocation()
);
Event event = createEventUseCase.createEvent(command);
var response = new CreateEventResponse();
response.setEventToken(event.getEventToken().value());
response.setOrganizerToken(event.getOrganizerToken().value());
response.setTitle(event.getTitle());
response.setDateTime(event.getDateTime());
response.setTimezone(event.getTimezone().getId());
response.setExpiryDate(event.getExpiryDate());
response.setEventToken(event.eventToken().value());
response.setOrganizerToken(event.organizerToken().value());
response.setTitle(event.title());
response.setDateTime(event.dateTime());
response.setTimezone(event.timezone().getId());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@Override
public ResponseEntity<GetEventResponse> getEvent(UUID token) {
var eventToken = new de.fete.domain.model.EventToken(token);
Event event = getEventUseCase.getByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(token));
public ResponseEntity<GetEventResponse> getEvent(UUID eventToken) {
var evtToken = new EventToken(eventToken);
Event event = getEventUseCase.getByEventToken(evtToken)
.orElseThrow(() -> new EventNotFoundException(eventToken));
var response = new GetEventResponse();
response.setEventToken(event.getEventToken().value());
response.setTitle(event.getTitle());
response.setDescription(event.getDescription());
response.setDateTime(event.getDateTime());
response.setTimezone(event.getTimezone().getId());
response.setLocation(event.getLocation());
response.setEventToken(event.eventToken().value());
response.setTitle(event.title());
response.setDescription(event.description());
response.setDateTime(event.dateTime());
response.setTimezone(event.timezone().getId());
response.setLocation(event.location());
response.setAttendeeCount(
(int) countAttendeesByEventUseCase.countByEvent(eventToken));
response.setExpired(
event.getExpiryDate().isBefore(LocalDate.now(clock)));
(int) countAttendeesByEventUseCase.countByEvent(evtToken));
response.setCancelled(event.cancelled());
response.setCancellationReason(event.cancellationReason());
return ResponseEntity.ok(response);
}
@Override
public ResponseEntity<Void> patchEvent(
UUID eventToken, UUID organizerToken, PatchEventRequest request) {
updateEventUseCase.cancelEvent(
new EventToken(eventToken),
new OrganizerToken(organizerToken),
request.getCancelled(),
request.getCancellationReason());
return ResponseEntity.noContent().build();
}
@Override
public ResponseEntity<GetAttendeesResponse> getAttendees(
UUID eventToken, UUID organizerToken) {
var evtToken = new EventToken(eventToken);
var orgToken = new OrganizerToken(organizerToken);
List<String> names = getAttendeesUseCase
.getAttendeeNames(evtToken, 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());
UUID eventToken, CreateRsvpRequest createRsvpRequest) {
var evtToken = new EventToken(eventToken);
Rsvp rsvp = createRsvpUseCase.createRsvp(evtToken, createRsvpRequest.getName());
var response = new CreateRsvpResponse();
response.setRsvpToken(rsvp.getRsvpToken().value());
response.setName(rsvp.getName());
response.setRsvpToken(rsvp.rsvpToken().value());
response.setName(rsvp.name());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@Override
public ResponseEntity<Void> cancelRsvp(UUID eventToken, UUID rsvpToken) {
cancelRsvpUseCase.cancelRsvp(new EventToken(eventToken), new RsvpToken(rsvpToken));
return ResponseEntity.noContent().build();
}
private static ZoneId parseTimezone(String timezone) {
try {
return ZoneId.of(timezone);

View File

@@ -1,10 +1,13 @@
package de.fete.adapter.in.web;
import de.fete.application.service.EventExpiredException;
import de.fete.application.service.EventNotFoundException;
import de.fete.application.service.ExpiryDateBeforeEventException;
import de.fete.application.service.ExpiryDateInPastException;
import de.fete.application.service.InvalidTimezoneException;
import de.fete.application.service.exception.EventAlreadyCancelledException;
import de.fete.application.service.exception.EventCancelledException;
import de.fete.application.service.exception.EventExpiredException;
import de.fete.application.service.exception.EventNotFoundException;
import de.fete.application.service.exception.ExpiryDateBeforeEventException;
import de.fete.application.service.exception.ExpiryDateInPastException;
import de.fete.application.service.exception.InvalidOrganizerTokenException;
import de.fete.application.service.exception.InvalidTimezoneException;
import java.net.URI;
import java.util.List;
import java.util.Map;
@@ -74,6 +77,32 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
.body(problemDetail);
}
/** Handles attempt to cancel an already cancelled event. */
@ExceptionHandler(EventAlreadyCancelledException.class)
public ResponseEntity<ProblemDetail> handleEventAlreadyCancelled(
EventAlreadyCancelledException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, ex.getMessage());
problemDetail.setTitle("Event Already Cancelled");
problemDetail.setType(URI.create("urn:problem-type:event-already-cancelled"));
return ResponseEntity.status(HttpStatus.CONFLICT)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Handles RSVP on cancelled event. */
@ExceptionHandler(EventCancelledException.class)
public ResponseEntity<ProblemDetail> handleEventCancelled(
EventCancelledException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, ex.getMessage());
problemDetail.setTitle("Event Cancelled");
problemDetail.setType(URI.create("urn:problem-type:event-cancelled"));
return ResponseEntity.status(HttpStatus.CONFLICT)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Handles RSVP on expired event. */
@ExceptionHandler(EventExpiredException.class)
public ResponseEntity<ProblemDetail> handleEventExpired(
@@ -87,6 +116,19 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
.body(problemDetail);
}
/** Handles invalid organizer token. */
@ExceptionHandler(InvalidOrganizerTokenException.class)
public ResponseEntity<ProblemDetail> handleInvalidOrganizerToken(
InvalidOrganizerTokenException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.FORBIDDEN, ex.getMessage());
problemDetail.setTitle("Forbidden");
problemDetail.setType(URI.create("urn:problem-type:invalid-organizer-token"));
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Handles event not found. */
@ExceptionHandler(EventNotFoundException.class)
public ResponseEntity<ProblemDetail> handleEventNotFound(

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/{eventToken}",
produces = MediaType.TEXT_HTML_VALUE
)
@ResponseBody
public String serveEventPage(@PathVariable String eventToken,
HttpServletRequest request) {
if (htmlTemplate == null) {
return "";
}
String baseUrl = getBaseUrl(request);
Map<String, String> meta = resolveEventMeta(eventToken, 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.title());
String description = formatDescription(event);
tags.put("og:title", title);
tags.put("og:description", description);
tags.put("og:url", baseUrl + "/events/" + event.eventToken().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.dateTime().atZoneSameInstant(event.timezone());
var sb = new StringBuilder();
sb.append("📅 ").append(zoned.format(DATE_FORMAT));
if (event.location() != null && !event.location().isBlank()) {
sb.append(" · 📍 ").append(event.location());
}
if (event.description() != null && !event.description().isBlank()) {
sb.append("").append(event.description());
}
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

@@ -46,6 +46,12 @@ public class EventJpaEntity {
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "cancelled", nullable = false)
private boolean cancelled;
@Column(name = "cancellation_reason", length = 2000)
private String cancellationReason;
/** Returns the internal database ID. */
public Long getId() {
return id;
@@ -145,4 +151,24 @@ public class EventJpaEntity {
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
/** Returns whether the event is cancelled. */
public boolean isCancelled() {
return cancelled;
}
/** Sets the cancelled flag. */
public void setCancelled(boolean cancelled) {
this.cancelled = cancelled;
}
/** Returns the cancellation reason. */
public String getCancellationReason() {
return cancellationReason;
}
/** Sets the cancellation reason. */
public void setCancellationReason(String cancellationReason) {
this.cancellationReason = cancellationReason;
}
}

View File

@@ -3,10 +3,17 @@ package de.fete.adapter.out.persistence;
import java.util.Optional;
import java.util.UUID;
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. */
public interface EventJpaRepository extends JpaRepository<EventJpaEntity, Long> {
/** Finds an event by its public event token. */
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

@@ -31,33 +31,41 @@ public class EventPersistenceAdapter implements EventRepository {
return jpaRepository.findByEventToken(eventToken.value()).map(this::toDomain);
}
@Override
public int deleteExpired() {
return jpaRepository.deleteExpired();
}
private EventJpaEntity toEntity(Event event) {
var entity = new EventJpaEntity();
entity.setId(event.getId());
entity.setEventToken(event.getEventToken().value());
entity.setOrganizerToken(event.getOrganizerToken().value());
entity.setTitle(event.getTitle());
entity.setDescription(event.getDescription());
entity.setDateTime(event.getDateTime());
entity.setTimezone(event.getTimezone().getId());
entity.setLocation(event.getLocation());
entity.setExpiryDate(event.getExpiryDate());
entity.setCreatedAt(event.getCreatedAt());
entity.setId(event.id());
entity.setEventToken(event.eventToken().value());
entity.setOrganizerToken(event.organizerToken().value());
entity.setTitle(event.title());
entity.setDescription(event.description());
entity.setDateTime(event.dateTime());
entity.setTimezone(event.timezone().getId());
entity.setLocation(event.location());
entity.setExpiryDate(event.expiryDate());
entity.setCreatedAt(event.createdAt());
entity.setCancelled(event.cancelled());
entity.setCancellationReason(event.cancellationReason());
return entity;
}
private Event toDomain(EventJpaEntity entity) {
var event = new Event();
event.setId(entity.getId());
event.setEventToken(new EventToken(entity.getEventToken()));
event.setOrganizerToken(new OrganizerToken(entity.getOrganizerToken()));
event.setTitle(entity.getTitle());
event.setDescription(entity.getDescription());
event.setDateTime(entity.getDateTime());
event.setTimezone(ZoneId.of(entity.getTimezone()));
event.setLocation(entity.getLocation());
event.setExpiryDate(entity.getExpiryDate());
event.setCreatedAt(entity.getCreatedAt());
return event;
return new Event(
entity.getId(),
new EventToken(entity.getEventToken()),
new OrganizerToken(entity.getOrganizerToken()),
entity.getTitle(),
entity.getDescription(),
entity.getDateTime(),
ZoneId.of(entity.getTimezone()),
entity.getLocation(),
entity.getExpiryDate(),
entity.getCreatedAt(),
entity.isCancelled(),
entity.getCancellationReason());
}
}

View File

@@ -11,4 +11,10 @@ public interface RsvpJpaRepository extends JpaRepository<RsvpJpaEntity, Long> {
/** 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);
/** Deletes an RSVP by event ID and RSVP token. Returns count of deleted rows. */
long deleteByEventIdAndRsvpToken(Long eventId, UUID rsvpToken);
}

View File

@@ -3,6 +3,7 @@ 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. */
@@ -28,21 +29,32 @@ public class RsvpPersistenceAdapter implements RsvpRepository {
return jpaRepository.countByEventId(eventId);
}
@Override
public List<Rsvp> findByEventId(Long eventId) {
return jpaRepository.findAllByEventIdOrderByIdAsc(eventId).stream()
.map(this::toDomain)
.toList();
}
@Override
public boolean deleteByEventIdAndRsvpToken(Long eventId, RsvpToken rsvpToken) {
return jpaRepository.deleteByEventIdAndRsvpToken(eventId, rsvpToken.value()) > 0;
}
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());
entity.setId(rsvp.id());
entity.setRsvpToken(rsvp.rsvpToken().value());
entity.setEventId(rsvp.eventId());
entity.setName(rsvp.name());
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;
return new Rsvp(
entity.getId(),
new RsvpToken(entity.getRsvpToken()),
entity.getEventId(),
entity.getName());
}
}

View File

@@ -1,21 +1,28 @@
package de.fete.application.service;
import de.fete.application.service.exception.EventAlreadyCancelledException;
import de.fete.application.service.exception.EventNotFoundException;
import de.fete.application.service.exception.InvalidOrganizerTokenException;
import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.in.GetEventUseCase;
import de.fete.domain.port.in.UpdateEventUseCase;
import de.fete.domain.port.out.EventRepository;
import java.time.Clock;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Application service implementing event creation and retrieval. */
@Service
public class EventService implements CreateEventUseCase, GetEventUseCase {
public class EventService implements CreateEventUseCase, GetEventUseCase, UpdateEventUseCase {
private static final int EXPIRY_DAYS_AFTER_EVENT = 7;
private final EventRepository eventRepository;
private final Clock clock;
@@ -28,24 +35,21 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
@Override
public Event createEvent(CreateEventCommand command) {
if (!command.expiryDate().isAfter(LocalDate.now(clock))) {
throw new ExpiryDateInPastException(command.expiryDate());
}
LocalDate expiryDate = command.dateTime().toLocalDate().plusDays(EXPIRY_DAYS_AFTER_EVENT);
if (!command.expiryDate().isAfter(command.dateTime().toLocalDate())) {
throw new ExpiryDateBeforeEventException(command.expiryDate(), command.dateTime());
}
var event = new Event();
event.setEventToken(EventToken.generate());
event.setOrganizerToken(OrganizerToken.generate());
event.setTitle(command.title());
event.setDescription(command.description());
event.setDateTime(command.dateTime());
event.setTimezone(command.timezone());
event.setLocation(command.location());
event.setExpiryDate(command.expiryDate());
event.setCreatedAt(OffsetDateTime.now(clock));
var event = new Event(
null,
EventToken.generate(),
OrganizerToken.generate(),
command.title(),
command.description(),
command.dateTime(),
command.timezone(),
command.location(),
expiryDate,
OffsetDateTime.now(clock),
false,
null);
return eventRepository.save(event);
}
@@ -54,4 +58,27 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
public Optional<Event> getByEventToken(EventToken eventToken) {
return eventRepository.findByEventToken(eventToken);
}
@Transactional
@Override
public void cancelEvent(
EventToken eventToken, OrganizerToken organizerToken,
Boolean cancelled, String reason) {
if (!Boolean.TRUE.equals(cancelled)) {
return;
}
Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
if (!event.organizerToken().equals(organizerToken)) {
throw new InvalidOrganizerTokenException();
}
if (event.cancelled()) {
throw new EventAlreadyCancelledException(eventToken.value());
}
eventRepository.save(event.withCancellation(true, reason));
}
}

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

@@ -1,20 +1,31 @@
package de.fete.application.service;
import de.fete.application.service.exception.EventCancelledException;
import de.fete.application.service.exception.EventExpiredException;
import de.fete.application.service.exception.EventNotFoundException;
import de.fete.application.service.exception.InvalidOrganizerTokenException;
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.CancelRsvpUseCase;
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 jakarta.transaction.Transactional;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import org.springframework.stereotype.Service;
/** Application service implementing RSVP creation. */
/** Application service implementing RSVP operations. */
@Service
public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseCase {
public class RsvpService
implements CreateRsvpUseCase, CancelRsvpUseCase, CountAttendeesByEventUseCase,
GetAttendeesUseCase {
private final EventRepository eventRepository;
private final RsvpRepository rsvpRepository;
@@ -35,22 +46,45 @@ public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseC
Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
if (!event.getExpiryDate().isAfter(LocalDate.now(clock))) {
if (event.cancelled()) {
throw new EventCancelledException(eventToken.value());
}
if (!event.expiryDate().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());
var rsvp = new Rsvp(null, RsvpToken.generate(), event.id(), name.strip());
return rsvpRepository.save(rsvp);
}
@Override
@Transactional
public void cancelRsvp(EventToken eventToken, RsvpToken rsvpToken) {
eventRepository.findByEventToken(eventToken)
.ifPresent(event ->
rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken));
}
@Override
public long countByEvent(EventToken eventToken) {
Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
return rsvpRepository.countByEventId(event.getId());
return rsvpRepository.countByEventId(event.id());
}
@Override
public List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken) {
Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
if (!event.organizerToken().equals(organizerToken)) {
throw new InvalidOrganizerTokenException();
}
return rsvpRepository.findByEventId(event.id()).stream()
.map(Rsvp::name)
.toList();
}
}

View File

@@ -0,0 +1,12 @@
package de.fete.application.service.exception;
import java.util.UUID;
/** Thrown when attempting to cancel an event that is already cancelled. */
public class EventAlreadyCancelledException extends RuntimeException {
/** Creates a new exception for the given event token. */
public EventAlreadyCancelledException(UUID eventToken) {
super("Event is already cancelled: " + eventToken);
}
}

View File

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

View File

@@ -1,4 +1,4 @@
package de.fete.application.service;
package de.fete.application.service.exception;
import java.util.UUID;

View File

@@ -1,4 +1,4 @@
package de.fete.application.service;
package de.fete.application.service.exception;
import java.util.UUID;

View File

@@ -1,4 +1,4 @@
package de.fete.application.service;
package de.fete.application.service.exception;
import java.time.LocalDate;
import java.time.OffsetDateTime;

View File

@@ -1,4 +1,4 @@
package de.fete.application.service;
package de.fete.application.service.exception;
import java.time.LocalDate;

View File

@@ -0,0 +1,10 @@
package de.fete.application.service.exception;
/** 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

@@ -1,4 +1,4 @@
package de.fete.application.service;
package de.fete.application.service.exception;
/** Thrown when an invalid IANA timezone ID is provided. */
public class InvalidTimezoneException extends RuntimeException {

View File

@@ -0,0 +1,4 @@
/**
* Application-layer exceptions thrown by service use case implementations.
*/
package de.fete.application.service.exception;

View File

@@ -1,21 +1,17 @@
package de.fete.config;
import java.io.IOException;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.web.bind.annotation.RestController;
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.resource.PathResourceResolver;
/** Configures API path prefix and SPA static resource serving. */
/** Configures API path prefix. Static resources served by default Spring Boot handler. */
@Configuration
public class WebConfig implements WebMvcConfigurer {
/** Provides a system clock bean for time-dependent services. */
@Bean
Clock clock() {
return Clock.systemDefaultZone();
@@ -25,23 +21,4 @@ public class WebConfig implements WebMvcConfigurer {
public void configurePathMatch(PathMatchConfigurer configurer) {
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,
OffsetDateTime dateTime,
ZoneId timezone,
String location,
LocalDate expiryDate
String location
) {}

View File

@@ -5,116 +5,26 @@ import java.time.OffsetDateTime;
import java.time.ZoneId;
/** Domain entity representing an event. */
public class Event {
public record Event(
Long id,
EventToken eventToken,
OrganizerToken organizerToken,
String title,
String description,
OffsetDateTime dateTime,
ZoneId timezone,
String location,
LocalDate expiryDate,
OffsetDateTime createdAt,
boolean cancelled,
String cancellationReason
) {
private Long id;
private EventToken eventToken;
private OrganizerToken organizerToken;
private String title;
private String description;
private OffsetDateTime dateTime;
private ZoneId timezone;
private String location;
private LocalDate expiryDate;
private OffsetDateTime createdAt;
/** Returns the internal database ID. */
public Long getId() {
return id;
}
/** Sets the internal database ID. */
public void setId(Long id) {
this.id = id;
}
/** Returns the public event token. */
public EventToken getEventToken() {
return eventToken;
}
/** Sets the public event token. */
public void setEventToken(EventToken eventToken) {
this.eventToken = eventToken;
}
/** Returns the secret organizer token. */
public OrganizerToken getOrganizerToken() {
return organizerToken;
}
/** Sets the secret organizer token. */
public void setOrganizerToken(OrganizerToken organizerToken) {
this.organizerToken = organizerToken;
}
/** Returns the event title. */
public String getTitle() {
return title;
}
/** Sets the event title. */
public void setTitle(String title) {
this.title = title;
}
/** Returns the event description. */
public String getDescription() {
return description;
}
/** Sets the event description. */
public void setDescription(String description) {
this.description = description;
}
/** Returns the event date and time with UTC offset. */
public OffsetDateTime getDateTime() {
return dateTime;
}
/** Sets the event date and time. */
public void setDateTime(OffsetDateTime dateTime) {
this.dateTime = dateTime;
}
/** Returns the IANA timezone. */
public ZoneId getTimezone() {
return timezone;
}
/** Sets the IANA timezone. */
public void setTimezone(ZoneId timezone) {
this.timezone = timezone;
}
/** Returns the event location. */
public String getLocation() {
return location;
}
/** Sets the event location. */
public void setLocation(String location) {
this.location = location;
}
/** Returns the expiry date after which event data is deleted. */
public LocalDate getExpiryDate() {
return expiryDate;
}
/** Sets the expiry date. */
public void setExpiryDate(LocalDate expiryDate) {
this.expiryDate = expiryDate;
}
/** Returns the creation timestamp. */
public OffsetDateTime getCreatedAt() {
return createdAt;
}
/** Sets the creation timestamp. */
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
/** Returns a copy of this event with cancellation applied. */
public Event withCancellation(boolean cancelled, String cancellationReason) {
return new Event(
id, eventToken, organizerToken, title, description,
dateTime, timezone, location, expiryDate, createdAt,
cancelled, cancellationReason);
}
}

View File

@@ -1,50 +1,9 @@
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;
}
}
public record Rsvp(
Long id,
RsvpToken rsvpToken,
Long eventId,
String name
) {}

View File

@@ -0,0 +1,11 @@
package de.fete.domain.port.in;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.RsvpToken;
/** Inbound port for cancelling an RSVP. */
public interface CancelRsvpUseCase {
/** Cancels the RSVP identified by the given tokens. Idempotent — no error if not found. */
void cancelRsvp(EventToken eventToken, RsvpToken rsvpToken);
}

View File

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

View File

@@ -0,0 +1,13 @@
package de.fete.domain.port.in;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
/** Inbound port for updating an event. */
public interface UpdateEventUseCase {
/** Cancels the event identified by the given token. */
void cancelEvent(
EventToken eventToken, OrganizerToken organizerToken,
Boolean cancelled, String reason);
}

View File

@@ -12,4 +12,7 @@ public interface EventRepository {
/** Finds an event by its public event token. */
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

@@ -1,6 +1,8 @@
package de.fete.domain.port.out;
import de.fete.domain.model.Rsvp;
import de.fete.domain.model.RsvpToken;
import java.util.List;
/** Outbound port for persisting and querying RSVPs. */
public interface RsvpRepository {
@@ -10,4 +12,10 @@ public interface RsvpRepository {
/** 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);
/** Deletes an RSVP by event ID and RSVP token. Returns true if a record was deleted. */
boolean deleteByEventIdAndRsvpToken(Long eventId, RsvpToken rsvpToken);
}

View File

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

View File

@@ -0,0 +1,17 @@
<?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="004-add-cancellation-columns" author="fete">
<addColumn tableName="events">
<column name="cancelled" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="cancellation_reason" type="VARCHAR(2000)"/>
</addColumn>
</changeSet>
</databaseChangeLog>

View File

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

View File

@@ -37,14 +37,46 @@ paths:
schema:
$ref: "#/components/schemas/ValidationProblemDetail"
/events/{token}/rsvps:
/events/{eventToken}/rsvps/{rsvpToken}:
delete:
operationId: cancelRsvp
summary: Cancel RSVP
description: |
Permanently deletes an RSVP identified by the RSVP token.
Idempotent: returns 204 whether the RSVP existed or not.
tags:
- events
parameters:
- name: eventToken
in: path
required: true
schema:
type: string
format: uuid
description: Event token (UUID)
- name: rsvpToken
in: path
required: true
schema:
type: string
format: uuid
description: RSVP token (UUID) identifying the attendance to cancel
responses:
"204":
description: >
RSVP successfully cancelled (or was already cancelled).
No response body.
"500":
description: Internal server error
/events/{eventToken}/rsvps:
post:
operationId: createRsvp
summary: Submit an RSVP for an event
tags:
- events
parameters:
- name: token
- name: eventToken
in: path
required: true
schema:
@@ -83,14 +115,55 @@ paths:
schema:
$ref: "#/components/schemas/ProblemDetail"
/events/{token}:
/events/{eventToken}/attendees:
get:
operationId: getAttendees
summary: Get attendee list for an event (organizer only)
tags:
- events
parameters:
- name: eventToken
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/{eventToken}:
get:
operationId: getEvent
summary: Get public event details by token
tags:
- events
parameters:
- name: token
- name: eventToken
in: path
required: true
schema:
@@ -111,6 +184,58 @@ paths:
schema:
$ref: "#/components/schemas/ProblemDetail"
patch:
operationId: patchEvent
summary: Update an event (currently cancel)
description: |
Partial update of an event resource. Currently the only supported operation
is cancellation (setting cancelled to true). Requires the organizer token.
Cancellation is irreversible.
tags:
- events
parameters:
- name: eventToken
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
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PatchEventRequest"
responses:
"204":
description: Event updated successfully
"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"
"409":
description: Event is already cancelled
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
components:
schemas:
CreateEventRequest:
@@ -119,7 +244,6 @@ components:
- title
- dateTime
- timezone
- expiryDate
properties:
title:
type: string
@@ -140,11 +264,6 @@ components:
location:
type: string
maxLength: 500
expiryDate:
type: string
format: date
description: Date after which event data is deleted. Must be in the future.
example: "2026-06-15"
CreateEventResponse:
type: object
@@ -154,7 +273,6 @@ components:
- title
- dateTime
- timezone
- expiryDate
properties:
eventToken:
type: string
@@ -177,10 +295,6 @@ components:
type: string
description: IANA timezone of the organizer
example: "Europe/Berlin"
expiryDate:
type: string
format: date
example: "2026-06-15"
GetEventResponse:
type: object
@@ -190,7 +304,7 @@ components:
- dateTime
- timezone
- attendeeCount
- expired
- cancelled
properties:
eventToken:
type: string
@@ -223,10 +337,31 @@ components:
minimum: 0
description: Number of confirmed attendees (attending=true)
example: 12
expired:
cancelled:
type: boolean
description: Whether the event's expiry date has passed
description: Whether the event has been cancelled
example: false
cancellationReason:
type:
- string
- "null"
description: Reason for cancellation, if provided
example: null
PatchEventRequest:
type: object
required:
- cancelled
properties:
cancelled:
type: boolean
description: Set to true to cancel the event (irreversible)
example: true
cancellationReason:
type: string
maxLength: 2000
description: Optional cancellation reason
example: "Unfortunately the venue is no longer available."
CreateRsvpRequest:
type: object
@@ -256,6 +391,30 @@ components:
description: Guest's display name as stored
example: "Max Mustermann"
GetAttendeesResponse:
type: object
required:
- attendees
properties:
attendees:
type: array
items:
$ref: "#/components/schemas/Attendee"
example:
- name: "Alice"
- name: "Bob"
Attendee:
type: object
required:
- name
properties:
name:
type: string
minLength: 1
maxLength: 100
example: "Alice"
ProblemDetail:
type: object
properties:

View File

@@ -4,10 +4,14 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.onionArchitecture;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
@AnalyzeClasses(packages = "de.fete", importOptions = ImportOption.DoNotIncludeTests.class)
class HexagonalArchitectureTest {
@@ -65,4 +69,24 @@ class HexagonalArchitectureTest {
static final ArchRule webAdapterMustNotDependOnOutboundPorts = noClasses()
.that().resideInAPackage("de.fete.adapter.in.web..")
.should().dependOnClassesThat().resideInAPackage("de.fete.domain.port.out..");
@ArchTest
static final ArchRule domainModelsMustBeRecords = classes()
.that().resideInAPackage("de.fete.domain.model..")
.and().doNotHaveSimpleName("package-info")
.should(beRecords());
private static ArchCondition<JavaClass> beRecords() {
return new ArchCondition<>("be records") {
@Override
public void check(JavaClass javaClass,
ConditionEvents events) {
boolean isRecord = javaClass.reflect().isRecord();
if (!isRecord) {
events.add(SimpleConditionEvent.violated(javaClass,
javaClass.getFullName() + " is not a record"));
}
}
};
}
}

View File

@@ -1,7 +1,9 @@
package de.fete.adapter.in.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -20,6 +22,7 @@ import de.fete.adapter.out.persistence.RsvpJpaRepository;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -55,8 +58,7 @@ class EventControllerIntegrationTest {
.description("Come celebrate!")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Europe/Berlin")
.location("Berlin")
.expiryDate(LocalDate.of(2026, 6, 16));
.location("Berlin");
var result = mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
@@ -67,7 +69,6 @@ class EventControllerIntegrationTest {
.andExpect(jsonPath("$.title").value("Birthday Party"))
.andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
.andExpect(jsonPath("$.dateTime").isNotEmpty())
.andExpect(jsonPath("$.expiryDate").isNotEmpty())
.andReturn();
var response = objectMapper.readValue(
@@ -79,7 +80,7 @@ class EventControllerIntegrationTest {
assertThat(persisted.getDescription()).isEqualTo("Come celebrate!");
assertThat(persisted.getTimezone()).isEqualTo("Europe/Berlin");
assertThat(persisted.getLocation()).isEqualTo("Berlin");
assertThat(persisted.getExpiryDate()).isEqualTo(request.getExpiryDate());
assertThat(persisted.getExpiryDate()).isEqualTo(LocalDate.of(2026, 6, 22));
assertThat(persisted.getDateTime().toInstant())
.isEqualTo(request.getDateTime().toInstant());
assertThat(persisted.getOrganizerToken()).isNotNull();
@@ -91,8 +92,7 @@ class EventControllerIntegrationTest {
var request = new CreateEventRequest()
.title("Minimal Event")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("UTC")
.expiryDate(LocalDate.of(2026, 6, 16));
.timezone("UTC");
var result = mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
@@ -119,8 +119,7 @@ class EventControllerIntegrationTest {
var request = new CreateEventRequest()
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Europe/Berlin")
.expiryDate(LocalDate.of(2026, 6, 16));
.timezone("Europe/Berlin");
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
@@ -139,26 +138,6 @@ class EventControllerIntegrationTest {
var request = new CreateEventRequest()
.title("No Date")
.timezone("Europe/Berlin")
.expiryDate(LocalDate.of(2026, 6, 16));
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.fieldErrors").isArray());
assertThat(jpaRepository.count()).isEqualTo(countBefore);
}
@Test
void createEventMissingExpiryDateReturns400() throws Exception {
long countBefore = jpaRepository.count();
var request = new CreateEventRequest()
.title("No Expiry")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Europe/Berlin");
mockMvc.perform(post("/api/events")
@@ -171,93 +150,12 @@ class EventControllerIntegrationTest {
assertThat(jpaRepository.count()).isEqualTo(countBefore);
}
@Test
void createEventExpiryDateInPastReturns400() throws Exception {
long countBefore = jpaRepository.count();
var request = new CreateEventRequest()
.title("Past Expiry")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Europe/Berlin")
.expiryDate(LocalDate.of(2025, 1, 1));
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
assertThat(jpaRepository.count()).isEqualTo(countBefore);
}
@Test
void createEventExpiryDateTodayReturns400() throws Exception {
long countBefore = jpaRepository.count();
var request = new CreateEventRequest()
.title("Today Expiry")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Europe/Berlin")
.expiryDate(LocalDate.now());
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
assertThat(jpaRepository.count()).isEqualTo(countBefore);
}
@Test
void createEventExpiryDateBeforeEventDateReturns400() throws Exception {
long countBefore = jpaRepository.count();
var request = new CreateEventRequest()
.title("Bad Expiry")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Europe/Berlin")
.expiryDate(LocalDate.of(2026, 6, 10));
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-before-event"));
assertThat(jpaRepository.count()).isEqualTo(countBefore);
}
@Test
void createEventExpiryDateSameAsEventDateReturns400() throws Exception {
long countBefore = jpaRepository.count();
var request = new CreateEventRequest()
.title("Same Day Expiry")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Europe/Berlin")
.expiryDate(LocalDate.of(2026, 6, 15));
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-before-event"));
assertThat(jpaRepository.count()).isEqualTo(countBefore);
}
@Test
void errorResponseContentTypeIsProblemJson() throws Exception {
var request = new CreateEventRequest()
.title("")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Europe/Berlin")
.expiryDate(LocalDate.of(2026, 6, 16));
.timezone("Europe/Berlin");
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
@@ -273,8 +171,7 @@ class EventControllerIntegrationTest {
var request = new CreateEventRequest()
.title("Bad TZ")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Not/A/Zone")
.expiryDate(LocalDate.of(2026, 6, 16));
.timezone("Not/A/Zone");
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
@@ -302,7 +199,6 @@ class EventControllerIntegrationTest {
.andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
.andExpect(jsonPath("$.location").value("Central Park"))
.andExpect(jsonPath("$.attendeeCount").value(0))
.andExpect(jsonPath("$.expired").value(false))
.andExpect(jsonPath("$.dateTime").isNotEmpty());
}
@@ -327,18 +223,6 @@ class EventControllerIntegrationTest {
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
}
@Test
void getExpiredEventReturnsExpiredTrue() throws Exception {
EventJpaEntity entity = seedEvent(
"Past Event", "It happened", "Europe/Berlin",
"Old Venue", LocalDate.now().minusDays(1));
mockMvc.perform(get("/api/events/" + entity.getEventToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Past Event"))
.andExpect(jsonPath("$.expired").value(true));
}
// --- RSVP tests ---
@Test
@@ -439,6 +323,275 @@ class EventControllerIntegrationTest {
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
}
// --- GET /events/{token}/attendees tests ---
@Test
void getAttendeesReturnsNamesForOrganizer() throws Exception {
EventJpaEntity event = seedEvent(
"Party", null, "Europe/Berlin", null,
LocalDate.now().plusDays(30));
seedRsvp(event, "Alice");
seedRsvp(event, "Bob");
mockMvc.perform(get("/api/events/" + event.getEventToken()
+ "/attendees?organizerToken=" + event.getOrganizerToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.attendees").isArray())
.andExpect(jsonPath("$.attendees.length()").value(2))
.andExpect(jsonPath("$.attendees[0].name").value("Alice"))
.andExpect(jsonPath("$.attendees[1].name").value("Bob"));
}
@Test
void getAttendeesReturnsEmptyListWhenNoRsvps() throws Exception {
EventJpaEntity event = seedEvent(
"Empty Party", null, "Europe/Berlin", null,
LocalDate.now().plusDays(30));
mockMvc.perform(get("/api/events/" + event.getEventToken()
+ "/attendees?organizerToken=" + event.getOrganizerToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.attendees").isArray())
.andExpect(jsonPath("$.attendees.length()").value(0));
}
@Test
void getAttendeesReturns403ForInvalidOrganizerToken() throws Exception {
EventJpaEntity event = seedEvent(
"Secret Party", null, "Europe/Berlin", null,
LocalDate.now().plusDays(30));
mockMvc.perform(get("/api/events/" + event.getEventToken()
+ "/attendees?organizerToken=" + UUID.randomUUID()))
.andExpect(status().isForbidden())
.andExpect(content().contentTypeCompatibleWith(
"application/problem+json"));
}
@Test
void getAttendeesReturns404ForUnknownEvent() throws Exception {
mockMvc.perform(get("/api/events/" + UUID.randomUUID()
+ "/attendees?organizerToken=" + UUID.randomUUID()))
.andExpect(status().isNotFound())
.andExpect(content().contentTypeCompatibleWith(
"application/problem+json"));
}
// --- Cancel RSVP tests ---
@Test
void cancelRsvpReturns204AndDeletesRow() throws Exception {
EventJpaEntity event = seedEvent(
"Cancel Event", null, "Europe/Berlin",
null, LocalDate.now().plusDays(30));
UUID rsvpToken = seedRsvpAndGetToken(event, "Departing Guest");
long countBefore = rsvpJpaRepository.count();
mockMvc.perform(delete("/api/events/" + event.getEventToken()
+ "/rsvps/" + rsvpToken))
.andExpect(status().isNoContent());
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore - 1);
assertThat(rsvpJpaRepository.findByRsvpToken(rsvpToken)).isEmpty();
}
@Test
void cancelRsvpReturns204WhenAlreadyDeleted() throws Exception {
EventJpaEntity event = seedEvent(
"Idempotent Event", null, "Europe/Berlin",
null, LocalDate.now().plusDays(30));
mockMvc.perform(delete("/api/events/" + event.getEventToken()
+ "/rsvps/" + UUID.randomUUID()))
.andExpect(status().isNoContent());
}
@Test
void cancelRsvpReturns204WhenEventNotFound() throws Exception {
mockMvc.perform(delete("/api/events/" + UUID.randomUUID()
+ "/rsvps/" + UUID.randomUUID()))
.andExpect(status().isNoContent());
}
@Test
void attendeeCountDecreasesAfterCancelRsvp() throws Exception {
EventJpaEntity event = seedEvent(
"Count Cancel Event", null, "Europe/Berlin",
null, LocalDate.now().plusDays(30));
UUID rsvpToken = seedRsvpAndGetToken(event, "Leaving Guest");
seedRsvp(event, "Staying Guest");
mockMvc.perform(get("/api/events/" + event.getEventToken()))
.andExpect(jsonPath("$.attendeeCount").value(2));
mockMvc.perform(delete("/api/events/" + event.getEventToken()
+ "/rsvps/" + rsvpToken))
.andExpect(status().isNoContent());
mockMvc.perform(get("/api/events/" + event.getEventToken()))
.andExpect(jsonPath("$.attendeeCount").value(1));
}
// --- Cancel Event tests ---
@Test
void cancelEventReturns204AndPersists() throws Exception {
EventJpaEntity event = seedEvent(
"Cancel Me", null, "Europe/Berlin", null, LocalDate.now().plusDays(30));
var body = Map.of(
"cancelled", true,
"cancellationReason", "Venue closed");
mockMvc.perform(patch("/api/events/" + event.getEventToken()
+ "?organizerToken=" + event.getOrganizerToken())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isNoContent());
EventJpaEntity persisted = jpaRepository
.findByEventToken(event.getEventToken()).orElseThrow();
assertThat(persisted.isCancelled()).isTrue();
assertThat(persisted.getCancellationReason()).isEqualTo("Venue closed");
}
@Test
void cancelEventWithoutReasonReturns204() throws Exception {
EventJpaEntity event = seedEvent(
"Cancel No Reason", null, "Europe/Berlin", null, LocalDate.now().plusDays(30));
var body = Map.of("cancelled", true);
mockMvc.perform(patch("/api/events/" + event.getEventToken()
+ "?organizerToken=" + event.getOrganizerToken())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isNoContent());
EventJpaEntity persisted = jpaRepository
.findByEventToken(event.getEventToken()).orElseThrow();
assertThat(persisted.isCancelled()).isTrue();
assertThat(persisted.getCancellationReason()).isNull();
}
@Test
void cancelEventWithWrongOrganizerTokenReturns403() throws Exception {
EventJpaEntity event = seedEvent(
"Wrong Token", null, "Europe/Berlin", null, LocalDate.now().plusDays(30));
var body = Map.of("cancelled", true);
mockMvc.perform(patch("/api/events/" + event.getEventToken()
+ "?organizerToken=" + UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isForbidden())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:invalid-organizer-token"));
assertThat(jpaRepository.findByEventToken(event.getEventToken())
.orElseThrow().isCancelled()).isFalse();
}
@Test
void cancelEventNotFoundReturns404() throws Exception {
var body = Map.of("cancelled", true);
mockMvc.perform(patch("/api/events/" + UUID.randomUUID()
+ "?organizerToken=" + UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isNotFound())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
}
@Test
void cancelAlreadyCancelledEventReturns409() throws Exception {
EventJpaEntity event = seedCancelledEvent("Already Cancelled");
var body = Map.of("cancelled", true);
mockMvc.perform(patch("/api/events/" + event.getEventToken()
+ "?organizerToken=" + event.getOrganizerToken())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isConflict())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:event-already-cancelled"));
}
@Test
void getEventReturnsCancelledFields() throws Exception {
EventJpaEntity event = seedCancelledEvent("Weather Event");
mockMvc.perform(get("/api/events/" + event.getEventToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.cancelled").value(true))
.andExpect(jsonPath("$.cancellationReason").value("Cancelled"));
}
@Test
void getEventReturnsNotCancelledByDefault() throws Exception {
EventJpaEntity event = seedEvent(
"Active Event", null, "Europe/Berlin", null, LocalDate.now().plusDays(30));
mockMvc.perform(get("/api/events/" + event.getEventToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.cancelled").value(false))
.andExpect(jsonPath("$.cancellationReason").doesNotExist());
}
@Test
void createRsvpOnCancelledEventReturns409() throws Exception {
EventJpaEntity event = seedCancelledEvent("Cancelled RSVP");
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-cancelled"));
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
}
private EventJpaEntity seedCancelledEvent(String title) {
var entity = new EventJpaEntity();
entity.setEventToken(UUID.randomUUID());
entity.setOrganizerToken(UUID.randomUUID());
entity.setTitle(title);
entity.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
entity.setTimezone("Europe/Berlin");
entity.setExpiryDate(LocalDate.now().plusDays(30));
entity.setCreatedAt(OffsetDateTime.now());
entity.setCancelled(true);
entity.setCancellationReason("Cancelled");
return jpaRepository.save(entity);
}
private UUID seedRsvpAndGetToken(EventJpaEntity event, String name) {
var rsvp = new RsvpJpaEntity();
UUID token = UUID.randomUUID();
rsvp.setRsvpToken(token);
rsvp.setEventId(event.getId());
rsvp.setName(name);
rsvpJpaRepository.save(rsvp);
return token;
}
private void seedRsvp(EventJpaEntity event, String name) {
var rsvp = new RsvpJpaEntity();
rsvp.setRsvpToken(UUID.randomUUID());
rsvp.setEventId(event.getId());
rsvp.setName(name);
rsvpJpaRepository.save(rsvp);
}
private EventJpaEntity seedEvent(
String title, String description, String timezone,
String location, LocalDate expiryDate) {

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,83 @@
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.eventToken())).isPresent();
}
@Test
void deleteExpiredKeepsEventsExpiringToday() {
Event today = buildEvent("Today Party", LocalDate.now());
Event saved = eventRepository.save(today);
eventRepository.deleteExpired();
assertThat(eventRepository.findByEventToken(saved.eventToken())).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) {
return new Event(
null,
EventToken.generate(),
OrganizerToken.generate(),
title,
"Test description",
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
ZoneId.of("Europe/Berlin"),
"Test Location",
expiryDate,
OffsetDateTime.now(),
false,
null);
}
}

View File

@@ -30,8 +30,8 @@ class EventPersistenceAdapterTest {
Event saved = eventRepository.save(event);
assertThat(saved.getId()).isNotNull();
assertThat(saved.getTitle()).isEqualTo("Test Event");
assertThat(saved.id()).isNotNull();
assertThat(saved.title()).isEqualTo("Test Event");
}
@Test
@@ -39,11 +39,11 @@ class EventPersistenceAdapterTest {
Event event = buildEvent();
Event saved = eventRepository.save(event);
Optional<Event> found = eventRepository.findByEventToken(saved.getEventToken());
Optional<Event> found = eventRepository.findByEventToken(saved.eventToken());
assertThat(found).isPresent();
assertThat(found.get().getTitle()).isEqualTo("Test Event");
assertThat(found.get().getId()).isEqualTo(saved.getId());
assertThat(found.get().title()).isEqualTo("Test Event");
assertThat(found.get().id()).isEqualTo(saved.id());
}
@Test
@@ -61,42 +61,47 @@ class EventPersistenceAdapterTest {
OffsetDateTime createdAt =
OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC);
var event = new Event();
event.setEventToken(EventToken.generate());
event.setOrganizerToken(OrganizerToken.generate());
event.setTitle("Full Event");
event.setDescription("A detailed description");
event.setDateTime(dateTime);
event.setTimezone(ZoneId.of("Europe/Berlin"));
event.setLocation("Berlin, Germany");
event.setExpiryDate(expiryDate);
event.setCreatedAt(createdAt);
var event = new Event(
null,
EventToken.generate(),
OrganizerToken.generate(),
"Full Event",
"A detailed description",
dateTime,
ZoneId.of("Europe/Berlin"),
"Berlin, Germany",
expiryDate,
createdAt,
false,
null);
Event saved = eventRepository.save(event);
Event found = eventRepository.findByEventToken(saved.getEventToken()).orElseThrow();
Event found = eventRepository.findByEventToken(saved.eventToken()).orElseThrow();
assertThat(found.getEventToken()).isEqualTo(event.getEventToken());
assertThat(found.getOrganizerToken()).isEqualTo(event.getOrganizerToken());
assertThat(found.getTitle()).isEqualTo("Full Event");
assertThat(found.getDescription()).isEqualTo("A detailed description");
assertThat(found.getDateTime().toInstant()).isEqualTo(dateTime.toInstant());
assertThat(found.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin"));
assertThat(found.getLocation()).isEqualTo("Berlin, Germany");
assertThat(found.getExpiryDate()).isEqualTo(expiryDate);
assertThat(found.getCreatedAt().toInstant()).isEqualTo(createdAt.toInstant());
assertThat(found.eventToken()).isEqualTo(event.eventToken());
assertThat(found.organizerToken()).isEqualTo(event.organizerToken());
assertThat(found.title()).isEqualTo("Full Event");
assertThat(found.description()).isEqualTo("A detailed description");
assertThat(found.dateTime().toInstant()).isEqualTo(dateTime.toInstant());
assertThat(found.timezone()).isEqualTo(ZoneId.of("Europe/Berlin"));
assertThat(found.location()).isEqualTo("Berlin, Germany");
assertThat(found.expiryDate()).isEqualTo(expiryDate);
assertThat(found.createdAt().toInstant()).isEqualTo(createdAt.toInstant());
}
private Event buildEvent() {
var event = new Event();
event.setEventToken(EventToken.generate());
event.setOrganizerToken(OrganizerToken.generate());
event.setTitle("Test Event");
event.setDescription("Test description");
event.setDateTime(OffsetDateTime.now().plusDays(7));
event.setTimezone(ZoneId.of("Europe/Berlin"));
event.setLocation("Somewhere");
event.setExpiryDate(LocalDate.now().plusDays(30));
event.setCreatedAt(OffsetDateTime.now());
return event;
return new Event(
null,
EventToken.generate(),
OrganizerToken.generate(),
"Test Event",
"Test description",
OffsetDateTime.now().plusDays(7),
ZoneId.of("Europe/Berlin"),
"Somewhere",
LocalDate.now().plusDays(30),
OffsetDateTime.now(),
false,
null);
}
}

View File

@@ -0,0 +1,133 @@
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.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import de.fete.application.service.exception.EventAlreadyCancelledException;
import de.fete.application.service.exception.EventNotFoundException;
import de.fete.application.service.exception.InvalidOrganizerTokenException;
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.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
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 EventServiceCancelTest {
private static final ZoneId ZONE = ZoneId.of("Europe/Berlin");
private static final Instant FIXED_INSTANT =
LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant();
private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE);
@Mock
private EventRepository eventRepository;
private EventService eventService;
@BeforeEach
void setUp() {
eventService = new EventService(eventRepository, FIXED_CLOCK);
}
@Test
void cancelEventDelegatesToDomainAndSaves() {
EventToken eventToken = EventToken.generate();
OrganizerToken organizerToken = OrganizerToken.generate();
var event = new Event(null, eventToken, organizerToken, null, null, null, null, null, null,
null, false, null);
when(eventRepository.findByEventToken(eventToken))
.thenReturn(Optional.of(event));
when(eventRepository.save(any(Event.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
eventService.cancelEvent(eventToken, organizerToken, true, "Venue unavailable");
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
verify(eventRepository).save(captor.capture());
assertThat(captor.getValue().cancelled()).isTrue();
assertThat(captor.getValue().cancellationReason()).isEqualTo("Venue unavailable");
}
@Test
void cancelEventWithNullReason() {
EventToken eventToken = EventToken.generate();
OrganizerToken organizerToken = OrganizerToken.generate();
var event = new Event(null, eventToken, organizerToken, null, null, null, null, null, null,
null, false, null);
when(eventRepository.findByEventToken(eventToken))
.thenReturn(Optional.of(event));
when(eventRepository.save(any(Event.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
eventService.cancelEvent(eventToken, organizerToken, true, null);
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
verify(eventRepository).save(captor.capture());
assertThat(captor.getValue().cancelled()).isTrue();
assertThat(captor.getValue().cancellationReason()).isNull();
}
@Test
void cancelEventThrows404WhenNotFound() {
EventToken eventToken = EventToken.generate();
OrganizerToken organizerToken = OrganizerToken.generate();
when(eventRepository.findByEventToken(eventToken))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> eventService.cancelEvent(eventToken, organizerToken, true, null))
.isInstanceOf(EventNotFoundException.class);
verify(eventRepository, never()).save(any());
}
@Test
void cancelEventThrows403WhenWrongOrganizerToken() {
EventToken eventToken = EventToken.generate();
OrganizerToken correctToken = OrganizerToken.generate();
var event = new Event(null, eventToken, correctToken, null, null, null, null, null, null,
null, false, null);
when(eventRepository.findByEventToken(eventToken))
.thenReturn(Optional.of(event));
final OrganizerToken wrongToken = OrganizerToken.generate();
assertThatThrownBy(() -> eventService.cancelEvent(eventToken, wrongToken, true, null))
.isInstanceOf(InvalidOrganizerTokenException.class);
verify(eventRepository, never()).save(any());
}
@Test
void cancelEventThrows409WhenAlreadyCancelled() {
EventToken eventToken = EventToken.generate();
OrganizerToken organizerToken = OrganizerToken.generate();
var event = new Event(null, eventToken, organizerToken, null, null, null, null, null, null,
null, true, null);
when(eventRepository.findByEventToken(eventToken))
.thenReturn(Optional.of(event));
assertThatThrownBy(() -> eventService.cancelEvent(eventToken, organizerToken, true, null))
.isInstanceOf(EventAlreadyCancelledException.class);
verify(eventRepository, never()).save(any());
}
}

View File

@@ -1,7 +1,6 @@
package de.fete.application.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -53,19 +52,18 @@ class EventServiceTest {
"Come celebrate!",
TODAY.plusDays(90).atStartOfDay(ZONE).toOffsetDateTime(),
ZONE,
"Berlin",
TODAY.plusDays(120)
"Berlin"
);
Event result = eventService.createEvent(command);
assertThat(result.getTitle()).isEqualTo("Birthday Party");
assertThat(result.getDescription()).isEqualTo("Come celebrate!");
assertThat(result.getTimezone()).isEqualTo(ZONE);
assertThat(result.getLocation()).isEqualTo("Berlin");
assertThat(result.getEventToken()).isNotNull();
assertThat(result.getOrganizerToken()).isNotNull();
assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE));
assertThat(result.title()).isEqualTo("Birthday Party");
assertThat(result.description()).isEqualTo("Come celebrate!");
assertThat(result.timezone()).isEqualTo(ZONE);
assertThat(result.location()).isEqualTo("Berlin");
assertThat(result.eventToken()).isNotNull();
assertThat(result.organizerToken()).isNotNull();
assertThat(result.createdAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE));
}
@Test
@@ -75,98 +73,30 @@ class EventServiceTest {
var command = new CreateEventCommand(
"Test", null,
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
TODAY.plusDays(11)
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null
);
eventService.createEvent(command);
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
verify(eventRepository, times(1)).save(captor.capture());
assertThat(captor.getValue().getTitle()).isEqualTo("Test");
assertThat(captor.getValue().title()).isEqualTo("Test");
}
@Test
void expiryDateTodayThrowsException() {
var command = new CreateEventCommand(
"Test", null,
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
TODAY
);
assertThatThrownBy(() -> eventService.createEvent(command))
.isInstanceOf(ExpiryDateInPastException.class);
}
@Test
void expiryDateInPastThrowsException() {
var command = new CreateEventCommand(
"Test", null,
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
TODAY.minusDays(5)
);
assertThatThrownBy(() -> eventService.createEvent(command))
.isInstanceOf(ExpiryDateInPastException.class);
}
@Test
void expiryDateTomorrowSucceeds() {
void expiryDateIsEventDatePlusSevenDays() {
when(eventRepository.save(any(Event.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
var eventDate = TODAY.plusDays(10);
var command = new CreateEventCommand(
"Test", null,
TODAY.plusDays(1).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
TODAY.plusDays(2)
eventDate.atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null
);
Event result = eventService.createEvent(command);
assertThat(result.getExpiryDate()).isEqualTo(TODAY.plusDays(2));
}
@Test
void expiryDateSameAsEventDateThrowsException() {
var command = new CreateEventCommand(
"Test", null,
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
ZONE, null,
TODAY.plusDays(10)
);
assertThatThrownBy(() -> eventService.createEvent(command))
.isInstanceOf(ExpiryDateBeforeEventException.class);
}
@Test
void expiryDateBeforeEventDateThrowsException() {
var command = new CreateEventCommand(
"Test", null,
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
ZONE, null,
TODAY.plusDays(5)
);
assertThatThrownBy(() -> eventService.createEvent(command))
.isInstanceOf(ExpiryDateBeforeEventException.class);
}
@Test
void expiryDateDayAfterEventDateSucceeds() {
when(eventRepository.save(any(Event.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
var command = new CreateEventCommand(
"Test", null,
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
ZONE, null,
TODAY.plusDays(11)
);
Event result = eventService.createEvent(command);
assertThat(result.getExpiryDate()).isEqualTo(TODAY.plusDays(11));
assertThat(result.expiryDate()).isEqualTo(eventDate.plusDays(7));
}
// --- GetEventUseCase tests (T004) ---
@@ -174,16 +104,15 @@ class EventServiceTest {
@Test
void getByEventTokenReturnsEvent() {
EventToken token = EventToken.generate();
var event = new Event();
event.setEventToken(token);
event.setTitle("Found Event");
var event = new Event(null, token, null, "Found Event", null, null, null, null, null, null,
false, null);
when(eventRepository.findByEventToken(token))
.thenReturn(Optional.of(event));
Optional<Event> result = eventService.getByEventToken(token);
assertThat(result).isPresent();
assertThat(result.get().getTitle()).isEqualTo("Found Event");
assertThat(result.get().title()).isEqualTo("Found Event");
}
@Test
@@ -207,12 +136,11 @@ class EventServiceTest {
var command = new CreateEventCommand(
"Test", null,
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
ZoneId.of("America/New_York"), null,
TODAY.plusDays(11)
ZoneId.of("America/New_York"), null
);
Event result = eventService.createEvent(command);
assertThat(result.getTimezone()).isEqualTo(ZoneId.of("America/New_York"));
assertThat(result.timezone()).isEqualTo(ZoneId.of("America/New_York"));
}
}

View File

@@ -6,10 +6,15 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import de.fete.application.service.exception.EventCancelledException;
import de.fete.application.service.exception.EventExpiredException;
import de.fete.application.service.exception.EventNotFoundException;
import de.fete.application.service.exception.InvalidOrganizerTokenException;
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;
@@ -18,6 +23,7 @@ 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;
@@ -49,23 +55,23 @@ class RsvpServiceTest {
@Test
void createRsvpSucceedsForActiveEvent() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
Event event = buildActiveEvent(TODAY.plusDays(30));
EventToken token = event.eventToken();
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());
assertThat(result.name()).isEqualTo("Max Mustermann");
assertThat(result.rsvpToken()).isNotNull();
assertThat(result.eventId()).isEqualTo(event.id());
}
@Test
void createRsvpPersistsViaRepository() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
Event event = buildActiveEvent(TODAY.plusDays(30));
EventToken token = event.eventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
when(rsvpRepository.save(any(Rsvp.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@@ -74,8 +80,8 @@ class RsvpServiceTest {
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());
assertThat(captor.getValue().name()).isEqualTo("Test Guest");
assertThat(captor.getValue().eventId()).isEqualTo(event.id());
}
@Test
@@ -89,22 +95,21 @@ class RsvpServiceTest {
@Test
void createRsvpTrimsName() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
Event event = buildActiveEvent(TODAY.plusDays(30));
EventToken token = event.eventToken();
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");
assertThat(result.name()).isEqualTo("Max");
}
@Test
void createRsvpThrowsWhenEventExpired() {
var event = buildActiveEvent();
event.setExpiryDate(TODAY.minusDays(1));
EventToken token = event.getEventToken();
Event event = buildActiveEvent(TODAY.minusDays(1));
EventToken token = event.eventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
@@ -113,25 +118,124 @@ class RsvpServiceTest {
@Test
void createRsvpThrowsWhenEventExpiresToday() {
var event = buildActiveEvent();
event.setExpiryDate(TODAY);
EventToken token = event.getEventToken();
Event event = buildActiveEvent(TODAY);
EventToken token = event.eventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
.isInstanceOf(EventExpiredException.class);
}
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;
@Test
void getAttendeeNamesReturnsNamesInOrder() {
Event event = buildActiveEvent(TODAY.plusDays(30));
EventToken token = event.eventToken();
OrganizerToken orgToken = event.organizerToken();
when(eventRepository.findByEventToken(token))
.thenReturn(Optional.of(event));
when(rsvpRepository.findByEventId(event.id()))
.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(TODAY.plusDays(30));
EventToken token = event.eventToken();
OrganizerToken orgToken = event.organizerToken();
when(eventRepository.findByEventToken(token))
.thenReturn(Optional.of(event));
when(rsvpRepository.findByEventId(event.id()))
.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(TODAY.plusDays(30));
EventToken token = event.eventToken();
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) {
return new Rsvp(id, RsvpToken.generate(), 1L, name);
}
@Test
void cancelRsvpDeletesWhenEventAndRsvpExist() {
Event event = buildActiveEvent(TODAY.plusDays(30));
EventToken token = event.eventToken();
RsvpToken rsvpToken = RsvpToken.generate();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
when(rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken)).thenReturn(true);
rsvpService.cancelRsvp(token, rsvpToken);
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.id(), rsvpToken);
}
@Test
void cancelRsvpSucceedsWhenRsvpNotFound() {
Event event = buildActiveEvent(TODAY.plusDays(30));
EventToken token = event.eventToken();
RsvpToken rsvpToken = RsvpToken.generate();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
when(rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken)).thenReturn(false);
rsvpService.cancelRsvp(token, rsvpToken);
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.id(), rsvpToken);
}
@Test
void cancelRsvpSucceedsWhenEventNotFound() {
EventToken token = EventToken.generate();
RsvpToken rsvpToken = RsvpToken.generate();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.empty());
rsvpService.cancelRsvp(token, rsvpToken);
}
private Event buildActiveEvent(LocalDate expiryDate) {
return new Event(
1L,
EventToken.generate(),
OrganizerToken.generate(),
"Test Event",
null,
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
ZONE,
null,
expiryDate,
OffsetDateTime.now(),
false,
null);
}
}

View File

@@ -29,8 +29,10 @@ class WebConfigTest {
@Test
void apiPrefixNotAccessibleWithoutIt() throws Exception {
// /events without /api prefix should not resolve to the API endpoint
mockMvc.perform(get("/events"))
.andExpect(status().isNotFound());
// /events without /api prefix should not resolve to the REST API endpoint;
// it is served by SpaController as HTML instead
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

@@ -0,0 +1,162 @@
import { http, HttpResponse } from 'msw'
import { test, expect } from './msw-setup'
import type { StoredEvent } from '../src/composables/useEventStorage'
const STORAGE_KEY = 'fete:events'
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,
cancelled: false,
cancellationReason: null,
}
const organizerToken = '550e8400-e29b-41d4-a716-446655440001'
function seedEvents(events: StoredEvent[]): string {
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
}
function organizerSeed(): StoredEvent {
return {
eventToken: fullEvent.eventToken,
organizerToken,
title: fullEvent.title,
dateTime: fullEvent.dateTime,
}
}
test.describe('US1: Organizer cancels event with reason', () => {
test('organizer opens cancel bottom sheet, enters reason, confirms — event shows as cancelled on reload', async ({
page,
network,
}) => {
let cancelled = false
network.use(
http.get('*/api/events/:token', () => {
if (cancelled) {
return HttpResponse.json({
...fullEvent,
cancelled: true,
cancellationReason: 'Venue closed',
})
}
return HttpResponse.json(fullEvent)
}),
http.patch('*/api/events/:token', ({ request }) => {
const url = new URL(request.url)
const token = url.searchParams.get('organizerToken')
if (token === organizerToken) {
cancelled = true
return new HttpResponse(null, { status: 204 })
}
return HttpResponse.json(
{ type: 'urn:problem-type:invalid-organizer-token', title: 'Forbidden', status: 403 },
{ status: 403 },
)
}),
)
await page.addInitScript(seedEvents([organizerSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Cancel button visible for organizer
const cancelBtn = page.getByRole('button', { name: /Cancel event/i })
await expect(cancelBtn).toBeVisible()
// Open cancel bottom sheet
await cancelBtn.click()
// Fill in reason
const reasonField = page.getByLabel(/reason/i)
await expect(reasonField).toBeVisible()
await reasonField.fill('Venue closed')
// Confirm cancellation
await page.getByRole('button', { name: /Confirm cancellation/i }).click()
// Event should show as cancelled
await expect(page.getByText(/This event has been cancelled/i)).toBeVisible()
await expect(page.getByText('Venue closed')).toBeVisible()
// Cancel button should be gone
await expect(cancelBtn).not.toBeVisible()
})
})
test.describe('US1: Organizer cancels event without reason', () => {
test('organizer cancels without reason — event shows as cancelled', async ({
page,
network,
}) => {
let cancelled = false
network.use(
http.get('*/api/events/:token', () => {
if (cancelled) {
return HttpResponse.json({
...fullEvent,
cancelled: true,
cancellationReason: null,
})
}
return HttpResponse.json(fullEvent)
}),
http.patch('*/api/events/:token', ({ request }) => {
const url = new URL(request.url)
const token = url.searchParams.get('organizerToken')
if (token === organizerToken) {
cancelled = true
return new HttpResponse(null, { status: 204 })
}
return HttpResponse.json({}, { status: 403 })
}),
)
await page.addInitScript(seedEvents([organizerSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
await page.getByRole('button', { name: /Cancel event/i }).click()
// Don't fill in reason, just confirm
await page.getByRole('button', { name: /Confirm cancellation/i }).click()
// Event should show as cancelled without reason text
await expect(page.getByText(/This event has been cancelled/i)).toBeVisible()
})
})
test.describe('US1: Cancel API failure', () => {
test('cancel API fails — error displayed in bottom sheet, button re-enabled for retry', async ({
page,
network,
}) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
http.patch('*/api/events/:token', () => {
return HttpResponse.json(
{
type: 'about:blank',
title: 'Internal Server Error',
status: 500,
detail: 'Something went wrong',
},
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } },
)
}),
)
await page.addInitScript(seedEvents([organizerSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
await page.getByRole('button', { name: /Cancel event/i }).click()
await page.getByRole('button', { name: /Confirm cancellation/i }).click()
// Error message in bottom sheet
await expect(page.getByText(/Could not cancel event/i)).toBeVisible()
// Confirm button should be re-enabled
await expect(page.getByRole('button', { name: /Confirm cancellation/i })).toBeEnabled()
})
})

View File

@@ -0,0 +1,276 @@
import { http, HttpResponse } from 'msw'
import { test, expect } from './msw-setup'
import type { StoredEvent } from '../src/composables/useEventStorage'
const STORAGE_KEY = 'fete:events'
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,
}
const rsvpToken = 'd4e5f6a7-b8c9-0123-4567-890abcdef012'
function seedEvents(events: StoredEvent[]): string {
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
}
function rsvpSeed(): StoredEvent {
return {
eventToken: fullEvent.eventToken,
title: fullEvent.title,
dateTime: fullEvent.dateTime,
rsvpToken,
rsvpName: 'Anna',
}
}
test.describe('US1: Cancel RSVP from Event Detail View', () => {
test('status bar shows cancel affordance when RSVP\'d', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Status bar visible
const statusBar = page.getByRole('button', { name: /You're attending/ })
await expect(statusBar).toBeVisible()
// Cancel button hidden initially
await expect(page.getByRole('button', { name: 'Cancel attendance' })).not.toBeVisible()
})
test('tapping status bar reveals cancel button', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Tap status bar
await page.getByRole('button', { name: /You're attending/ }).click()
// Cancel button appears
await expect(page.getByRole('button', { name: 'Cancel attendance' })).toBeVisible()
})
test('confirm cancellation → localStorage cleared, count decremented, bar reset', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return new HttpResponse(null, { status: 204 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Expand → Cancel attendance → Confirm in dialog
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
// Confirm dialog
await expect(page.getByText('Your attendance will be permanently cancelled.')).toBeVisible()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
// Bar resets to CTA state
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
await expect(page.getByText("You're attending!")).not.toBeVisible()
// Attendee count decremented
await expect(page.getByText('11 going')).toBeVisible()
// localStorage cleared
const stored = await page.evaluate(() => {
const raw = localStorage.getItem('fete:events')
return raw ? JSON.parse(raw) : null
})
const event = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
expect(event?.rsvpToken).toBeUndefined()
expect(event?.rsvpName).toBeUndefined()
})
test('server error → error message, state unchanged', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return HttpResponse.json({ error: 'fail' }, { status: 500 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Expand → Cancel → Confirm in dialog
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
// Error message
await expect(page.getByText('Could not cancel RSVP. Please try again.')).toBeVisible()
// Attendee count unchanged
await expect(page.getByText('12 going')).toBeVisible()
})
test('re-RSVP after cancel works', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return new HttpResponse(null, { status: 204 })
}),
http.post('*/api/events/:token/rsvps', () => {
return HttpResponse.json(
{ rsvpToken: 'new-rsvp-token', name: 'Max' },
{ status: 201 },
)
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Cancel first
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
// CTA should be back
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
// Re-RSVP
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()
// Status bar returns
await expect(page.getByText("You're attending!")).toBeVisible()
})
})
test.describe('US2: Auto-Cancel on Event List Removal', () => {
test('removal of RSVP\'d event shows attendance warning in dialog', async ({ page }) => {
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await expect(page.getByText('your attendance will be cancelled')).toBeVisible()
})
test('removal of non-RSVP\'d event shows standard dialog', async ({ page }) => {
const noRsvp: StoredEvent = {
eventToken: 'no-rsvp-token',
title: 'No RSVP Event',
dateTime: '2027-06-15T18:00:00Z',
organizerToken: 'org-123',
}
await page.addInitScript(seedEvents([noRsvp]))
await page.goto('/')
await page.getByRole('button', { name: /Remove No RSVP Event/ }).click()
await expect(page.getByText('This event will be removed from your list.')).toBeVisible()
await expect(page.getByText('attendance will be cancelled')).not.toBeVisible()
})
test('confirm removal → DELETE called → event removed from list', async ({ page, network }) => {
network.use(
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return new HttpResponse(null, { status: 204 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await page.getByRole('button', { name: 'Remove', exact: true }).click()
// Event gone
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
// localStorage updated
const stored = await page.evaluate(() => {
const raw = localStorage.getItem('fete:events')
return raw ? JSON.parse(raw) : null
})
const found = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
expect(found).toBeUndefined()
})
test('server error on DELETE → error message, event stays in list', async ({ page, network }) => {
network.use(
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return HttpResponse.json({ error: 'fail' }, { status: 500 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await page.getByRole('button', { name: 'Remove', exact: true }).click()
// Event still in list
await expect(page.getByText('Summer BBQ')).toBeVisible()
})
test('dismiss dialog → no changes', async ({ page }) => {
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await page.getByRole('button', { name: 'Cancel' }).click()
// Event still there
await expect(page.getByText('Summer BBQ')).toBeVisible()
})
})
test.describe('US3: Cancel RSVP with Stale/Invalid Token', () => {
test('cancel from detail view with stale token (404) → treated as success', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return HttpResponse.json({ error: 'not found' }, { status: 404 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Cancel flow
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
// Treated as success — CTA returns
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
// localStorage cleaned
const stored = await page.evaluate(() => {
const raw = localStorage.getItem('fete:events')
return raw ? JSON.parse(raw) : null
})
const event = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
expect(event?.rsvpToken).toBeUndefined()
})
test('event list removal with stale token (404) → treated as success', async ({ page, network }) => {
network.use(
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return HttpResponse.json({ error: 'not found' }, { status: 404 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await page.getByRole('button', { name: 'Remove', exact: true }).click()
// Event removed from list
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
})
})

View File

@@ -0,0 +1,74 @@
import { http, HttpResponse } from 'msw'
import { test, expect } from './msw-setup'
const cancelledEventWithReason = {
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,
cancelled: true,
cancellationReason: 'Venue no longer available',
}
const cancelledEventWithoutReason = {
...cancelledEventWithReason,
cancellationReason: null,
}
test.describe('US2: Visitor sees cancelled event with reason', () => {
test('visitor sees red banner with cancellation reason on cancelled event', async ({
page,
network,
}) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(cancelledEventWithReason)),
)
await page.goto(`/events/${cancelledEventWithReason.eventToken}`)
await expect(page.getByText(/This event has been cancelled/i)).toBeVisible()
await expect(page.getByText('Venue no longer available')).toBeVisible()
})
})
test.describe('US2: Visitor sees cancelled event without reason', () => {
test('visitor sees red banner without reason when no reason was provided', async ({
page,
network,
}) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(cancelledEventWithoutReason)),
)
await page.goto(`/events/${cancelledEventWithoutReason.eventToken}`)
await expect(page.getByText(/This event has been cancelled/i)).toBeVisible()
// No reason text shown
await expect(page.getByText('Venue no longer available')).not.toBeVisible()
})
})
test.describe('US2: RSVP buttons hidden on cancelled event', () => {
test('RSVP buttons hidden on cancelled event, other details remain visible', async ({
page,
network,
}) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(cancelledEventWithReason)),
)
await page.goto(`/events/${cancelledEventWithReason.eventToken}`)
// Event details are still visible
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
await expect(page.getByText('Bring your own drinks!')).toBeVisible()
await expect(page.getByText('Central Park, NYC')).toBeVisible()
await expect(page.getByText('12 going')).toBeVisible()
// RSVP bar is NOT visible
await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible()
})
})

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('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 }) => {
@@ -19,7 +18,6 @@ test.describe('US-1: Create an event', () => {
await page.getByLabel(/description/i).fill('Bring your own drinks')
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
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()
@@ -31,7 +29,6 @@ test.describe('US-1: Create an event', () => {
await page.getByLabel(/title/i).fill('Summer BBQ')
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 expect(page).toHaveURL(/\/events\/.+/)
@@ -59,7 +56,6 @@ test.describe('US-1: Create an event', () => {
await page.goto('/create')
await page.getByLabel(/title/i).fill('Test')
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()

View File

@@ -9,7 +9,6 @@ const fullEvent = {
timezone: 'Europe/Berlin',
location: 'Central Park, NYC',
attendeeCount: 12,
expired: false,
}
test.describe('US1: RSVP submission flow', () => {
@@ -170,16 +169,4 @@ test.describe('US1: RSVP submission flow', () => {
await expect(page.getByText("You're attending!")).not.toBeVisible()
})
test('does not show RSVP bar on expired event', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json({ ...fullEvent, expired: true })
}),
)
await page.goto(`/events/${fullEvent.eventToken}`)
await expect(page.getByText('This event has ended.')).toBeVisible()
await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible()
})
})

View File

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

View File

@@ -0,0 +1,371 @@
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 watcher 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' })
const badge = card.locator('.event-card__badge')
await expect(badge).toBeVisible()
await expect(badge).toHaveText('Watching')
await expect(badge).toHaveClass(/event-card__badge--watcher/)
})
})
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

@@ -0,0 +1,218 @@
import { http, HttpResponse } from 'msw'
import { test, expect } from './msw-setup'
import type { StoredEvent } from '../src/composables/useEventStorage'
const STORAGE_KEY = 'fete:events'
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,
cancelled: false,
}
const rsvpToken = 'd4e5f6a7-b8c9-0123-4567-890abcdef012'
const organizerToken = 'org-token-1234'
function seedEvents(events: StoredEvent[]): string {
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
}
function watchSeed(): StoredEvent {
return {
eventToken: fullEvent.eventToken,
title: fullEvent.title,
dateTime: fullEvent.dateTime,
}
}
function rsvpSeed(): StoredEvent {
return {
eventToken: fullEvent.eventToken,
title: fullEvent.title,
dateTime: fullEvent.dateTime,
rsvpToken,
rsvpName: 'Anna',
}
}
function organizerSeed(): StoredEvent {
return {
eventToken: fullEvent.eventToken,
title: fullEvent.title,
dateTime: fullEvent.dateTime,
organizerToken,
}
}
test.describe('US1: Watch event from detail page', () => {
test('bookmark unfilled by default, tapping watches the event', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
)
await page.goto(`/events/${fullEvent.eventToken}`)
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
await expect(bookmark).toBeVisible()
await expect(bookmark).toHaveAttribute('aria-label', 'Watch this event')
await bookmark.click()
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
// Navigate to event list via back link
await page.locator('.detail__back').click()
// Event appears with "Watching" label
await expect(page.getByText('Summer BBQ')).toBeVisible()
await expect(page.getByText('Watching')).toBeVisible()
})
})
test.describe('US2: Un-watch event from detail page', () => {
test('tapping filled bookmark un-watches the event', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
)
await page.addInitScript(seedEvents([watchSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
await bookmark.click()
await expect(bookmark).toHaveAttribute('aria-label', 'Watch this event')
// Navigate to event list via back link (avoid page.goto re-running addInitScript)
await page.locator('.detail__back').click()
// Event is gone
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
})
})
test.describe('US3: Bookmark reflects attending status', () => {
test('bookmark is not visible when user has RSVPed, list shows Attendee', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Bookmark not shown for attendees — RsvpBar shows status state
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
await expect(bookmark).not.toBeVisible()
// Navigate to list via back link
await page.locator('.detail__back').click()
await expect(page.getByText('Attendee')).toBeVisible()
await expect(page.getByText('Watching')).not.toBeVisible()
})
})
test.describe('US4: RSVP cancellation preserves watch status', () => {
test('cancel RSVP → bookmark reappears, list shows Watching', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return new HttpResponse(null, { status: 204 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Cancel RSVP
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
// Bookmark reappears in CTA state, filled because event is still stored
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
await expect(bookmark).toBeVisible()
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
// Navigate to list via back link
await page.locator('.detail__back').click()
await expect(page.getByText('Watching')).toBeVisible()
await expect(page.getByText('Attendee')).not.toBeVisible()
})
})
test.describe('US5: No bookmark for attendees and organizers', () => {
test('attendee does not see bookmark', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
await expect(bookmark).not.toBeVisible()
})
test('organizer does not see bookmark', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
)
await page.addInitScript(seedEvents([organizerSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
await expect(bookmark).not.toBeVisible()
})
})
test.describe('US6: Un-watch from event list', () => {
test('deleting a watched event skips confirmation dialog', async ({ page }) => {
await page.addInitScript(seedEvents([watchSeed()]))
await page.goto('/')
await expect(page.getByText('Summer BBQ')).toBeVisible()
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
// No confirmation dialog — event removed immediately
await expect(page.getByText('Remove event?')).not.toBeVisible()
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
})
})
test.describe('US7: Watcher upgrades to attendee', () => {
test('watch → RSVP → bookmark disappears, list shows Attendee', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
http.post('*/api/events/:token/rsvps', () => {
return HttpResponse.json(
{ rsvpToken: 'new-rsvp-token', name: 'Max' },
{ status: 201 },
)
}),
)
await page.addInitScript(seedEvents([watchSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Verify watching state — bookmark visible
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
await expect(bookmark).toBeVisible()
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
// RSVP
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()
// Bookmark gone — status bar shown instead
await expect(bookmark).not.toBeVisible()
// Navigate to list via back link
await page.locator('.detail__back').click()
await expect(page.getByText('Attendee')).toBeVisible()
await expect(page.getByText('Watching')).not.toBeVisible()
})
})

View File

@@ -3,6 +3,8 @@
<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>

File diff suppressed because it is too large Load Diff

View File

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

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,34 @@
--color-text-on-gradient: #ffffff;
--color-surface: #fff5f8;
--color-card: #ffffff;
--color-dark-base: #1B1730;
/* Danger / destructive actions */
--color-danger: #fca5a5;
--color-danger-bg: rgba(220, 38, 38, 0.15);
--color-danger-bg-hover: rgba(220, 38, 38, 0.25);
--color-danger-bg-strong: rgba(220, 38, 38, 0.2);
--color-danger-border: rgba(220, 38, 38, 0.3);
--color-danger-border-strong: rgba(220, 38, 38, 0.4);
/* 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-primary: linear-gradient(135deg, #f06292 0%, #ab47bc 50%, #5c6bc0 100%);
@@ -33,7 +61,7 @@
--radius-button: 14px;
/* 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);
/* Layout */
@@ -60,7 +88,22 @@ html {
body {
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 {
@@ -82,28 +125,35 @@ body {
/* Card-style form fields */
.form-field {
background: var(--color-card);
border: none;
border: 1px solid #e0e0e0;
border-radius: var(--radius-card);
padding: var(--spacing-md) var(--spacing-md);
box-shadow: var(--shadow-card);
width: 100%;
font-family: inherit;
font-size: 0.95rem;
font-weight: 400;
color: var(--color-text);
outline: none;
transition: box-shadow 0.2s ease;
transition: border-color 0.2s ease;
}
.form-field.glass {
color: var(--color-text-on-gradient);
}
.form-field:focus {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
border-color: var(--color-glass-border-hover);
}
.form-field::placeholder {
color: #999;
color: var(--color-text-muted);
font-weight: 400;
}
.form-field.glass::placeholder {
color: var(--color-text-muted);
}
textarea.form-field {
resize: vertical;
min-height: 5rem;
@@ -128,22 +178,29 @@ textarea.form-field {
display: block;
width: 100%;
padding: var(--spacing-md) var(--spacing-lg);
background: var(--color-accent);
background: var(--color-card);
color: var(--color-text);
border: none;
border: 1px solid #e0e0e0;
border-radius: var(--radius-button);
font-family: inherit;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
box-shadow: var(--shadow-button);
transition: opacity 0.2s ease, transform 0.1s ease;
transition: border-color 0.2s ease, transform 0.1s ease;
text-align: center;
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 {
opacity: 0.92;
border-color: var(--color-glass-border-hover);
}
.btn-primary:active {
@@ -176,6 +233,68 @@ textarea.form-field {
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 */
.text-center {
text-align: center;
@@ -197,7 +316,7 @@ textarea.form-field {
.sheet-title {
font-size: 1.2rem;
font-weight: 700;
color: var(--color-text);
color: var(--color-text-on-gradient);
}
.rsvp-form {
@@ -209,7 +328,7 @@ textarea.form-field {
.rsvp-form__label {
font-size: 0.85rem;
font-weight: 700;
color: var(--color-text);
color: var(--color-text-on-gradient);
padding-left: 0.25rem;
}

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

@@ -45,7 +45,7 @@ watch(
.sheet-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
background: var(--color-glass-overlay);
display: flex;
align-items: flex-end;
justify-content: center;
@@ -53,7 +53,11 @@ watch(
}
.sheet {
background: var(--color-card);
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%;
@@ -67,7 +71,7 @@ watch(
.sheet__handle {
width: 36px;
height: 4px;
background: #ccc;
background: var(--color-glass-border-hover);
border-radius: 2px;
align-self: center;
flex-shrink: 0;

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,186 @@
<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' : eventRole === 'attendee' ? 'Attendee' : 'Watching' }}
</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' | 'watcher'
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__badge--watcher {
background: var(--color-glass);
color: var(--color-text-secondary);
border: 1px solid var(--color-glass-border);
}
.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,137 @@
<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="deleteDialogMessage"
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 { api } from '../api/client'
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, getRsvp, removeEvent } = useEventStorage()
const pendingDeleteToken = ref<string | null>(null)
const deleteError = ref('')
const deleteDialogMessage = computed(() => {
if (!pendingDeleteToken.value) return ''
const rsvp = getRsvp(pendingDeleteToken.value)
if (rsvp) {
return 'This event will be removed from your list and your attendance will be cancelled.'
}
return 'This event will be removed from your list.'
})
function requestDelete(eventToken: string) {
deleteError.value = ''
const role = getRole(getStoredEvents().find((e) => e.eventToken === eventToken)!)
if (role === 'watcher') {
removeEvent(eventToken)
return
}
pendingDeleteToken.value = eventToken
}
async function confirmDelete() {
if (!pendingDeleteToken.value) return
const eventToken = pendingDeleteToken.value
const rsvp = getRsvp(eventToken)
if (rsvp) {
try {
const { response } = await api.DELETE('/events/{eventToken}/rsvps/{rsvpToken}', {
params: {
path: {
eventToken: eventToken,
rsvpToken: rsvp.rsvpToken,
},
},
})
if (response.status !== 204 && response.status !== 404) {
deleteError.value = 'Could not cancel attendance. Please try again.'
pendingDeleteToken.value = null
return
}
} catch {
deleteError.value = 'Could not cancel attendance. Please try again.'
pendingDeleteToken.value = null
return
}
}
removeEvent(eventToken)
pendingDeleteToken.value = null
}
function cancelDelete() {
pendingDeleteToken.value = null
}
function getRole(event: StoredEvent): 'organizer' | 'attendee' | 'watcher' {
if (event.organizerToken) return 'organizer'
if (event.rsvpToken) return 'attendee'
return 'watcher'
}
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

@@ -2,27 +2,90 @@
<div class="rsvp-bar">
<div class="rsvp-bar__inner">
<!-- Status state: already RSVPed -->
<div v-if="hasRsvp" class="rsvp-bar__status">
<div v-if="hasRsvp" class="rsvp-bar__status-wrapper">
<div
class="rsvp-bar__status"
role="button"
tabindex="0"
:aria-expanded="expanded"
aria-label="You're attending. Tap to show cancel option."
@click="expanded = !expanded"
@keydown.enter.prevent="expanded = !expanded"
@keydown.space.prevent="expanded = !expanded"
@keydown.escape="expanded = false"
>
<span class="rsvp-bar__check" aria-hidden="true"></span>
<span class="rsvp-bar__text">You're attending!</span>
<span class="rsvp-bar__chevron" :class="{ 'rsvp-bar__chevron--open': expanded }" aria-hidden="true"></span>
</div>
<Transition name="rsvp-bar-cancel">
<button
v-if="expanded"
class="rsvp-bar__cancel"
type="button"
@click="$emit('cancel')"
>
Cancel attendance
</button>
</Transition>
</div>
<!-- CTA state: no RSVP yet -->
<button v-else class="btn-primary rsvp-bar__cta" type="button" @click="$emit('open')">
<div v-else class="rsvp-bar__row">
<div 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 class="rsvp-bar__bookmark glow-border glow-border--animated">
<button
class="rsvp-bar__bookmark-inner glass-inner"
type="button"
:aria-label="bookmarked ? 'Stop watching this event' : 'Watch this event'"
@click="$emit('bookmark')"
>
<svg width="20" height="20" viewBox="0 0 24 24" :fill="bookmarked ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
import { ref, watch } from 'vue'
const props = defineProps<{
hasRsvp?: boolean
bookmarked?: boolean
}>()
defineEmits<{
open: []
cancel: []
bookmark: []
}>()
const expanded = ref(false)
watch(() => props.hasRsvp, () => {
expanded.value = false
})
function onClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.rsvp-bar__status-wrapper')) {
expanded.value = false
}
}
watch(expanded, (isExpanded) => {
if (isExpanded) {
document.addEventListener('click', onClickOutside, { capture: true })
} else {
document.removeEventListener('click', onClickOutside, { capture: true })
}
})
</script>
<style scoped>
@@ -43,8 +106,44 @@ defineEmits<{
max-width: var(--content-max-width);
}
.rsvp-bar__row {
display: flex;
gap: var(--spacing-sm);
}
.rsvp-bar__cta {
flex: 1;
min-width: 0;
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-wrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.rsvp-bar__status {
@@ -52,13 +151,23 @@ defineEmits<{
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
background: var(--color-card);
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);
color: var(--color-text-on-gradient);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
}
.rsvp-bar__status:hover {
background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%);
}
.rsvp-bar__check {
@@ -72,4 +181,82 @@ defineEmits<{
text-overflow: ellipsis;
white-space: nowrap;
}
.rsvp-bar__chevron {
font-size: 1.2rem;
font-weight: 700;
transition: transform 0.2s ease;
transform: rotate(0deg);
margin-left: auto;
}
.rsvp-bar__chevron--open {
transform: rotate(90deg);
}
.rsvp-bar__cancel {
display: block;
width: 100%;
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius-card);
font-family: inherit;
font-size: 0.9rem;
font-weight: 600;
color: #ef5350;
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);
cursor: pointer;
text-align: center;
transition: background 0.15s ease;
}
.rsvp-bar__cancel:hover {
background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%);
}
.rsvp-bar-cancel-enter-active,
.rsvp-bar-cancel-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.rsvp-bar-cancel-enter-from,
.rsvp-bar-cancel-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.rsvp-bar__bookmark {
flex-shrink: 0;
border-radius: var(--radius-button);
transition: transform 0.1s ease;
}
.rsvp-bar__bookmark:hover {
transform: scale(1.02);
}
.rsvp-bar__bookmark:active {
transform: scale(0.98);
}
.rsvp-bar__bookmark-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: var(--spacing-md);
border-radius: calc(var(--radius-button) - 2px);
border: none;
cursor: pointer;
color: var(--color-text-on-gradient);
line-height: 0;
}
.rsvp-bar__bookmark-inner svg {
display: block;
}
</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,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,106 @@
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 watcher badge when eventRole is watcher', () => {
const wrapper = mountCard({ eventRole: 'watcher' })
expect(wrapper.find('.event-card__badge--watcher').exists()).toBe(true)
expect(wrapper.text()).toContain('Watching')
})
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,172 @@
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' },
{ eventToken: 'org-1', title: 'Organized Event', dateTime: '2026-03-11T19:00:00', organizerToken: 'org-token' },
{ eventToken: 'rsvp-1', title: 'Attending Event', dateTime: '2026-03-11T20:00:00', rsvpToken: 'rsvp-token', rsvpName: 'Max' },
]
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,
getRsvp: (token: string) => {
const evt = mockEvents.find((e) => e.eventToken === token)
if (evt && 'rsvpToken' in evt && 'rsvpName' in evt) {
return { rsvpToken: evt.rsvpToken, rsvpName: evt.rsvpName }
}
return undefined
},
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-11T18')) return 'in 6 hours'
if (dateTime.includes('03-11T19')) return 'in 7 hours'
if (dateTime.includes('03-11T20')) return 'in 8 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(7)
})
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)
})
it('assigns watcher role when event has no organizerToken and no rsvpToken', () => {
const wrapper = mountList()
const badges = wrapper.findAll('.event-card__badge--watcher')
expect(badges.length).toBeGreaterThanOrEqual(1)
expect(badges[0]!.text()).toBe('Watching')
})
it('assigns organizer role when event has organizerToken', () => {
const wrapper = mountList()
const badge = wrapper.find('.event-card__badge--organizer')
expect(badge.exists()).toBe(true)
expect(badge.text()).toBe('Organizer')
})
it('assigns attendee role when event has rsvpToken', () => {
const wrapper = mountList()
const badge = wrapper.find('.event-card__badge--attendee')
expect(badge.exists()).toBe(true)
expect(badge.text()).toBe('Attendee')
})
})

View File

@@ -6,7 +6,7 @@ describe('RsvpBar', () => {
it('renders CTA button when hasRsvp is false', () => {
const wrapper = mount(RsvpBar)
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending")
expect(wrapper.find('.rsvp-bar__cta-inner').text()).toBe("I'm attending")
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(false)
})
@@ -19,7 +19,7 @@ describe('RsvpBar', () => {
it('emits open when CTA button is clicked', async () => {
const wrapper = mount(RsvpBar)
await wrapper.find('.rsvp-bar__cta').trigger('click')
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
expect(wrapper.emitted('open')).toHaveLength(1)
})

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',
title: 'Birthday',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
const events = getStoredEvents()
@@ -61,7 +60,6 @@ describe('useEventStorage', () => {
organizerToken: 'org-456',
title: 'Test',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
expect(getOrganizerToken('abc-123')).toBe('org-456')
@@ -79,14 +77,12 @@ describe('useEventStorage', () => {
eventToken: 'event-1',
title: 'First',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
saveCreatedEvent({
eventToken: 'event-2',
title: 'Second',
dateTime: '2026-07-15T20:00:00+02:00',
expiryDate: '2026-08-15',
})
const events = getStoredEvents()
@@ -102,14 +98,12 @@ describe('useEventStorage', () => {
eventToken: 'abc-123',
title: 'Old Title',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
saveCreatedEvent({
eventToken: 'abc-123',
title: 'New Title',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
const events = getStoredEvents()
@@ -124,7 +118,6 @@ describe('useEventStorage', () => {
eventToken: 'abc-123',
title: 'Birthday',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
saveRsvp('abc-123', 'rsvp-token-1', 'Max', 'Birthday', '2026-06-15T20:00:00+02:00')
@@ -154,7 +147,6 @@ describe('useEventStorage', () => {
eventToken: 'abc-123',
title: 'Test',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
expect(getRsvp('abc-123')).toBeUndefined()
@@ -164,4 +156,181 @@ describe('useEventStorage', () => {
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('useEventStorage saveWatch / isStored', () => {
beforeEach(() => {
clearStorage()
})
it('saves a watch-only event (no rsvpToken, no organizerToken)', () => {
const { saveWatch, getStoredEvents } = useEventStorage()
saveWatch('watch-1', 'Concert', '2026-07-01T20:00:00+02:00')
const events = getStoredEvents()
expect(events).toHaveLength(1)
expect(events[0]!.eventToken).toBe('watch-1')
expect(events[0]!.title).toBe('Concert')
expect(events[0]!.dateTime).toBe('2026-07-01T20:00:00+02:00')
expect(events[0]!.rsvpToken).toBeUndefined()
expect(events[0]!.organizerToken).toBeUndefined()
})
it('does not duplicate if event already stored', () => {
const { saveWatch, saveRsvp, getStoredEvents } = useEventStorage()
saveRsvp('evt-1', 'rsvp-1', 'Max', 'Party', '2026-07-01T20:00:00+02:00')
saveWatch('evt-1', 'Party', '2026-07-01T20:00:00+02:00')
expect(getStoredEvents()).toHaveLength(1)
expect(getStoredEvents()[0]!.rsvpToken).toBe('rsvp-1')
})
it('isStored returns true for watched events', () => {
const { saveWatch, isStored } = useEventStorage()
saveWatch('watch-1', 'Concert', '2026-07-01T20:00:00+02:00')
expect(isStored('watch-1')).toBe(true)
})
it('isStored returns true for attended events', () => {
const { saveRsvp, isStored } = useEventStorage()
saveRsvp('evt-1', 'rsvp-1', 'Max', 'Party', '2026-07-01T20:00:00+02:00')
expect(isStored('evt-1')).toBe(true)
})
it('isStored returns true for organized events', () => {
const { saveCreatedEvent, isStored } = useEventStorage()
saveCreatedEvent({
eventToken: 'evt-1',
organizerToken: 'org-1',
title: 'My Event',
dateTime: '2026-07-01T20:00:00+02:00',
})
expect(isStored('evt-1')).toBe(true)
})
it('isStored returns false for unknown tokens', () => {
const { isStored } = useEventStorage()
expect(isStored('unknown')).toBe(false)
})
})
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,13 +3,30 @@ export interface StoredEvent {
organizerToken?: string
title: string
dateTime: string
expiryDate: string
rsvpToken?: string
rsvpName?: string
}
import { ref } from 'vue'
const STORAGE_KEY = 'fete:events'
const version = ref(0)
export function isValidStoredEvent(e: unknown): e is StoredEvent {
if (typeof e !== 'object' || e === null) return false
const obj = e as Record<string, unknown>
return (
typeof obj.eventToken === 'string' &&
obj.eventToken.length > 0 &&
typeof obj.title === 'string' &&
obj.title.length > 0 &&
typeof obj.dateTime === 'string' &&
obj.dateTime.length > 0 &&
!isNaN(new Date(obj.dateTime).getTime())
)
}
function readEvents(): StoredEvent[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
@@ -21,6 +38,7 @@ function readEvents(): StoredEvent[] {
function writeEvents(events: StoredEvent[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(events))
version.value++
}
export function useEventStorage() {
@@ -31,6 +49,7 @@ export function useEventStorage() {
}
function getStoredEvents(): StoredEvent[] {
void version.value
return readEvents()
}
@@ -46,7 +65,7 @@ export function useEventStorage() {
existing.rsvpToken = rsvpToken
existing.rsvpName = rsvpName
} else {
events.push({ eventToken, title, dateTime, expiryDate: '', rsvpToken, rsvpName })
events.push({ eventToken, title, dateTime, rsvpToken, rsvpName })
}
writeEvents(events)
}
@@ -59,5 +78,34 @@ export function useEventStorage() {
return undefined
}
return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp }
function removeRsvp(eventToken: string): void {
const events = readEvents()
const event = events.find((e) => e.eventToken === eventToken)
if (event) {
delete event.rsvpToken
delete event.rsvpName
writeEvents(events)
}
}
function saveWatch(eventToken: string, title: string, dateTime: string): void {
const events = readEvents()
const existing = events.find((e) => e.eventToken === eventToken)
if (!existing) {
events.push({ eventToken, title, dateTime })
writeEvents(events)
}
}
function isStored(eventToken: string): boolean {
void version.value
return readEvents().some((e) => e.eventToken === eventToken)
}
function removeEvent(eventToken: string): void {
const events = readEvents().filter((e) => e.eventToken !== eventToken)
writeEvents(events)
}
return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp, removeRsvp, saveWatch, isStored, 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'),
},
{
path: '/events/:token',
path: '/events/:eventToken',
name: 'event',
component: () => import('../views/EventDetailView.vue'),
},

View File

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

View File

@@ -1,12 +1,22 @@
<template>
<main class="detail">
<!-- Hero image with overlaid header -->
<div class="detail__hero">
<img
class="detail__hero-img"
src="@/assets/images/event-hero-placeholder.jpg"
alt=""
/>
<div class="detail__hero-overlay" />
<header class="detail__header">
<RouterLink to="/" class="detail__back" aria-label="Back to home">&larr;</RouterLink>
<span class="detail__brand">fete</span>
</header>
</div>
<div class="detail__body">
<!-- Loading state -->
<div v-if="state === 'loading'" class="detail__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--line" />
<div class="skeleton skeleton--line skeleton--short" />
@@ -14,52 +24,117 @@
</div>
<!-- Loaded state -->
<div v-else-if="state === 'loaded' && event" class="detail__card">
<div v-else-if="state === 'loaded' && event" class="detail__content">
<!-- Cancellation banner -->
<div v-if="event.cancelled" class="detail__cancelled-banner" role="alert">
<p class="detail__cancelled-banner-title">This event has been cancelled</p>
<p v-if="event.cancellationReason" class="detail__cancelled-banner-reason">{{ event.cancellationReason }}</p>
</div>
<h1 class="detail__title">{{ event.title }}</h1>
<dl class="detail__fields">
<div class="detail__field">
<dt class="detail__label">Date &amp; Time</dt>
<dd class="detail__value">{{ formattedDateTime }}</dd>
<dl class="detail__meta">
<div class="detail__meta-item">
<dt class="detail__meta-icon" aria-label="Date and time">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</dt>
<dd class="detail__meta-text">{{ formattedDateTime }}</dd>
</div>
<div v-if="event.description" class="detail__field">
<dt class="detail__label">Description</dt>
<dd class="detail__value">{{ event.description }}</dd>
<div v-if="event.location" class="detail__meta-item">
<dt class="detail__meta-icon" aria-label="Location">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
</dt>
<dd class="detail__meta-text">{{ event.location }}</dd>
</div>
<div v-if="event.location" class="detail__field">
<dt class="detail__label">Location</dt>
<dd class="detail__value">{{ event.location }}</dd>
</div>
<div class="detail__field">
<dt class="detail__label">Attendees</dt>
<dd class="detail__value">{{ event.attendeeCount }}</dd>
<div class="detail__meta-item">
<dt class="detail__meta-icon" aria-label="Attendees">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</dt>
<dd class="detail__meta-text">{{ event.attendeeCount }} going</dd>
</div>
</dl>
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
This event has ended.
<AttendeeList v-if="isOrganizer && attendeeNames !== null" :attendees="attendeeNames" />
<div v-if="event.description" class="detail__section">
<h2 class="detail__section-title">About</h2>
<p class="detail__description">{{ event.description }}</p>
</div>
</div>
<!-- Not found state -->
<div v-else-if="state === 'not-found'" class="detail__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>
</div>
<!-- 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>
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
<button class="btn-primary glass" type="button" @click="fetchEvent">Retry</button>
</div>
</div>
<!-- RSVP bar (only for loaded, non-expired events) -->
<!-- Cancel event button (organizer only, not already cancelled) -->
<div v-if="state === 'loaded' && event && isOrganizer && !event.cancelled" class="detail__cancel-event">
<button class="detail__cancel-event-btn" type="button" @click="cancelSheetOpen = true">
Cancel event
</button>
</div>
<!-- Cancel event bottom sheet -->
<BottomSheet :open="cancelSheetOpen" label="Cancel event" @close="cancelSheetOpen = false">
<h2 class="sheet-title">Cancel event</h2>
<form class="cancel-form" @submit.prevent="handleCancelEvent" novalidate>
<div class="form-group">
<label class="cancel-form__label" for="cancel-reason">Reason (optional)</label>
<textarea
id="cancel-reason"
v-model.trim="cancelReasonInput"
class="form-field glass cancel-form__textarea"
placeholder="e.g. Venue no longer available"
maxlength="2000"
rows="3"
@input="cancelEventError = ''"
/>
<span class="cancel-form__counter">{{ cancelReasonInput.length }} / 2000</span>
</div>
<button
class="cancel-form__confirm glass-inner"
type="submit"
:disabled="cancellingEvent"
>
{{ cancellingEvent ? 'Cancelling…' : 'Confirm cancellation' }}
</button>
<p v-if="cancelEventError" class="cancel-form__error" role="alert">{{ cancelEventError }}</p>
</form>
</BottomSheet>
<!-- Cancel RSVP error message -->
<div v-if="cancelError" class="detail__cancel-error" role="alert">
<p>{{ cancelError }}</p>
</div>
<!-- RSVP bar (hidden when cancelled) -->
<RsvpBar
v-if="state === 'loaded' && event && !event.expired && !isOrganizer"
v-if="state === 'loaded' && event && !isOrganizer && !event.cancelled"
:has-rsvp="!!rsvpName"
:bookmarked="eventIsStored"
@open="sheetOpen = true"
@cancel="confirmCancelOpen = true"
@bookmark="handleBookmarkClick"
/>
<!-- Cancel confirmation dialog -->
<ConfirmDialog
:open="confirmCancelOpen"
title="Cancel attendance?"
message="Your attendance will be permanently cancelled."
confirm-label="Cancel attendance"
cancel-label="Keep"
@confirm="handleCancelRsvp"
@cancel="confirmCancelOpen = false"
/>
<!-- RSVP bottom sheet -->
@@ -71,7 +146,7 @@
<input
id="rsvp-name"
v-model.trim="nameInput"
class="form-field"
class="form-field glass"
type="text"
placeholder="e.g. Max Mustermann"
maxlength="100"
@@ -80,9 +155,11 @@
/>
<span v-if="nameError" class="rsvp-form__field-error" role="alert">{{ nameError }}</span>
</div>
<button class="btn-primary" type="submit" :disabled="submitting">
<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>
@@ -94,7 +171,9 @@ import { ref, computed, onMounted } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { api } from '@/api/client'
import { useEventStorage } from '@/composables/useEventStorage'
import AttendeeList from '@/components/AttendeeList.vue'
import BottomSheet from '@/components/BottomSheet.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import RsvpBar from '@/components/RsvpBar.vue'
import type { components } from '@/api/schema'
@@ -102,7 +181,7 @@ type GetEventResponse = components['schemas']['GetEventResponse']
type State = 'loading' | 'loaded' | 'not-found' | 'error'
const route = useRoute()
const { saveRsvp, getRsvp, getOrganizerToken } = useEventStorage()
const { saveRsvp, getRsvp, removeRsvp, getOrganizerToken, saveWatch, isStored, removeEvent } = useEventStorage()
const state = ref<State>('loading')
const event = ref<GetEventResponse | null>(null)
@@ -114,7 +193,30 @@ const nameError = ref('')
const submitError = ref('')
const submitting = ref(false)
const rsvpName = ref<string | undefined>(undefined)
const confirmCancelOpen = ref(false)
const cancelError = ref('')
const isOrganizer = ref(false)
const attendeeNames = ref<string[] | null>(null)
// Cancel event state
const cancelSheetOpen = ref(false)
const cancelReasonInput = ref('')
const cancelEventError = ref('')
const cancellingEvent = ref(false)
const eventToken = computed(() => route.params.eventToken as string)
const eventIsStored = computed(() => isStored(eventToken.value))
function handleBookmarkClick() {
if (!event.value) return
if (isOrganizer.value || rsvpName.value) return
if (eventIsStored.value) {
removeEvent(eventToken.value)
} else {
saveWatch(eventToken.value, event.value.title, event.value.dateTime)
}
}
const formattedDateTime = computed(() => {
if (!event.value) return ''
@@ -130,8 +232,8 @@ async function fetchEvent() {
event.value = null
try {
const { data, error, response } = await api.GET('/events/{token}', {
params: { path: { token: route.params.token as string } },
const { data, error, response } = await api.GET('/events/{eventToken}', {
params: { path: { eventToken: route.params.eventToken as string } },
})
if (error) {
@@ -143,7 +245,13 @@ async function fetchEvent() {
state.value = 'loaded'
// Check if current user is the organizer
isOrganizer.value = !!getOrganizerToken(event.value.eventToken)
const orgToken = getOrganizerToken(event.value.eventToken)
isOrganizer.value = !!orgToken
// Fetch attendee list for organizer
if (orgToken) {
fetchAttendees(event.value.eventToken, orgToken)
}
// Restore RSVP status from localStorage
const stored = getRsvp(event.value.eventToken)
@@ -172,8 +280,8 @@ async function submitRsvp() {
submitting.value = true
try {
const { data, error } = await api.POST('/events/{token}/rsvps', {
params: { path: { token: route.params.token as string } },
const { data, error } = await api.POST('/events/{eventToken}/rsvps', {
params: { path: { eventToken: route.params.eventToken as string } },
body: { name: nameInput.value },
})
@@ -203,6 +311,88 @@ async function submitRsvp() {
}
}
async function handleCancelRsvp() {
confirmCancelOpen.value = false
cancelError.value = ''
const stored = getRsvp(route.params.eventToken as string)
if (!stored) return
try {
const { response } = await api.DELETE('/events/{eventToken}/rsvps/{rsvpToken}', {
params: {
path: {
eventToken: route.params.eventToken as string,
rsvpToken: stored.rsvpToken,
},
},
})
if (response.status === 204 || response.status === 404) {
removeRsvp(route.params.eventToken as string)
rsvpName.value = undefined
if (event.value) {
event.value.attendeeCount = Math.max(0, event.value.attendeeCount - 1)
}
} else {
cancelError.value = 'Could not cancel RSVP. Please try again.'
}
} catch {
cancelError.value = 'Could not cancel RSVP. Please try again.'
}
}
async function handleCancelEvent() {
cancelEventError.value = ''
cancellingEvent.value = true
const orgToken = getOrganizerToken(route.params.eventToken as string)
if (!orgToken) return
try {
const { error } = await api.PATCH('/events/{eventToken}', {
params: {
path: { eventToken: route.params.eventToken as string },
query: { organizerToken: orgToken },
},
body: {
cancelled: true,
cancellationReason: cancelReasonInput.value || undefined,
},
})
if (error) {
cancelEventError.value = 'Could not cancel event. Please try again.'
return
}
cancelSheetOpen.value = false
cancelReasonInput.value = ''
await fetchEvent()
} catch {
cancelEventError.value = 'Could not cancel event. Please try again.'
} finally {
cancellingEvent.value = false
}
}
async function fetchAttendees(eventToken: string, organizerToken: string) {
try {
const { data, error } = await api.GET('/events/{eventToken}/attendees', {
params: {
path: { eventToken: eventToken },
query: { organizerToken },
},
})
if (!error) {
attendeeNames.value = data!.attendees.map((a) => a.name)
}
} catch {
// Silently degrade — don't show attendee list
}
}
onMounted(fetchEvent)
</script>
@@ -210,14 +400,56 @@ onMounted(fetchEvent)
.detail {
display: flex;
flex-direction: column;
gap: var(--spacing-2xl);
padding-top: var(--spacing-lg);
/* Break out of .app-container constraints */
width: 100dvw;
flex: 1;
position: relative;
left: 50%;
transform: translateX(-50%);
margin: calc(-1 * var(--content-padding)) 0;
overflow-x: hidden;
}
/* Hero image section */
.detail__hero {
position: relative;
width: 100%;
height: 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 {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-lg) var(--content-padding);
padding-top: env(safe-area-inset-top, var(--spacing-lg));
z-index: 1;
}
.detail__back {
@@ -233,85 +465,267 @@ onMounted(fetchEvent)
color: var(--color-text-on-gradient);
}
.detail__card {
background: var(--color-card);
border-radius: var(--radius-card);
padding: var(--spacing-xl);
box-shadow: var(--shadow-card);
.detail__body {
flex: 1;
padding: var(--spacing-lg) var(--content-padding);
padding-bottom: 6rem;
}
.detail__content {
display: flex;
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;
text-align: center;
padding-top: 4rem;
}
.detail__meta-icon svg {
display: block;
}
/* Title */
.detail__title {
font-size: 1.4rem;
font-weight: 700;
color: var(--color-text);
font-size: 2rem;
font-weight: 800;
color: var(--color-text-on-gradient);
word-break: break-word;
line-height: 1.2;
letter-spacing: -0.02em;
}
.detail__fields {
/* Meta rows: icon + text */
.detail__meta {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.detail__field {
.detail__meta-item {
display: flex;
flex-direction: column;
gap: 0.15rem;
align-items: center;
gap: var(--spacing-sm);
}
.detail__label {
font-size: 0.8rem;
font-weight: 700;
color: #888;
text-transform: uppercase;
letter-spacing: 0.04em;
.detail__meta-icon {
flex-shrink: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
color: var(--color-text-on-gradient);
line-height: 0;
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);
}
.detail__value {
font-size: 0.95rem;
color: var(--color-text);
.detail__meta-text {
font-size: 0.9rem;
color: var(--color-text-on-gradient);
word-break: break-word;
}
/* About section */
.detail__section {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.detail__section-title {
font-size: 0.75rem;
font-weight: 700;
color: 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 {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-card);
border-radius: 10px;
font-weight: 600;
font-size: 0.9rem;
font-size: 0.85rem;
text-align: center;
}
.detail__banner--expired {
background: #fff3e0;
color: #e65100;
}
/* Error / not-found message */
.detail__message {
font-size: 1rem;
font-size: 1.1rem;
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 {
height: 1.6rem;
width: 60%;
height: 2rem;
width: 70%;
border-radius: 8px;
}
.skeleton--line {
height: 1rem;
width: 80%;
width: 85%;
border-radius: 6px;
}
.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;
}
/* Cancellation banner */
.detail__cancelled-banner {
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius-card);
background: var(--color-danger-bg);
border: 1px solid var(--color-danger-border-strong);
text-align: center;
}
.detail__cancelled-banner-title {
font-weight: 700;
font-size: 0.95rem;
color: var(--color-danger);
}
.detail__cancelled-banner-reason {
margin-top: var(--spacing-xs);
font-size: 0.85rem;
color: var(--color-text-soft);
word-break: break-word;
}
/* Cancel event button */
.detail__cancel-event {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: var(--spacing-md) var(--content-padding);
padding-bottom: calc(var(--spacing-md) + env(safe-area-inset-bottom, 0px));
display: flex;
justify-content: center;
z-index: 10;
}
.detail__cancel-event-btn {
width: 100%;
max-width: var(--content-max-width);
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius-button);
font-family: inherit;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-danger);
background: var(--color-danger-bg);
border: 1px solid var(--color-danger-border);
cursor: pointer;
transition: background 0.15s ease;
}
.detail__cancel-event-btn:hover {
background: var(--color-danger-bg-hover);
}
/* Cancel event form (inside bottom sheet) */
.cancel-form__textarea {
resize: vertical;
min-height: 4rem;
font-family: inherit;
}
.cancel-form__counter {
display: block;
text-align: right;
font-size: 0.75rem;
color: var(--color-text-muted);
margin-top: var(--spacing-xs);
}
.cancel-form__confirm {
display: block;
width: 100%;
margin-top: var(--spacing-md);
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius-button);
font-family: inherit;
font-size: 1rem;
font-weight: 700;
color: var(--color-danger);
background: var(--color-danger-bg-strong);
border: 1px solid var(--color-danger-border);
cursor: pointer;
transition: background 0.15s ease;
}
.cancel-form__confirm:hover {
background: var(--color-danger-bg-hover);
}
.cancel-form__confirm:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.cancel-form__error {
margin-top: var(--spacing-sm);
font-size: 0.85rem;
color: var(--color-danger);
text-align: center;
}
</style>

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ function createTestRouter() {
routes: [
{ path: '/', name: 'home', component: { template: '<div />' } },
{ path: '/create', name: 'create-event', component: EventCreateView },
{ path: '/events/:token', name: 'event', component: { template: '<div />' } },
{ path: '/events/:eventToken', name: 'event', component: { template: '<div />' } },
],
})
}
@@ -44,7 +44,6 @@ describe('EventCreateView', () => {
expect(wrapper.find('#description').exists()).toBe(true)
expect(wrapper.find('#dateTime').exists()).toBe(true)
expect(wrapper.find('#location').exists()).toBe(true)
expect(wrapper.find('#expiryDate').exists()).toBe(true)
})
it('has required attribute on required fields', async () => {
@@ -58,7 +57,6 @@ describe('EventCreateView', () => {
expect(wrapper.find('#title').attributes('required')).toBeDefined()
expect(wrapper.find('#dateTime').attributes('required')).toBeDefined()
expect(wrapper.find('#expiryDate').attributes('required')).toBeDefined()
})
it('does not have required attribute on optional fields', async () => {
@@ -102,7 +100,6 @@ describe('EventCreateView', () => {
// Fill required fields
await wrapper.find('#title').setValue('My Event')
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
await wrapper.find('#expiryDate').setValue('2026-12-24')
await wrapper.find('form').trigger('submit')
await flushPromises()
@@ -127,7 +124,7 @@ describe('EventCreateView', () => {
await wrapper.find('form').trigger('submit')
const errorsBefore = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0)
expect(errorsBefore.length).toBeGreaterThanOrEqual(3)
expect(errorsBefore.length).toBeGreaterThanOrEqual(2)
// Type into title field
await wrapper.find('#title').setValue('My Event')
@@ -138,9 +135,6 @@ describe('EventCreateView', () => {
const dateTimeError = wrapper.find('#dateTime').element.closest('.form-group')!.querySelector('[role="alert"]')!
expect(dateTimeError.textContent).not.toBe('')
const expiryError = wrapper.find('#expiryDate').element.closest('.form-group')!.querySelector('[role="alert"]')!
expect(expiryError.textContent).not.toBe('')
})
it('shows validation errors when submitting empty form', async () => {
@@ -156,7 +150,7 @@ describe('EventCreateView', () => {
const errorElements = wrapper.findAll('[role="alert"]')
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 () => {
@@ -169,6 +163,10 @@ describe('EventCreateView', () => {
getOrganizerToken: vi.fn(),
saveRsvp: vi.fn(),
getRsvp: vi.fn(),
removeRsvp: vi.fn(),
saveWatch: vi.fn(),
isStored: vi.fn(() => false),
removeEvent: vi.fn(),
})
vi.mocked(api.POST).mockResolvedValueOnce({
@@ -178,7 +176,6 @@ describe('EventCreateView', () => {
title: 'Birthday Party',
dateTime: '2026-12-25T18:00:00+01:00',
timezone: 'Europe/Berlin',
expiryDate: '2026-12-24',
},
error: undefined,
response: new Response(),
@@ -197,7 +194,6 @@ describe('EventCreateView', () => {
await wrapper.find('#description').setValue('Come celebrate!')
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
await wrapper.find('#location').setValue('Berlin')
await wrapper.find('#expiryDate').setValue('2026-12-24')
await wrapper.find('form').trigger('submit')
await flushPromises()
@@ -207,7 +203,6 @@ describe('EventCreateView', () => {
title: 'Birthday Party',
description: 'Come celebrate!',
location: 'Berlin',
expiryDate: '2026-12-24',
}),
})
@@ -216,12 +211,11 @@ describe('EventCreateView', () => {
organizerToken: 'org-456',
title: 'Birthday Party',
dateTime: '2026-12-25T18:00:00+01:00',
expiryDate: '2026-12-24',
})
expect(pushSpy).toHaveBeenCalledWith({
name: 'event',
params: { token: 'abc-123' },
params: { eventToken: 'abc-123' },
})
})
@@ -244,7 +238,6 @@ describe('EventCreateView', () => {
await wrapper.find('#title').setValue('Duplicate Event')
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
await wrapper.find('#expiryDate').setValue('2026-12-24')
await wrapper.find('form').trigger('submit')
await flushPromises()
@@ -255,6 +248,5 @@ describe('EventCreateView', () => {
// Other field errors should not be present
expect(wrapper.find('#dateTime-error').exists()).toBe(false)
expect(wrapper.find('#expiryDate-error').exists()).toBe(false)
})
})

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