Compare commits
48 Commits
6e655597d7
...
0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 752d153cd4 | |||
| 763811fce6 | |||
| d7ed28e036 | |||
| a52d0cd1d3 | |||
| 373f3671f6 | |||
| 8f78c6cd45 | |||
| fe291e36e4 | |||
| e56998b17c | |||
| 1b3eafa8d1 | |||
| 061d507825 | |||
| d79a19ca15 | |||
| 2da36058ae | |||
| 90bfd12bf3 | |||
| 4d6df8d16b | |||
| be1c5062a2 | |||
| d9136481d8 | |||
| e248a2ee06 | |||
| fc77248c38 | |||
| a625e34fe4 | |||
| 4828d06aba | |||
| cac2903807 | |||
|
|
210118bf9a | ||
| 9a78ebd9b0 | |||
| 5f50ea991b | |||
| fd9175925e | |||
| 63108f4eb5 | |||
| cd71110514 | |||
| 76b48d8b61 | |||
| e5d0dd5f8f | |||
| e77e479e2a | |||
| 80d79c3596 | |||
| 7efe932621 | |||
| a56a26b1f0 | |||
| 906ba99b75 | |||
| da08752642 | |||
| 014b3b0171 | |||
| 33aff5bff5 | |||
| 6de0769d70 | |||
| 6a16255984 | |||
| 2ce3ce0d05 | |||
|
|
ca651d4c05 | ||
|
|
1e065bef18 | ||
|
|
e10b88ee5f | ||
|
|
465fc2178f | ||
|
|
9e48debca7 | ||
|
|
fc344d3ca0 | ||
|
|
e04a86399c | ||
|
|
0069747e68 |
@@ -16,7 +16,7 @@ cd "$CLAUDE_PROJECT_DIR/frontend"
|
|||||||
ERRORS=""
|
ERRORS=""
|
||||||
|
|
||||||
# Type-check
|
# Type-check
|
||||||
if OUTPUT=$(npx vue-tsc --noEmit 2>&1); then
|
if OUTPUT=$(npm run type-check 2>&1); then
|
||||||
:
|
:
|
||||||
else
|
else
|
||||||
ERRORS+="Type-check failed:\n$OUTPUT\n\n"
|
ERRORS+="Type-check failed:\n$OUTPUT\n\n"
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ PASSED=""
|
|||||||
|
|
||||||
# Run backend tests if Java sources changed
|
# Run backend tests if Java sources changed
|
||||||
if [[ -n "$HAS_BACKEND" ]]; then
|
if [[ -n "$HAS_BACKEND" ]]; then
|
||||||
if OUTPUT=$(cd backend && ./mvnw test -q 2>&1); then
|
if OUTPUT=$(cd backend && ./mvnw verify -q 2>&1); then
|
||||||
PASSED+="✓ Backend tests passed. "
|
PASSED+="✓ Backend tests passed. "
|
||||||
else
|
else
|
||||||
# Filter: only [ERROR] lines, skip Maven boilerplate
|
# Filter: only [ERROR] lines, skip Maven boilerplate
|
||||||
|
|||||||
94
.claude/skills/merge-pr/SKILL.md
Normal file
94
.claude/skills/merge-pr/SKILL.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
name: merge-pr
|
||||||
|
description: Create a Gitea pull request, monitor CI pipeline status, and merge when green. Use this skill when the user asks to "create a PR", "merge the PR", "ship it", "make it ready to merge", or when you need to open a pull request and wait for CI before merging. Also use when asked to check CI/PR status on Gitea.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Merge PR
|
||||||
|
|
||||||
|
Create a pull request on Gitea, monitor the CI pipeline via the Actions API, and merge once all jobs pass.
|
||||||
|
|
||||||
|
## Why this skill exists
|
||||||
|
|
||||||
|
The Gitea MCP pull request API does not return CI status directly. To know if a PR is ready to merge, you must cross-reference the PR's `head.sha` with the Actions runs API, find the matching run, and check job conclusions. This skill encodes that workflow so it doesn't have to be rediscovered.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
The Gitea MCP tools must be available. The key tools are:
|
||||||
|
|
||||||
|
- `mcp__gitea__pull_request_write` (method: `create`, `merge`)
|
||||||
|
- `mcp__gitea__pull_request_read` (method: `get`)
|
||||||
|
- `mcp__gitea__actions_run_read` (methods: `list_runs`, `list_run_jobs`)
|
||||||
|
|
||||||
|
If these tools are not yet loaded, use ToolSearch to discover and load them before proceeding.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### 1. Create the PR
|
||||||
|
|
||||||
|
Use `mcp__gitea__pull_request_write` with method `create`. Include a clear title, body with summary and test plan, head branch, and base branch (usually `master`).
|
||||||
|
|
||||||
|
Save the returned `head.sha` — you need it to find the CI run.
|
||||||
|
|
||||||
|
### 2. Find the CI run for the PR
|
||||||
|
|
||||||
|
The Actions API has no direct "get CI status for PR" call. Instead:
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp__gitea__actions_run_read(method: "list_runs", owner, repo, perPage: 5)
|
||||||
|
```
|
||||||
|
|
||||||
|
Find the run whose `head_sha` matches the PR's `head.sha`. This is the CI run triggered by the push that the PR points to. If the branch was force-pushed or new commits were added, always match against the latest `head.sha` from a fresh `get` on the PR.
|
||||||
|
|
||||||
|
### 3. Monitor job status
|
||||||
|
|
||||||
|
Once you have the run ID:
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp__gitea__actions_run_read(method: "list_run_jobs", owner, repo, run_id: <id>)
|
||||||
|
```
|
||||||
|
|
||||||
|
This returns all jobs with their `status` (queued/in_progress/completed) and `conclusion` (success/failure/skipped/null).
|
||||||
|
|
||||||
|
Present a status table to the user:
|
||||||
|
|
||||||
|
| Job | Status |
|
||||||
|
|-----|--------|
|
||||||
|
| backend-test | success |
|
||||||
|
| frontend-test | in_progress |
|
||||||
|
| frontend-e2e | queued |
|
||||||
|
|
||||||
|
If jobs are still running, wait ~30 seconds and check again. Don't poll in a tight loop.
|
||||||
|
|
||||||
|
### 4. Handle failures
|
||||||
|
|
||||||
|
If any job has `conclusion: failure`:
|
||||||
|
- Use `mcp__gitea__actions_run_read` with method `get_job_log_preview` to fetch the failing job's log
|
||||||
|
- Report the failure to the user with relevant log output
|
||||||
|
- Do NOT attempt to merge
|
||||||
|
|
||||||
|
### 5. Merge when green
|
||||||
|
|
||||||
|
Once all jobs show `conclusion: success` (or `skipped` for conditional jobs like `build-and-publish`):
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp__gitea__pull_request_write(
|
||||||
|
method: "merge",
|
||||||
|
owner, repo,
|
||||||
|
index: <pr_number>,
|
||||||
|
merge_style: "merge",
|
||||||
|
delete_branch: true
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ask the user for confirmation before merging. They may want to review the PR in the web UI first.
|
||||||
|
|
||||||
|
### 6. Post-merge cleanup
|
||||||
|
|
||||||
|
After a successful merge, suggest:
|
||||||
|
- `git checkout master && git pull origin master`
|
||||||
|
- `git branch -d <feature-branch>` (local cleanup)
|
||||||
|
- Tagging a release if appropriate (see `/release` skill)
|
||||||
|
|
||||||
|
## Abbreviated flow
|
||||||
|
|
||||||
|
When the user just wants a quick status check (e.g. "how's the PR?"), skip straight to steps 2-3: find the run by SHA, show the job status table.
|
||||||
@@ -7,7 +7,7 @@ jobs:
|
|||||||
backend-test:
|
backend-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up JDK 25
|
- name: Set up JDK 25
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v5
|
||||||
@@ -21,10 +21,10 @@ jobs:
|
|||||||
frontend-test:
|
frontend-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up Node 24
|
- name: Set up Node 24
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
|
|
||||||
@@ -49,10 +49,10 @@ jobs:
|
|||||||
frontend-e2e:
|
frontend-e2e:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up Node 24
|
- name: Set up Node 24
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
run: cd frontend && npm run test:e2e
|
run: cd frontend && npm run test:e2e
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
@@ -78,7 +78,9 @@ jobs:
|
|||||||
if: startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, '.')
|
if: startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, '.')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Parse SemVer tag
|
- name: Parse SemVer tag
|
||||||
id: semver
|
id: semver
|
||||||
@@ -114,3 +116,22 @@ jobs:
|
|||||||
docker push "${IMAGE}:${{ steps.semver.outputs.minor }}"
|
docker push "${IMAGE}:${{ steps.semver.outputs.minor }}"
|
||||||
docker push "${IMAGE}:${{ steps.semver.outputs.major }}"
|
docker push "${IMAGE}:${{ steps.semver.outputs.major }}"
|
||||||
docker push "${IMAGE}:latest"
|
docker push "${IMAGE}:latest"
|
||||||
|
|
||||||
|
- name: Generate changelog
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
PREV_TAG=$(git tag --sort=-v:refname | sed -n '2p')
|
||||||
|
if [ -z "$PREV_TAG" ]; then
|
||||||
|
git log --oneline --no-merges > RELEASE_NOTES.md
|
||||||
|
else
|
||||||
|
git log --oneline --no-merges "${PREV_TAG}..HEAD" > RELEASE_NOTES.md
|
||||||
|
fi
|
||||||
|
echo "Container image: \`${IMAGE}:${{ steps.semver.outputs.full }}\`" >> RELEASE_NOTES.md
|
||||||
|
|
||||||
|
- name: Create Gitea release
|
||||||
|
uses: akkuman/gitea-release-action@v1
|
||||||
|
with:
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
name: v${{ steps.semver.outputs.full }}
|
||||||
|
body_path: RELEASE_NOTES.md
|
||||||
|
token: ${{ github.token }}
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,6 +14,9 @@ Thumbs.db
|
|||||||
.agent-tests/
|
.agent-tests/
|
||||||
.ralph/*/iteration-*.jsonl
|
.ralph/*/iteration-*.jsonl
|
||||||
|
|
||||||
|
# Test results (Playwright artifacts)
|
||||||
|
test-results/
|
||||||
|
|
||||||
# Java/Maven
|
# Java/Maven
|
||||||
*.class
|
*.class
|
||||||
*.jar
|
*.jar
|
||||||
|
|||||||
@@ -107,8 +107,10 @@ Accessibility is a baseline requirement, not an afterthought.
|
|||||||
rationale. Never rewrite or delete the original decision.
|
rationale. Never rewrite or delete the original decision.
|
||||||
- The visual design system in `.specify/memory/design-system.md` is authoritative. All
|
- The visual design system in `.specify/memory/design-system.md` is authoritative. All
|
||||||
frontend implementation MUST follow it.
|
frontend implementation MUST follow it.
|
||||||
- Research reports go to `docs/agents/research/`, implementation plans to
|
- Feature specs, research, and plans live in `specs/NNN-feature-name/`
|
||||||
`docs/agents/plan/`.
|
(spec-kit format). Cross-cutting research goes to
|
||||||
|
`.specify/memory/research/`, cross-cutting plans to
|
||||||
|
`.specify/memory/plans/`.
|
||||||
- Conversation and brainstorming in German; code, comments, commits, and
|
- Conversation and brainstorming in German; code, comments, commits, and
|
||||||
documentation in English.
|
documentation in English.
|
||||||
- Documentation lives in the README. No wiki, no elaborate docs site.
|
- Documentation lives in the README. No wiki, no elaborate docs site.
|
||||||
|
|||||||
@@ -33,12 +33,16 @@ Person erstellt via App eine Veranstaltung und schickt seine Freunden irgendwie
|
|||||||
* Updaten der Veranstaltung
|
* Updaten der Veranstaltung
|
||||||
* Einsicht angemeldete Gäste, kann bei Bedarf Einträge entfernen
|
* Einsicht angemeldete Gäste, kann bei Bedarf Einträge entfernen
|
||||||
* Featureideen:
|
* Featureideen:
|
||||||
|
* Organisator kann einstellen, ob Attendee-Namensliste öffentlich auf der Event-Seite sichtbar ist (default: nur für Organisator). Wenn öffentlich, muss im RSVP-Bottom-Sheet eine Warnung angezeigt werden, dass der Name öffentlich sichtbar sein wird.
|
||||||
|
* Link-Previews (OpenGraph Meta-Tags): Generische OG-Tags mit App-Branding (z.B. "fete — Du wurdest eingeladen") damit geteilte Links in WhatsApp/Signal/Telegram hübsch aussehen. Keine Event-Daten an Crawler aus Privacy-Gründen. → Eigene User Story.
|
||||||
* Kalender-Integration: .ics-Download + optional webcal:// für Live-Updates bei Änderungen
|
* Kalender-Integration: .ics-Download + optional webcal:// für Live-Updates bei Änderungen
|
||||||
* Änderungen zum ursprünglichen Inhalt (z.b. geändertes datum/ort) werden iwi hervorgehoben
|
* Änderungen zum ursprünglichen Inhalt (z.b. geändertes datum/ort) werden iwi hervorgehoben
|
||||||
* Veranstalter kann Updatenachrichten im Event posten, pro Device wird via LocalStorage gemerkt was man schon gesehen hat (Badge/Hervorhebung für neue Updates)
|
* Veranstalter kann Updatenachrichten im Event posten, pro Device wird via LocalStorage gemerkt was man schon gesehen hat (Badge/Hervorhebung für neue Updates)
|
||||||
* QR Code generieren (z.B. für Plakate/Flyer)
|
* QR Code generieren (z.B. für Plakate/Flyer)
|
||||||
* Ablaufdatum als Pflichtfeld, nach dem alle gespeicherten Daten gelöscht werden
|
* Ablaufdatum als Pflichtfeld, nach dem alle gespeicherten Daten gelöscht werden
|
||||||
* Übersichtsliste im LocalStorage: Alle Events die man zugesagt oder gemerkt hat (vgl. spliit)
|
* Übersichtsliste im LocalStorage: Alle Events die man zugesagt oder gemerkt hat (vgl. spliit)
|
||||||
|
* RSVP editieren: Gast kann seine bestehende Zusage bearbeiten (Name ändern via PUT mit rsvpToken) oder zurückziehen (DELETE mit rsvpToken). Bottom Sheet öffnet sich im Edit-Mode mit pre-filled Name + "Zusage zurückziehen"-Button. Später ergänzen: "Absagen und merken" (Kombination mit 011-bookmark-event). Ausgelagert aus 008-rsvp um den Scope klein zu halten.
|
||||||
|
* Organizer-Gästeliste: Namensliste der Zusagen nur für Organisator sichtbar (über Organizer-Link). Gehört thematisch zu 009-guest-list, nicht zu 008-rsvp.
|
||||||
* Sicherheit/Missbrauchsschutz:
|
* Sicherheit/Missbrauchsschutz:
|
||||||
* Nicht-erratbare Event-Tokens (z.B. UUIDs)
|
* Nicht-erratbare Event-Tokens (z.B. UUIDs)
|
||||||
* Event-Erstellung ist offen, kein Login/Passwort/Invite-Code nötig
|
* Event-Erstellung ist offen, kein Login/Passwort/Invite-Code nötig
|
||||||
@@ -78,3 +82,113 @@ Die folgenden Punkte wurden in Diskussionen bereits geklärt und sind verbindlic
|
|||||||
* Frontend: Vue 3 (mit Vite als Bundler, TypeScript, Vue Router)
|
* Frontend: Vue 3 (mit Vite als Bundler, TypeScript, Vue Router)
|
||||||
* Architekturentscheidungen die NOCH NICHT getroffen wurden (hier darf nichts eigenmächtig entschieden werden!):
|
* Architekturentscheidungen die NOCH NICHT getroffen wurden (hier darf nichts eigenmächtig entschieden werden!):
|
||||||
* (derzeit keine offenen Architekturentscheidungen)
|
* (derzeit keine offenen Architekturentscheidungen)
|
||||||
|
|
||||||
|
## Nicht umgesetzte Feature-Ideen (ehemals Specs 009–026)
|
||||||
|
|
||||||
|
### 009 – Gästeliste
|
||||||
|
Organisator sieht alle RSVPs (Name, Status) und kann einzelne Einträge löschen.
|
||||||
|
* Nur mit gültigem Organizer-Token sichtbar
|
||||||
|
* Gäste ohne Token sehen keine Gästeliste
|
||||||
|
* Löschung serverseitig validiert
|
||||||
|
|
||||||
|
### 010 – Event bearbeiten
|
||||||
|
Organisator kann Titel, Beschreibung, Datum, Ort und Ablaufdatum ändern.
|
||||||
|
* Formular vorausgefüllt mit aktuellen Werten
|
||||||
|
* Ablaufdatum muss in der Zukunft liegen
|
||||||
|
* Ohne Organizer-Token kein Edit-UI sichtbar
|
||||||
|
|
||||||
|
### 011 – Event merken/bookmarken
|
||||||
|
Gäste können Events lokal merken, ohne RSVP abzugeben — rein clientseitig via localStorage.
|
||||||
|
* Kein Serverkontakt nötig
|
||||||
|
* Unabhängig vom RSVP-Status
|
||||||
|
* Auch bei abgelaufenen Events möglich
|
||||||
|
|
||||||
|
### 012 – Lokale Event-Übersicht
|
||||||
|
Startseite (`/`) zeigt alle getrackten Events (erstellt, zugesagt, gemerkt) aus localStorage.
|
||||||
|
* Zeigt Titel, Datum, Beziehungstyp (Organisator/Gast/Gemerkt)
|
||||||
|
* Vergangene Events als "beendet" markiert
|
||||||
|
* Einträge können entfernt werden
|
||||||
|
|
||||||
|
### 013 – Kalender-Export
|
||||||
|
.ics-Download (RFC 5545) mit Event-Details, optional webcal:// für Live-Updates.
|
||||||
|
* Stabile UID aus Event-Token (Re-Import aktualisiert statt dupliziert)
|
||||||
|
* Bei Absage: STATUS:CANCELLED im .ics
|
||||||
|
* Kein externer Kalenderservice kontaktiert
|
||||||
|
|
||||||
|
### 014 – Änderungen hervorheben
|
||||||
|
Geänderte Felder werden visuell hervorgehoben, wenn der Gast seit der letzten Änderung nicht mehr auf der Seite war.
|
||||||
|
* Server trackt `last_edited_at` + geänderte Feldnamen
|
||||||
|
* Client speichert `last_seen_at` in localStorage
|
||||||
|
* Privacy-freundlich: kein serverseitiges Read-Tracking
|
||||||
|
|
||||||
|
### 015 – Organisator-Updates
|
||||||
|
Organisator kann Textnachrichten im Event posten (Pinnwand-Stil).
|
||||||
|
* Chronologisch sortiert, löschbar durch Organisator
|
||||||
|
* Nach Ablauf kein Posting mehr möglich
|
||||||
|
* Ohne Organizer-Token kein Compose-UI
|
||||||
|
|
||||||
|
### 016 – Gast-Benachrichtigungen
|
||||||
|
Badge/Indikator bei ungelesenen Organisator-Updates, rein clientseitig via localStorage.
|
||||||
|
* Eigener Timestamp `updates_last_seen_at` (getrennt von Feld-Änderungen)
|
||||||
|
* Kein Indikator beim ersten Besuch
|
||||||
|
* Kein serverseitiges Tracking (Privacy)
|
||||||
|
|
||||||
|
### 017 – QR-Code
|
||||||
|
Event-Seite zeigt QR-Code mit der öffentlichen Event-URL.
|
||||||
|
* Serverseitig generiert (kein externer QR-Service)
|
||||||
|
* Download als SVG oder hochauflösendes PNG
|
||||||
|
* Auch bei abgelaufenen Events verfügbar
|
||||||
|
|
||||||
|
### 018 – Datenlöschung
|
||||||
|
Automatische Löschung aller Event-Daten nach Ablaufdatum (Privacy-Garantie).
|
||||||
|
* Scheduled Job oder Lazy Cleanup bei Zugriff
|
||||||
|
* Löscht Event, RSVPs, Updates, Bilder, Metadaten
|
||||||
|
* Idempotent, kein PII im Log
|
||||||
|
|
||||||
|
### 019 – Instanz-Limit
|
||||||
|
`MAX_ACTIVE_EVENTS` als Env-Variable begrenzt aktive Events für Self-Hoster.
|
||||||
|
* Nur nicht-abgelaufene Events zählen
|
||||||
|
* Unset/leer = unbegrenzt
|
||||||
|
* Serverseitige Durchsetzung bei Event-Erstellung
|
||||||
|
|
||||||
|
### 020 – PWA
|
||||||
|
Web App Manifest + Service Worker für Installierbarkeit und Offline-Caching.
|
||||||
|
* Standalone-Modus ohne Browser-Chrome
|
||||||
|
* Icon + Name auf Home-Screen
|
||||||
|
* Alle Assets selbstgehostet
|
||||||
|
|
||||||
|
### 021 – Farbthemen
|
||||||
|
Organisator wählt bei Erstellung ein vordefiniertes Farbthema für die Event-Seite.
|
||||||
|
* Nur auf der Gast-Seite angewendet (nicht global)
|
||||||
|
* Änderbar beim Bearbeiten
|
||||||
|
* Unabhängig von Dark/Light Mode
|
||||||
|
|
||||||
|
### 022 – Headerbild
|
||||||
|
Organisator sucht Headerbild über integrierte Unsplash-Suche.
|
||||||
|
* Serverseitig geproxied (Client kontaktiert nie Unsplash)
|
||||||
|
* Bild lokal gespeichert + Unsplash-Attribution
|
||||||
|
* Feature deaktiviert wenn kein API-Key konfiguriert
|
||||||
|
|
||||||
|
### 023 – Dark Mode
|
||||||
|
App erkennt `prefers-color-scheme` und bietet manuellen Toggle.
|
||||||
|
* Manuelle Auswahl in localStorage gespeichert
|
||||||
|
* Gilt für globales App-Chrome, nicht Event-Farbthemen
|
||||||
|
* Beide Modi WCAG AA konform
|
||||||
|
|
||||||
|
### 024 – Event absagen
|
||||||
|
Organisator kann Event absagen (mit optionaler Nachricht, Einweg-Transition).
|
||||||
|
* RSVPs werden nach Absage abgelehnt
|
||||||
|
* Absage-Nachricht nachträglich editierbar
|
||||||
|
* Kann nicht rückgängig gemacht werden
|
||||||
|
|
||||||
|
### 025 – Event löschen
|
||||||
|
Organisator löscht Event permanent und unwiderruflich.
|
||||||
|
* Entfernt alle zugehörigen Daten sofort
|
||||||
|
* localStorage-Eintrag wird entfernt, Redirect zu `/`
|
||||||
|
* Funktioniert in jedem Event-Status
|
||||||
|
|
||||||
|
### 026 – 404-Seite
|
||||||
|
Catch-all Route für ungültige Pfade mit "Seite nicht gefunden" und Link zur Startseite.
|
||||||
|
* Folgt dem Design System (Electric Dusk + Sora)
|
||||||
|
* WCAG AA konform
|
||||||
|
* Verhindert leere Seiten bei Fehlnavigation
|
||||||
|
|||||||
@@ -49,3 +49,12 @@ The following skills are available and should be used for their respective purpo
|
|||||||
- The loop runner is `ralph.sh`. Each run lives in its own directory under `.ralph/`.
|
- 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).
|
- 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).
|
- Project specifications (user stories, setup tasks, personas, etc.) live in `specs/` (feature dirs) and `.specify/memory/` (cross-cutting docs).
|
||||||
|
|
||||||
|
## Active Technologies
|
||||||
|
- Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript (007-view-event)
|
||||||
|
- PostgreSQL (JPA via Spring Data, Liquibase migrations) (007-view-event)
|
||||||
|
- TypeScript 5.9 (frontend only) + Vue 3, Vue Router 5 (existing — no additions) (010-event-list-grouping)
|
||||||
|
- localStorage via `useEventStorage.ts` composable (existing — no changes) (010-event-list-grouping)
|
||||||
|
|
||||||
|
## Recent Changes
|
||||||
|
- 007-view-event: Added Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ COPY backend/src/main/resources/openapi/api.yaml \
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Build backend with frontend assets baked in
|
# Stage 2: Build backend with frontend assets baked in
|
||||||
FROM eclipse-temurin:25-jdk-alpine AS backend-build
|
FROM eclipse-temurin:25.0.2_10-jdk-alpine AS backend-build
|
||||||
WORKDIR /app/backend
|
WORKDIR /app/backend
|
||||||
COPY backend/ ./
|
COPY backend/ ./
|
||||||
COPY --from=frontend-build /app/frontend/dist src/main/resources/static/
|
COPY --from=frontend-build /app/frontend/dist src/main/resources/static/
|
||||||
RUN ./mvnw -B -DskipTests -Dcheckstyle.skip -Dspotbugs.skip package
|
RUN ./mvnw -B -DskipTests -Dcheckstyle.skip -Dspotbugs.skip package
|
||||||
|
|
||||||
# Stage 3: Runtime
|
# Stage 3: Runtime
|
||||||
FROM eclipse-temurin:25-jre-alpine
|
FROM eclipse-temurin:25.0.2_10-jre-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=backend-build /app/backend/target/*.jar app.jar
|
COPY --from=backend-build /app/backend/target/*.jar app.jar
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
wrapperVersion=3.3.4
|
wrapperVersion=3.3.4
|
||||||
distributionType=only-script
|
distributionType=only-script
|
||||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.13/apache-maven-3.9.13-bin.zip
|
||||||
|
|||||||
@@ -179,7 +179,7 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.codehaus.mojo</groupId>
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
<artifactId>build-helper-maven-plugin</artifactId>
|
<artifactId>build-helper-maven-plugin</artifactId>
|
||||||
<version>3.6.0</version>
|
<version>3.6.1</version>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
<id>add-openapi-sources</id>
|
<id>add-openapi-sources</id>
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
package de.fete.adapter.in.web;
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
import de.fete.adapter.in.web.api.EventsApi;
|
import de.fete.adapter.in.web.api.EventsApi;
|
||||||
|
import de.fete.adapter.in.web.model.Attendee;
|
||||||
import de.fete.adapter.in.web.model.CreateEventRequest;
|
import de.fete.adapter.in.web.model.CreateEventRequest;
|
||||||
import de.fete.adapter.in.web.model.CreateEventResponse;
|
import de.fete.adapter.in.web.model.CreateEventResponse;
|
||||||
|
import de.fete.adapter.in.web.model.CreateRsvpRequest;
|
||||||
|
import de.fete.adapter.in.web.model.CreateRsvpResponse;
|
||||||
|
import de.fete.adapter.in.web.model.GetAttendeesResponse;
|
||||||
|
import de.fete.adapter.in.web.model.GetEventResponse;
|
||||||
|
import de.fete.application.service.EventNotFoundException;
|
||||||
|
import de.fete.application.service.InvalidTimezoneException;
|
||||||
import de.fete.domain.model.CreateEventCommand;
|
import de.fete.domain.model.CreateEventCommand;
|
||||||
import de.fete.domain.model.Event;
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.OrganizerToken;
|
||||||
|
import de.fete.domain.model.Rsvp;
|
||||||
|
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
|
||||||
import de.fete.domain.port.in.CreateEventUseCase;
|
import de.fete.domain.port.in.CreateEventUseCase;
|
||||||
|
import de.fete.domain.port.in.CreateRsvpUseCase;
|
||||||
|
import de.fete.domain.port.in.GetAttendeesUseCase;
|
||||||
|
import de.fete.domain.port.in.GetEventUseCase;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.DateTimeException;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
@@ -15,19 +35,38 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
public class EventController implements EventsApi {
|
public class EventController implements EventsApi {
|
||||||
|
|
||||||
private final CreateEventUseCase createEventUseCase;
|
private final CreateEventUseCase createEventUseCase;
|
||||||
|
private final GetEventUseCase getEventUseCase;
|
||||||
|
private final CreateRsvpUseCase createRsvpUseCase;
|
||||||
|
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
|
||||||
|
private final GetAttendeesUseCase getAttendeesUseCase;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
/** Creates a new controller with the given use case. */
|
/** Creates a new controller with the given use cases and clock. */
|
||||||
public EventController(CreateEventUseCase createEventUseCase) {
|
public EventController(
|
||||||
|
CreateEventUseCase createEventUseCase,
|
||||||
|
GetEventUseCase getEventUseCase,
|
||||||
|
CreateRsvpUseCase createRsvpUseCase,
|
||||||
|
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
|
||||||
|
GetAttendeesUseCase getAttendeesUseCase,
|
||||||
|
Clock clock) {
|
||||||
this.createEventUseCase = createEventUseCase;
|
this.createEventUseCase = createEventUseCase;
|
||||||
|
this.getEventUseCase = getEventUseCase;
|
||||||
|
this.createRsvpUseCase = createRsvpUseCase;
|
||||||
|
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
|
||||||
|
this.getAttendeesUseCase = getAttendeesUseCase;
|
||||||
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<CreateEventResponse> createEvent(
|
public ResponseEntity<CreateEventResponse> createEvent(
|
||||||
CreateEventRequest request) {
|
CreateEventRequest request) {
|
||||||
|
ZoneId zoneId = parseTimezone(request.getTimezone());
|
||||||
|
|
||||||
var command = new CreateEventCommand(
|
var command = new CreateEventCommand(
|
||||||
request.getTitle(),
|
request.getTitle(),
|
||||||
request.getDescription(),
|
request.getDescription(),
|
||||||
request.getDateTime(),
|
request.getDateTime(),
|
||||||
|
zoneId,
|
||||||
request.getLocation(),
|
request.getLocation(),
|
||||||
request.getExpiryDate()
|
request.getExpiryDate()
|
||||||
);
|
);
|
||||||
@@ -35,12 +74,74 @@ public class EventController implements EventsApi {
|
|||||||
Event event = createEventUseCase.createEvent(command);
|
Event event = createEventUseCase.createEvent(command);
|
||||||
|
|
||||||
var response = new CreateEventResponse();
|
var response = new CreateEventResponse();
|
||||||
response.setEventToken(event.getEventToken());
|
response.setEventToken(event.getEventToken().value());
|
||||||
response.setOrganizerToken(event.getOrganizerToken());
|
response.setOrganizerToken(event.getOrganizerToken().value());
|
||||||
response.setTitle(event.getTitle());
|
response.setTitle(event.getTitle());
|
||||||
response.setDateTime(event.getDateTime());
|
response.setDateTime(event.getDateTime());
|
||||||
|
response.setTimezone(event.getTimezone().getId());
|
||||||
response.setExpiryDate(event.getExpiryDate());
|
response.setExpiryDate(event.getExpiryDate());
|
||||||
|
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<GetEventResponse> getEvent(UUID token) {
|
||||||
|
var eventToken = new de.fete.domain.model.EventToken(token);
|
||||||
|
Event event = getEventUseCase.getByEventToken(eventToken)
|
||||||
|
.orElseThrow(() -> new EventNotFoundException(token));
|
||||||
|
|
||||||
|
var response = new GetEventResponse();
|
||||||
|
response.setEventToken(event.getEventToken().value());
|
||||||
|
response.setTitle(event.getTitle());
|
||||||
|
response.setDescription(event.getDescription());
|
||||||
|
response.setDateTime(event.getDateTime());
|
||||||
|
response.setTimezone(event.getTimezone().getId());
|
||||||
|
response.setLocation(event.getLocation());
|
||||||
|
response.setAttendeeCount(
|
||||||
|
(int) countAttendeesByEventUseCase.countByEvent(eventToken));
|
||||||
|
response.setExpired(
|
||||||
|
event.getExpiryDate().isBefore(LocalDate.now(clock)));
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<GetAttendeesResponse> getAttendees(
|
||||||
|
UUID token, UUID organizerToken) {
|
||||||
|
var eventToken = new EventToken(token);
|
||||||
|
var orgToken = new OrganizerToken(organizerToken);
|
||||||
|
|
||||||
|
List<String> names = getAttendeesUseCase
|
||||||
|
.getAttendeeNames(eventToken, orgToken);
|
||||||
|
|
||||||
|
var attendees = names.stream()
|
||||||
|
.map(name -> new Attendee().name(name))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
var response = new GetAttendeesResponse();
|
||||||
|
response.setAttendees(attendees);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<CreateRsvpResponse> createRsvp(
|
||||||
|
UUID token, CreateRsvpRequest createRsvpRequest) {
|
||||||
|
var eventToken = new EventToken(token);
|
||||||
|
Rsvp rsvp = createRsvpUseCase.createRsvp(eventToken, createRsvpRequest.getName());
|
||||||
|
|
||||||
|
var response = new CreateRsvpResponse();
|
||||||
|
response.setRsvpToken(rsvp.getRsvpToken().value());
|
||||||
|
response.setName(rsvp.getName());
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ZoneId parseTimezone(String timezone) {
|
||||||
|
try {
|
||||||
|
return ZoneId.of(timezone);
|
||||||
|
} catch (DateTimeException e) {
|
||||||
|
throw new InvalidTimezoneException(timezone);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package de.fete.adapter.in.web;
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
|
import de.fete.application.service.EventExpiredException;
|
||||||
|
import de.fete.application.service.EventNotFoundException;
|
||||||
|
import de.fete.application.service.ExpiryDateBeforeEventException;
|
||||||
import de.fete.application.service.ExpiryDateInPastException;
|
import de.fete.application.service.ExpiryDateInPastException;
|
||||||
|
import de.fete.application.service.InvalidOrganizerTokenException;
|
||||||
|
import de.fete.application.service.InvalidTimezoneException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -44,6 +49,19 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
|||||||
return handleExceptionInternal(ex, problemDetail, headers, status, request);
|
return handleExceptionInternal(ex, problemDetail, headers, status, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Handles expiry date before event date. */
|
||||||
|
@ExceptionHandler(ExpiryDateBeforeEventException.class)
|
||||||
|
public ResponseEntity<ProblemDetail> handleExpiryDateBeforeEvent(
|
||||||
|
ExpiryDateBeforeEventException ex) {
|
||||||
|
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||||
|
problemDetail.setTitle("Invalid Expiry Date");
|
||||||
|
problemDetail.setType(URI.create("urn:problem-type:expiry-date-before-event"));
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||||
|
.body(problemDetail);
|
||||||
|
}
|
||||||
|
|
||||||
/** Handles expiry date validation failures. */
|
/** Handles expiry date validation failures. */
|
||||||
@ExceptionHandler(ExpiryDateInPastException.class)
|
@ExceptionHandler(ExpiryDateInPastException.class)
|
||||||
public ResponseEntity<ProblemDetail> handleExpiryDateInPast(
|
public ResponseEntity<ProblemDetail> handleExpiryDateInPast(
|
||||||
@@ -57,6 +75,58 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
|||||||
.body(problemDetail);
|
.body(problemDetail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Handles RSVP on expired event. */
|
||||||
|
@ExceptionHandler(EventExpiredException.class)
|
||||||
|
public ResponseEntity<ProblemDetail> handleEventExpired(
|
||||||
|
EventExpiredException ex) {
|
||||||
|
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.CONFLICT, ex.getMessage());
|
||||||
|
problemDetail.setTitle("Event Expired");
|
||||||
|
problemDetail.setType(URI.create("urn:problem-type:event-expired"));
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
|
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||||
|
.body(problemDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handles invalid organizer token. */
|
||||||
|
@ExceptionHandler(InvalidOrganizerTokenException.class)
|
||||||
|
public ResponseEntity<ProblemDetail> handleInvalidOrganizerToken(
|
||||||
|
InvalidOrganizerTokenException ex) {
|
||||||
|
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.FORBIDDEN, ex.getMessage());
|
||||||
|
problemDetail.setTitle("Forbidden");
|
||||||
|
problemDetail.setType(URI.create("urn:problem-type:invalid-organizer-token"));
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||||
|
.body(problemDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handles event not found. */
|
||||||
|
@ExceptionHandler(EventNotFoundException.class)
|
||||||
|
public ResponseEntity<ProblemDetail> handleEventNotFound(
|
||||||
|
EventNotFoundException ex) {
|
||||||
|
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.NOT_FOUND, ex.getMessage());
|
||||||
|
problemDetail.setTitle("Event Not Found");
|
||||||
|
problemDetail.setType(URI.create("urn:problem-type:event-not-found"));
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||||
|
.body(problemDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handles invalid timezone. */
|
||||||
|
@ExceptionHandler(InvalidTimezoneException.class)
|
||||||
|
public ResponseEntity<ProblemDetail> handleInvalidTimezone(
|
||||||
|
InvalidTimezoneException ex) {
|
||||||
|
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||||
|
problemDetail.setTitle("Invalid Timezone");
|
||||||
|
problemDetail.setType(URI.create("urn:problem-type:invalid-timezone"));
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||||
|
.body(problemDetail);
|
||||||
|
}
|
||||||
|
|
||||||
/** Catches all unhandled exceptions. */
|
/** Catches all unhandled exceptions. */
|
||||||
@ExceptionHandler(Exception.class)
|
@ExceptionHandler(Exception.class)
|
||||||
public ResponseEntity<ProblemDetail> handleAll(Exception ex) {
|
public ResponseEntity<ProblemDetail> handleAll(Exception ex) {
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ public class EventJpaEntity {
|
|||||||
@Column(name = "date_time", nullable = false)
|
@Column(name = "date_time", nullable = false)
|
||||||
private OffsetDateTime dateTime;
|
private OffsetDateTime dateTime;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 64)
|
||||||
|
private String timezone;
|
||||||
|
|
||||||
@Column(length = 500)
|
@Column(length = 500)
|
||||||
private String location;
|
private String location;
|
||||||
|
|
||||||
@@ -103,6 +106,16 @@ public class EventJpaEntity {
|
|||||||
this.dateTime = dateTime;
|
this.dateTime = dateTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns the IANA timezone name. */
|
||||||
|
public String getTimezone() {
|
||||||
|
return timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the IANA timezone name. */
|
||||||
|
public void setTimezone(String timezone) {
|
||||||
|
this.timezone = timezone;
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns the event location. */
|
/** Returns the event location. */
|
||||||
public String getLocation() {
|
public String getLocation() {
|
||||||
return location;
|
return location;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package de.fete.adapter.out.persistence;
|
package de.fete.adapter.out.persistence;
|
||||||
|
|
||||||
import de.fete.domain.model.Event;
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.OrganizerToken;
|
||||||
import de.fete.domain.port.out.EventRepository;
|
import de.fete.domain.port.out.EventRepository;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
/** Persistence adapter implementing the EventRepository outbound port. */
|
/** Persistence adapter implementing the EventRepository outbound port. */
|
||||||
@@ -25,18 +27,19 @@ public class EventPersistenceAdapter implements EventRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<Event> findByEventToken(UUID eventToken) {
|
public Optional<Event> findByEventToken(EventToken eventToken) {
|
||||||
return jpaRepository.findByEventToken(eventToken).map(this::toDomain);
|
return jpaRepository.findByEventToken(eventToken.value()).map(this::toDomain);
|
||||||
}
|
}
|
||||||
|
|
||||||
private EventJpaEntity toEntity(Event event) {
|
private EventJpaEntity toEntity(Event event) {
|
||||||
var entity = new EventJpaEntity();
|
var entity = new EventJpaEntity();
|
||||||
entity.setId(event.getId());
|
entity.setId(event.getId());
|
||||||
entity.setEventToken(event.getEventToken());
|
entity.setEventToken(event.getEventToken().value());
|
||||||
entity.setOrganizerToken(event.getOrganizerToken());
|
entity.setOrganizerToken(event.getOrganizerToken().value());
|
||||||
entity.setTitle(event.getTitle());
|
entity.setTitle(event.getTitle());
|
||||||
entity.setDescription(event.getDescription());
|
entity.setDescription(event.getDescription());
|
||||||
entity.setDateTime(event.getDateTime());
|
entity.setDateTime(event.getDateTime());
|
||||||
|
entity.setTimezone(event.getTimezone().getId());
|
||||||
entity.setLocation(event.getLocation());
|
entity.setLocation(event.getLocation());
|
||||||
entity.setExpiryDate(event.getExpiryDate());
|
entity.setExpiryDate(event.getExpiryDate());
|
||||||
entity.setCreatedAt(event.getCreatedAt());
|
entity.setCreatedAt(event.getCreatedAt());
|
||||||
@@ -46,11 +49,12 @@ public class EventPersistenceAdapter implements EventRepository {
|
|||||||
private Event toDomain(EventJpaEntity entity) {
|
private Event toDomain(EventJpaEntity entity) {
|
||||||
var event = new Event();
|
var event = new Event();
|
||||||
event.setId(entity.getId());
|
event.setId(entity.getId());
|
||||||
event.setEventToken(entity.getEventToken());
|
event.setEventToken(new EventToken(entity.getEventToken()));
|
||||||
event.setOrganizerToken(entity.getOrganizerToken());
|
event.setOrganizerToken(new OrganizerToken(entity.getOrganizerToken()));
|
||||||
event.setTitle(entity.getTitle());
|
event.setTitle(entity.getTitle());
|
||||||
event.setDescription(entity.getDescription());
|
event.setDescription(entity.getDescription());
|
||||||
event.setDateTime(entity.getDateTime());
|
event.setDateTime(entity.getDateTime());
|
||||||
|
event.setTimezone(ZoneId.of(entity.getTimezone()));
|
||||||
event.setLocation(entity.getLocation());
|
event.setLocation(entity.getLocation());
|
||||||
event.setExpiryDate(entity.getExpiryDate());
|
event.setExpiryDate(entity.getExpiryDate());
|
||||||
event.setCreatedAt(entity.getCreatedAt());
|
event.setCreatedAt(entity.getCreatedAt());
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package de.fete.adapter.out.persistence;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** JPA entity mapping to the rsvps table. */
|
||||||
|
@Entity
|
||||||
|
@Table(name = "rsvps")
|
||||||
|
public class RsvpJpaEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "rsvp_token", nullable = false, unique = true)
|
||||||
|
private UUID rsvpToken;
|
||||||
|
|
||||||
|
@Column(name = "event_id", nullable = false)
|
||||||
|
private Long eventId;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 100)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/** Returns the internal database ID. */
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the internal database ID. */
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the RSVP token. */
|
||||||
|
public UUID getRsvpToken() {
|
||||||
|
return rsvpToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the RSVP token. */
|
||||||
|
public void setRsvpToken(UUID rsvpToken) {
|
||||||
|
this.rsvpToken = rsvpToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the event ID. */
|
||||||
|
public Long getEventId() {
|
||||||
|
return eventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the event ID. */
|
||||||
|
public void setEventId(Long eventId) {
|
||||||
|
this.eventId = eventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the guest's display name. */
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the guest's display name. */
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.fete.adapter.out.persistence;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
/** Spring Data JPA repository for RSVP entities. */
|
||||||
|
public interface RsvpJpaRepository extends JpaRepository<RsvpJpaEntity, Long> {
|
||||||
|
|
||||||
|
/** Finds an RSVP by its token. */
|
||||||
|
java.util.Optional<RsvpJpaEntity> findByRsvpToken(UUID rsvpToken);
|
||||||
|
|
||||||
|
/** Counts RSVPs for the given event. */
|
||||||
|
long countByEventId(Long eventId);
|
||||||
|
|
||||||
|
/** Finds all RSVPs for the given event, ordered by ID ascending. */
|
||||||
|
java.util.List<RsvpJpaEntity> findAllByEventIdOrderByIdAsc(Long eventId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package de.fete.adapter.out.persistence;
|
||||||
|
|
||||||
|
import de.fete.domain.model.Rsvp;
|
||||||
|
import de.fete.domain.model.RsvpToken;
|
||||||
|
import de.fete.domain.port.out.RsvpRepository;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
/** Persistence adapter implementing the RsvpRepository outbound port. */
|
||||||
|
@Repository
|
||||||
|
public class RsvpPersistenceAdapter implements RsvpRepository {
|
||||||
|
|
||||||
|
private final RsvpJpaRepository jpaRepository;
|
||||||
|
|
||||||
|
/** Creates a new adapter with the given JPA repository. */
|
||||||
|
public RsvpPersistenceAdapter(RsvpJpaRepository jpaRepository) {
|
||||||
|
this.jpaRepository = jpaRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Rsvp save(Rsvp rsvp) {
|
||||||
|
RsvpJpaEntity entity = toEntity(rsvp);
|
||||||
|
RsvpJpaEntity saved = jpaRepository.save(entity);
|
||||||
|
return toDomain(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long countByEventId(Long eventId) {
|
||||||
|
return jpaRepository.countByEventId(eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Rsvp> findByEventId(Long eventId) {
|
||||||
|
return jpaRepository.findAllByEventIdOrderByIdAsc(eventId).stream()
|
||||||
|
.map(this::toDomain)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private RsvpJpaEntity toEntity(Rsvp rsvp) {
|
||||||
|
var entity = new RsvpJpaEntity();
|
||||||
|
entity.setId(rsvp.getId());
|
||||||
|
entity.setRsvpToken(rsvp.getRsvpToken().value());
|
||||||
|
entity.setEventId(rsvp.getEventId());
|
||||||
|
entity.setName(rsvp.getName());
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rsvp toDomain(RsvpJpaEntity entity) {
|
||||||
|
var rsvp = new Rsvp();
|
||||||
|
rsvp.setId(entity.getId());
|
||||||
|
rsvp.setRsvpToken(new RsvpToken(entity.getRsvpToken()));
|
||||||
|
rsvp.setEventId(entity.getEventId());
|
||||||
|
rsvp.setName(entity.getName());
|
||||||
|
return rsvp;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Thrown when an RSVP is attempted on an expired event. */
|
||||||
|
public class EventExpiredException extends RuntimeException {
|
||||||
|
|
||||||
|
/** Creates a new exception for the given event token. */
|
||||||
|
public EventExpiredException(UUID eventToken) {
|
||||||
|
super("Event has expired: " + eventToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Thrown when an event cannot be found by its token. */
|
||||||
|
public class EventNotFoundException extends RuntimeException {
|
||||||
|
|
||||||
|
/** Creates a new exception for the given event token. */
|
||||||
|
public EventNotFoundException(UUID eventToken) {
|
||||||
|
super("Event not found: " + eventToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,20 @@ package de.fete.application.service;
|
|||||||
|
|
||||||
import de.fete.domain.model.CreateEventCommand;
|
import de.fete.domain.model.CreateEventCommand;
|
||||||
import de.fete.domain.model.Event;
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.OrganizerToken;
|
||||||
import de.fete.domain.port.in.CreateEventUseCase;
|
import de.fete.domain.port.in.CreateEventUseCase;
|
||||||
|
import de.fete.domain.port.in.GetEventUseCase;
|
||||||
import de.fete.domain.port.out.EventRepository;
|
import de.fete.domain.port.out.EventRepository;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.UUID;
|
import java.util.Optional;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
/** Application service implementing event creation. */
|
/** Application service implementing event creation and retrieval. */
|
||||||
@Service
|
@Service
|
||||||
public class EventService implements CreateEventUseCase {
|
public class EventService implements CreateEventUseCase, GetEventUseCase {
|
||||||
|
|
||||||
private final EventRepository eventRepository;
|
private final EventRepository eventRepository;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
@@ -29,16 +32,26 @@ public class EventService implements CreateEventUseCase {
|
|||||||
throw new ExpiryDateInPastException(command.expiryDate());
|
throw new ExpiryDateInPastException(command.expiryDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!command.expiryDate().isAfter(command.dateTime().toLocalDate())) {
|
||||||
|
throw new ExpiryDateBeforeEventException(command.expiryDate(), command.dateTime());
|
||||||
|
}
|
||||||
|
|
||||||
var event = new Event();
|
var event = new Event();
|
||||||
event.setEventToken(UUID.randomUUID());
|
event.setEventToken(EventToken.generate());
|
||||||
event.setOrganizerToken(UUID.randomUUID());
|
event.setOrganizerToken(OrganizerToken.generate());
|
||||||
event.setTitle(command.title());
|
event.setTitle(command.title());
|
||||||
event.setDescription(command.description());
|
event.setDescription(command.description());
|
||||||
event.setDateTime(command.dateTime());
|
event.setDateTime(command.dateTime());
|
||||||
|
event.setTimezone(command.timezone());
|
||||||
event.setLocation(command.location());
|
event.setLocation(command.location());
|
||||||
event.setExpiryDate(command.expiryDate());
|
event.setExpiryDate(command.expiryDate());
|
||||||
event.setCreatedAt(OffsetDateTime.now(clock));
|
event.setCreatedAt(OffsetDateTime.now(clock));
|
||||||
|
|
||||||
return eventRepository.save(event);
|
return eventRepository.save(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Event> getByEventToken(EventToken eventToken) {
|
||||||
|
return eventRepository.findByEventToken(eventToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/** Thrown when an event's expiry date is not after the event date. */
|
||||||
|
public class ExpiryDateBeforeEventException extends RuntimeException {
|
||||||
|
|
||||||
|
/** Creates a new exception for the given dates. */
|
||||||
|
public ExpiryDateBeforeEventException(LocalDate expiryDate, OffsetDateTime dateTime) {
|
||||||
|
super("Expiry date " + expiryDate + " must be after event date " + dateTime.toLocalDate());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
/** Thrown when an invalid organizer token is provided. */
|
||||||
|
public class InvalidOrganizerTokenException extends RuntimeException {
|
||||||
|
|
||||||
|
/** Creates a new exception for an invalid organizer token. */
|
||||||
|
public InvalidOrganizerTokenException() {
|
||||||
|
super("Invalid organizer token.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
/** Thrown when an invalid IANA timezone ID is provided. */
|
||||||
|
public class InvalidTimezoneException extends RuntimeException {
|
||||||
|
|
||||||
|
/** Creates a new exception for the given invalid timezone. */
|
||||||
|
public InvalidTimezoneException(String timezone) {
|
||||||
|
super("Invalid IANA timezone: " + timezone);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.OrganizerToken;
|
||||||
|
import de.fete.domain.model.Rsvp;
|
||||||
|
import de.fete.domain.model.RsvpToken;
|
||||||
|
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
|
||||||
|
import de.fete.domain.port.in.CreateRsvpUseCase;
|
||||||
|
import de.fete.domain.port.in.GetAttendeesUseCase;
|
||||||
|
import de.fete.domain.port.out.EventRepository;
|
||||||
|
import de.fete.domain.port.out.RsvpRepository;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/** Application service implementing RSVP operations. */
|
||||||
|
@Service
|
||||||
|
public class RsvpService
|
||||||
|
implements CreateRsvpUseCase, CountAttendeesByEventUseCase, GetAttendeesUseCase {
|
||||||
|
|
||||||
|
private final EventRepository eventRepository;
|
||||||
|
private final RsvpRepository rsvpRepository;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
/** Creates a new RsvpService. */
|
||||||
|
public RsvpService(
|
||||||
|
EventRepository eventRepository,
|
||||||
|
RsvpRepository rsvpRepository,
|
||||||
|
Clock clock) {
|
||||||
|
this.eventRepository = eventRepository;
|
||||||
|
this.rsvpRepository = rsvpRepository;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Rsvp createRsvp(EventToken eventToken, String name) {
|
||||||
|
Event event = eventRepository.findByEventToken(eventToken)
|
||||||
|
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||||
|
|
||||||
|
if (!event.getExpiryDate().isAfter(LocalDate.now(clock))) {
|
||||||
|
throw new EventExpiredException(eventToken.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
var rsvp = new Rsvp();
|
||||||
|
rsvp.setRsvpToken(RsvpToken.generate());
|
||||||
|
rsvp.setEventId(event.getId());
|
||||||
|
rsvp.setName(name.strip());
|
||||||
|
|
||||||
|
return rsvpRepository.save(rsvp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long countByEvent(EventToken eventToken) {
|
||||||
|
Event event = eventRepository.findByEventToken(eventToken)
|
||||||
|
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||||
|
return rsvpRepository.countByEventId(event.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken) {
|
||||||
|
Event event = eventRepository.findByEventToken(eventToken)
|
||||||
|
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||||
|
|
||||||
|
if (!event.getOrganizerToken().equals(organizerToken)) {
|
||||||
|
throw new InvalidOrganizerTokenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return rsvpRepository.findByEventId(event.getId()).stream()
|
||||||
|
.map(Rsvp::getName)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ package de.fete.domain.model;
|
|||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
/** Command carrying the data needed to create an event. */
|
/** Command carrying the data needed to create an event. */
|
||||||
public record CreateEventCommand(
|
public record CreateEventCommand(
|
||||||
String title,
|
String title,
|
||||||
String description,
|
String description,
|
||||||
OffsetDateTime dateTime,
|
OffsetDateTime dateTime,
|
||||||
|
ZoneId timezone,
|
||||||
String location,
|
String location,
|
||||||
LocalDate expiryDate
|
LocalDate expiryDate
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -2,17 +2,18 @@ package de.fete.domain.model;
|
|||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.UUID;
|
import java.time.ZoneId;
|
||||||
|
|
||||||
/** Domain entity representing an event. */
|
/** Domain entity representing an event. */
|
||||||
public class Event {
|
public class Event {
|
||||||
|
|
||||||
private Long id;
|
private Long id;
|
||||||
private UUID eventToken;
|
private EventToken eventToken;
|
||||||
private UUID organizerToken;
|
private OrganizerToken organizerToken;
|
||||||
private String title;
|
private String title;
|
||||||
private String description;
|
private String description;
|
||||||
private OffsetDateTime dateTime;
|
private OffsetDateTime dateTime;
|
||||||
|
private ZoneId timezone;
|
||||||
private String location;
|
private String location;
|
||||||
private LocalDate expiryDate;
|
private LocalDate expiryDate;
|
||||||
private OffsetDateTime createdAt;
|
private OffsetDateTime createdAt;
|
||||||
@@ -27,23 +28,23 @@ public class Event {
|
|||||||
this.id = id;
|
this.id = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the public event token (UUID). */
|
/** Returns the public event token. */
|
||||||
public UUID getEventToken() {
|
public EventToken getEventToken() {
|
||||||
return eventToken;
|
return eventToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sets the public event token. */
|
/** Sets the public event token. */
|
||||||
public void setEventToken(UUID eventToken) {
|
public void setEventToken(EventToken eventToken) {
|
||||||
this.eventToken = eventToken;
|
this.eventToken = eventToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the secret organizer token (UUID). */
|
/** Returns the secret organizer token. */
|
||||||
public UUID getOrganizerToken() {
|
public OrganizerToken getOrganizerToken() {
|
||||||
return organizerToken;
|
return organizerToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sets the secret organizer token. */
|
/** Sets the secret organizer token. */
|
||||||
public void setOrganizerToken(UUID organizerToken) {
|
public void setOrganizerToken(OrganizerToken organizerToken) {
|
||||||
this.organizerToken = organizerToken;
|
this.organizerToken = organizerToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +78,16 @@ public class Event {
|
|||||||
this.dateTime = 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. */
|
/** Returns the event location. */
|
||||||
public String getLocation() {
|
public String getLocation() {
|
||||||
return location;
|
return location;
|
||||||
|
|||||||
18
backend/src/main/java/de/fete/domain/model/EventToken.java
Normal file
18
backend/src/main/java/de/fete/domain/model/EventToken.java
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package de.fete.domain.model;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Type-safe wrapper for the public event token. */
|
||||||
|
public record EventToken(UUID value) {
|
||||||
|
|
||||||
|
/** Validates that the token value is not null. */
|
||||||
|
public EventToken {
|
||||||
|
Objects.requireNonNull(value, "eventToken must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generates a new random event token. */
|
||||||
|
public static EventToken generate() {
|
||||||
|
return new EventToken(UUID.randomUUID());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package de.fete.domain.model;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Type-safe wrapper for the secret organizer token. */
|
||||||
|
public record OrganizerToken(UUID value) {
|
||||||
|
|
||||||
|
/** Validates that the token value is not null. */
|
||||||
|
public OrganizerToken {
|
||||||
|
Objects.requireNonNull(value, "organizerToken must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generates a new random organizer token. */
|
||||||
|
public static OrganizerToken generate() {
|
||||||
|
return new OrganizerToken(UUID.randomUUID());
|
||||||
|
}
|
||||||
|
}
|
||||||
50
backend/src/main/java/de/fete/domain/model/Rsvp.java
Normal file
50
backend/src/main/java/de/fete/domain/model/Rsvp.java
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package de.fete.domain.model;
|
||||||
|
|
||||||
|
/** Domain entity representing an RSVP. */
|
||||||
|
public class Rsvp {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private RsvpToken rsvpToken;
|
||||||
|
private Long eventId;
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/** Returns the internal database ID. */
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the internal database ID. */
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the RSVP token. */
|
||||||
|
public RsvpToken getRsvpToken() {
|
||||||
|
return rsvpToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the RSVP token. */
|
||||||
|
public void setRsvpToken(RsvpToken rsvpToken) {
|
||||||
|
this.rsvpToken = rsvpToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the event ID this RSVP belongs to. */
|
||||||
|
public Long getEventId() {
|
||||||
|
return eventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the event ID. */
|
||||||
|
public void setEventId(Long eventId) {
|
||||||
|
this.eventId = eventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the guest's display name. */
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the guest's display name. */
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/main/java/de/fete/domain/model/RsvpToken.java
Normal file
18
backend/src/main/java/de/fete/domain/model/RsvpToken.java
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package de.fete.domain.model;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Type-safe wrapper for the RSVP token. */
|
||||||
|
public record RsvpToken(UUID value) {
|
||||||
|
|
||||||
|
/** Validates that the token value is not null. */
|
||||||
|
public RsvpToken {
|
||||||
|
Objects.requireNonNull(value, "rsvpToken must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generates a new random RSVP token. */
|
||||||
|
public static RsvpToken generate() {
|
||||||
|
return new RsvpToken(UUID.randomUUID());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package de.fete.domain.port.in;
|
||||||
|
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
|
||||||
|
/** Inbound port for counting attendees of an event. */
|
||||||
|
public interface CountAttendeesByEventUseCase {
|
||||||
|
|
||||||
|
/** Counts the number of confirmed attendees for the given event. */
|
||||||
|
long countByEvent(EventToken eventToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package de.fete.domain.port.in;
|
||||||
|
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.Rsvp;
|
||||||
|
|
||||||
|
/** Inbound port for creating a new RSVP. */
|
||||||
|
public interface CreateRsvpUseCase {
|
||||||
|
|
||||||
|
/** Creates an RSVP for the given event and guest name. */
|
||||||
|
Rsvp createRsvp(EventToken eventToken, String name);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.fete.domain.port.in;
|
||||||
|
|
||||||
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/** Inbound port for retrieving a public event by its token. */
|
||||||
|
public interface GetEventUseCase {
|
||||||
|
|
||||||
|
/** Finds an event by its public event token. */
|
||||||
|
Optional<Event> getByEventToken(EventToken eventToken);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package de.fete.domain.port.out;
|
package de.fete.domain.port.out;
|
||||||
|
|
||||||
import de.fete.domain.model.Event;
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/** Outbound port for persisting and retrieving events. */
|
/** Outbound port for persisting and retrieving events. */
|
||||||
public interface EventRepository {
|
public interface EventRepository {
|
||||||
@@ -11,5 +11,5 @@ public interface EventRepository {
|
|||||||
Event save(Event event);
|
Event save(Event event);
|
||||||
|
|
||||||
/** Finds an event by its public event token. */
|
/** Finds an event by its public event token. */
|
||||||
Optional<Event> findByEventToken(UUID eventToken);
|
Optional<Event> findByEventToken(EventToken eventToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.fete.domain.port.out;
|
||||||
|
|
||||||
|
import de.fete.domain.model.Rsvp;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Outbound port for persisting and querying RSVPs. */
|
||||||
|
public interface RsvpRepository {
|
||||||
|
|
||||||
|
/** Persists the given RSVP and returns it with generated fields populated. */
|
||||||
|
Rsvp save(Rsvp rsvp);
|
||||||
|
|
||||||
|
/** Counts the number of RSVPs for the given event. */
|
||||||
|
long countByEventId(Long eventId);
|
||||||
|
|
||||||
|
/** Finds all RSVPs for the given event, ordered by ID ascending. */
|
||||||
|
List<Rsvp> findByEventId(Long eventId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||||
|
|
||||||
|
<changeSet id="002-add-timezone-column" author="fete">
|
||||||
|
<addColumn tableName="events">
|
||||||
|
<column name="timezone" type="varchar(64)" defaultValue="UTC">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||||
|
|
||||||
|
<changeSet id="003-create-rsvps-table" author="fete">
|
||||||
|
<createTable tableName="rsvps">
|
||||||
|
<column name="id" type="bigserial" autoIncrement="true">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="rsvp_token" type="uuid">
|
||||||
|
<constraints nullable="false" unique="true"/>
|
||||||
|
</column>
|
||||||
|
<column name="event_id" type="bigint">
|
||||||
|
<constraints nullable="false"
|
||||||
|
foreignKeyName="fk_rsvps_event_id"
|
||||||
|
references="events(id)"
|
||||||
|
deleteCascade="true"/>
|
||||||
|
</column>
|
||||||
|
<column name="name" type="varchar(100)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
</createTable>
|
||||||
|
|
||||||
|
<createIndex tableName="rsvps" indexName="idx_rsvps_event_id">
|
||||||
|
<column name="event_id"/>
|
||||||
|
</createIndex>
|
||||||
|
|
||||||
|
<createIndex tableName="rsvps" indexName="idx_rsvps_rsvp_token">
|
||||||
|
<column name="rsvp_token"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
@@ -7,5 +7,7 @@
|
|||||||
|
|
||||||
<include file="db/changelog/000-baseline.xml"/>
|
<include file="db/changelog/000-baseline.xml"/>
|
||||||
<include file="db/changelog/001-create-events-table.xml"/>
|
<include file="db/changelog/001-create-events-table.xml"/>
|
||||||
|
<include file="db/changelog/002-add-timezone-column.xml"/>
|
||||||
|
<include file="db/changelog/003-create-rsvps-table.xml"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
@@ -37,6 +37,121 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ValidationProblemDetail"
|
$ref: "#/components/schemas/ValidationProblemDetail"
|
||||||
|
|
||||||
|
/events/{token}/rsvps:
|
||||||
|
post:
|
||||||
|
operationId: createRsvp
|
||||||
|
summary: Submit an RSVP for an event
|
||||||
|
tags:
|
||||||
|
- events
|
||||||
|
parameters:
|
||||||
|
- name: token
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Public event token
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CreateRsvpRequest"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: RSVP created successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CreateRsvpResponse"
|
||||||
|
"400":
|
||||||
|
description: Validation failed (e.g. blank name)
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ValidationProblemDetail"
|
||||||
|
"404":
|
||||||
|
description: Event not found
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
"409":
|
||||||
|
description: Event has expired — RSVPs no longer accepted
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
|
||||||
|
/events/{token}/attendees:
|
||||||
|
get:
|
||||||
|
operationId: getAttendees
|
||||||
|
summary: Get attendee list for an event (organizer only)
|
||||||
|
tags:
|
||||||
|
- events
|
||||||
|
parameters:
|
||||||
|
- name: token
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Public event token
|
||||||
|
- name: organizerToken
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Organizer token for authorization
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Attendee list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/GetAttendeesResponse"
|
||||||
|
"403":
|
||||||
|
description: Invalid organizer token
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
"404":
|
||||||
|
description: Event not found
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
|
||||||
|
/events/{token}:
|
||||||
|
get:
|
||||||
|
operationId: getEvent
|
||||||
|
summary: Get public event details by token
|
||||||
|
tags:
|
||||||
|
- events
|
||||||
|
parameters:
|
||||||
|
- name: token
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Public event token
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Event found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/GetEventResponse"
|
||||||
|
"404":
|
||||||
|
description: Event not found
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
|
||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
CreateEventRequest:
|
CreateEventRequest:
|
||||||
@@ -44,6 +159,7 @@ components:
|
|||||||
required:
|
required:
|
||||||
- title
|
- title
|
||||||
- dateTime
|
- dateTime
|
||||||
|
- timezone
|
||||||
- expiryDate
|
- expiryDate
|
||||||
properties:
|
properties:
|
||||||
title:
|
title:
|
||||||
@@ -58,6 +174,10 @@ components:
|
|||||||
format: date-time
|
format: date-time
|
||||||
description: Event date and time with UTC offset (ISO 8601)
|
description: Event date and time with UTC offset (ISO 8601)
|
||||||
example: "2026-03-15T20:00:00+01:00"
|
example: "2026-03-15T20:00:00+01:00"
|
||||||
|
timezone:
|
||||||
|
type: string
|
||||||
|
description: IANA timezone of the organizer
|
||||||
|
example: "Europe/Berlin"
|
||||||
location:
|
location:
|
||||||
type: string
|
type: string
|
||||||
maxLength: 500
|
maxLength: 500
|
||||||
@@ -74,6 +194,7 @@ components:
|
|||||||
- organizerToken
|
- organizerToken
|
||||||
- title
|
- title
|
||||||
- dateTime
|
- dateTime
|
||||||
|
- timezone
|
||||||
- expiryDate
|
- expiryDate
|
||||||
properties:
|
properties:
|
||||||
eventToken:
|
eventToken:
|
||||||
@@ -93,11 +214,113 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
example: "2026-03-15T20:00:00+01:00"
|
example: "2026-03-15T20:00:00+01:00"
|
||||||
|
timezone:
|
||||||
|
type: string
|
||||||
|
description: IANA timezone of the organizer
|
||||||
|
example: "Europe/Berlin"
|
||||||
expiryDate:
|
expiryDate:
|
||||||
type: string
|
type: string
|
||||||
format: date
|
format: date
|
||||||
example: "2026-06-15"
|
example: "2026-06-15"
|
||||||
|
|
||||||
|
GetEventResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- eventToken
|
||||||
|
- title
|
||||||
|
- dateTime
|
||||||
|
- timezone
|
||||||
|
- attendeeCount
|
||||||
|
- expired
|
||||||
|
properties:
|
||||||
|
eventToken:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Public event token
|
||||||
|
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
description: Event title
|
||||||
|
example: "Summer BBQ"
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: Event description (absent if not set)
|
||||||
|
example: "Bring your own drinks!"
|
||||||
|
dateTime:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Event date/time with organizer's UTC offset
|
||||||
|
example: "2026-03-15T20:00:00+01:00"
|
||||||
|
timezone:
|
||||||
|
type: string
|
||||||
|
description: IANA timezone name of the organizer
|
||||||
|
example: "Europe/Berlin"
|
||||||
|
location:
|
||||||
|
type: string
|
||||||
|
description: Event location (absent if not set)
|
||||||
|
example: "Central Park, NYC"
|
||||||
|
attendeeCount:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
description: Number of confirmed attendees (attending=true)
|
||||||
|
example: 12
|
||||||
|
expired:
|
||||||
|
type: boolean
|
||||||
|
description: Whether the event's expiry date has passed
|
||||||
|
example: false
|
||||||
|
|
||||||
|
CreateRsvpRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 100
|
||||||
|
description: Guest's display name
|
||||||
|
example: "Max Mustermann"
|
||||||
|
|
||||||
|
CreateRsvpResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- rsvpToken
|
||||||
|
- name
|
||||||
|
properties:
|
||||||
|
rsvpToken:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Token identifying this RSVP (store client-side for future updates)
|
||||||
|
example: "d4e5f6a7-b8c9-0123-4567-890abcdef012"
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Guest's display name as stored
|
||||||
|
example: "Max Mustermann"
|
||||||
|
|
||||||
|
GetAttendeesResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- attendees
|
||||||
|
properties:
|
||||||
|
attendees:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Attendee"
|
||||||
|
example:
|
||||||
|
- name: "Alice"
|
||||||
|
- name: "Bob"
|
||||||
|
|
||||||
|
Attendee:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 100
|
||||||
|
example: "Alice"
|
||||||
|
|
||||||
ProblemDetail:
|
ProblemDetail:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -60,4 +60,9 @@ class HexagonalArchitectureTest {
|
|||||||
static final ArchRule persistenceMustNotDependOnWeb = noClasses()
|
static final ArchRule persistenceMustNotDependOnWeb = noClasses()
|
||||||
.that().resideInAPackage("de.fete.adapter.out.persistence..")
|
.that().resideInAPackage("de.fete.adapter.out.persistence..")
|
||||||
.should().dependOnClassesThat().resideInAPackage("de.fete.adapter.in.web..");
|
.should().dependOnClassesThat().resideInAPackage("de.fete.adapter.in.web..");
|
||||||
|
|
||||||
|
@ArchTest
|
||||||
|
static final ArchRule webAdapterMustNotDependOnOutboundPorts = noClasses()
|
||||||
|
.that().resideInAPackage("de.fete.adapter.in.web..")
|
||||||
|
.should().dependOnClassesThat().resideInAPackage("de.fete.domain.port.out..");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
package de.fete.adapter.in.web;
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import de.fete.TestcontainersConfig;
|
import de.fete.TestcontainersConfig;
|
||||||
|
import de.fete.adapter.in.web.model.CreateEventRequest;
|
||||||
|
import de.fete.adapter.in.web.model.CreateEventResponse;
|
||||||
|
import de.fete.adapter.in.web.model.CreateRsvpRequest;
|
||||||
|
import de.fete.adapter.in.web.model.CreateRsvpResponse;
|
||||||
|
import de.fete.adapter.out.persistence.EventJpaEntity;
|
||||||
|
import de.fete.adapter.out.persistence.EventJpaRepository;
|
||||||
|
import de.fete.adapter.out.persistence.RsvpJpaEntity;
|
||||||
|
import de.fete.adapter.out.persistence.RsvpJpaRepository;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
@@ -23,158 +37,483 @@ class EventControllerIntegrationTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private EventJpaRepository jpaRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RsvpJpaRepository rsvpJpaRepository;
|
||||||
|
|
||||||
|
// --- Create Event tests ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createEventWithValidBody() throws Exception {
|
void createEventWithValidBody() throws Exception {
|
||||||
String body =
|
var request = new CreateEventRequest()
|
||||||
"""
|
.title("Birthday Party")
|
||||||
{
|
.description("Come celebrate!")
|
||||||
"title": "Birthday Party",
|
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||||
"description": "Come celebrate!",
|
.timezone("Europe/Berlin")
|
||||||
"dateTime": "2026-06-15T20:00:00+02:00",
|
.location("Berlin")
|
||||||
"location": "Berlin",
|
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||||
"expiryDate": "%s"
|
|
||||||
}
|
|
||||||
""".formatted(LocalDate.now().plusDays(30));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
var result = mockMvc.perform(post("/api/events")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
.andExpect(jsonPath("$.eventToken").isNotEmpty())
|
.andExpect(jsonPath("$.eventToken").isNotEmpty())
|
||||||
.andExpect(jsonPath("$.organizerToken").isNotEmpty())
|
.andExpect(jsonPath("$.organizerToken").isNotEmpty())
|
||||||
.andExpect(jsonPath("$.title").value("Birthday Party"))
|
.andExpect(jsonPath("$.title").value("Birthday Party"))
|
||||||
|
.andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
|
||||||
.andExpect(jsonPath("$.dateTime").isNotEmpty())
|
.andExpect(jsonPath("$.dateTime").isNotEmpty())
|
||||||
.andExpect(jsonPath("$.expiryDate").isNotEmpty());
|
.andExpect(jsonPath("$.expiryDate").isNotEmpty())
|
||||||
|
.andReturn();
|
||||||
|
|
||||||
|
var response = objectMapper.readValue(
|
||||||
|
result.getResponse().getContentAsString(), CreateEventResponse.class);
|
||||||
|
|
||||||
|
EventJpaEntity persisted = jpaRepository
|
||||||
|
.findByEventToken(response.getEventToken()).orElseThrow();
|
||||||
|
assertThat(persisted.getTitle()).isEqualTo("Birthday Party");
|
||||||
|
assertThat(persisted.getDescription()).isEqualTo("Come celebrate!");
|
||||||
|
assertThat(persisted.getTimezone()).isEqualTo("Europe/Berlin");
|
||||||
|
assertThat(persisted.getLocation()).isEqualTo("Berlin");
|
||||||
|
assertThat(persisted.getExpiryDate()).isEqualTo(request.getExpiryDate());
|
||||||
|
assertThat(persisted.getDateTime().toInstant())
|
||||||
|
.isEqualTo(request.getDateTime().toInstant());
|
||||||
|
assertThat(persisted.getOrganizerToken()).isNotNull();
|
||||||
|
assertThat(persisted.getCreatedAt()).isNotNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createEventWithOptionalFieldsNull() throws Exception {
|
void createEventWithOptionalFieldsNull() throws Exception {
|
||||||
String body =
|
var request = new CreateEventRequest()
|
||||||
"""
|
.title("Minimal Event")
|
||||||
{
|
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||||
"title": "Minimal Event",
|
.timezone("UTC")
|
||||||
"dateTime": "2026-06-15T20:00:00+02:00",
|
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||||
"expiryDate": "%s"
|
|
||||||
}
|
|
||||||
""".formatted(LocalDate.now().plusDays(30));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
var result = mockMvc.perform(post("/api/events")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
.andExpect(jsonPath("$.eventToken").isNotEmpty())
|
.andExpect(jsonPath("$.eventToken").isNotEmpty())
|
||||||
.andExpect(jsonPath("$.organizerToken").isNotEmpty())
|
.andExpect(jsonPath("$.organizerToken").isNotEmpty())
|
||||||
.andExpect(jsonPath("$.title").value("Minimal Event"));
|
.andExpect(jsonPath("$.title").value("Minimal Event"))
|
||||||
|
.andReturn();
|
||||||
|
|
||||||
|
var response = objectMapper.readValue(
|
||||||
|
result.getResponse().getContentAsString(), CreateEventResponse.class);
|
||||||
|
|
||||||
|
EventJpaEntity persisted = jpaRepository
|
||||||
|
.findByEventToken(response.getEventToken()).orElseThrow();
|
||||||
|
assertThat(persisted.getTitle()).isEqualTo("Minimal Event");
|
||||||
|
assertThat(persisted.getDescription()).isNull();
|
||||||
|
assertThat(persisted.getLocation()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createEventMissingTitleReturns400() throws Exception {
|
void createEventMissingTitleReturns400() throws Exception {
|
||||||
String body =
|
long countBefore = jpaRepository.count();
|
||||||
"""
|
|
||||||
{
|
var request = new CreateEventRequest()
|
||||||
"dateTime": "2026-06-15T20:00:00+02:00",
|
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||||
"expiryDate": "%s"
|
.timezone("Europe/Berlin")
|
||||||
}
|
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||||
""".formatted(LocalDate.now().plusDays(30));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
mockMvc.perform(post("/api/events")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
.andExpect(jsonPath("$.title").value("Validation Failed"))
|
.andExpect(jsonPath("$.title").value("Validation Failed"))
|
||||||
.andExpect(jsonPath("$.fieldErrors").isArray());
|
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||||
|
|
||||||
|
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createEventMissingDateTimeReturns400() throws Exception {
|
void createEventMissingDateTimeReturns400() throws Exception {
|
||||||
String body =
|
long countBefore = jpaRepository.count();
|
||||||
"""
|
|
||||||
{
|
var request = new CreateEventRequest()
|
||||||
"title": "No Date",
|
.title("No Date")
|
||||||
"expiryDate": "%s"
|
.timezone("Europe/Berlin")
|
||||||
}
|
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||||
""".formatted(LocalDate.now().plusDays(30));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
mockMvc.perform(post("/api/events")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
.andExpect(jsonPath("$.fieldErrors").isArray());
|
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||||
|
|
||||||
|
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createEventMissingExpiryDateReturns400() throws Exception {
|
void createEventMissingExpiryDateReturns400() throws Exception {
|
||||||
String body =
|
long countBefore = jpaRepository.count();
|
||||||
"""
|
|
||||||
{
|
var request = new CreateEventRequest()
|
||||||
"title": "No Expiry",
|
.title("No Expiry")
|
||||||
"dateTime": "2026-06-15T20:00:00+02:00"
|
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||||
}
|
.timezone("Europe/Berlin");
|
||||||
""";
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
mockMvc.perform(post("/api/events")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
.andExpect(jsonPath("$.fieldErrors").isArray());
|
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||||
|
|
||||||
|
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createEventExpiryDateInPastReturns400() throws Exception {
|
void createEventExpiryDateInPastReturns400() throws Exception {
|
||||||
String body =
|
long countBefore = jpaRepository.count();
|
||||||
"""
|
|
||||||
{
|
var request = new CreateEventRequest()
|
||||||
"title": "Past Expiry",
|
.title("Past Expiry")
|
||||||
"dateTime": "2026-06-15T20:00:00+02:00",
|
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||||
"expiryDate": "2025-01-01"
|
.timezone("Europe/Berlin")
|
||||||
}
|
.expiryDate(LocalDate.of(2025, 1, 1));
|
||||||
""";
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
mockMvc.perform(post("/api/events")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
||||||
|
|
||||||
|
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createEventExpiryDateTodayReturns400() throws Exception {
|
void createEventExpiryDateTodayReturns400() throws Exception {
|
||||||
String body =
|
long countBefore = jpaRepository.count();
|
||||||
"""
|
|
||||||
{
|
var request = new CreateEventRequest()
|
||||||
"title": "Today Expiry",
|
.title("Today Expiry")
|
||||||
"dateTime": "2026-06-15T20:00:00+02:00",
|
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||||
"expiryDate": "%s"
|
.timezone("Europe/Berlin")
|
||||||
}
|
.expiryDate(LocalDate.now());
|
||||||
""".formatted(LocalDate.now());
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
mockMvc.perform(post("/api/events")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
.andExpect(jsonPath("$.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
|
@Test
|
||||||
void errorResponseContentTypeIsProblemJson() throws Exception {
|
void errorResponseContentTypeIsProblemJson() throws Exception {
|
||||||
String body =
|
var request = new CreateEventRequest()
|
||||||
"""
|
.title("")
|
||||||
{
|
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||||
"title": "",
|
.timezone("Europe/Berlin")
|
||||||
"dateTime": "2026-06-15T20:00:00+02:00",
|
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||||
"expiryDate": "%s"
|
|
||||||
}
|
|
||||||
""".formatted(LocalDate.now().plusDays(30));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
mockMvc.perform(post("/api/events")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"));
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createEventWithInvalidTimezoneReturns400() throws Exception {
|
||||||
|
long countBefore = jpaRepository.count();
|
||||||
|
|
||||||
|
var request = new CreateEventRequest()
|
||||||
|
.title("Bad TZ")
|
||||||
|
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||||
|
.timezone("Not/A/Zone")
|
||||||
|
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.type").value("urn:problem-type:invalid-timezone"));
|
||||||
|
|
||||||
|
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GET /events/{token} tests ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getEventReturnsFullResponse() throws Exception {
|
||||||
|
EventJpaEntity entity = seedEvent(
|
||||||
|
"Summer BBQ", "Bring drinks!", "Europe/Berlin",
|
||||||
|
"Central Park", LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + entity.getEventToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.eventToken").value(entity.getEventToken().toString()))
|
||||||
|
.andExpect(jsonPath("$.title").value("Summer BBQ"))
|
||||||
|
.andExpect(jsonPath("$.description").value("Bring drinks!"))
|
||||||
|
.andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
|
||||||
|
.andExpect(jsonPath("$.location").value("Central Park"))
|
||||||
|
.andExpect(jsonPath("$.attendeeCount").value(0))
|
||||||
|
.andExpect(jsonPath("$.expired").value(false))
|
||||||
|
.andExpect(jsonPath("$.dateTime").isNotEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getEventWithOptionalFieldsAbsent() throws Exception {
|
||||||
|
EventJpaEntity entity = seedEvent(
|
||||||
|
"Minimal", null, "UTC", null, LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + entity.getEventToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.title").value("Minimal"))
|
||||||
|
.andExpect(jsonPath("$.description").doesNotExist())
|
||||||
|
.andExpect(jsonPath("$.location").doesNotExist())
|
||||||
|
.andExpect(jsonPath("$.attendeeCount").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getEventNotFoundReturns404() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/events/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getExpiredEventReturnsExpiredTrue() throws Exception {
|
||||||
|
EventJpaEntity entity = seedEvent(
|
||||||
|
"Past Event", "It happened", "Europe/Berlin",
|
||||||
|
"Old Venue", LocalDate.now().minusDays(1));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + entity.getEventToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.title").value("Past Event"))
|
||||||
|
.andExpect(jsonPath("$.expired").value(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RSVP tests ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRsvpReturns201WithToken() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"RSVP Event", "Join us!", "Europe/Berlin",
|
||||||
|
"Berlin", LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
var request = new CreateRsvpRequest().name("Max Mustermann");
|
||||||
|
|
||||||
|
var result = mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.rsvpToken").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.name").value("Max Mustermann"))
|
||||||
|
.andReturn();
|
||||||
|
|
||||||
|
var response = objectMapper.readValue(
|
||||||
|
result.getResponse().getContentAsString(), CreateRsvpResponse.class);
|
||||||
|
|
||||||
|
RsvpJpaEntity persisted = rsvpJpaRepository
|
||||||
|
.findByRsvpToken(response.getRsvpToken()).orElseThrow();
|
||||||
|
assertThat(persisted.getName()).isEqualTo("Max Mustermann");
|
||||||
|
assertThat(persisted.getEventId()).isEqualTo(event.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRsvpWithBlankNameReturns400() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"RSVP Event", null, "Europe/Berlin",
|
||||||
|
null, LocalDate.now().plusDays(30));
|
||||||
|
long countBefore = rsvpJpaRepository.count();
|
||||||
|
|
||||||
|
var request = new CreateRsvpRequest().name("");
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"));
|
||||||
|
|
||||||
|
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void attendeeCountIncreasesAfterRsvp() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Count Event", null, "Europe/Berlin",
|
||||||
|
null, LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()))
|
||||||
|
.andExpect(jsonPath("$.attendeeCount").value(0));
|
||||||
|
|
||||||
|
var request = new CreateRsvpRequest().name("First Guest");
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()))
|
||||||
|
.andExpect(jsonPath("$.attendeeCount").value(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRsvpForUnknownEventReturns404() throws Exception {
|
||||||
|
long countBefore = rsvpJpaRepository.count();
|
||||||
|
|
||||||
|
var request = new CreateRsvpRequest().name("Ghost");
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events/" + UUID.randomUUID() + "/rsvps")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
|
||||||
|
|
||||||
|
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRsvpForExpiredEventReturns409() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Expired Party", null, "Europe/Berlin",
|
||||||
|
null, LocalDate.now().minusDays(1));
|
||||||
|
long countBefore = rsvpJpaRepository.count();
|
||||||
|
|
||||||
|
var request = new CreateRsvpRequest().name("Late Guest");
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isConflict())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.type").value("urn:problem-type:event-expired"));
|
||||||
|
|
||||||
|
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GET /events/{token}/attendees tests ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeesReturnsNamesForOrganizer() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Party", null, "Europe/Berlin", null,
|
||||||
|
LocalDate.now().plusDays(30));
|
||||||
|
seedRsvp(event, "Alice");
|
||||||
|
seedRsvp(event, "Bob");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()
|
||||||
|
+ "/attendees?organizerToken=" + event.getOrganizerToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.attendees").isArray())
|
||||||
|
.andExpect(jsonPath("$.attendees.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.attendees[0].name").value("Alice"))
|
||||||
|
.andExpect(jsonPath("$.attendees[1].name").value("Bob"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeesReturnsEmptyListWhenNoRsvps() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Empty Party", null, "Europe/Berlin", null,
|
||||||
|
LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()
|
||||||
|
+ "/attendees?organizerToken=" + event.getOrganizerToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.attendees").isArray())
|
||||||
|
.andExpect(jsonPath("$.attendees.length()").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeesReturns403ForInvalidOrganizerToken() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Secret Party", null, "Europe/Berlin", null,
|
||||||
|
LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()
|
||||||
|
+ "/attendees?organizerToken=" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith(
|
||||||
|
"application/problem+json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeesReturns404ForUnknownEvent() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/events/" + UUID.randomUUID()
|
||||||
|
+ "/attendees?organizerToken=" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith(
|
||||||
|
"application/problem+json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void seedRsvp(EventJpaEntity event, String name) {
|
||||||
|
var rsvp = new RsvpJpaEntity();
|
||||||
|
rsvp.setRsvpToken(UUID.randomUUID());
|
||||||
|
rsvp.setEventId(event.getId());
|
||||||
|
rsvp.setName(name);
|
||||||
|
rsvpJpaRepository.save(rsvp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private EventJpaEntity seedEvent(
|
||||||
|
String title, String description, String timezone,
|
||||||
|
String location, LocalDate expiryDate) {
|
||||||
|
var entity = new EventJpaEntity();
|
||||||
|
entity.setEventToken(UUID.randomUUID());
|
||||||
|
entity.setOrganizerToken(UUID.randomUUID());
|
||||||
|
entity.setTitle(title);
|
||||||
|
entity.setDescription(description);
|
||||||
|
entity.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
|
||||||
|
entity.setTimezone(timezone);
|
||||||
|
entity.setLocation(location);
|
||||||
|
entity.setExpiryDate(expiryDate);
|
||||||
|
entity.setCreatedAt(OffsetDateTime.now());
|
||||||
|
return jpaRepository.save(entity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
|
|
||||||
import de.fete.TestcontainersConfig;
|
import de.fete.TestcontainersConfig;
|
||||||
import de.fete.domain.model.Event;
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.OrganizerToken;
|
||||||
import de.fete.domain.port.out.EventRepository;
|
import de.fete.domain.port.out.EventRepository;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
@@ -46,7 +48,7 @@ class EventPersistenceAdapterTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findByUnknownEventTokenReturnsEmpty() {
|
void findByUnknownEventTokenReturnsEmpty() {
|
||||||
Optional<Event> found = eventRepository.findByEventToken(UUID.randomUUID());
|
Optional<Event> found = eventRepository.findByEventToken(EventToken.generate());
|
||||||
|
|
||||||
assertThat(found).isEmpty();
|
assertThat(found).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -60,11 +62,12 @@ class EventPersistenceAdapterTest {
|
|||||||
OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC);
|
OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC);
|
||||||
|
|
||||||
var event = new Event();
|
var event = new Event();
|
||||||
event.setEventToken(UUID.randomUUID());
|
event.setEventToken(EventToken.generate());
|
||||||
event.setOrganizerToken(UUID.randomUUID());
|
event.setOrganizerToken(OrganizerToken.generate());
|
||||||
event.setTitle("Full Event");
|
event.setTitle("Full Event");
|
||||||
event.setDescription("A detailed description");
|
event.setDescription("A detailed description");
|
||||||
event.setDateTime(dateTime);
|
event.setDateTime(dateTime);
|
||||||
|
event.setTimezone(ZoneId.of("Europe/Berlin"));
|
||||||
event.setLocation("Berlin, Germany");
|
event.setLocation("Berlin, Germany");
|
||||||
event.setExpiryDate(expiryDate);
|
event.setExpiryDate(expiryDate);
|
||||||
event.setCreatedAt(createdAt);
|
event.setCreatedAt(createdAt);
|
||||||
@@ -77,6 +80,7 @@ class EventPersistenceAdapterTest {
|
|||||||
assertThat(found.getTitle()).isEqualTo("Full Event");
|
assertThat(found.getTitle()).isEqualTo("Full Event");
|
||||||
assertThat(found.getDescription()).isEqualTo("A detailed description");
|
assertThat(found.getDescription()).isEqualTo("A detailed description");
|
||||||
assertThat(found.getDateTime().toInstant()).isEqualTo(dateTime.toInstant());
|
assertThat(found.getDateTime().toInstant()).isEqualTo(dateTime.toInstant());
|
||||||
|
assertThat(found.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin"));
|
||||||
assertThat(found.getLocation()).isEqualTo("Berlin, Germany");
|
assertThat(found.getLocation()).isEqualTo("Berlin, Germany");
|
||||||
assertThat(found.getExpiryDate()).isEqualTo(expiryDate);
|
assertThat(found.getExpiryDate()).isEqualTo(expiryDate);
|
||||||
assertThat(found.getCreatedAt().toInstant()).isEqualTo(createdAt.toInstant());
|
assertThat(found.getCreatedAt().toInstant()).isEqualTo(createdAt.toInstant());
|
||||||
@@ -84,11 +88,12 @@ class EventPersistenceAdapterTest {
|
|||||||
|
|
||||||
private Event buildEvent() {
|
private Event buildEvent() {
|
||||||
var event = new Event();
|
var event = new Event();
|
||||||
event.setEventToken(UUID.randomUUID());
|
event.setEventToken(EventToken.generate());
|
||||||
event.setOrganizerToken(UUID.randomUUID());
|
event.setOrganizerToken(OrganizerToken.generate());
|
||||||
event.setTitle("Test Event");
|
event.setTitle("Test Event");
|
||||||
event.setDescription("Test description");
|
event.setDescription("Test description");
|
||||||
event.setDateTime(OffsetDateTime.now().plusDays(7));
|
event.setDateTime(OffsetDateTime.now().plusDays(7));
|
||||||
|
event.setTimezone(ZoneId.of("Europe/Berlin"));
|
||||||
event.setLocation("Somewhere");
|
event.setLocation("Somewhere");
|
||||||
event.setExpiryDate(LocalDate.now().plusDays(30));
|
event.setExpiryDate(LocalDate.now().plusDays(30));
|
||||||
event.setCreatedAt(OffsetDateTime.now());
|
event.setCreatedAt(OffsetDateTime.now());
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import static org.mockito.Mockito.when;
|
|||||||
|
|
||||||
import de.fete.domain.model.CreateEventCommand;
|
import de.fete.domain.model.CreateEventCommand;
|
||||||
import de.fete.domain.model.Event;
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
import de.fete.domain.port.out.EventRepository;
|
import de.fete.domain.port.out.EventRepository;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.ZoneOffset;
|
import java.util.Optional;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -30,6 +31,7 @@ class EventServiceTest {
|
|||||||
private static final Instant FIXED_INSTANT =
|
private static final Instant FIXED_INSTANT =
|
||||||
LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant();
|
LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant();
|
||||||
private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE);
|
private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE);
|
||||||
|
private static final LocalDate TODAY = LocalDate.ofInstant(FIXED_INSTANT, ZONE);
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private EventRepository eventRepository;
|
private EventRepository eventRepository;
|
||||||
@@ -49,35 +51,21 @@ class EventServiceTest {
|
|||||||
var command = new CreateEventCommand(
|
var command = new CreateEventCommand(
|
||||||
"Birthday Party",
|
"Birthday Party",
|
||||||
"Come celebrate!",
|
"Come celebrate!",
|
||||||
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
|
TODAY.plusDays(90).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||||
|
ZONE,
|
||||||
"Berlin",
|
"Berlin",
|
||||||
LocalDate.of(2026, 7, 15)
|
TODAY.plusDays(120)
|
||||||
);
|
);
|
||||||
|
|
||||||
Event result = eventService.createEvent(command);
|
Event result = eventService.createEvent(command);
|
||||||
|
|
||||||
assertThat(result.getTitle()).isEqualTo("Birthday Party");
|
assertThat(result.getTitle()).isEqualTo("Birthday Party");
|
||||||
assertThat(result.getDescription()).isEqualTo("Come celebrate!");
|
assertThat(result.getDescription()).isEqualTo("Come celebrate!");
|
||||||
|
assertThat(result.getTimezone()).isEqualTo(ZONE);
|
||||||
assertThat(result.getLocation()).isEqualTo("Berlin");
|
assertThat(result.getLocation()).isEqualTo("Berlin");
|
||||||
assertThat(result.getEventToken()).isNotNull();
|
assertThat(result.getEventToken()).isNotNull();
|
||||||
assertThat(result.getOrganizerToken()).isNotNull();
|
assertThat(result.getOrganizerToken()).isNotNull();
|
||||||
assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.now(FIXED_CLOCK));
|
assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE));
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void eventTokenAndOrganizerTokenAreDifferent() {
|
|
||||||
when(eventRepository.save(any(Event.class)))
|
|
||||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
|
||||||
|
|
||||||
var command = new CreateEventCommand(
|
|
||||||
"Test", null,
|
|
||||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
|
||||||
LocalDate.now(FIXED_CLOCK).plusDays(30)
|
|
||||||
);
|
|
||||||
|
|
||||||
Event result = eventService.createEvent(command);
|
|
||||||
|
|
||||||
assertThat(result.getEventToken()).isNotEqualTo(result.getOrganizerToken());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -87,8 +75,8 @@ class EventServiceTest {
|
|||||||
|
|
||||||
var command = new CreateEventCommand(
|
var command = new CreateEventCommand(
|
||||||
"Test", null,
|
"Test", null,
|
||||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
||||||
LocalDate.now(FIXED_CLOCK).plusDays(30)
|
TODAY.plusDays(11)
|
||||||
);
|
);
|
||||||
|
|
||||||
eventService.createEvent(command);
|
eventService.createEvent(command);
|
||||||
@@ -102,8 +90,8 @@ class EventServiceTest {
|
|||||||
void expiryDateTodayThrowsException() {
|
void expiryDateTodayThrowsException() {
|
||||||
var command = new CreateEventCommand(
|
var command = new CreateEventCommand(
|
||||||
"Test", null,
|
"Test", null,
|
||||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
||||||
LocalDate.now(FIXED_CLOCK)
|
TODAY
|
||||||
);
|
);
|
||||||
|
|
||||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||||
@@ -114,8 +102,8 @@ class EventServiceTest {
|
|||||||
void expiryDateInPastThrowsException() {
|
void expiryDateInPastThrowsException() {
|
||||||
var command = new CreateEventCommand(
|
var command = new CreateEventCommand(
|
||||||
"Test", null,
|
"Test", null,
|
||||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
||||||
LocalDate.now(FIXED_CLOCK).minusDays(5)
|
TODAY.minusDays(5)
|
||||||
);
|
);
|
||||||
|
|
||||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||||
@@ -129,12 +117,102 @@ class EventServiceTest {
|
|||||||
|
|
||||||
var command = new CreateEventCommand(
|
var command = new CreateEventCommand(
|
||||||
"Test", null,
|
"Test", null,
|
||||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
TODAY.plusDays(1).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
||||||
LocalDate.now(FIXED_CLOCK).plusDays(1)
|
TODAY.plusDays(2)
|
||||||
);
|
);
|
||||||
|
|
||||||
Event result = eventService.createEvent(command);
|
Event result = eventService.createEvent(command);
|
||||||
|
|
||||||
assertThat(result.getExpiryDate()).isEqualTo(LocalDate.of(2026, 3, 6));
|
assertThat(result.getExpiryDate()).isEqualTo(TODAY.plusDays(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiryDateSameAsEventDateThrowsException() {
|
||||||
|
var command = new CreateEventCommand(
|
||||||
|
"Test", null,
|
||||||
|
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||||
|
ZONE, null,
|
||||||
|
TODAY.plusDays(10)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||||
|
.isInstanceOf(ExpiryDateBeforeEventException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiryDateBeforeEventDateThrowsException() {
|
||||||
|
var command = new CreateEventCommand(
|
||||||
|
"Test", null,
|
||||||
|
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||||
|
ZONE, null,
|
||||||
|
TODAY.plusDays(5)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||||
|
.isInstanceOf(ExpiryDateBeforeEventException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiryDateDayAfterEventDateSucceeds() {
|
||||||
|
when(eventRepository.save(any(Event.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
var command = new CreateEventCommand(
|
||||||
|
"Test", null,
|
||||||
|
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||||
|
ZONE, null,
|
||||||
|
TODAY.plusDays(11)
|
||||||
|
);
|
||||||
|
|
||||||
|
Event result = eventService.createEvent(command);
|
||||||
|
|
||||||
|
assertThat(result.getExpiryDate()).isEqualTo(TODAY.plusDays(11));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GetEventUseCase tests (T004) ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getByEventTokenReturnsEvent() {
|
||||||
|
EventToken token = EventToken.generate();
|
||||||
|
var event = new Event();
|
||||||
|
event.setEventToken(token);
|
||||||
|
event.setTitle("Found Event");
|
||||||
|
when(eventRepository.findByEventToken(token))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
|
||||||
|
Optional<Event> result = eventService.getByEventToken(token);
|
||||||
|
|
||||||
|
assertThat(result).isPresent();
|
||||||
|
assertThat(result.get().getTitle()).isEqualTo("Found Event");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getByEventTokenReturnsEmptyForUnknownToken() {
|
||||||
|
EventToken token = EventToken.generate();
|
||||||
|
when(eventRepository.findByEventToken(token))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
Optional<Event> result = eventService.getByEventToken(token);
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Timezone validation tests (T006) ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createEventWithValidTimezoneSucceeds() {
|
||||||
|
when(eventRepository.save(any(Event.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
var command = new CreateEventCommand(
|
||||||
|
"Test", null,
|
||||||
|
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||||
|
ZoneId.of("America/New_York"), null,
|
||||||
|
TODAY.plusDays(11)
|
||||||
|
);
|
||||||
|
|
||||||
|
Event result = eventService.createEvent(command);
|
||||||
|
|
||||||
|
assertThat(result.getTimezone()).isEqualTo(ZoneId.of("America/New_York"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.OrganizerToken;
|
||||||
|
import de.fete.domain.model.Rsvp;
|
||||||
|
import de.fete.domain.model.RsvpToken;
|
||||||
|
import de.fete.domain.port.out.EventRepository;
|
||||||
|
import de.fete.domain.port.out.RsvpRepository;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class RsvpServiceTest {
|
||||||
|
|
||||||
|
private static final ZoneId ZONE = ZoneId.of("Europe/Berlin");
|
||||||
|
private static final Instant NOW = Instant.parse("2026-03-08T12:00:00Z");
|
||||||
|
private static final Clock FIXED_CLOCK = Clock.fixed(NOW, ZONE);
|
||||||
|
private static final LocalDate TODAY = LocalDate.ofInstant(NOW, ZONE);
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private EventRepository eventRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private RsvpRepository rsvpRepository;
|
||||||
|
|
||||||
|
private RsvpService rsvpService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
rsvpService = new RsvpService(eventRepository, rsvpRepository, FIXED_CLOCK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRsvpSucceedsForActiveEvent() {
|
||||||
|
Event event = buildActiveEvent();
|
||||||
|
EventToken token = event.getEventToken();
|
||||||
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
|
when(rsvpRepository.save(any(Rsvp.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
Rsvp result = rsvpService.createRsvp(token, "Max Mustermann");
|
||||||
|
|
||||||
|
assertThat(result.getName()).isEqualTo("Max Mustermann");
|
||||||
|
assertThat(result.getRsvpToken()).isNotNull();
|
||||||
|
assertThat(result.getEventId()).isEqualTo(event.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRsvpPersistsViaRepository() {
|
||||||
|
Event event = buildActiveEvent();
|
||||||
|
EventToken token = event.getEventToken();
|
||||||
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
|
when(rsvpRepository.save(any(Rsvp.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
rsvpService.createRsvp(token, "Test Guest");
|
||||||
|
|
||||||
|
ArgumentCaptor<Rsvp> captor = ArgumentCaptor.forClass(Rsvp.class);
|
||||||
|
verify(rsvpRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getName()).isEqualTo("Test Guest");
|
||||||
|
assertThat(captor.getValue().getEventId()).isEqualTo(event.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRsvpThrowsWhenEventNotFound() {
|
||||||
|
EventToken token = EventToken.generate();
|
||||||
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Guest"))
|
||||||
|
.isInstanceOf(EventNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRsvpTrimsName() {
|
||||||
|
Event event = buildActiveEvent();
|
||||||
|
EventToken token = event.getEventToken();
|
||||||
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
|
when(rsvpRepository.save(any(Rsvp.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
Rsvp result = rsvpService.createRsvp(token, " Max ");
|
||||||
|
|
||||||
|
assertThat(result.getName()).isEqualTo("Max");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRsvpThrowsWhenEventExpired() {
|
||||||
|
var event = buildActiveEvent();
|
||||||
|
event.setExpiryDate(TODAY.minusDays(1));
|
||||||
|
EventToken token = event.getEventToken();
|
||||||
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
|
||||||
|
.isInstanceOf(EventExpiredException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRsvpThrowsWhenEventExpiresToday() {
|
||||||
|
var event = buildActiveEvent();
|
||||||
|
event.setExpiryDate(TODAY);
|
||||||
|
EventToken token = event.getEventToken();
|
||||||
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
|
||||||
|
.isInstanceOf(EventExpiredException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeeNamesReturnsNamesInOrder() {
|
||||||
|
Event event = buildActiveEvent();
|
||||||
|
EventToken token = event.getEventToken();
|
||||||
|
OrganizerToken orgToken = event.getOrganizerToken();
|
||||||
|
when(eventRepository.findByEventToken(token))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
when(rsvpRepository.findByEventId(event.getId()))
|
||||||
|
.thenReturn(List.of(
|
||||||
|
buildRsvp(1L, "Alice"),
|
||||||
|
buildRsvp(2L, "Bob"),
|
||||||
|
buildRsvp(3L, "Charlie")));
|
||||||
|
|
||||||
|
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
|
||||||
|
|
||||||
|
assertThat(names).containsExactly("Alice", "Bob", "Charlie");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeeNamesReturnsEmptyListWhenNoRsvps() {
|
||||||
|
Event event = buildActiveEvent();
|
||||||
|
EventToken token = event.getEventToken();
|
||||||
|
OrganizerToken orgToken = event.getOrganizerToken();
|
||||||
|
when(eventRepository.findByEventToken(token))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
when(rsvpRepository.findByEventId(event.getId()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
|
||||||
|
|
||||||
|
assertThat(names).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeeNamesThrowsWhenEventNotFound() {
|
||||||
|
EventToken token = EventToken.generate();
|
||||||
|
OrganizerToken orgToken = OrganizerToken.generate();
|
||||||
|
when(eventRepository.findByEventToken(token))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() -> rsvpService.getAttendeeNames(token, orgToken))
|
||||||
|
.isInstanceOf(EventNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeeNamesThrowsWhenOrganizerTokenInvalid() {
|
||||||
|
Event event = buildActiveEvent();
|
||||||
|
EventToken token = event.getEventToken();
|
||||||
|
OrganizerToken wrongToken = OrganizerToken.generate();
|
||||||
|
when(eventRepository.findByEventToken(token))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() -> rsvpService.getAttendeeNames(token, wrongToken))
|
||||||
|
.isInstanceOf(InvalidOrganizerTokenException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rsvp buildRsvp(Long id, String name) {
|
||||||
|
var rsvp = new Rsvp();
|
||||||
|
rsvp.setId(id);
|
||||||
|
rsvp.setRsvpToken(RsvpToken.generate());
|
||||||
|
rsvp.setEventId(1L);
|
||||||
|
rsvp.setName(name);
|
||||||
|
return rsvp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Event buildActiveEvent() {
|
||||||
|
var event = new Event();
|
||||||
|
event.setId(1L);
|
||||||
|
event.setEventToken(EventToken.generate());
|
||||||
|
event.setOrganizerToken(OrganizerToken.generate());
|
||||||
|
event.setTitle("Test Event");
|
||||||
|
event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
|
||||||
|
event.setTimezone(ZONE);
|
||||||
|
event.setExpiryDate(TODAY.plusDays(30));
|
||||||
|
event.setCreatedAt(OffsetDateTime.now());
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ test.describe('US-1: Create an event', () => {
|
|||||||
await expect(page.getByText('Expiry date is required.')).toBeVisible()
|
await expect(page.getByText('Expiry date is required.')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('creates an event and redirects to stub page', async ({ page }) => {
|
test('creates an event and redirects to event detail page', async ({ page }) => {
|
||||||
await page.goto('/create')
|
await page.goto('/create')
|
||||||
|
|
||||||
await page.getByLabel(/title/i).fill('Summer BBQ')
|
await page.getByLabel(/title/i).fill('Summer BBQ')
|
||||||
@@ -24,7 +24,6 @@ test.describe('US-1: Create an event', () => {
|
|||||||
await page.getByRole('button', { name: /create event/i }).click()
|
await page.getByRole('button', { name: /create event/i }).click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/events\/.+/)
|
await expect(page).toHaveURL(/\/events\/.+/)
|
||||||
await expect(page.getByText('Event created!')).toBeVisible()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('stores event data in localStorage after creation', async ({ page }) => {
|
test('stores event data in localStorage after creation', async ({ page }) => {
|
||||||
|
|||||||
185
frontend/e2e/event-rsvp.spec.ts
Normal file
185
frontend/e2e/event-rsvp.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { test, expect } from './msw-setup'
|
||||||
|
|
||||||
|
const fullEvent = {
|
||||||
|
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||||
|
title: 'Summer BBQ',
|
||||||
|
description: 'Bring your own drinks!',
|
||||||
|
dateTime: '2026-03-15T20:00:00+01:00',
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
location: 'Central Park, NYC',
|
||||||
|
attendeeCount: 12,
|
||||||
|
expired: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US1: RSVP submission flow', () => {
|
||||||
|
test('submits RSVP, updates attendee count, and persists in localStorage', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
http.post('*/api/events/:token/rsvps', () => {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ rsvpToken: 'd4e5f6a7-b8c9-0123-4567-890abcdef012', name: 'Max Mustermann' },
|
||||||
|
{ status: 201 },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// CTA is visible
|
||||||
|
const cta = page.getByRole('button', { name: "I'm attending" })
|
||||||
|
await expect(cta).toBeVisible()
|
||||||
|
|
||||||
|
// Open bottom sheet
|
||||||
|
await cta.click()
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'RSVP' })
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
// Fill name and submit
|
||||||
|
await dialog.getByLabel('Your name').fill('Max Mustermann')
|
||||||
|
await dialog.getByRole('button', { name: 'Count me in' }).click()
|
||||||
|
|
||||||
|
// Bottom sheet closes, status bar appears
|
||||||
|
await expect(dialog).not.toBeVisible()
|
||||||
|
await expect(page.getByText("You're attending!")).toBeVisible()
|
||||||
|
await expect(cta).not.toBeVisible()
|
||||||
|
|
||||||
|
// Attendee count incremented
|
||||||
|
await expect(page.getByText('13')).toBeVisible()
|
||||||
|
|
||||||
|
// Verify localStorage
|
||||||
|
const stored = await page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem('fete:events')
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
})
|
||||||
|
expect(stored).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||||
|
rsvpToken: 'd4e5f6a7-b8c9-0123-4567-890abcdef012',
|
||||||
|
rsvpName: 'Max Mustermann',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows validation error when name is empty', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
await page.getByRole('button', { name: "I'm attending" }).click()
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'RSVP' })
|
||||||
|
await dialog.getByRole('button', { name: 'Count me in' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByText('Please enter your name.')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('restores RSVP status from localStorage on page load', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-seed localStorage
|
||||||
|
await page.goto('/')
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'fete:events',
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||||
|
title: 'Summer BBQ',
|
||||||
|
dateTime: '2026-03-15T20:00:00+01:00',
|
||||||
|
expiryDate: '',
|
||||||
|
rsvpToken: 'existing-rsvp-token',
|
||||||
|
rsvpName: 'Anna',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Status bar should show, not CTA
|
||||||
|
await expect(page.getByText("You're attending!")).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows error when server is unreachable during RSVP', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
http.post('*/api/events/:token/rsvps', () => {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ type: 'about:blank', title: 'Bad Request', status: 400 },
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/problem+json' } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
await page.getByRole('button', { name: "I'm attending" }).click()
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'RSVP' })
|
||||||
|
await dialog.getByLabel('Your name').fill('Max')
|
||||||
|
await dialog.getByRole('button', { name: 'Count me in' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByText('Could not submit RSVP. Please try again.')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not show RSVP bar for organizer', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-seed localStorage with organizer token
|
||||||
|
await page.goto('/')
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'fete:events',
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||||
|
organizerToken: 'org-token-123',
|
||||||
|
title: 'Summer BBQ',
|
||||||
|
dateTime: '2026-03-15T20:00:00+01:00',
|
||||||
|
expiryDate: '',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Event content should load
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
|
||||||
|
// But no RSVP bar
|
||||||
|
await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible()
|
||||||
|
await expect(page.getByText("You're attending!")).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not show RSVP bar on expired event', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json({ ...fullEvent, expired: true })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByText('This event has ended.')).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
127
frontend/e2e/event-view.spec.ts
Normal file
127
frontend/e2e/event-view.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { test, expect } from './msw-setup'
|
||||||
|
|
||||||
|
const fullEvent = {
|
||||||
|
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||||
|
title: 'Summer BBQ',
|
||||||
|
description: 'Bring your own drinks!',
|
||||||
|
dateTime: '2026-03-15T20:00:00+01:00',
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
location: 'Central Park, NYC',
|
||||||
|
attendeeCount: 12,
|
||||||
|
expired: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US-1: View event details', () => {
|
||||||
|
test('displays all event fields for a valid event', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||||
|
await expect(page.getByText('Bring your own drinks!')).toBeVisible()
|
||||||
|
await expect(page.getByText('Central Park, NYC')).toBeVisible()
|
||||||
|
await expect(page.getByText('12')).toBeVisible()
|
||||||
|
await expect(page.getByText('Europe/Berlin')).toBeVisible()
|
||||||
|
await expect(page.getByText('2026')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not load external resources', async ({ page, network }) => {
|
||||||
|
const externalRequests: string[] = []
|
||||||
|
page.on('request', (req) => {
|
||||||
|
const url = new URL(req.url())
|
||||||
|
if (!['localhost', '127.0.0.1'].includes(url.hostname)) {
|
||||||
|
externalRequests.push(req.url())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||||
|
|
||||||
|
expect(externalRequests).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US-2: View expired event', () => {
|
||||||
|
test('shows "event has ended" banner for expired event', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json({ ...fullEvent, expired: true })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByText('This event has ended.')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US-4: Event not found', () => {
|
||||||
|
test('shows "event not found" for unknown token', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ type: 'urn:problem-type:event-not-found', title: 'Event Not Found', status: 404, detail: 'Event not found.' },
|
||||||
|
{ status: 404, headers: { 'Content-Type': 'application/problem+json' } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/events/00000000-0000-0000-0000-000000000000')
|
||||||
|
|
||||||
|
await expect(page.getByText('Event not found.')).toBeVisible()
|
||||||
|
// No event data visible
|
||||||
|
await expect(page.locator('.detail__title')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Server error', () => {
|
||||||
|
test('shows error message and retry button on 500', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ type: 'about:blank', title: 'Internal Server Error', status: 500, detail: 'An unexpected error occurred.' },
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByText('Something went wrong.')).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retry button re-fetches the event', async ({ page, network }) => {
|
||||||
|
let callCount = 0
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
callCount++
|
||||||
|
if (callCount === 1) {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ type: 'about:blank', title: 'Error', status: 500 },
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
await expect(page.getByText('Something went wrong.')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Retry' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
377
frontend/e2e/home-events.spec.ts
Normal file
377
frontend/e2e/home-events.spec.ts
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import { test, expect } from './msw-setup'
|
||||||
|
import type { StoredEvent } from '../src/composables/useEventStorage'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'fete:events'
|
||||||
|
|
||||||
|
const futureEvent1: StoredEvent = {
|
||||||
|
eventToken: 'future-aaa',
|
||||||
|
title: 'Summer BBQ',
|
||||||
|
dateTime: '2027-06-15T18:00:00Z',
|
||||||
|
expiryDate: '2027-06-16T00:00:00Z',
|
||||||
|
organizerToken: 'org-token-1',
|
||||||
|
}
|
||||||
|
|
||||||
|
const futureEvent2: StoredEvent = {
|
||||||
|
eventToken: 'future-bbb',
|
||||||
|
title: 'Team Meeting',
|
||||||
|
dateTime: '2027-01-10T09:00:00Z',
|
||||||
|
expiryDate: '2027-01-11T00:00:00Z',
|
||||||
|
rsvpToken: 'rsvp-token-1',
|
||||||
|
rsvpName: 'Alice',
|
||||||
|
}
|
||||||
|
|
||||||
|
const pastEvent: StoredEvent = {
|
||||||
|
eventToken: 'past-ccc',
|
||||||
|
title: 'New Year Party',
|
||||||
|
dateTime: '2025-01-01T00:00:00Z',
|
||||||
|
expiryDate: '2025-01-02T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedEvents(events: StoredEvent[]): string {
|
||||||
|
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US2: Empty State', () => {
|
||||||
|
test('shows empty state when no events are stored', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await expect(page.getByText('No events yet')).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: /Create Event/ })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('empty state links to create page', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const link = page.getByRole('link', { name: /Create Event/ })
|
||||||
|
await expect(link).toHaveAttribute('href', '/create')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('empty state is hidden when events exist', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await expect(page.getByText('No events yet')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US4: Past Events Appear Faded', () => {
|
||||||
|
test('past events have the faded modifier class', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1, pastEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const cards = page.locator('.event-card')
|
||||||
|
await expect(cards).toHaveCount(2)
|
||||||
|
|
||||||
|
// Future event should NOT have past class
|
||||||
|
const futureCard = cards.filter({ hasText: 'Summer BBQ' })
|
||||||
|
await expect(futureCard).not.toHaveClass(/event-card--past/)
|
||||||
|
|
||||||
|
// Past event should have past class
|
||||||
|
const pastCard = cards.filter({ hasText: 'New Year Party' })
|
||||||
|
await expect(pastCard).toHaveClass(/event-card--past/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('past events remain clickable', async ({ page, network }) => {
|
||||||
|
await page.addInitScript(seedEvents([pastEvent]))
|
||||||
|
|
||||||
|
const { http, HttpResponse } = await import('msw')
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
eventToken: pastEvent.eventToken,
|
||||||
|
title: pastEvent.title,
|
||||||
|
dateTime: pastEvent.dateTime,
|
||||||
|
description: '',
|
||||||
|
location: '',
|
||||||
|
timezone: 'UTC',
|
||||||
|
attendeeCount: 0,
|
||||||
|
expired: true,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByText('New Year Party').click()
|
||||||
|
await expect(page).toHaveURL(`/events/${pastEvent.eventToken}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US3: Remove Event from List', () => {
|
||||||
|
test('delete icon triggers confirmation dialog, confirm removes event', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1, futureEvent2]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
// Both events visible
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
await expect(page.getByText('Team Meeting')).toBeVisible()
|
||||||
|
|
||||||
|
// Click delete on Summer BBQ
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
|
||||||
|
// Confirmation dialog appears
|
||||||
|
await expect(page.getByText('Remove event?')).toBeVisible()
|
||||||
|
|
||||||
|
// Confirm removal
|
||||||
|
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||||
|
|
||||||
|
// Event is gone, other remains
|
||||||
|
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||||
|
await expect(page.getByText('Team Meeting')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cancel keeps the event in the list', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await expect(page.getByText('Remove event?')).toBeVisible()
|
||||||
|
|
||||||
|
// Cancel
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||||
|
|
||||||
|
// Dialog gone, event still there
|
||||||
|
await expect(page.getByText('Remove event?')).not.toBeVisible()
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US5: Visual Distinction for Event Roles', () => {
|
||||||
|
test('shows organizer badge for events with organizerToken', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const card = page.locator('.event-card').filter({ hasText: 'Summer BBQ' })
|
||||||
|
const badge = card.locator('.event-card__badge')
|
||||||
|
await expect(badge).toBeVisible()
|
||||||
|
await expect(badge).toHaveText('Organizer')
|
||||||
|
await expect(badge).toHaveClass(/event-card__badge--organizer/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows attendee badge for events with rsvpToken only', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent2]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const card = page.locator('.event-card').filter({ hasText: 'Team Meeting' })
|
||||||
|
const badge = card.locator('.event-card__badge')
|
||||||
|
await expect(badge).toBeVisible()
|
||||||
|
await expect(badge).toHaveText('Attendee')
|
||||||
|
await expect(badge).toHaveClass(/event-card__badge--attendee/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows no badge for events without organizerToken or rsvpToken', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([pastEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const card = page.locator('.event-card').filter({ hasText: 'New Year Party' })
|
||||||
|
await expect(card.locator('.event-card__badge')).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('FAB: Create Event Button', () => {
|
||||||
|
test('FAB is visible when events exist', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const fab = page.getByRole('link', { name: 'Create event' })
|
||||||
|
await expect(fab).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('FAB navigates to create page', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const fab = page.getByRole('link', { name: 'Create event' })
|
||||||
|
await expect(fab).toHaveAttribute('href', '/create')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('FAB is not visible on empty state (empty state has its own CTA)', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await expect(page.locator('.fab')).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Temporal Grouping: Section Headers', () => {
|
||||||
|
test('events are distributed under correct section headers', async ({ page }) => {
|
||||||
|
// Use dates relative to "now" to ensure correct section assignment
|
||||||
|
const now = new Date()
|
||||||
|
const todayEvent: StoredEvent = {
|
||||||
|
eventToken: 'today-1',
|
||||||
|
title: 'Today Standup',
|
||||||
|
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0, 0).toISOString(),
|
||||||
|
expiryDate: '',
|
||||||
|
}
|
||||||
|
const laterEvent: StoredEvent = {
|
||||||
|
eventToken: 'later-1',
|
||||||
|
title: 'Future Conference',
|
||||||
|
dateTime: new Date(now.getFullYear() + 1, 0, 15, 10, 0, 0).toISOString(),
|
||||||
|
expiryDate: '',
|
||||||
|
}
|
||||||
|
await page.addInitScript(seedEvents([todayEvent, laterEvent, pastEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
// Verify section headers appear
|
||||||
|
await expect(page.getByRole('heading', { name: 'Today', level: 2 })).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Later', level: 2 })).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Past', level: 2 })).toBeVisible()
|
||||||
|
|
||||||
|
// Events are in the correct sections
|
||||||
|
const sections = page.locator('.event-section')
|
||||||
|
const todaySection = sections.filter({ has: page.getByRole('heading', { name: 'Today', level: 2 }) })
|
||||||
|
await expect(todaySection.getByText('Today Standup')).toBeVisible()
|
||||||
|
|
||||||
|
const laterSection = sections.filter({ has: page.getByRole('heading', { name: 'Later', level: 2 }) })
|
||||||
|
await expect(laterSection.getByText('Future Conference')).toBeVisible()
|
||||||
|
|
||||||
|
const pastSection = sections.filter({ has: page.getByRole('heading', { name: 'Past', level: 2 }) })
|
||||||
|
await expect(pastSection.getByText('New Year Party')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('empty sections are not rendered', async ({ page }) => {
|
||||||
|
// Only a past event — no Today, This Week, or Later sections
|
||||||
|
await page.addInitScript(seedEvents([pastEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Past', level: 2 })).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Today', level: 2 })).toHaveCount(0)
|
||||||
|
await expect(page.getByRole('heading', { name: 'This Week', level: 2 })).toHaveCount(0)
|
||||||
|
await expect(page.getByRole('heading', { name: 'Next Week', level: 2 })).toHaveCount(0)
|
||||||
|
await expect(page.getByRole('heading', { name: 'Later', level: 2 })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Today section header has emphasis CSS class', async ({ page }) => {
|
||||||
|
const now = new Date()
|
||||||
|
const todayEvent: StoredEvent = {
|
||||||
|
eventToken: 'today-emph',
|
||||||
|
title: 'Emphasis Test',
|
||||||
|
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 20, 0, 0).toISOString(),
|
||||||
|
expiryDate: '',
|
||||||
|
}
|
||||||
|
await page.addInitScript(seedEvents([todayEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const todayHeader = page.getByRole('heading', { name: 'Today', level: 2 })
|
||||||
|
await expect(todayHeader).toHaveClass(/section-header--emphasized/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Temporal Grouping: Date Subheaders', () => {
|
||||||
|
test('no date subheader in Today section', async ({ page }) => {
|
||||||
|
const now = new Date()
|
||||||
|
const todayEvent: StoredEvent = {
|
||||||
|
eventToken: 'today-sub',
|
||||||
|
title: 'No Subheader Test',
|
||||||
|
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 19, 0, 0).toISOString(),
|
||||||
|
expiryDate: '',
|
||||||
|
}
|
||||||
|
await page.addInitScript(seedEvents([todayEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const todaySection = page.locator('.event-section').filter({
|
||||||
|
has: page.getByRole('heading', { name: 'Today', level: 2 }),
|
||||||
|
})
|
||||||
|
await expect(todaySection.locator('.date-subheader')).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('date subheaders appear in Later section', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1, futureEvent2]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const laterSection = page.locator('.event-section').filter({
|
||||||
|
has: page.getByRole('heading', { name: 'Later', level: 2 }),
|
||||||
|
})
|
||||||
|
// Both future events are on different dates, so expect subheaders
|
||||||
|
const subheaders = laterSection.locator('.date-subheader')
|
||||||
|
await expect(subheaders).toHaveCount(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('date subheaders appear in Past section', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([pastEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const pastSection = page.locator('.event-section').filter({
|
||||||
|
has: page.getByRole('heading', { name: 'Past', level: 2 }),
|
||||||
|
})
|
||||||
|
await expect(pastSection.locator('.date-subheader')).toHaveCount(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Temporal Grouping: Time Display', () => {
|
||||||
|
test('future event cards show clock time', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const timeLabel = page.locator('.event-card__time')
|
||||||
|
const text = await timeLabel.first().textContent()
|
||||||
|
// Should show clock time (e.g., "18:00" or "6:00 PM"), not relative time
|
||||||
|
expect(text).toMatch(/\d{1,2}[:.]\d{2}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('past event cards show relative time', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([pastEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const timeLabel = page.locator('.event-card__time')
|
||||||
|
const text = await timeLabel.first().textContent()
|
||||||
|
// Should show relative time like "X years ago" or "last year"
|
||||||
|
expect(text).toMatch(/ago|last|yesterday/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US1: View My Events', () => {
|
||||||
|
test('displays all stored events with title and relative time', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1, futureEvent2, pastEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
await expect(page.getByText('Team Meeting')).toBeVisible()
|
||||||
|
await expect(page.getByText('New Year Party')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('events are sorted: upcoming ascending, then past', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1, futureEvent2, pastEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const titles = page.locator('.event-card__title')
|
||||||
|
await expect(titles).toHaveCount(3)
|
||||||
|
// Team Meeting (Jan 2027) before Summer BBQ (Jun 2027), then past event
|
||||||
|
await expect(titles.nth(0)).toHaveText('Team Meeting')
|
||||||
|
await expect(titles.nth(1)).toHaveText('Summer BBQ')
|
||||||
|
await expect(titles.nth(2)).toHaveText('New Year Party')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking an event navigates to its detail page', async ({ page, network }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
|
||||||
|
// Mock the event detail API so navigation doesn't fail
|
||||||
|
const { http, HttpResponse } = await import('msw')
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
eventToken: futureEvent1.eventToken,
|
||||||
|
title: futureEvent1.title,
|
||||||
|
dateTime: futureEvent1.dateTime,
|
||||||
|
description: '',
|
||||||
|
location: '',
|
||||||
|
timezone: 'UTC',
|
||||||
|
attendeeCount: 0,
|
||||||
|
expired: false,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByText('Summer BBQ').click()
|
||||||
|
await expect(page).toHaveURL(`/events/${futureEvent1.eventToken}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('each event shows a relative time label', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
// The relative time element should exist and contain text (exact value depends on current time)
|
||||||
|
const timeLabel = page.locator('.event-card__time')
|
||||||
|
await expect(timeLabel).toHaveCount(1)
|
||||||
|
await expect(timeLabel.first()).not.toBeEmpty()
|
||||||
|
})
|
||||||
|
})
|
||||||
99
frontend/e2e/view-attendee-list.spec.ts
Normal file
99
frontend/e2e/view-attendee-list.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
230
frontend/package-lock.json
generated
230
frontend/package-lock.json
generated
@@ -23,17 +23,17 @@
|
|||||||
"@vitest/eslint-plugin": "^1.6.9",
|
"@vitest/eslint-plugin": "^1.6.9",
|
||||||
"@vue/eslint-config-typescript": "^14.7.0",
|
"@vue/eslint-config-typescript": "^14.7.0",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.9.0",
|
||||||
"eslint": "^10.0.2",
|
"eslint": "^10.0.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-oxlint": "~1.50.0",
|
"eslint-plugin-oxlint": "~1.51.0",
|
||||||
"eslint-plugin-vue": "~10.8.0",
|
"eslint-plugin-vue": "~10.8.0",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"msw": "^2.12.10",
|
"msw": "^2.12.10",
|
||||||
"npm-run-all2": "^8.0.4",
|
"npm-run-all2": "^8.0.4",
|
||||||
"openapi-typescript": "^7.13.0",
|
"openapi-typescript": "^7.13.0",
|
||||||
"oxlint": "~1.50.0",
|
"oxlint": "~1.51.0",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
@@ -1201,15 +1201,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/config-array": {
|
"node_modules/@eslint/config-array": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.3",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
|
||||||
"integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==",
|
"integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/object-schema": "^3.0.2",
|
"@eslint/object-schema": "^3.0.3",
|
||||||
"debug": "^4.3.1",
|
"debug": "^4.3.1",
|
||||||
"minimatch": "^10.2.1"
|
"minimatch": "^10.2.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
@@ -1229,9 +1229,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/core": {
|
"node_modules/@eslint/core": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
|
||||||
"integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==",
|
"integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1242,9 +1242,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/object-schema": {
|
"node_modules/@eslint/object-schema": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
|
||||||
"integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==",
|
"integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1252,13 +1252,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/plugin-kit": {
|
"node_modules/@eslint/plugin-kit": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
|
||||||
"integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==",
|
"integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/core": "^1.1.0",
|
"@eslint/core": "^1.1.1",
|
||||||
"levn": "^0.4.1"
|
"levn": "^0.4.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1727,9 +1727,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-android-arm-eabi": {
|
"node_modules/@oxlint/binding-android-arm-eabi": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.51.0.tgz",
|
||||||
"integrity": "sha512-G7MRGk/6NCe+L8ntonRdZP7IkBfEpiZ/he3buLK6JkLgMHgJShXZ+BeOwADmspXez7U7F7L1Anf4xLSkLHiGTg==",
|
"integrity": "sha512-jJYIqbx4sX+suIxWstc4P7SzhEwb4ArWA2KVrmEuu9vH2i0qM6QIHz/ehmbGE4/2fZbpuMuBzTl7UkfNoqiSgw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1744,9 +1744,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-android-arm64": {
|
"node_modules/@oxlint/binding-android-arm64": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.51.0.tgz",
|
||||||
"integrity": "sha512-GeSuMoJWCVpovJi/e3xDSNgjeR8WEZ6MCXL6EtPiCIM2NTzv7LbflARINTXTJy2oFBYyvdf/l2PwHzYo6EdXvg==",
|
"integrity": "sha512-GtXyBCcH4ti98YdiMNCrpBNGitx87EjEWxevnyhcBK12k/Vu4EzSB45rzSC4fGFUD6sQgeaxItRCEEWeVwPafw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1761,9 +1761,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-darwin-arm64": {
|
"node_modules/@oxlint/binding-darwin-arm64": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.51.0.tgz",
|
||||||
"integrity": "sha512-w3SY5YtxGnxCHPJ8Twl3KmS9oja1gERYk3AMoZ7Hv8P43ZtB6HVfs02TxvarxfL214Tm3uzvc2vn+DhtUNeKnw==",
|
"integrity": "sha512-3QJbeYaMHn6Bh2XeBXuITSsbnIctyTjvHf5nRjKYrT9pPeErNIpp5VDEeAXC0CZSwSVTsc8WOSDwgrAI24JolQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1778,9 +1778,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-darwin-x64": {
|
"node_modules/@oxlint/binding-darwin-x64": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.51.0.tgz",
|
||||||
"integrity": "sha512-hNfogDqy7tvmllXKBSlHo6k5x7dhTUVOHbMSE15CCAcXzmqf5883aPvBYPOq9AE7DpDUQUZ1kVE22YbiGW+tuw==",
|
"integrity": "sha512-NzErhMaTEN1cY0E8C5APy74lw5VwsNfJfVPBMWPVQLqAbO0k4FFLjvHURvkUL+Y18Wu+8Vs1kbqPh2hjXYA4pg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1795,9 +1795,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-freebsd-x64": {
|
"node_modules/@oxlint/binding-freebsd-x64": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.51.0.tgz",
|
||||||
"integrity": "sha512-ykZevOWEyu0nsxolA911ucxpEv0ahw8jfEeGWOwwb/VPoE4xoexuTOAiPNlWZNJqANlJl7yp8OyzCtXTUAxotw==",
|
"integrity": "sha512-msAIh3vPAoKoHlOE/oe6Q5C/n9umypv/k81lED82ibrJotn+3YG2Qp1kiR8o/Dg5iOEU97c6tl0utxcyFenpFw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1812,9 +1812,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
|
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.51.0.tgz",
|
||||||
"integrity": "sha512-hif3iDk7vo5GGJ4OLCCZAf2vjnU9FztGw4L0MbQL0M2iY9LKFtDMMiQAHmkF0PQGQMVbTYtPdXCLKVgdkiqWXQ==",
|
"integrity": "sha512-CqQPcvqYyMe9ZBot2stjGogEzk1z8gGAngIX7srSzrzexmXixwVxBdFZyxTVM0CjGfDeV+Ru0w25/WNjlMM2Hw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1829,9 +1829,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
|
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.51.0.tgz",
|
||||||
"integrity": "sha512-dVp9iSssiGAnTNey2Ruf6xUaQhdnvcFOJyRWd/mu5o2jVbFK15E5fbWGeFRfmuobu5QXuROtFga44+7DOS3PLg==",
|
"integrity": "sha512-dstrlYQgZMnyOssxSbolGCge/sDbko12N/35RBNuqLpoPbft2aeBidBAb0dvQlyBd9RJ6u8D4o4Eh8Un6iTgyQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1846,9 +1846,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-arm64-gnu": {
|
"node_modules/@oxlint/binding-linux-arm64-gnu": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.51.0.tgz",
|
||||||
"integrity": "sha512-1cT7yz2HA910CKA9NkH1ZJo50vTtmND2fkoW1oyiSb0j6WvNtJ0Wx2zoySfXWc/c+7HFoqRK5AbEoL41LOn9oA==",
|
"integrity": "sha512-QEjUpXO7d35rP1/raLGGbAsBLLGZIzV3ZbeSjqWlD3oRnxpRIZ6iL4o51XQHkconn3uKssc+1VKdtHJ81BBhDA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1863,9 +1863,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-arm64-musl": {
|
"node_modules/@oxlint/binding-linux-arm64-musl": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.51.0.tgz",
|
||||||
"integrity": "sha512-++B3k/HEPFVlj89cOz8kWfQccMZB/aWL9AhsW7jPIkG++63Mpwb2cE9XOEsd0PATbIan78k2Gky+09uWM1d/gQ==",
|
"integrity": "sha512-YSJua5irtG4DoMAjUapDTPhkQLHhBIY0G9JqlZS6/SZPzqDkPku/1GdWs0D6h/wyx0Iz31lNCfIaWKBQhzP0wQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1880,9 +1880,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
|
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.51.0.tgz",
|
||||||
"integrity": "sha512-Z9b/KpFMkx66w3gVBqjIC1AJBTZAGoI9+U+K5L4QM0CB/G0JSNC1es9b3Y0Vcrlvtdn8A+IQTkYjd/Q0uCSaZw==",
|
"integrity": "sha512-7L4Wj2IEUNDETKssB9IDYt16T6WlF+X2jgC/hBq3diGHda9vJLpAgb09+D3quFq7TdkFtI7hwz/jmuQmQFPc1Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1897,9 +1897,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
|
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.51.0.tgz",
|
||||||
"integrity": "sha512-jvmuIw8wRSohsQlFNIST5uUwkEtEJmOQYr33bf/K2FrFPXHhM4KqGekI3ShYJemFS/gARVacQFgBzzJKCAyJjg==",
|
"integrity": "sha512-cBUHqtOXy76G41lOB401qpFoKx1xq17qYkhWrLSM7eEjiHM9sOtYqpr6ZdqCnN9s6ZpzudX4EkeHOFH2E9q0vA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1914,9 +1914,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-riscv64-musl": {
|
"node_modules/@oxlint/binding-linux-riscv64-musl": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.51.0.tgz",
|
||||||
"integrity": "sha512-x+UrN47oYNh90nmAAyql8eQaaRpHbDPu5guasDg10+OpszUQ3/1+1J6zFMmV4xfIEgTcUXG/oI5fxJhF4eWCNA==",
|
"integrity": "sha512-WKbg8CysgZcHfZX0ixQFBRSBvFZUHa3SBnEjHY2FVYt2nbNJEjzTxA3ZR5wMU0NOCNKIAFUFvAh5/XJKPRJuJg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1931,9 +1931,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-s390x-gnu": {
|
"node_modules/@oxlint/binding-linux-s390x-gnu": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.51.0.tgz",
|
||||||
"integrity": "sha512-i/JLi2ljLUIVfekMj4ISmdt+Hn11wzYUdRRrkVUYsCWw7zAy5xV7X9iA+KMyM156LTFympa7s3oKBjuCLoTAUQ==",
|
"integrity": "sha512-N1QRUvJTxqXNSu35YOufdjsAVmKVx5bkrggOWAhTWBc3J4qjcBwr1IfyLh/6YCg8sYRSR1GraldS9jUgJL/U4A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1948,9 +1948,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-x64-gnu": {
|
"node_modules/@oxlint/binding-linux-x64-gnu": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.51.0.tgz",
|
||||||
"integrity": "sha512-/C7brhn6c6UUPccgSPCcpLQXcp+xKIW/3sji/5VZ8/OItL3tQ2U7KalHz887UxxSQeEOmd1kY6lrpuwFnmNqOA==",
|
"integrity": "sha512-e0Mz0DizsCoqNIjeOg6OUKe8JKJWZ5zZlwsd05Bmr51Jo3AOL4UJnPvwKumr4BBtBrDZkCmOLhCvDGm95nJM2g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1965,9 +1965,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-x64-musl": {
|
"node_modules/@oxlint/binding-linux-x64-musl": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.51.0.tgz",
|
||||||
"integrity": "sha512-oDR1f+bGOYU8LfgtEW8XtotWGB63ghtcxk5Jm6IDTCk++rTA/IRMsjOid2iMd+1bW+nP9Mdsmcdc7VbPD3+iyQ==",
|
"integrity": "sha512-wD8HGTWhYBKXvRDvoBVB1y+fEYV01samhWQSy1Zkxq2vpezvMnjaFKRuiP6tBNITLGuffbNDEXOwcAhJ3gI5Ug==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1982,9 +1982,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-openharmony-arm64": {
|
"node_modules/@oxlint/binding-openharmony-arm64": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.51.0.tgz",
|
||||||
"integrity": "sha512-4CmRGPp5UpvXyu4jjP9Tey/SrXDQLRvZXm4pb4vdZBxAzbFZkCyh0KyRy4txld/kZKTJlW4TO8N1JKrNEk+mWw==",
|
"integrity": "sha512-5NSwQ2hDEJ0GPXqikjWtwzgAQCsS7P9aLMNenjjKa+gknN3lTCwwwERsT6lKXSirfU3jLjexA2XQvQALh5h27w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1999,9 +1999,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-win32-arm64-msvc": {
|
"node_modules/@oxlint/binding-win32-arm64-msvc": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.51.0.tgz",
|
||||||
"integrity": "sha512-Fq0M6vsGcFsSfeuWAACDhd5KJrO85ckbEfe1EGuBj+KPyJz7KeWte2fSFrFGmNKNXyhEMyx4tbgxiWRujBM2KQ==",
|
"integrity": "sha512-JEZyah1M0RHMw8d+jjSSJmSmO8sABA1J1RtrHYujGPeCkYg1NeH0TGuClpe2h5QtioRTaF57y/TZfn/2IFV6fA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2016,9 +2016,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-win32-ia32-msvc": {
|
"node_modules/@oxlint/binding-win32-ia32-msvc": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.51.0.tgz",
|
||||||
"integrity": "sha512-qTdWR9KwY/vxJGhHVIZG2eBOhidOQvOwzDxnX+jhW/zIVacal1nAhR8GLkiywW8BIFDkQKXo/zOfT+/DY+ns/w==",
|
"integrity": "sha512-q3cEoKH6kwjz/WRyHwSf0nlD2F5Qw536kCXvmlSu+kaShzgrA0ojmh45CA81qL+7udfCaZL2SdKCZlLiGBVFlg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -2033,9 +2033,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-win32-x64-msvc": {
|
"node_modules/@oxlint/binding-win32-x64-msvc": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.51.0.tgz",
|
||||||
"integrity": "sha512-682t7npLC4G2Ca+iNlI9fhAKTcFPYYXJjwoa88H4q+u5HHHlsnL/gHULapX3iqp+A8FIJbgdylL5KMYo2LaluQ==",
|
"integrity": "sha512-Q14+fOGb9T28nWF/0EUsYqERiRA7cl1oy4TJrGmLaqhm+aO2cV+JttboHI3CbdeMCAyDI1+NoSlrM7Melhp/cw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3423,9 +3423,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/tsconfig": {
|
"node_modules/@vue/tsconfig": {
|
||||||
"version": "0.8.1",
|
"version": "0.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.9.0.tgz",
|
||||||
"integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==",
|
"integrity": "sha512-RP+v9Cpbsk1ZVXltCHHkYBr7+624x6gcijJXVjIcsYk7JXqvIpRtMwU2ARLvWDhmy9ffdFYxhsfJnPztADBohQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -4304,18 +4304,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint": {
|
"node_modules/eslint": {
|
||||||
"version": "10.0.2",
|
"version": "10.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz",
|
||||||
"integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==",
|
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
"@eslint/config-array": "^0.23.2",
|
"@eslint/config-array": "^0.23.3",
|
||||||
"@eslint/config-helpers": "^0.5.2",
|
"@eslint/config-helpers": "^0.5.2",
|
||||||
"@eslint/core": "^1.1.0",
|
"@eslint/core": "^1.1.1",
|
||||||
"@eslint/plugin-kit": "^0.6.0",
|
"@eslint/plugin-kit": "^0.6.1",
|
||||||
"@humanfs/node": "^0.16.6",
|
"@humanfs/node": "^0.16.6",
|
||||||
"@humanwhocodes/module-importer": "^1.0.1",
|
"@humanwhocodes/module-importer": "^1.0.1",
|
||||||
"@humanwhocodes/retry": "^0.4.2",
|
"@humanwhocodes/retry": "^0.4.2",
|
||||||
@@ -4324,7 +4324,7 @@
|
|||||||
"cross-spawn": "^7.0.6",
|
"cross-spawn": "^7.0.6",
|
||||||
"debug": "^4.3.2",
|
"debug": "^4.3.2",
|
||||||
"escape-string-regexp": "^4.0.0",
|
"escape-string-regexp": "^4.0.0",
|
||||||
"eslint-scope": "^9.1.1",
|
"eslint-scope": "^9.1.2",
|
||||||
"eslint-visitor-keys": "^5.0.1",
|
"eslint-visitor-keys": "^5.0.1",
|
||||||
"espree": "^11.1.1",
|
"espree": "^11.1.1",
|
||||||
"esquery": "^1.7.0",
|
"esquery": "^1.7.0",
|
||||||
@@ -4337,7 +4337,7 @@
|
|||||||
"imurmurhash": "^0.1.4",
|
"imurmurhash": "^0.1.4",
|
||||||
"is-glob": "^4.0.0",
|
"is-glob": "^4.0.0",
|
||||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||||
"minimatch": "^10.2.1",
|
"minimatch": "^10.2.4",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
"optionator": "^0.9.3"
|
"optionator": "^0.9.3"
|
||||||
},
|
},
|
||||||
@@ -4376,9 +4376,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-oxlint": {
|
"node_modules/eslint-plugin-oxlint": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-oxlint/-/eslint-plugin-oxlint-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-oxlint/-/eslint-plugin-oxlint-1.51.0.tgz",
|
||||||
"integrity": "sha512-QAxeFeUHuekmLkuRLdzHH8Z0JvC7482OaQ3jlUMdEd0gcS6m+MYHei3Favoew9DdvTQT7yHxrm7BL0iXoenb6w==",
|
"integrity": "sha512-lct8LD1AxfHF1PcsuK6mFYals+zX0mx/WP2G4i16h0iR8jpT3xCfGTmTNwXiImcevzGIiJ/VDBgQ7t0B9z2Jeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4418,9 +4418,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-scope": {
|
"node_modules/eslint-scope": {
|
||||||
"version": "9.1.1",
|
"version": "9.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
|
||||||
"integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==",
|
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5776,9 +5776,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/oxlint": {
|
"node_modules/oxlint": {
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.51.0.tgz",
|
||||||
"integrity": "sha512-iSJ4IZEICBma8cZX7kxIIz9PzsYLF2FaLAYN6RKu7VwRVKdu7RIgpP99bTZaGl//Yao7fsaGZLSEo5xBrI5ReQ==",
|
"integrity": "sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -5791,28 +5791,28 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@oxlint/binding-android-arm-eabi": "1.50.0",
|
"@oxlint/binding-android-arm-eabi": "1.51.0",
|
||||||
"@oxlint/binding-android-arm64": "1.50.0",
|
"@oxlint/binding-android-arm64": "1.51.0",
|
||||||
"@oxlint/binding-darwin-arm64": "1.50.0",
|
"@oxlint/binding-darwin-arm64": "1.51.0",
|
||||||
"@oxlint/binding-darwin-x64": "1.50.0",
|
"@oxlint/binding-darwin-x64": "1.51.0",
|
||||||
"@oxlint/binding-freebsd-x64": "1.50.0",
|
"@oxlint/binding-freebsd-x64": "1.51.0",
|
||||||
"@oxlint/binding-linux-arm-gnueabihf": "1.50.0",
|
"@oxlint/binding-linux-arm-gnueabihf": "1.51.0",
|
||||||
"@oxlint/binding-linux-arm-musleabihf": "1.50.0",
|
"@oxlint/binding-linux-arm-musleabihf": "1.51.0",
|
||||||
"@oxlint/binding-linux-arm64-gnu": "1.50.0",
|
"@oxlint/binding-linux-arm64-gnu": "1.51.0",
|
||||||
"@oxlint/binding-linux-arm64-musl": "1.50.0",
|
"@oxlint/binding-linux-arm64-musl": "1.51.0",
|
||||||
"@oxlint/binding-linux-ppc64-gnu": "1.50.0",
|
"@oxlint/binding-linux-ppc64-gnu": "1.51.0",
|
||||||
"@oxlint/binding-linux-riscv64-gnu": "1.50.0",
|
"@oxlint/binding-linux-riscv64-gnu": "1.51.0",
|
||||||
"@oxlint/binding-linux-riscv64-musl": "1.50.0",
|
"@oxlint/binding-linux-riscv64-musl": "1.51.0",
|
||||||
"@oxlint/binding-linux-s390x-gnu": "1.50.0",
|
"@oxlint/binding-linux-s390x-gnu": "1.51.0",
|
||||||
"@oxlint/binding-linux-x64-gnu": "1.50.0",
|
"@oxlint/binding-linux-x64-gnu": "1.51.0",
|
||||||
"@oxlint/binding-linux-x64-musl": "1.50.0",
|
"@oxlint/binding-linux-x64-musl": "1.51.0",
|
||||||
"@oxlint/binding-openharmony-arm64": "1.50.0",
|
"@oxlint/binding-openharmony-arm64": "1.51.0",
|
||||||
"@oxlint/binding-win32-arm64-msvc": "1.50.0",
|
"@oxlint/binding-win32-arm64-msvc": "1.51.0",
|
||||||
"@oxlint/binding-win32-ia32-msvc": "1.50.0",
|
"@oxlint/binding-win32-ia32-msvc": "1.51.0",
|
||||||
"@oxlint/binding-win32-x64-msvc": "1.50.0"
|
"@oxlint/binding-win32-x64-msvc": "1.51.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"oxlint-tsgolint": ">=0.14.1"
|
"oxlint-tsgolint": ">=0.15.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"oxlint-tsgolint": {
|
"oxlint-tsgolint": {
|
||||||
|
|||||||
@@ -35,17 +35,17 @@
|
|||||||
"@vitest/eslint-plugin": "^1.6.9",
|
"@vitest/eslint-plugin": "^1.6.9",
|
||||||
"@vue/eslint-config-typescript": "^14.7.0",
|
"@vue/eslint-config-typescript": "^14.7.0",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.9.0",
|
||||||
"eslint": "^10.0.2",
|
"eslint": "^10.0.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-oxlint": "~1.50.0",
|
"eslint-plugin-oxlint": "~1.51.0",
|
||||||
"eslint-plugin-vue": "~10.8.0",
|
"eslint-plugin-vue": "~10.8.0",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"msw": "^2.12.10",
|
"msw": "^2.12.10",
|
||||||
"npm-run-all2": "^8.0.4",
|
"npm-run-all2": "^8.0.4",
|
||||||
"openapi-typescript": "^7.13.0",
|
"openapi-typescript": "^7.13.0",
|
||||||
"oxlint": "~1.50.0",
|
"oxlint": "~1.51.0",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
|
|||||||
BIN
frontend/src/assets/images/event-hero-placeholder.jpg
Normal file
BIN
frontend/src/assets/images/event-hero-placeholder.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
@@ -163,6 +163,19 @@ textarea.form-field {
|
|||||||
padding-left: 0.25rem;
|
padding-left: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Skeleton shimmer loading state */
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, var(--color-card) 25%, #e0e0e0 50%, var(--color-card) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Utility */
|
/* Utility */
|
||||||
.text-center {
|
.text-center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -179,3 +192,34 @@ textarea.form-field {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bottom sheet form */
|
||||||
|
.sheet-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-form__label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-form__field-error {
|
||||||
|
color: #d32f2f;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-form__error {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|||||||
59
frontend/src/components/AttendeeList.vue
Normal file
59
frontend/src/components/AttendeeList.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<section class="attendee-list">
|
||||||
|
<h3 class="attendee-list__heading">
|
||||||
|
{{ attendees.length === 1 ? '1 Attendee' : `${attendees.length} Attendees` }}
|
||||||
|
</h3>
|
||||||
|
<ul v-if="attendees.length > 0" class="attendee-list__items">
|
||||||
|
<li v-for="(name, index) in attendees" :key="index" class="attendee-list__item">
|
||||||
|
{{ name }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="attendee-list__empty">No attendees yet.</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
attendees: string[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.attendee-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendee-list__heading {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendee-list__items {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendee-list__item {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendee-list__empty {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
96
frontend/src/components/BottomSheet.vue
Normal file
96
frontend/src/components/BottomSheet.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="sheet">
|
||||||
|
<div v-if="open" class="sheet-backdrop" @click.self="$emit('close')" @keydown.escape="$emit('close')">
|
||||||
|
<div class="sheet" role="dialog" aria-modal="true" :aria-label="label" ref="sheetEl" tabindex="-1">
|
||||||
|
<div class="sheet__handle" aria-hidden="true" />
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
open: boolean
|
||||||
|
label: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sheetEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => sheetEl.value,
|
||||||
|
async (el) => {
|
||||||
|
if (el) {
|
||||||
|
await nextTick()
|
||||||
|
const firstInput = el.querySelector<HTMLElement>('input, textarea, button[type="submit"]')
|
||||||
|
if (firstInput) {
|
||||||
|
firstInput.focus()
|
||||||
|
} else {
|
||||||
|
el.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sheet-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
background: var(--color-card);
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
padding: var(--spacing-lg) var(--spacing-xl) var(--spacing-2xl);
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--content-max-width);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__handle {
|
||||||
|
width: 36px;
|
||||||
|
height: 4px;
|
||||||
|
background: #ccc;
|
||||||
|
border-radius: 2px;
|
||||||
|
align-self: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition */
|
||||||
|
.sheet-enter-active,
|
||||||
|
.sheet-leave-active {
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-enter-active .sheet,
|
||||||
|
.sheet-leave-active .sheet {
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-enter-from,
|
||||||
|
.sheet-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-enter-from .sheet,
|
||||||
|
.sheet-leave-to .sheet {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
151
frontend/src/components/ConfirmDialog.vue
Normal file
151
frontend/src/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="confirm-dialog">
|
||||||
|
<div v-if="open" class="confirm-dialog__overlay" @click.self="$emit('cancel')">
|
||||||
|
<div
|
||||||
|
class="confirm-dialog"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="title"
|
||||||
|
@keydown.escape="$emit('cancel')"
|
||||||
|
>
|
||||||
|
<p class="confirm-dialog__title">{{ title }}</p>
|
||||||
|
<p class="confirm-dialog__message">{{ message }}</p>
|
||||||
|
<div class="confirm-dialog__actions">
|
||||||
|
<button
|
||||||
|
ref="cancelBtn"
|
||||||
|
class="confirm-dialog__btn confirm-dialog__btn--cancel"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('cancel')"
|
||||||
|
>
|
||||||
|
{{ cancelLabel }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="confirm-dialog__btn confirm-dialog__btn--confirm"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('confirm')"
|
||||||
|
>
|
||||||
|
{{ confirmLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
open: boolean
|
||||||
|
title?: string
|
||||||
|
message?: string
|
||||||
|
confirmLabel?: string
|
||||||
|
cancelLabel?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: 'Are you sure?',
|
||||||
|
message: '',
|
||||||
|
confirmLabel: 'Remove',
|
||||||
|
cancelLabel: 'Cancel',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
confirm: []
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const cancelBtn = ref<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
async (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
await nextTick()
|
||||||
|
cancelBtn.value?.focus()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.confirm-dialog__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog {
|
||||||
|
background: var(--color-card);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
max-width: 320px;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__message {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-button);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__btn--cancel {
|
||||||
|
background: #e8e8e8;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__btn--confirm {
|
||||||
|
background: #d32f2f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-enter-active,
|
||||||
|
.confirm-dialog-leave-active {
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-enter-from,
|
||||||
|
.confirm-dialog-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
49
frontend/src/components/CreateEventFab.vue
Normal file
49
frontend/src/components/CreateEventFab.vue
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<RouterLink to="/create" class="fab" aria-label="Create event">
|
||||||
|
<span class="fab__icon" aria-hidden="true">+</span>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(1.2rem + env(safe-area-inset-bottom));
|
||||||
|
right: 1.2rem;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
text-decoration: none;
|
||||||
|
z-index: 100;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:hover {
|
||||||
|
transform: scale(1.08);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:focus-visible {
|
||||||
|
outline: 2px solid #fff;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab__icon {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
19
frontend/src/components/DateSubheader.vue
Normal file
19
frontend/src/components/DateSubheader.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<h3 class="date-subheader">{{ label }}</h3>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.date-subheader {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--spacing-xs) 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
31
frontend/src/components/EmptyState.vue
Normal file
31
frontend/src/components/EmptyState.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div class="empty-state">
|
||||||
|
<p class="empty-state__message">No events yet.<br />Create your first one!</p>
|
||||||
|
<RouterLink to="/create" class="btn-primary empty-state__cta">+ Create Event</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__message {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
opacity: 0.9;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__cta {
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
181
frontend/src/components/EventCard.vue
Normal file
181
frontend/src/components/EventCard.vue
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="event-card"
|
||||||
|
:class="{ 'event-card--past': isPast, 'event-card--swiping': isSwiping }"
|
||||||
|
:style="swipeStyle"
|
||||||
|
@touchstart="onTouchStart"
|
||||||
|
@touchmove="onTouchMove"
|
||||||
|
@touchend="onTouchEnd"
|
||||||
|
>
|
||||||
|
<RouterLink :to="`/events/${eventToken}`" class="event-card__link">
|
||||||
|
<span class="event-card__title">{{ title }}</span>
|
||||||
|
<span class="event-card__time">{{ displayTime }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
<span v-if="eventRole" class="event-card__badge" :class="`event-card__badge--${eventRole}`">
|
||||||
|
{{ eventRole === 'organizer' ? 'Organizer' : 'Attendee' }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="event-card__delete"
|
||||||
|
type="button"
|
||||||
|
:aria-label="`Remove ${title}`"
|
||||||
|
@click.stop="$emit('delete', eventToken)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
eventToken: string
|
||||||
|
title: string
|
||||||
|
relativeTime: string
|
||||||
|
isPast: boolean
|
||||||
|
eventRole?: 'organizer' | 'attendee'
|
||||||
|
timeDisplayMode?: 'clock' | 'relative'
|
||||||
|
dateTime?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
delete: [eventToken: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const displayTime = computed(() => {
|
||||||
|
if (props.timeDisplayMode === 'clock' && props.dateTime) {
|
||||||
|
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(new Date(props.dateTime))
|
||||||
|
}
|
||||||
|
return props.relativeTime
|
||||||
|
})
|
||||||
|
|
||||||
|
const SWIPE_THRESHOLD = 80
|
||||||
|
|
||||||
|
const startX = ref(0)
|
||||||
|
const deltaX = ref(0)
|
||||||
|
const isSwiping = ref(false)
|
||||||
|
|
||||||
|
const swipeStyle = computed(() => {
|
||||||
|
if (deltaX.value === 0) return {}
|
||||||
|
return { transform: `translateX(${deltaX.value}px)` }
|
||||||
|
})
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
const touch = e.touches[0]
|
||||||
|
if (!touch) return
|
||||||
|
startX.value = touch.clientX
|
||||||
|
deltaX.value = 0
|
||||||
|
isSwiping.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
const touch = e.touches[0]
|
||||||
|
if (!touch) return
|
||||||
|
const diff = touch.clientX - startX.value
|
||||||
|
// Only allow leftward swipe
|
||||||
|
if (diff < 0) {
|
||||||
|
deltaX.value = diff
|
||||||
|
isSwiping.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd() {
|
||||||
|
if (deltaX.value < -SWIPE_THRESHOLD) {
|
||||||
|
emit('delete', props.eventToken)
|
||||||
|
}
|
||||||
|
deltaX.value = 0
|
||||||
|
isSwiping.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.event-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-card);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card--past {
|
||||||
|
opacity: 0.6;
|
||||||
|
filter: saturate(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card:not(.event-card--swiping) {
|
||||||
|
transition: opacity 0.2s ease, filter 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__link {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__time {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__badge--organizer {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__badge--attendee {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__delete {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #bbb;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: color 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__delete:hover {
|
||||||
|
color: #d32f2f;
|
||||||
|
background: rgba(211, 47, 47, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__delete:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
94
frontend/src/components/EventList.vue
Normal file
94
frontend/src/components/EventList.vue
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<div class="event-list">
|
||||||
|
<section
|
||||||
|
v-for="section in groupedSections"
|
||||||
|
:key="section.key"
|
||||||
|
:aria-label="section.label"
|
||||||
|
class="event-section"
|
||||||
|
>
|
||||||
|
<SectionHeader :label="section.label" :emphasized="section.emphasized" />
|
||||||
|
<div role="list">
|
||||||
|
<template v-for="group in section.dateGroups" :key="group.dateKey">
|
||||||
|
<DateSubheader v-if="group.showSubheader" :label="group.label" />
|
||||||
|
<div v-for="event in group.events" :key="event.eventToken" role="listitem">
|
||||||
|
<EventCard
|
||||||
|
:event-token="event.eventToken"
|
||||||
|
:title="event.title"
|
||||||
|
:relative-time="formatRelativeTime(event.dateTime)"
|
||||||
|
:is-past="section.key === 'past'"
|
||||||
|
:event-role="getRole(event)"
|
||||||
|
:time-display-mode="section.key === 'past' ? 'relative' : 'clock'"
|
||||||
|
:date-time="event.dateTime"
|
||||||
|
@delete="requestDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="!!pendingDeleteToken"
|
||||||
|
title="Remove event?"
|
||||||
|
message="This event will be removed from your list."
|
||||||
|
confirm-label="Remove"
|
||||||
|
cancel-label="Cancel"
|
||||||
|
@confirm="confirmDelete"
|
||||||
|
@cancel="cancelDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useEventStorage, isValidStoredEvent } from '../composables/useEventStorage'
|
||||||
|
import { useEventGrouping } from '../composables/useEventGrouping'
|
||||||
|
import { formatRelativeTime } from '../composables/useRelativeTime'
|
||||||
|
import EventCard from './EventCard.vue'
|
||||||
|
import SectionHeader from './SectionHeader.vue'
|
||||||
|
import DateSubheader from './DateSubheader.vue'
|
||||||
|
import ConfirmDialog from './ConfirmDialog.vue'
|
||||||
|
import type { StoredEvent } from '../composables/useEventStorage'
|
||||||
|
|
||||||
|
const { getStoredEvents, removeEvent } = useEventStorage()
|
||||||
|
|
||||||
|
const pendingDeleteToken = ref<string | null>(null)
|
||||||
|
|
||||||
|
function requestDelete(eventToken: string) {
|
||||||
|
pendingDeleteToken.value = eventToken
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
if (pendingDeleteToken.value) {
|
||||||
|
removeEvent(pendingDeleteToken.value)
|
||||||
|
}
|
||||||
|
pendingDeleteToken.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDelete() {
|
||||||
|
pendingDeleteToken.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRole(event: StoredEvent): 'organizer' | 'attendee' | undefined {
|
||||||
|
if (event.organizerToken) return 'organizer'
|
||||||
|
if (event.rsvpToken) return 'attendee'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedSections = computed(() => {
|
||||||
|
const valid = getStoredEvents().filter(isValidStoredEvent)
|
||||||
|
return useEventGrouping(valid)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.event-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-section [role="list"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
75
frontend/src/components/RsvpBar.vue
Normal file
75
frontend/src/components/RsvpBar.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rsvp-bar">
|
||||||
|
<div class="rsvp-bar__inner">
|
||||||
|
<!-- Status state: already RSVPed -->
|
||||||
|
<div v-if="hasRsvp" class="rsvp-bar__status">
|
||||||
|
<span class="rsvp-bar__check" aria-hidden="true">✓</span>
|
||||||
|
<span class="rsvp-bar__text">You're attending!</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA state: no RSVP yet -->
|
||||||
|
<button v-else class="btn-primary rsvp-bar__cta" type="button" @click="$emit('open')">
|
||||||
|
I'm attending
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
hasRsvp?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
open: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rsvp-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
padding: var(--spacing-md) var(--content-padding);
|
||||||
|
padding-bottom: calc(var(--spacing-md) + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__inner {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--content-max-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__cta {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
background: var(--color-card);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__check {
|
||||||
|
color: #4caf50;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
27
frontend/src/components/SectionHeader.vue
Normal file
27
frontend/src/components/SectionHeader.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<h2 class="section-header" :class="{ 'section-header--emphasized': emphasized }">
|
||||||
|
{{ label }}
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
emphasized?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.section-header {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--spacing-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header--emphasized {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
50
frontend/src/components/__tests__/AttendeeList.spec.ts
Normal file
50
frontend/src/components/__tests__/AttendeeList.spec.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
51
frontend/src/components/__tests__/BottomSheet.spec.ts
Normal file
51
frontend/src/components/__tests__/BottomSheet.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import BottomSheet from '../BottomSheet.vue'
|
||||||
|
|
||||||
|
function mountSheet(open = true) {
|
||||||
|
return mount(BottomSheet, {
|
||||||
|
props: { open, label: 'Test Sheet' },
|
||||||
|
slots: { default: '<p>Sheet content</p>' },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BottomSheet', () => {
|
||||||
|
it('renders slot content when open', () => {
|
||||||
|
const wrapper = mountSheet(true)
|
||||||
|
expect(document.body.textContent).toContain('Sheet content')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render content when closed', () => {
|
||||||
|
const wrapper = mountSheet(false)
|
||||||
|
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has aria-modal and aria-label on the dialog', () => {
|
||||||
|
const wrapper = mountSheet(true)
|
||||||
|
const dialog = document.body.querySelector('[role="dialog"]')!
|
||||||
|
expect(dialog.getAttribute('aria-modal')).toBe('true')
|
||||||
|
expect(dialog.getAttribute('aria-label')).toBe('Test Sheet')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits close when backdrop is clicked', async () => {
|
||||||
|
const wrapper = mountSheet(true)
|
||||||
|
const backdrop = document.body.querySelector('.sheet-backdrop')! as HTMLElement
|
||||||
|
await backdrop.click()
|
||||||
|
// Vue test utils tracks emitted events on the wrapper
|
||||||
|
expect(wrapper.emitted('close')).toBeTruthy()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits close on Escape key', async () => {
|
||||||
|
const wrapper = mountSheet(true)
|
||||||
|
const backdrop = document.body.querySelector('.sheet-backdrop')! as HTMLElement
|
||||||
|
backdrop.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.emitted('close')).toBeTruthy()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
111
frontend/src/components/__tests__/ConfirmDialog.spec.ts
Normal file
111
frontend/src/components/__tests__/ConfirmDialog.spec.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
17
frontend/src/components/__tests__/DateSubheader.spec.ts
Normal file
17
frontend/src/components/__tests__/DateSubheader.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
35
frontend/src/components/__tests__/EmptyState.spec.ts
Normal file
35
frontend/src/components/__tests__/EmptyState.spec.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
100
frontend/src/components/__tests__/EventCard.spec.ts
Normal file
100
frontend/src/components/__tests__/EventCard.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import EventCard from '../EventCard.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', component: { template: '<div />' } },
|
||||||
|
{ path: '/events/:eventToken', name: 'event', component: { template: '<div />' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
function mountCard(props: Record<string, unknown> = {}) {
|
||||||
|
return mount(EventCard, {
|
||||||
|
props: {
|
||||||
|
eventToken: 'abc-123',
|
||||||
|
title: 'Birthday Party',
|
||||||
|
relativeTime: 'in 3 days',
|
||||||
|
isPast: false,
|
||||||
|
...props,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
plugins: [router],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EventCard', () => {
|
||||||
|
it('renders the event title', () => {
|
||||||
|
const wrapper = mountCard()
|
||||||
|
expect(wrapper.text()).toContain('Birthday Party')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders relative time', () => {
|
||||||
|
const wrapper = mountCard({ relativeTime: 'yesterday' })
|
||||||
|
expect(wrapper.text()).toContain('yesterday')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links to the event detail page', () => {
|
||||||
|
const wrapper = mountCard({ eventToken: 'xyz-789' })
|
||||||
|
const link = wrapper.find('a')
|
||||||
|
expect(link.attributes('href')).toBe('/events/xyz-789')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies past modifier class when isPast is true', () => {
|
||||||
|
const wrapper = mountCard({ isPast: true })
|
||||||
|
expect(wrapper.find('.event-card--past').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not apply past modifier class when isPast is false', () => {
|
||||||
|
const wrapper = mountCard({ isPast: false })
|
||||||
|
expect(wrapper.find('.event-card--past').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders organizer badge when eventRole is organizer', () => {
|
||||||
|
const wrapper = mountCard({ eventRole: 'organizer' })
|
||||||
|
expect(wrapper.text()).toContain('Organizer')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders attendee badge when eventRole is attendee', () => {
|
||||||
|
const wrapper = mountCard({ eventRole: 'attendee' })
|
||||||
|
expect(wrapper.text()).toContain('Attendee')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders no badge when eventRole is undefined', () => {
|
||||||
|
const wrapper = mountCard({ eventRole: undefined })
|
||||||
|
expect(wrapper.find('.event-card__badge').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits delete event with eventToken when delete button is clicked', async () => {
|
||||||
|
const wrapper = mountCard({ eventToken: 'abc-123' })
|
||||||
|
await wrapper.find('.event-card__delete').trigger('click')
|
||||||
|
expect(wrapper.emitted('delete')).toEqual([['abc-123']])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays clock time when timeDisplayMode is clock', () => {
|
||||||
|
const wrapper = mountCard({
|
||||||
|
timeDisplayMode: 'clock',
|
||||||
|
dateTime: '2026-03-11T18:30:00',
|
||||||
|
})
|
||||||
|
const timeText = wrapper.find('.event-card__time').text()
|
||||||
|
// Locale-dependent: could be "18:30" or "06:30 PM"
|
||||||
|
expect(timeText).toMatch(/(?:18.30|6.30\s*PM)/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays relative time when timeDisplayMode is relative', () => {
|
||||||
|
const wrapper = mountCard({
|
||||||
|
relativeTime: '3 days ago',
|
||||||
|
timeDisplayMode: 'relative',
|
||||||
|
dateTime: '2026-03-08T10:00:00',
|
||||||
|
})
|
||||||
|
expect(wrapper.find('.event-card__time').text()).toBe('3 days ago')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to relativeTime when timeDisplayMode is not set', () => {
|
||||||
|
const wrapper = mountCard({ relativeTime: 'in 3 days' })
|
||||||
|
expect(wrapper.find('.event-card__time').text()).toBe('in 3 days')
|
||||||
|
})
|
||||||
|
})
|
||||||
140
frontend/src/components/__tests__/EventList.spec.ts
Normal file
140
frontend/src/components/__tests__/EventList.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import EventList from '../EventList.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', component: { template: '<div />' } },
|
||||||
|
{ path: '/events/:eventToken', name: 'event', component: { template: '<div />' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fixed "now": Wednesday, 2026-03-11 12:00
|
||||||
|
const NOW = new Date(2026, 2, 11, 12, 0, 0)
|
||||||
|
|
||||||
|
const mockEvents = [
|
||||||
|
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2026-03-01T10:00:00', expiryDate: '' },
|
||||||
|
{ eventToken: 'later-1', title: 'Later Event', dateTime: '2027-06-15T10:00:00', expiryDate: '' },
|
||||||
|
{ eventToken: 'today-1', title: 'Today Event', dateTime: '2026-03-11T18:00:00', expiryDate: '' },
|
||||||
|
{ eventToken: 'week-1', title: 'This Week Event', dateTime: '2026-03-13T10:00:00', expiryDate: '' },
|
||||||
|
{ eventToken: 'nextweek-1', title: 'Next Week Event', dateTime: '2026-03-16T10:00:00', expiryDate: '' },
|
||||||
|
]
|
||||||
|
|
||||||
|
vi.mock('../../composables/useEventStorage', () => ({
|
||||||
|
isValidStoredEvent: (e: unknown) => {
|
||||||
|
if (typeof e !== 'object' || e === null) return false
|
||||||
|
const obj = e as Record<string, unknown>
|
||||||
|
return typeof obj.eventToken === 'string' && obj.eventToken.length > 0
|
||||||
|
&& typeof obj.title === 'string' && obj.title.length > 0
|
||||||
|
&& typeof obj.dateTime === 'string' && obj.dateTime.length > 0
|
||||||
|
},
|
||||||
|
useEventStorage: () => ({
|
||||||
|
getStoredEvents: () => mockEvents,
|
||||||
|
removeEvent: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../composables/useRelativeTime', () => ({
|
||||||
|
formatRelativeTime: (dateTime: string) => {
|
||||||
|
if (dateTime.includes('03-01')) return '10 days ago'
|
||||||
|
if (dateTime.includes('06-15')) return 'in 1 year'
|
||||||
|
if (dateTime.includes('03-11')) return 'in 6 hours'
|
||||||
|
if (dateTime.includes('03-13')) return 'in 2 days'
|
||||||
|
if (dateTime.includes('03-16')) return 'in 5 days'
|
||||||
|
return 'sometime'
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
function mountList() {
|
||||||
|
return mount(EventList, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EventList', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(NOW)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders section headers for each non-empty section', () => {
|
||||||
|
const wrapper = mountList()
|
||||||
|
const headers = wrapper.findAll('.section-header')
|
||||||
|
expect(headers).toHaveLength(5)
|
||||||
|
expect(headers[0]!.text()).toBe('Today')
|
||||||
|
expect(headers[1]!.text()).toBe('This Week')
|
||||||
|
expect(headers[2]!.text()).toBe('Next Week')
|
||||||
|
expect(headers[3]!.text()).toBe('Later')
|
||||||
|
expect(headers[4]!.text()).toBe('Past')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders events within their correct sections', () => {
|
||||||
|
const wrapper = mountList()
|
||||||
|
const sections = wrapper.findAll('.event-section')
|
||||||
|
expect(sections).toHaveLength(5)
|
||||||
|
|
||||||
|
expect(sections[0]!.text()).toContain('Today Event')
|
||||||
|
expect(sections[1]!.text()).toContain('This Week Event')
|
||||||
|
expect(sections[2]!.text()).toContain('Next Week Event')
|
||||||
|
expect(sections[3]!.text()).toContain('Later Event')
|
||||||
|
expect(sections[4]!.text()).toContain('Past Event')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all valid events as cards', () => {
|
||||||
|
const wrapper = mountList()
|
||||||
|
const cards = wrapper.findAll('.event-card')
|
||||||
|
expect(cards).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks past events with isPast class', () => {
|
||||||
|
const wrapper = mountList()
|
||||||
|
const pastSection = wrapper.findAll('.event-section')[4]!
|
||||||
|
const pastCards = pastSection.findAll('.event-card')
|
||||||
|
expect(pastCards).toHaveLength(1)
|
||||||
|
expect(pastCards[0]!.classes()).toContain('event-card--past')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not mark non-past events with isPast class', () => {
|
||||||
|
const wrapper = mountList()
|
||||||
|
const todaySection = wrapper.findAll('.event-section')[0]!
|
||||||
|
const cards = todaySection.findAll('.event-card')
|
||||||
|
expect(cards[0]!.classes()).not.toContain('event-card--past')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sections have aria-label attributes', () => {
|
||||||
|
const wrapper = mountList()
|
||||||
|
const sections = wrapper.findAll('section')
|
||||||
|
expect(sections[0]!.attributes('aria-label')).toBe('Today')
|
||||||
|
expect(sections[1]!.attributes('aria-label')).toBe('This Week')
|
||||||
|
expect(sections[2]!.attributes('aria-label')).toBe('Next Week')
|
||||||
|
expect(sections[3]!.attributes('aria-label')).toBe('Later')
|
||||||
|
expect(sections[4]!.attributes('aria-label')).toBe('Past')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render date subheader in "Today" section', () => {
|
||||||
|
const wrapper = mountList()
|
||||||
|
const todaySection = wrapper.findAll('.event-section')[0]!
|
||||||
|
expect(todaySection.find('.date-subheader').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders date subheaders in non-today sections', () => {
|
||||||
|
const wrapper = mountList()
|
||||||
|
const thisWeekSection = wrapper.findAll('.event-section')[1]!
|
||||||
|
expect(thisWeekSection.find('.date-subheader').exists()).toBe(true)
|
||||||
|
|
||||||
|
const nextWeekSection = wrapper.findAll('.event-section')[2]!
|
||||||
|
expect(nextWeekSection.find('.date-subheader').exists()).toBe(true)
|
||||||
|
|
||||||
|
const laterSection = wrapper.findAll('.event-section')[3]!
|
||||||
|
expect(laterSection.find('.date-subheader').exists()).toBe(true)
|
||||||
|
|
||||||
|
const pastSection = wrapper.findAll('.event-section')[4]!
|
||||||
|
expect(pastSection.find('.date-subheader').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
30
frontend/src/components/__tests__/RsvpBar.spec.ts
Normal file
30
frontend/src/components/__tests__/RsvpBar.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import RsvpBar from '../RsvpBar.vue'
|
||||||
|
|
||||||
|
describe('RsvpBar', () => {
|
||||||
|
it('renders CTA button when hasRsvp is false', () => {
|
||||||
|
const wrapper = mount(RsvpBar)
|
||||||
|
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending")
|
||||||
|
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders status text when hasRsvp is true', () => {
|
||||||
|
const wrapper = mount(RsvpBar, { props: { hasRsvp: true } })
|
||||||
|
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
|
||||||
|
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits open when CTA button is clicked', async () => {
|
||||||
|
const wrapper = mount(RsvpBar)
|
||||||
|
await wrapper.find('.rsvp-bar__cta').trigger('click')
|
||||||
|
expect(wrapper.emitted('open')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render CTA button when hasRsvp is true', () => {
|
||||||
|
const wrapper = mount(RsvpBar, { props: { hasRsvp: true } })
|
||||||
|
expect(wrapper.find('button').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
27
frontend/src/components/__tests__/SectionHeader.spec.ts
Normal file
27
frontend/src/components/__tests__/SectionHeader.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
158
frontend/src/components/__tests__/useEventGrouping.spec.ts
Normal file
158
frontend/src/components/__tests__/useEventGrouping.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { useEventGrouping } from '../../composables/useEventGrouping'
|
||||||
|
import type { StoredEvent } from '../../composables/useEventStorage'
|
||||||
|
|
||||||
|
function makeEvent(overrides: Partial<StoredEvent> & { dateTime: string }): StoredEvent {
|
||||||
|
return {
|
||||||
|
eventToken: `evt-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
title: 'Test Event',
|
||||||
|
expiryDate: '',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useEventGrouping', () => {
|
||||||
|
// Fixed "now": Wednesday, 2026-03-11 12:00 local
|
||||||
|
const NOW = new Date(2026, 2, 11, 12, 0, 0)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(NOW)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array when no events', () => {
|
||||||
|
const sections = useEventGrouping([], NOW)
|
||||||
|
expect(sections).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('classifies a today event into "today" section', () => {
|
||||||
|
const event = makeEvent({ dateTime: '2026-03-11T18:30:00' })
|
||||||
|
const sections = useEventGrouping([event], NOW)
|
||||||
|
expect(sections).toHaveLength(1)
|
||||||
|
expect(sections[0]!.key).toBe('today')
|
||||||
|
expect(sections[0]!.label).toBe('Today')
|
||||||
|
expect(sections[0]!.dateGroups[0]!.events).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('classifies events into all five sections', () => {
|
||||||
|
const events = [
|
||||||
|
makeEvent({ title: 'Today', dateTime: '2026-03-11T10:00:00' }),
|
||||||
|
makeEvent({ title: 'This Week', dateTime: '2026-03-13T10:00:00' }), // Friday (same week)
|
||||||
|
makeEvent({ title: 'Next Week', dateTime: '2026-03-16T10:00:00' }), // Monday next week
|
||||||
|
makeEvent({ title: 'Later', dateTime: '2026-03-30T10:00:00' }), // far future
|
||||||
|
makeEvent({ title: 'Past', dateTime: '2026-03-09T10:00:00' }), // Monday (past)
|
||||||
|
]
|
||||||
|
const sections = useEventGrouping(events, NOW)
|
||||||
|
expect(sections).toHaveLength(5)
|
||||||
|
expect(sections[0]!.key).toBe('today')
|
||||||
|
expect(sections[1]!.key).toBe('thisWeek')
|
||||||
|
expect(sections[2]!.key).toBe('nextWeek')
|
||||||
|
expect(sections[3]!.key).toBe('later')
|
||||||
|
expect(sections[4]!.key).toBe('past')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits empty sections', () => {
|
||||||
|
const events = [
|
||||||
|
makeEvent({ title: 'Today', dateTime: '2026-03-11T10:00:00' }),
|
||||||
|
makeEvent({ title: 'Past', dateTime: '2026-03-01T10:00:00' }),
|
||||||
|
]
|
||||||
|
const sections = useEventGrouping(events, NOW)
|
||||||
|
expect(sections).toHaveLength(2)
|
||||||
|
expect(sections[0]!.key).toBe('today')
|
||||||
|
expect(sections[1]!.key).toBe('past')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts upcoming events ascending by time', () => {
|
||||||
|
const events = [
|
||||||
|
makeEvent({ title: 'Later', dateTime: '2026-03-11T20:00:00' }),
|
||||||
|
makeEvent({ title: 'Earlier', dateTime: '2026-03-11T08:00:00' }),
|
||||||
|
]
|
||||||
|
const sections = useEventGrouping(events, NOW)
|
||||||
|
const todayEvents = sections[0]!.dateGroups[0]!.events
|
||||||
|
expect(todayEvents[0]!.title).toBe('Earlier')
|
||||||
|
expect(todayEvents[1]!.title).toBe('Later')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts past events descending by time (most recent first)', () => {
|
||||||
|
const events = [
|
||||||
|
makeEvent({ title: 'Older', dateTime: '2026-03-01T10:00:00' }),
|
||||||
|
makeEvent({ title: 'Newer', dateTime: '2026-03-09T10:00:00' }),
|
||||||
|
]
|
||||||
|
const sections = useEventGrouping(events, NOW)
|
||||||
|
const pastEvents = sections[0]!.dateGroups
|
||||||
|
expect(pastEvents[0]!.events[0]!.title).toBe('Newer')
|
||||||
|
expect(pastEvents[1]!.events[0]!.title).toBe('Older')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('groups events by date within a section', () => {
|
||||||
|
const events = [
|
||||||
|
makeEvent({ title: 'Fri AM', dateTime: '2026-03-13T09:00:00' }),
|
||||||
|
makeEvent({ title: 'Fri PM', dateTime: '2026-03-13T18:00:00' }),
|
||||||
|
makeEvent({ title: 'Sat', dateTime: '2026-03-14T12:00:00' }),
|
||||||
|
]
|
||||||
|
const sections = useEventGrouping(events, NOW)
|
||||||
|
expect(sections[0]!.key).toBe('thisWeek')
|
||||||
|
const dateGroups = sections[0]!.dateGroups
|
||||||
|
expect(dateGroups).toHaveLength(2) // Friday and Saturday
|
||||||
|
expect(dateGroups[0]!.events).toHaveLength(2) // Two Friday events
|
||||||
|
expect(dateGroups[1]!.events).toHaveLength(1) // One Saturday event
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets showSubheader=false for "today" section', () => {
|
||||||
|
const event = makeEvent({ dateTime: '2026-03-11T18:00:00' })
|
||||||
|
const sections = useEventGrouping([event], NOW)
|
||||||
|
expect(sections[0]!.dateGroups[0]!.showSubheader).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets showSubheader=true for non-today sections', () => {
|
||||||
|
const events = [
|
||||||
|
makeEvent({ dateTime: '2026-03-13T10:00:00' }), // thisWeek
|
||||||
|
makeEvent({ dateTime: '2026-03-30T10:00:00' }), // later (beyond next week)
|
||||||
|
makeEvent({ dateTime: '2026-03-01T10:00:00' }), // past
|
||||||
|
]
|
||||||
|
const sections = useEventGrouping(events, NOW)
|
||||||
|
for (const section of sections) {
|
||||||
|
for (const group of section.dateGroups) {
|
||||||
|
expect(group.showSubheader).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets emphasized=true only for "today" section', () => {
|
||||||
|
const events = [
|
||||||
|
makeEvent({ dateTime: '2026-03-11T18:00:00' }),
|
||||||
|
makeEvent({ dateTime: '2026-03-30T10:00:00' }),
|
||||||
|
]
|
||||||
|
const sections = useEventGrouping(events, NOW)
|
||||||
|
expect(sections[0]!.emphasized).toBe(true) // today
|
||||||
|
expect(sections[1]!.emphasized).toBe(false) // later
|
||||||
|
})
|
||||||
|
|
||||||
|
it('on Sunday, tomorrow (Monday) goes to "nextWeek" not "thisWeek"', () => {
|
||||||
|
// Sunday 2026-03-15
|
||||||
|
const sunday = new Date(2026, 2, 15, 12, 0, 0)
|
||||||
|
const mondayEvent = makeEvent({ title: 'Monday', dateTime: '2026-03-16T10:00:00' })
|
||||||
|
const sections = useEventGrouping([mondayEvent], sunday)
|
||||||
|
expect(sections).toHaveLength(1)
|
||||||
|
expect(sections[0]!.key).toBe('nextWeek')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('on Sunday, today events still appear under "today"', () => {
|
||||||
|
const sunday = new Date(2026, 2, 15, 12, 0, 0)
|
||||||
|
const todayEvent = makeEvent({ dateTime: '2026-03-15T18:00:00' })
|
||||||
|
const sections = useEventGrouping([todayEvent], sunday)
|
||||||
|
expect(sections[0]!.key).toBe('today')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dateGroup labels are formatted via Intl', () => {
|
||||||
|
const event = makeEvent({ dateTime: '2026-03-13T10:00:00' }) // Friday
|
||||||
|
const sections = useEventGrouping([event], NOW)
|
||||||
|
const label = sections[0]!.dateGroups[0]!.label
|
||||||
|
// The exact format depends on locale, but should contain the day number
|
||||||
|
expect(label).toContain('13')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -116,4 +116,168 @@ describe('useEventStorage', () => {
|
|||||||
expect(events).toHaveLength(1)
|
expect(events).toHaveLength(1)
|
||||||
expect(events[0]!.title).toBe('New Title')
|
expect(events[0]!.title).toBe('New Title')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('saves and retrieves RSVP for an existing event', () => {
|
||||||
|
const { saveCreatedEvent, saveRsvp, getRsvp } = useEventStorage()
|
||||||
|
|
||||||
|
saveCreatedEvent({
|
||||||
|
eventToken: 'abc-123',
|
||||||
|
title: 'Birthday',
|
||||||
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
|
expiryDate: '2026-07-15',
|
||||||
|
})
|
||||||
|
|
||||||
|
saveRsvp('abc-123', 'rsvp-token-1', 'Max', 'Birthday', '2026-06-15T20:00:00+02:00')
|
||||||
|
|
||||||
|
const rsvp = getRsvp('abc-123')
|
||||||
|
expect(rsvp).toEqual({ rsvpToken: 'rsvp-token-1', rsvpName: 'Max' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves RSVP for a new event (not previously stored)', () => {
|
||||||
|
const { saveRsvp, getRsvp, getStoredEvents } = useEventStorage()
|
||||||
|
|
||||||
|
saveRsvp('new-event', 'rsvp-token-2', 'Anna', 'Party', '2026-08-01T18:00:00+02:00')
|
||||||
|
|
||||||
|
const rsvp = getRsvp('new-event')
|
||||||
|
expect(rsvp).toEqual({ rsvpToken: 'rsvp-token-2', rsvpName: 'Anna' })
|
||||||
|
|
||||||
|
const events = getStoredEvents()
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
expect(events[0]!.eventToken).toBe('new-event')
|
||||||
|
expect(events[0]!.title).toBe('Party')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined RSVP for event without RSVP', () => {
|
||||||
|
const { saveCreatedEvent, getRsvp } = useEventStorage()
|
||||||
|
|
||||||
|
saveCreatedEvent({
|
||||||
|
eventToken: 'abc-123',
|
||||||
|
title: 'Test',
|
||||||
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
|
expiryDate: '2026-07-15',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getRsvp('abc-123')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined RSVP for unknown event', () => {
|
||||||
|
const { getRsvp } = useEventStorage()
|
||||||
|
expect(getRsvp('unknown')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes an event by token', () => {
|
||||||
|
const { saveCreatedEvent, getStoredEvents, removeEvent } = useEventStorage()
|
||||||
|
|
||||||
|
saveCreatedEvent({
|
||||||
|
eventToken: 'event-1',
|
||||||
|
title: 'First',
|
||||||
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
|
expiryDate: '2026-07-15',
|
||||||
|
})
|
||||||
|
|
||||||
|
saveCreatedEvent({
|
||||||
|
eventToken: 'event-2',
|
||||||
|
title: 'Second',
|
||||||
|
dateTime: '2026-07-15T20:00:00+02:00',
|
||||||
|
expiryDate: '2026-08-15',
|
||||||
|
})
|
||||||
|
|
||||||
|
removeEvent('event-1')
|
||||||
|
|
||||||
|
const events = getStoredEvents()
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
expect(events[0]!.eventToken).toBe('event-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removeEvent does nothing for unknown token', () => {
|
||||||
|
const { saveCreatedEvent, getStoredEvents, removeEvent } = useEventStorage()
|
||||||
|
|
||||||
|
saveCreatedEvent({
|
||||||
|
eventToken: 'event-1',
|
||||||
|
title: 'First',
|
||||||
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
|
expiryDate: '2026-07-15',
|
||||||
|
})
|
||||||
|
|
||||||
|
removeEvent('nonexistent')
|
||||||
|
|
||||||
|
expect(getStoredEvents()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isValidStoredEvent', () => {
|
||||||
|
// Import directly since it's an exported function
|
||||||
|
let isValidStoredEvent: (e: unknown) => boolean
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const mod = await import('../useEventStorage')
|
||||||
|
isValidStoredEvent = mod.isValidStoredEvent
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true for a valid event', () => {
|
||||||
|
expect(
|
||||||
|
isValidStoredEvent({
|
||||||
|
eventToken: 'abc-123',
|
||||||
|
title: 'Birthday',
|
||||||
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
|
expiryDate: '2026-07-15',
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for null', () => {
|
||||||
|
expect(isValidStoredEvent(null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for non-object', () => {
|
||||||
|
expect(isValidStoredEvent('string')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when eventToken is missing', () => {
|
||||||
|
expect(
|
||||||
|
isValidStoredEvent({
|
||||||
|
title: 'Birthday',
|
||||||
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when eventToken is empty', () => {
|
||||||
|
expect(
|
||||||
|
isValidStoredEvent({
|
||||||
|
eventToken: '',
|
||||||
|
title: 'Birthday',
|
||||||
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when title is missing', () => {
|
||||||
|
expect(
|
||||||
|
isValidStoredEvent({
|
||||||
|
eventToken: 'abc-123',
|
||||||
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when dateTime is invalid', () => {
|
||||||
|
expect(
|
||||||
|
isValidStoredEvent({
|
||||||
|
eventToken: 'abc-123',
|
||||||
|
title: 'Birthday',
|
||||||
|
dateTime: 'not-a-date',
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when dateTime is empty', () => {
|
||||||
|
expect(
|
||||||
|
isValidStoredEvent({
|
||||||
|
eventToken: 'abc-123',
|
||||||
|
title: 'Birthday',
|
||||||
|
dateTime: '',
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
72
frontend/src/composables/__tests__/useRelativeTime.spec.ts
Normal file
72
frontend/src/composables/__tests__/useRelativeTime.spec.ts
Normal 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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
149
frontend/src/composables/useEventGrouping.ts
Normal file
149
frontend/src/composables/useEventGrouping.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -4,10 +4,30 @@ export interface StoredEvent {
|
|||||||
title: string
|
title: string
|
||||||
dateTime: string
|
dateTime: string
|
||||||
expiryDate: string
|
expiryDate: string
|
||||||
|
rsvpToken?: string
|
||||||
|
rsvpName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const STORAGE_KEY = 'fete:events'
|
const STORAGE_KEY = 'fete:events'
|
||||||
|
|
||||||
|
const version = ref(0)
|
||||||
|
|
||||||
|
export function isValidStoredEvent(e: unknown): e is StoredEvent {
|
||||||
|
if (typeof e !== 'object' || e === null) return false
|
||||||
|
const obj = e as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
typeof obj.eventToken === 'string' &&
|
||||||
|
obj.eventToken.length > 0 &&
|
||||||
|
typeof obj.title === 'string' &&
|
||||||
|
obj.title.length > 0 &&
|
||||||
|
typeof obj.dateTime === 'string' &&
|
||||||
|
obj.dateTime.length > 0 &&
|
||||||
|
!isNaN(new Date(obj.dateTime).getTime())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function readEvents(): StoredEvent[] {
|
function readEvents(): StoredEvent[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
@@ -19,6 +39,7 @@ function readEvents(): StoredEvent[] {
|
|||||||
|
|
||||||
function writeEvents(events: StoredEvent[]): void {
|
function writeEvents(events: StoredEvent[]): void {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(events))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(events))
|
||||||
|
version.value++
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEventStorage() {
|
export function useEventStorage() {
|
||||||
@@ -29,6 +50,7 @@ export function useEventStorage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getStoredEvents(): StoredEvent[] {
|
function getStoredEvents(): StoredEvent[] {
|
||||||
|
void version.value
|
||||||
return readEvents()
|
return readEvents()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,5 +59,30 @@ export function useEventStorage() {
|
|||||||
return event?.organizerToken
|
return event?.organizerToken
|
||||||
}
|
}
|
||||||
|
|
||||||
return { saveCreatedEvent, getStoredEvents, getOrganizerToken }
|
function saveRsvp(eventToken: string, rsvpToken: string, rsvpName: string, title: string, dateTime: string): void {
|
||||||
|
const events = readEvents()
|
||||||
|
const existing = events.find((e) => e.eventToken === eventToken)
|
||||||
|
if (existing) {
|
||||||
|
existing.rsvpToken = rsvpToken
|
||||||
|
existing.rsvpName = rsvpName
|
||||||
|
} else {
|
||||||
|
events.push({ eventToken, title, dateTime, expiryDate: '', rsvpToken, rsvpName })
|
||||||
|
}
|
||||||
|
writeEvents(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRsvp(eventToken: string): { rsvpToken: string; rsvpName: string } | undefined {
|
||||||
|
const event = readEvents().find((e) => e.eventToken === eventToken)
|
||||||
|
if (event?.rsvpToken && event?.rsvpName) {
|
||||||
|
return { rsvpToken: event.rsvpToken, rsvpName: event.rsvpName }
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEvent(eventToken: string): void {
|
||||||
|
const events = readEvents().filter((e) => e.eventToken !== eventToken)
|
||||||
|
writeEvents(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp, removeEvent }
|
||||||
}
|
}
|
||||||
|
|||||||
23
frontend/src/composables/useRelativeTime.ts
Normal file
23
frontend/src/composables/useRelativeTime.ts
Normal 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')
|
||||||
|
}
|
||||||
@@ -15,9 +15,9 @@ const router = createRouter({
|
|||||||
component: () => import('../views/EventCreateView.vue'),
|
component: () => import('../views/EventCreateView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/events/:token',
|
path: '/events/:eventToken',
|
||||||
name: 'event',
|
name: 'event',
|
||||||
component: () => import('../views/EventStubView.vue'),
|
component: () => import('../views/EventDetailView.vue'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ async function handleSubmit() {
|
|||||||
title: form.title.trim(),
|
title: form.title.trim(),
|
||||||
description: form.description.trim() || undefined,
|
description: form.description.trim() || undefined,
|
||||||
dateTime: dateTimeWithOffset,
|
dateTime: dateTimeWithOffset,
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
location: form.location.trim() || undefined,
|
location: form.location.trim() || undefined,
|
||||||
expiryDate: form.expiryDate,
|
expiryDate: form.expiryDate,
|
||||||
},
|
},
|
||||||
@@ -214,7 +215,7 @@ async function handleSubmit() {
|
|||||||
expiryDate: data.expiryDate,
|
expiryDate: data.expiryDate,
|
||||||
})
|
})
|
||||||
|
|
||||||
router.push({ name: 'event', params: { token: data.eventToken } })
|
router.push({ name: 'event', params: { eventToken: data.eventToken } })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
|
|||||||
445
frontend/src/views/EventDetailView.vue
Normal file
445
frontend/src/views/EventDetailView.vue
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
<template>
|
||||||
|
<main class="detail">
|
||||||
|
<!-- Hero image with overlaid header -->
|
||||||
|
<div class="detail__hero">
|
||||||
|
<img
|
||||||
|
class="detail__hero-img"
|
||||||
|
src="@/assets/images/event-hero-placeholder.jpg"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div class="detail__hero-overlay" />
|
||||||
|
<header class="detail__header">
|
||||||
|
<RouterLink to="/" class="detail__back" aria-label="Back to home">←</RouterLink>
|
||||||
|
<span class="detail__brand">fete</span>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail__body">
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="state === 'loading'" class="detail__content" aria-busy="true" aria-label="Loading event details">
|
||||||
|
<div class="skeleton skeleton--title" />
|
||||||
|
<div class="skeleton skeleton--line" />
|
||||||
|
<div class="skeleton skeleton--line skeleton--short" />
|
||||||
|
<div class="skeleton skeleton--line" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loaded state -->
|
||||||
|
<div v-else-if="state === 'loaded' && event" class="detail__content">
|
||||||
|
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
|
||||||
|
This event has ended.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="detail__title">{{ event.title }}</h1>
|
||||||
|
|
||||||
|
<dl class="detail__meta">
|
||||||
|
<div class="detail__meta-item">
|
||||||
|
<dt class="detail__meta-icon" aria-label="Date and time">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||||
|
</dt>
|
||||||
|
<dd class="detail__meta-text">{{ formattedDateTime }}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="event.location" class="detail__meta-item">
|
||||||
|
<dt class="detail__meta-icon" aria-label="Location">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||||
|
</dt>
|
||||||
|
<dd class="detail__meta-text">{{ event.location }}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail__meta-item">
|
||||||
|
<dt class="detail__meta-icon" aria-label="Attendees">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
|
</dt>
|
||||||
|
<dd class="detail__meta-text">{{ event.attendeeCount }} going</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<AttendeeList v-if="isOrganizer && attendeeNames !== null" :attendees="attendeeNames" />
|
||||||
|
|
||||||
|
<div v-if="event.description" class="detail__section">
|
||||||
|
<h2 class="detail__section-title">About</h2>
|
||||||
|
<p class="detail__description">{{ event.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Not found state -->
|
||||||
|
<div v-else-if="state === 'not-found'" class="detail__content detail__content--center" role="status">
|
||||||
|
<p class="detail__message">Event not found.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-else-if="state === 'error'" class="detail__content detail__content--center" role="alert">
|
||||||
|
<p class="detail__message">Something went wrong.</p>
|
||||||
|
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RSVP bar (only for loaded, non-expired events) -->
|
||||||
|
<RsvpBar
|
||||||
|
v-if="state === 'loaded' && event && !event.expired && !isOrganizer"
|
||||||
|
:has-rsvp="!!rsvpName"
|
||||||
|
@open="sheetOpen = true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- RSVP bottom sheet -->
|
||||||
|
<BottomSheet :open="sheetOpen" label="RSVP" @close="sheetOpen = false">
|
||||||
|
<h2 class="sheet-title">RSVP</h2>
|
||||||
|
<form class="rsvp-form" @submit.prevent="submitRsvp" novalidate>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="rsvp-form__label" for="rsvp-name">Your name</label>
|
||||||
|
<input
|
||||||
|
id="rsvp-name"
|
||||||
|
v-model.trim="nameInput"
|
||||||
|
class="form-field"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Max Mustermann"
|
||||||
|
maxlength="100"
|
||||||
|
required
|
||||||
|
@input="nameError = ''"
|
||||||
|
/>
|
||||||
|
<span v-if="nameError" class="rsvp-form__field-error" role="alert">{{ nameError }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" type="submit" :disabled="submitting">
|
||||||
|
{{ submitting ? 'Sending…' : "Count me in" }}
|
||||||
|
</button>
|
||||||
|
<p v-if="submitError" class="rsvp-form__field-error rsvp-form__error" role="alert">{{ submitError }}</p>
|
||||||
|
</form>
|
||||||
|
</BottomSheet>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
|
import { api } from '@/api/client'
|
||||||
|
import { useEventStorage } from '@/composables/useEventStorage'
|
||||||
|
import AttendeeList from '@/components/AttendeeList.vue'
|
||||||
|
import BottomSheet from '@/components/BottomSheet.vue'
|
||||||
|
import RsvpBar from '@/components/RsvpBar.vue'
|
||||||
|
import type { components } from '@/api/schema'
|
||||||
|
|
||||||
|
type GetEventResponse = components['schemas']['GetEventResponse']
|
||||||
|
type State = 'loading' | 'loaded' | 'not-found' | 'error'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { saveRsvp, getRsvp, getOrganizerToken } = useEventStorage()
|
||||||
|
|
||||||
|
const state = ref<State>('loading')
|
||||||
|
const event = ref<GetEventResponse | null>(null)
|
||||||
|
|
||||||
|
// RSVP state
|
||||||
|
const sheetOpen = ref(false)
|
||||||
|
const nameInput = ref('')
|
||||||
|
const nameError = ref('')
|
||||||
|
const submitError = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const rsvpName = ref<string | undefined>(undefined)
|
||||||
|
const isOrganizer = ref(false)
|
||||||
|
const attendeeNames = ref<string[] | null>(null)
|
||||||
|
|
||||||
|
const formattedDateTime = computed(() => {
|
||||||
|
if (!event.value) return ''
|
||||||
|
const formatted = new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'long',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(new Date(event.value.dateTime))
|
||||||
|
return `${formatted} (${event.value.timezone})`
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchEvent() {
|
||||||
|
state.value = 'loading'
|
||||||
|
event.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error, response } = await api.GET('/events/{token}', {
|
||||||
|
params: { path: { token: route.params.eventToken as string } },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
state.value = response.status === 404 ? 'not-found' : 'error'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.value = data!
|
||||||
|
state.value = 'loaded'
|
||||||
|
|
||||||
|
// Check if current user is the organizer
|
||||||
|
const orgToken = getOrganizerToken(event.value.eventToken)
|
||||||
|
isOrganizer.value = !!orgToken
|
||||||
|
|
||||||
|
// Fetch attendee list for organizer
|
||||||
|
if (orgToken) {
|
||||||
|
fetchAttendees(event.value.eventToken, orgToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore RSVP status from localStorage
|
||||||
|
const stored = getRsvp(event.value.eventToken)
|
||||||
|
if (stored) {
|
||||||
|
rsvpName.value = stored.rsvpName
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
state.value = 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRsvp() {
|
||||||
|
nameError.value = ''
|
||||||
|
submitError.value = ''
|
||||||
|
|
||||||
|
if (!nameInput.value) {
|
||||||
|
nameError.value = 'Please enter your name.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nameInput.value.length > 100) {
|
||||||
|
nameError.value = 'Name must be 100 characters or fewer.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error } = await api.POST('/events/{token}/rsvps', {
|
||||||
|
params: { path: { token: route.params.eventToken as string } },
|
||||||
|
body: { name: nameInput.value },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
submitError.value = 'Could not submit RSVP. Please try again.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist RSVP in localStorage
|
||||||
|
saveRsvp(
|
||||||
|
event.value!.eventToken,
|
||||||
|
data!.rsvpToken,
|
||||||
|
data!.name,
|
||||||
|
event.value!.title,
|
||||||
|
event.value!.dateTime,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
rsvpName.value = data!.name
|
||||||
|
event.value!.attendeeCount += 1
|
||||||
|
sheetOpen.value = false
|
||||||
|
nameInput.value = ''
|
||||||
|
} catch {
|
||||||
|
submitError.value = 'Could not submit RSVP. Please try again.'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAttendees(eventToken: string, organizerToken: string) {
|
||||||
|
try {
|
||||||
|
const { data, error } = await api.GET('/events/{token}/attendees', {
|
||||||
|
params: {
|
||||||
|
path: { token: eventToken },
|
||||||
|
query: { organizerToken },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
attendeeNames.value = data!.attendees.map((a) => a.name)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently degrade — don't show attendee list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchEvent)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* Break out of .app-container constraints */
|
||||||
|
width: 100dvw;
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
margin: calc(-1 * var(--content-padding)) 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero image section */
|
||||||
|
.detail__hero {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 260px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__hero-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__hero-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(0, 0, 0, 0.4) 0%,
|
||||||
|
transparent 50%,
|
||||||
|
var(--color-gradient-start) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__header {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-lg) var(--content-padding);
|
||||||
|
padding-top: env(safe-area-inset-top, var(--spacing-lg));
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__back {
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__brand {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__body {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--spacing-lg) var(--content-padding);
|
||||||
|
padding-bottom: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-2xl);
|
||||||
|
max-width: var(--content-max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__content--center {
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title */
|
||||||
|
.detail__title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Meta rows: icon + text */
|
||||||
|
.detail__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__meta-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__meta-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* About section */
|
||||||
|
.detail__section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__section-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__description {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
line-height: 1.6;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expired banner */
|
||||||
|
.detail__banner {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__banner--expired {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error / not-found message */
|
||||||
|
.detail__message {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton – shimmer on gradient */
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.25) 50%, rgba(255, 255, 255, 0.1) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton--title {
|
||||||
|
height: 2rem;
|
||||||
|
width: 70%;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton--line {
|
||||||
|
height: 1rem;
|
||||||
|
width: 85%;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton--short {
|
||||||
|
width: 45%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -27,7 +27,7 @@ const route = useRoute()
|
|||||||
const copyState = ref<'idle' | 'copied' | 'failed'>('idle')
|
const copyState = ref<'idle' | 'copied' | 'failed'>('idle')
|
||||||
|
|
||||||
const eventUrl = computed(() => {
|
const eventUrl = computed(() => {
|
||||||
return window.location.origin + '/events/' + route.params.token
|
return window.location.origin + '/events/' + route.params.eventToken
|
||||||
})
|
})
|
||||||
|
|
||||||
const copyLabel = computed(() => {
|
const copyLabel = computed(() => {
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="home">
|
<main class="home">
|
||||||
<h1 class="home__title">fete</h1>
|
<h1 class="home__title">fete</h1>
|
||||||
<p class="home__subtitle">No events yet.<br />Create your first one!</p>
|
<template v-if="events.length > 0">
|
||||||
<RouterLink to="/create" class="btn-primary home__cta">+ Create Event</RouterLink>
|
<EventList />
|
||||||
|
<CreateEventFab />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<EmptyState />
|
||||||
|
</template>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink } from 'vue-router'
|
import { computed } from 'vue'
|
||||||
|
import { useEventStorage, isValidStoredEvent } from '../composables/useEventStorage'
|
||||||
|
import EventList from '../components/EventList.vue'
|
||||||
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
|
import CreateEventFab from '../components/CreateEventFab.vue'
|
||||||
|
|
||||||
|
const { getStoredEvents } = useEventStorage()
|
||||||
|
|
||||||
|
const events = computed(() => getStoredEvents().filter(isValidStoredEvent))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -15,27 +28,15 @@ import { RouterLink } from 'vue-router'
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--spacing-lg);
|
gap: var(--spacing-lg);
|
||||||
text-align: center;
|
padding-top: var(--spacing-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home__title {
|
.home__title {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--color-text-on-gradient);
|
color: var(--color-text-on-gradient);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home__subtitle {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--color-text-on-gradient);
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home__cta {
|
|
||||||
margin-top: var(--spacing-md);
|
|
||||||
max-width: 280px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ vi.mock('@/composables/useEventStorage', () => ({
|
|||||||
saveCreatedEvent: vi.fn(),
|
saveCreatedEvent: vi.fn(),
|
||||||
getStoredEvents: vi.fn(() => []),
|
getStoredEvents: vi.fn(() => []),
|
||||||
getOrganizerToken: vi.fn(),
|
getOrganizerToken: vi.fn(),
|
||||||
|
saveRsvp: vi.fn(),
|
||||||
|
getRsvp: vi.fn(),
|
||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -23,7 +25,7 @@ function createTestRouter() {
|
|||||||
routes: [
|
routes: [
|
||||||
{ path: '/', name: 'home', component: { template: '<div />' } },
|
{ path: '/', name: 'home', component: { template: '<div />' } },
|
||||||
{ path: '/create', name: 'create-event', component: EventCreateView },
|
{ path: '/create', name: 'create-event', component: EventCreateView },
|
||||||
{ path: '/events/:token', name: 'event', component: { template: '<div />' } },
|
{ path: '/events/:eventToken', name: 'event', component: { template: '<div />' } },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -165,6 +167,9 @@ describe('EventCreateView', () => {
|
|||||||
saveCreatedEvent: mockSave,
|
saveCreatedEvent: mockSave,
|
||||||
getStoredEvents: vi.fn(() => []),
|
getStoredEvents: vi.fn(() => []),
|
||||||
getOrganizerToken: vi.fn(),
|
getOrganizerToken: vi.fn(),
|
||||||
|
saveRsvp: vi.fn(),
|
||||||
|
getRsvp: vi.fn(),
|
||||||
|
removeEvent: vi.fn(),
|
||||||
})
|
})
|
||||||
|
|
||||||
vi.mocked(api.POST).mockResolvedValueOnce({
|
vi.mocked(api.POST).mockResolvedValueOnce({
|
||||||
@@ -173,6 +178,7 @@ describe('EventCreateView', () => {
|
|||||||
organizerToken: 'org-456',
|
organizerToken: 'org-456',
|
||||||
title: 'Birthday Party',
|
title: 'Birthday Party',
|
||||||
dateTime: '2026-12-25T18:00:00+01:00',
|
dateTime: '2026-12-25T18:00:00+01:00',
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
expiryDate: '2026-12-24',
|
expiryDate: '2026-12-24',
|
||||||
},
|
},
|
||||||
error: undefined,
|
error: undefined,
|
||||||
@@ -216,7 +222,7 @@ describe('EventCreateView', () => {
|
|||||||
|
|
||||||
expect(pushSpy).toHaveBeenCalledWith({
|
expect(pushSpy).toHaveBeenCalledWith({
|
||||||
name: 'event',
|
name: 'event',
|
||||||
params: { token: 'abc-123' },
|
params: { eventToken: 'abc-123' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
404
frontend/src/views/__tests__/EventDetailView.spec.ts
Normal file
404
frontend/src/views/__tests__/EventDetailView.spec.ts
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import EventDetailView from '../EventDetailView.vue'
|
||||||
|
import { api } from '@/api/client'
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
api: {
|
||||||
|
GET: vi.fn(),
|
||||||
|
POST: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockSaveRsvp = vi.fn()
|
||||||
|
const mockGetRsvp = vi.fn()
|
||||||
|
const mockGetOrganizerToken = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/composables/useEventStorage', () => ({
|
||||||
|
useEventStorage: vi.fn(() => ({
|
||||||
|
saveCreatedEvent: vi.fn(),
|
||||||
|
getStoredEvents: vi.fn(() => []),
|
||||||
|
getOrganizerToken: mockGetOrganizerToken,
|
||||||
|
saveRsvp: mockSaveRsvp,
|
||||||
|
getRsvp: mockGetRsvp,
|
||||||
|
removeEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function createTestRouter(_token?: string) {
|
||||||
|
return createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', name: 'home', component: { template: '<div />' } },
|
||||||
|
{ path: '/events/:eventToken', name: 'event', component: EventDetailView },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountWithToken(token = 'test-token') {
|
||||||
|
const router = createTestRouter(token)
|
||||||
|
await router.push(`/events/${token}`)
|
||||||
|
await router.isReady()
|
||||||
|
return mount(EventDetailView, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullEvent = {
|
||||||
|
eventToken: 'abc-123',
|
||||||
|
title: 'Summer BBQ',
|
||||||
|
description: 'Bring your own drinks!',
|
||||||
|
dateTime: '2026-03-15T20:00:00+01:00',
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
location: 'Central Park, NYC',
|
||||||
|
attendeeCount: 12,
|
||||||
|
expired: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockLoadedEvent(eventOverrides = {}) {
|
||||||
|
vi.mocked(api.GET).mockResolvedValue({
|
||||||
|
data: { ...fullEvent, ...eventOverrides },
|
||||||
|
error: undefined,
|
||||||
|
response: new Response(null, { status: 200 }),
|
||||||
|
} as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
mockGetRsvp.mockReturnValue(undefined)
|
||||||
|
mockGetOrganizerToken.mockReturnValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('EventDetailView', () => {
|
||||||
|
// Loading state
|
||||||
|
it('renders skeleton shimmer placeholders while loading', async () => {
|
||||||
|
vi.mocked(api.GET).mockReturnValue(new Promise(() => {}))
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
|
||||||
|
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.findAll('.skeleton').length).toBeGreaterThanOrEqual(3)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Loaded state — all fields
|
||||||
|
it('renders all event fields when loaded', async () => {
|
||||||
|
mockLoadedEvent()
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.detail__title').text()).toBe('Summer BBQ')
|
||||||
|
expect(wrapper.text()).toContain('Bring your own drinks!')
|
||||||
|
expect(wrapper.text()).toContain('Central Park, NYC')
|
||||||
|
expect(wrapper.text()).toContain('12')
|
||||||
|
expect(wrapper.text()).toContain('Europe/Berlin')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Loaded state — locale-formatted date/time
|
||||||
|
it('formats date/time with Intl.DateTimeFormat and timezone', async () => {
|
||||||
|
mockLoadedEvent()
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const dateField = wrapper.findAll('.detail__meta-text')[0]!
|
||||||
|
expect(dateField.text()).toContain('(Europe/Berlin)')
|
||||||
|
expect(dateField.text()).toContain('2026')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Loaded state — optional fields absent
|
||||||
|
it('does not render description and location when absent', async () => {
|
||||||
|
mockLoadedEvent({ description: undefined, location: undefined, attendeeCount: 0 })
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('Description')
|
||||||
|
expect(wrapper.text()).not.toContain('Location')
|
||||||
|
expect(wrapper.text()).toContain('0')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Expired state
|
||||||
|
it('renders "event has ended" banner when expired', async () => {
|
||||||
|
mockLoadedEvent({ expired: true })
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('This event has ended.')
|
||||||
|
expect(wrapper.find('.detail__banner--expired').exists()).toBe(true)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
// No expired banner when not expired
|
||||||
|
it('does not render expired banner when event is active', async () => {
|
||||||
|
mockLoadedEvent()
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.detail__banner--expired').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Not found state
|
||||||
|
it('renders "event not found" when API returns 404', async () => {
|
||||||
|
vi.mocked(api.GET).mockResolvedValue({
|
||||||
|
data: undefined,
|
||||||
|
error: { type: 'about:blank', title: 'Not Found', status: 404 },
|
||||||
|
response: new Response(null, { status: 404 }),
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Event not found.')
|
||||||
|
expect(wrapper.find('.detail__title').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Server error + retry
|
||||||
|
it('renders error state with retry button on server error', async () => {
|
||||||
|
vi.mocked(api.GET).mockResolvedValue({
|
||||||
|
data: undefined,
|
||||||
|
error: { type: 'about:blank', title: 'Internal Server Error', status: 500 },
|
||||||
|
response: new Response(null, { status: 500 }),
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Something went wrong.')
|
||||||
|
expect(wrapper.find('button').text()).toBe('Retry')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Retry button re-fetches
|
||||||
|
it('retry button triggers a new fetch', async () => {
|
||||||
|
vi.mocked(api.GET)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: undefined,
|
||||||
|
error: { type: 'about:blank', title: 'Error', status: 500 },
|
||||||
|
response: new Response(null, { status: 500 }),
|
||||||
|
} as never)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: fullEvent,
|
||||||
|
error: undefined,
|
||||||
|
response: new Response(null, { status: 200 }),
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Something went wrong.')
|
||||||
|
|
||||||
|
await wrapper.find('button').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.detail__title').text()).toBe('Summer BBQ')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
// RSVP bar
|
||||||
|
it('shows RSVP CTA bar on active event', async () => {
|
||||||
|
mockLoadedEvent()
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending")
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show RSVP bar for organizer', async () => {
|
||||||
|
mockGetOrganizerToken.mockReturnValue('org-token-123')
|
||||||
|
mockLoadedEvent()
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.rsvp-bar').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show RSVP bar on expired event', async () => {
|
||||||
|
mockLoadedEvent({ expired: true })
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.rsvp-bar').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows RSVP status bar when localStorage has RSVP', async () => {
|
||||||
|
mockGetRsvp.mockReturnValue({ rsvpToken: 'rsvp-1', rsvpName: 'Max' })
|
||||||
|
mockLoadedEvent()
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
|
||||||
|
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
// RSVP form submission
|
||||||
|
it('opens bottom sheet when CTA is clicked', async () => {
|
||||||
|
mockLoadedEvent()
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
|
||||||
|
await wrapper.find('.rsvp-bar__cta').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows validation error when submitting empty name', async () => {
|
||||||
|
mockLoadedEvent()
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.rsvp-bar__cta').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Form is inside Teleport — find via document.body
|
||||||
|
const form = document.body.querySelector('.rsvp-form')! as HTMLFormElement
|
||||||
|
form.dispatchEvent(new Event('submit', { bubbles: true }))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(document.body.querySelector('.rsvp-form__field-error')?.textContent).toBe('Please enter your name.')
|
||||||
|
expect(vi.mocked(api.POST)).not.toHaveBeenCalled()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits RSVP, saves to storage, and shows status', async () => {
|
||||||
|
mockLoadedEvent()
|
||||||
|
vi.mocked(api.POST).mockResolvedValue({
|
||||||
|
data: { rsvpToken: 'rsvp-token-1', name: 'Max' },
|
||||||
|
error: undefined,
|
||||||
|
response: new Response(null, { status: 201 }),
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Open sheet
|
||||||
|
await wrapper.find('.rsvp-bar__cta').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Fill name via Teleported input
|
||||||
|
const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement
|
||||||
|
input.value = 'Max'
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
const form = document.body.querySelector('.rsvp-form')! as HTMLFormElement
|
||||||
|
form.dispatchEvent(new Event('submit', { bubbles: true }))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Verify API call
|
||||||
|
expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events/{token}/rsvps', {
|
||||||
|
params: { path: { token: 'test-token' } },
|
||||||
|
body: { name: 'Max' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify storage
|
||||||
|
expect(mockSaveRsvp).toHaveBeenCalledWith(
|
||||||
|
'abc-123',
|
||||||
|
'rsvp-token-1',
|
||||||
|
'Max',
|
||||||
|
'Summer BBQ',
|
||||||
|
'2026-03-15T20:00:00+01:00',
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify UI switched to status
|
||||||
|
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
|
||||||
|
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
||||||
|
|
||||||
|
// Verify attendee count incremented
|
||||||
|
expect(wrapper.text()).toContain('13')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Attendee list (organizer)
|
||||||
|
it('shows attendee list for organizer', async () => {
|
||||||
|
mockGetOrganizerToken.mockReturnValue('org-token-123')
|
||||||
|
mockLoadedEvent()
|
||||||
|
vi.mocked(api.GET)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: fullEvent,
|
||||||
|
error: undefined,
|
||||||
|
response: new Response(null, { status: 200 }),
|
||||||
|
} as never)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { attendees: [{ name: 'Alice' }, { name: 'Bob' }] },
|
||||||
|
error: undefined,
|
||||||
|
response: new Response(null, { status: 200 }),
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.attendee-list').exists()).toBe(true)
|
||||||
|
expect(wrapper.text()).toContain('Alice')
|
||||||
|
expect(wrapper.text()).toContain('Bob')
|
||||||
|
expect(wrapper.find('.attendee-list__heading').text()).toBe('2 Attendees')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show attendee list for visitor', async () => {
|
||||||
|
mockLoadedEvent()
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.attendee-list').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error when RSVP submission fails', async () => {
|
||||||
|
mockLoadedEvent()
|
||||||
|
vi.mocked(api.POST).mockResolvedValue({
|
||||||
|
data: undefined,
|
||||||
|
error: { type: 'about:blank', title: 'Bad Request', status: 400 },
|
||||||
|
response: new Response(null, { status: 400 }),
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.rsvp-bar__cta').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement
|
||||||
|
input.value = 'Max'
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const form = document.body.querySelector('.rsvp-form')! as HTMLFormElement
|
||||||
|
form.dispatchEvent(new Event('submit', { bubbles: true }))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('Could not submit RSVP. Please try again.')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,7 +8,7 @@ function createTestRouter() {
|
|||||||
history: createMemoryHistory(),
|
history: createMemoryHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', name: 'home', component: { template: '<div />' } },
|
{ path: '/', name: 'home', component: { template: '<div />' } },
|
||||||
{ path: '/events/:token', name: 'event', component: EventStubView },
|
{ path: '/events/:eventToken', name: 'event', component: EventStubView },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
94
specs/007-view-event/contracts/get-event.yaml
Normal file
94
specs/007-view-event/contracts/get-event.yaml
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# OpenAPI contract addition for GET /events/{token}
|
||||||
|
# To be merged into backend/src/main/resources/openapi/api.yaml
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/events/{token}:
|
||||||
|
get:
|
||||||
|
operationId: getEvent
|
||||||
|
summary: Get public event details by token
|
||||||
|
tags:
|
||||||
|
- events
|
||||||
|
parameters:
|
||||||
|
- name: token
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Public event token
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Event found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/GetEventResponse"
|
||||||
|
"404":
|
||||||
|
description: Event not found
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
GetEventResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- eventToken
|
||||||
|
- title
|
||||||
|
- dateTime
|
||||||
|
- timezone
|
||||||
|
- attendeeCount
|
||||||
|
- expired
|
||||||
|
properties:
|
||||||
|
eventToken:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Public event token
|
||||||
|
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
description: Event title
|
||||||
|
example: "Summer BBQ"
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: Event description (absent if not set)
|
||||||
|
example: "Bring your own drinks!"
|
||||||
|
dateTime:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Event date/time with organizer's UTC offset
|
||||||
|
example: "2026-03-15T20:00:00+01:00"
|
||||||
|
timezone:
|
||||||
|
type: string
|
||||||
|
description: IANA timezone name of the organizer
|
||||||
|
example: "Europe/Berlin"
|
||||||
|
location:
|
||||||
|
type: string
|
||||||
|
description: Event location (absent if not set)
|
||||||
|
example: "Central Park, NYC"
|
||||||
|
attendeeCount:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
description: Number of confirmed attendees (attending=true)
|
||||||
|
example: 12
|
||||||
|
expired:
|
||||||
|
type: boolean
|
||||||
|
description: Whether the event's expiry date has passed
|
||||||
|
example: false
|
||||||
|
|
||||||
|
# Modification to existing CreateEventRequest — add timezone field
|
||||||
|
# CreateEventRequest (additions):
|
||||||
|
# timezone:
|
||||||
|
# type: string
|
||||||
|
# description: IANA timezone of the organizer
|
||||||
|
# example: "Europe/Berlin"
|
||||||
|
# (make required)
|
||||||
|
|
||||||
|
# Modification to existing CreateEventResponse — add timezone field
|
||||||
|
# CreateEventResponse (additions):
|
||||||
|
# timezone:
|
||||||
|
# type: string
|
||||||
|
# description: IANA timezone of the organizer
|
||||||
|
# example: "Europe/Berlin"
|
||||||
56
specs/007-view-event/data-model.md
Normal file
56
specs/007-view-event/data-model.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Data Model: View Event Landing Page (007)
|
||||||
|
|
||||||
|
**Date**: 2026-03-06
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### Event (modified — adds `timezone` field)
|
||||||
|
|
||||||
|
| Field | Type | Required | Constraints | Notes |
|
||||||
|
|-----------------|------------------|----------|--------------------------|----------------------------------|
|
||||||
|
| id | Long | yes | BIGSERIAL, PK | Internal only, never exposed |
|
||||||
|
| eventToken | UUID | yes | UNIQUE, NOT NULL | Public identifier in URLs |
|
||||||
|
| organizerToken | UUID | yes | UNIQUE, NOT NULL | Secret, never in public API |
|
||||||
|
| title | String | yes | 1–200 chars | |
|
||||||
|
| description | String | no | max 2000 chars | |
|
||||||
|
| dateTime | OffsetDateTime | yes | | Organizer's original offset |
|
||||||
|
| timezone | String | yes | IANA zone ID, max 64 | **NEW** — e.g. "Europe/Berlin" |
|
||||||
|
| location | String | no | max 500 chars | |
|
||||||
|
| expiryDate | LocalDate | yes | Must be future at create | Auto-deletion trigger |
|
||||||
|
| createdAt | OffsetDateTime | yes | Server-generated | |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- `timezone` must be a valid IANA zone ID (`ZoneId.getAvailableZoneIds()`).
|
||||||
|
- `expiryDate` must be in the future at creation time (existing rule).
|
||||||
|
|
||||||
|
**State transitions**:
|
||||||
|
- Active → Expired: when `expiryDate < today` (computed, not stored).
|
||||||
|
- Active → Cancelled: future (US-18), adds `cancelledAt` + `cancellationMessage`.
|
||||||
|
|
||||||
|
### RSVP (future — not created in this feature)
|
||||||
|
|
||||||
|
Documented here for context only. Created when the RSVP feature (US-8+) is implemented.
|
||||||
|
|
||||||
|
| Field | Type | Required | Constraints |
|
||||||
|
|------------|---------|----------|------------------------------|
|
||||||
|
| id | Long | yes | BIGSERIAL, PK |
|
||||||
|
| eventId | Long | yes | FK → events.id |
|
||||||
|
| guestName | String | yes | 1–100 chars |
|
||||||
|
| attending | Boolean | yes | true = attending |
|
||||||
|
| createdAt | OffsetDateTime | yes | Server-generated |
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
```
|
||||||
|
Event 1 ←── * RSVP (future)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Type Mapping (full stack)
|
||||||
|
|
||||||
|
| Concept | Java | PostgreSQL | OpenAPI | TypeScript |
|
||||||
|
|--------------|-------------------|---------------|---------------------|------------|
|
||||||
|
| Event time | `OffsetDateTime` | `timestamptz` | `string` `date-time`| `string` |
|
||||||
|
| Timezone | `String` | `varchar(64)` | `string` | `string` |
|
||||||
|
| Expiry date | `LocalDate` | `date` | `string` `date` | `string` |
|
||||||
|
| Token | `UUID` | `uuid` | `string` `uuid` | `string` |
|
||||||
|
| Count | `int` | `integer` | `integer` | `number` |
|
||||||
89
specs/007-view-event/plan.md
Normal file
89
specs/007-view-event/plan.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Implementation Plan: View Event Landing Page
|
||||||
|
|
||||||
|
**Branch**: `007-view-event` | **Date**: 2026-03-06 | **Spec**: [spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/007-view-event/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a public event detail page at `/events/:token` that displays event information (title, date/time with IANA timezone, description, location, attendee count) without requiring authentication. The page handles four states: loaded, expired ("event has ended"), not found (404), and server error (retry button). Loading uses skeleton-shimmer placeholders. Backend adds `GET /events/{token}` endpoint and a `timezone` field to the Event model (cross-cutting change to US-1).
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: Java 25 (backend), TypeScript 5.9 (frontend)
|
||||||
|
**Primary Dependencies**: Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
|
||||||
|
**Storage**: PostgreSQL (JPA via Spring Data, Liquibase migrations)
|
||||||
|
**Testing**: JUnit (backend), Vitest (frontend unit), Playwright + MSW (frontend E2E)
|
||||||
|
**Target Platform**: Self-hosted web application (Docker)
|
||||||
|
**Project Type**: Web service + SPA
|
||||||
|
**Performance Goals**: N/A (single-user scale, self-hosted)
|
||||||
|
**Constraints**: No external resources (CDNs, fonts, tracking), WCAG AA, privacy-first
|
||||||
|
**Scale/Scope**: Single new view + one new API endpoint + one cross-cutting model change
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Privacy by Design | PASS | No PII exposed. Only attendee count shown (not names). No external resources. No tracking. |
|
||||||
|
| II. Test-Driven Methodology | PASS | TDD enforced: backend unit tests, frontend unit tests, E2E tests per spec. |
|
||||||
|
| III. API-First Development | PASS | OpenAPI spec updated first. Types generated. Response schemas include `example:` fields. |
|
||||||
|
| IV. Simplicity & Quality | PASS | Minimal changes: one GET endpoint, one new view, one model field. `attendeeCount` returns 0 (no RSVP stub). Cancelled state deferred. |
|
||||||
|
| V. Dependency Discipline | PASS | No new dependencies. Skeleton shimmer is CSS-only. |
|
||||||
|
| VI. Accessibility | PASS | Semantic HTML, ARIA attributes, keyboard navigable, WCAG AA contrast via design system. |
|
||||||
|
|
||||||
|
**Post-Phase-1 re-check**: All gates still pass. The `timezone` field addition is a justified cross-cutting change documented in research.md R-1.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/007-view-event/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── spec.md # Feature specification
|
||||||
|
├── research.md # Phase 0: research decisions
|
||||||
|
├── data-model.md # Phase 1: entity definitions
|
||||||
|
├── quickstart.md # Phase 1: implementation overview
|
||||||
|
├── contracts/
|
||||||
|
│ └── get-event.yaml # Phase 1: GET endpoint contract
|
||||||
|
└── tasks.md # Phase 2: implementation tasks (via /speckit.tasks)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/
|
||||||
|
├── src/main/java/de/fete/
|
||||||
|
│ ├── domain/
|
||||||
|
│ │ ├── model/Event.java # Add timezone field
|
||||||
|
│ │ └── port/in/GetEventUseCase.java # NEW: inbound port
|
||||||
|
│ ├── application/service/EventService.java # Implement GetEventUseCase
|
||||||
|
│ ├── adapter/
|
||||||
|
│ │ ├── in/web/EventController.java # Implement getEvent()
|
||||||
|
│ │ └── out/persistence/
|
||||||
|
│ │ ├── EventJpaEntity.java # Add timezone column
|
||||||
|
│ │ └── EventPersistenceAdapter.java # Map timezone field
|
||||||
|
│ └── config/
|
||||||
|
├── src/main/resources/
|
||||||
|
│ ├── openapi/api.yaml # Add GET endpoint + timezone
|
||||||
|
│ └── db/changelog/ # Liquibase: add timezone column
|
||||||
|
└── src/test/java/de/fete/ # Unit + integration tests
|
||||||
|
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── api/schema.d.ts # Regenerated from OpenAPI
|
||||||
|
│ ├── views/EventDetailView.vue # NEW: event detail page
|
||||||
|
│ ├── views/EventCreateView.vue # Add timezone to create request
|
||||||
|
│ ├── router/index.ts # Point /events/:token to EventDetailView
|
||||||
|
│ └── assets/main.css # Skeleton shimmer styles
|
||||||
|
├── e2e/
|
||||||
|
│ └── event-view.spec.ts # NEW: E2E tests for view event
|
||||||
|
└── src/__tests__/ # Unit tests for EventDetailView
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Existing web application structure (backend + frontend). No new packages or modules — extends existing hexagonal architecture with one new inbound port and one new frontend view.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations. No entries needed.
|
||||||
39
specs/007-view-event/quickstart.md
Normal file
39
specs/007-view-event/quickstart.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Quickstart: View Event Landing Page (007)
|
||||||
|
|
||||||
|
## What this feature does
|
||||||
|
|
||||||
|
Adds a public event detail page at `/events/:token`. Guests open a shared link and see:
|
||||||
|
- Event title, date/time (with IANA timezone), description, location
|
||||||
|
- Count of confirmed attendees (no names)
|
||||||
|
- "Event has ended" state for expired events
|
||||||
|
- "Event not found" for invalid tokens
|
||||||
|
- Skeleton shimmer while loading
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- US-1 (Create Event) is implemented — Event entity, JPA persistence, POST endpoint exist.
|
||||||
|
- No RSVP model yet — attendee count returns 0 until RSVP feature is built.
|
||||||
|
|
||||||
|
## Key changes
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
1. **OpenAPI**: Add `GET /events/{token}` endpoint + `GetEventResponse` schema. Add `timezone` field to `CreateEventRequest`, `CreateEventResponse`, and `GetEventResponse`.
|
||||||
|
2. **Domain**: Add `timezone` (String) to `Event.java`.
|
||||||
|
3. **Persistence**: Add `timezone` column to `EventJpaEntity`, Liquibase migration.
|
||||||
|
4. **Use case**: New `GetEventUseCase` (inbound port) + implementation in `EventService`.
|
||||||
|
5. **Controller**: `EventController` implements `getEvent()` — maps to `GetEventResponse`, computes `expired` and `attendeeCount`.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
1. **API types**: Regenerate `schema.d.ts` from updated OpenAPI spec.
|
||||||
|
2. **EventDetailView.vue**: New view component — fetches event by token, renders detail card.
|
||||||
|
3. **Router**: Replace `EventStubView` import at `/events/:token` with `EventDetailView`.
|
||||||
|
4. **States**: Loading (skeleton shimmer), loaded, expired, not-found, server-error (retry button).
|
||||||
|
5. **Create form**: Send `timezone` field (auto-detected via `Intl.DateTimeFormat`).
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Backend: Unit tests for `GetEventUseCase`, controller tests for GET endpoint (200, 404).
|
||||||
|
- Frontend: Unit tests for EventDetailView (all states).
|
||||||
|
- E2E: Playwright tests with MSW mocks for all states (loaded, expired, not-found, error).
|
||||||
100
specs/007-view-event/research.md
Normal file
100
specs/007-view-event/research.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Research: View Event Landing Page (007)
|
||||||
|
|
||||||
|
**Date**: 2026-03-06 | **Status**: Complete
|
||||||
|
|
||||||
|
## R-1: Timezone Field (Cross-Cutting)
|
||||||
|
|
||||||
|
**Decision**: Add `timezone` String field (IANA zone ID) to Event entity, JPA entity, and OpenAPI schemas (both Create and Get).
|
||||||
|
|
||||||
|
**Rationale**: The spec requires displaying the IANA timezone name (e.g. "Europe/Berlin") alongside the event time. `OffsetDateTime` preserves the offset (e.g. `+01:00`) but loses the IANA zone name. Since Europe/Berlin and Africa/Lagos both use `+01:00`, the zone name must be stored separately.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Store `ZonedDateTime` instead of `OffsetDateTime` — rejected because `OffsetDateTime` is already the established type in the stack (see `datetime-best-practices.md`), and `ZonedDateTime` serialization is non-standard in JSON/OpenAPI.
|
||||||
|
- Derive timezone from offset — rejected because offset-to-zone mapping is ambiguous.
|
||||||
|
|
||||||
|
**Impact on US-1 (Create Event)**:
|
||||||
|
- `CreateEventRequest` gains a required `timezone` field (string, IANA zone ID).
|
||||||
|
- `CreateEventResponse` gains a `timezone` field.
|
||||||
|
- Frontend auto-detects via `Intl.DateTimeFormat().resolvedOptions().timeZone`.
|
||||||
|
- Backend validates against `java.time.ZoneId.getAvailableZoneIds()`.
|
||||||
|
- JPA: new `VARCHAR(64)` column `timezone` on `events` table.
|
||||||
|
- Liquibase changeset: add `timezone` column. Existing events without timezone get `UTC` as default (pre-launch, destructive migration acceptable).
|
||||||
|
|
||||||
|
## R-2: GET Endpoint Design
|
||||||
|
|
||||||
|
**Decision**: `GET /api/events/{token}` returns public event data. Uses the existing hexagonal architecture pattern.
|
||||||
|
|
||||||
|
**Rationale**: Follows the established pattern from `POST /events`. The event token is the public identifier — no auth required.
|
||||||
|
|
||||||
|
**Flow**:
|
||||||
|
1. `EventController` implements generated `EventsApi.getEvent()`.
|
||||||
|
2. New inbound port: `GetEventUseCase` with `getByEventToken(UUID): Optional<Event>`.
|
||||||
|
3. `EventService` implements the use case, delegates to `EventRepository.findByEventToken()` (already exists).
|
||||||
|
4. Controller maps domain `Event` to `GetEventResponse` DTO.
|
||||||
|
5. 404 returns `ProblemDetail` (RFC 9457) — no event data leaked.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Separate `/event/{token}` path (singular) — rejected because OpenAPI groups by resource; `/events/{token}` is RESTful convention.
|
||||||
|
- Note: Frontend route is `/event/:token` (spec clarification), but API path is `/api/events/{token}`. These are independent.
|
||||||
|
|
||||||
|
## R-3: Attendee Count Without RSVP Model
|
||||||
|
|
||||||
|
**Decision**: Include `attendeeCount` (integer) in the `GetEventResponse`. Return `0` until the RSVP feature (US-8+) is implemented.
|
||||||
|
|
||||||
|
**Rationale**: FR-001 requires attendee count display. The API contract should be stable from the start — consumers should not need to change when RSVP is added later. Returning `0` is correct (no RSVPs exist yet).
|
||||||
|
|
||||||
|
**Future hook**: When RSVP is implemented, `EventService` or a dedicated query will `COUNT(*) WHERE event_id = ? AND status = 'ATTENDING'`.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Omit `attendeeCount` until RSVP exists — rejected because it would require API consumers to handle the field's absence, then handle its presence later. Breaking change.
|
||||||
|
- Add a stub RSVP table now — rejected (YAGNI, violates Principle IV).
|
||||||
|
|
||||||
|
## R-4: Expired Event Detection
|
||||||
|
|
||||||
|
**Decision**: Server-side. The `GetEventResponse` includes a boolean `expired` field, computed by comparing `expiryDate` with the server's current date.
|
||||||
|
|
||||||
|
**Rationale**: Server is the source of truth for time. Client clocks may be wrong. The frontend uses this flag to toggle the "event has ended" state.
|
||||||
|
|
||||||
|
**Computation**: `event.getExpiryDate().isBefore(LocalDate.now(clock))` — uses the injected `Clock` bean (already exists for testability in `EventService`).
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Client-side comparison — rejected because client clock may differ from server, leading to inconsistent behavior.
|
||||||
|
- Separate endpoint for status — rejected (over-engineering).
|
||||||
|
|
||||||
|
## R-5: URL Pattern
|
||||||
|
|
||||||
|
**Decision**: Frontend route stays at `/events/:token` (plural). API path is `/api/events/{token}`. Both use the plural RESTful convention consistently.
|
||||||
|
|
||||||
|
**Rationale**: `/events/:token` is the standard REST resource pattern (collection + identifier). The existing router already uses this path. Consistency between frontend route and API resource name reduces cognitive overhead.
|
||||||
|
|
||||||
|
**Impact**: No route change needed — the existing `/events/:token` route in the router is correct.
|
||||||
|
|
||||||
|
## R-6: Skeleton Shimmer Loading State
|
||||||
|
|
||||||
|
**Decision**: CSS-only shimmer animation using a gradient sweep. No additional dependencies.
|
||||||
|
|
||||||
|
**Rationale**: The spec requires skeleton-shimmer placeholders during API loading. A CSS-only approach is lightweight and matches the dependency discipline principle.
|
||||||
|
|
||||||
|
**Implementation pattern**:
|
||||||
|
```css
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, var(--color-card) 25%, #e0e0e0 50%, var(--color-card) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Skeleton blocks match the approximate shape/size of the real content fields (title, date, location, etc.).
|
||||||
|
|
||||||
|
## R-7: Cancelled Event State (Deferred)
|
||||||
|
|
||||||
|
**Decision**: The `GetEventResponse` does NOT include cancellation fields yet. US-3 (view cancelled event) is explicitly deferred until US-18 (cancel event) is implemented.
|
||||||
|
|
||||||
|
**Rationale**: Spec says "[Deferred until US-18 is implemented]". Adding unused fields violates Principle IV (KISS).
|
||||||
|
|
||||||
|
**Future hook**: When US-18 lands, add `cancelled: boolean` and `cancellationMessage: string` to the response schema.
|
||||||
@@ -9,18 +9,18 @@
|
|||||||
|
|
||||||
### User Story 1 - View event details as guest (Priority: P1)
|
### User Story 1 - View event details as guest (Priority: P1)
|
||||||
|
|
||||||
A guest receives a shared event link, opens it, and sees all relevant event information: title, description (if provided), date and time, location (if provided), and the list of confirmed attendees with a count.
|
A guest receives a shared event link, opens it, and sees all relevant event information: title, description (if provided), date and time, location (if provided), and the count of confirmed attendees.
|
||||||
|
|
||||||
**Why this priority**: Core value of the feature — without this, no other part of the event page is meaningful.
|
**Why this priority**: Core value of the feature — without this, no other part of the event page is meaningful.
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by navigating to a valid event URL and verifying all event fields are displayed correctly, including attendee list and count.
|
**Independent Test**: Can be fully tested by navigating to a valid event URL and verifying all event fields are displayed correctly, including attendee count.
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
1. **Given** a valid event link, **When** a guest opens the URL, **Then** the page displays the event title, date and time, and attendee count.
|
1. **Given** a valid event link, **When** a guest opens the URL, **Then** the page displays the event title, date and time, and attendee count.
|
||||||
2. **Given** a valid event link for an event with optional fields set, **When** a guest opens the URL, **Then** the description and location are also displayed.
|
2. **Given** a valid event link for an event with optional fields set, **When** a guest opens the URL, **Then** the description and location are also displayed.
|
||||||
3. **Given** a valid event link for an event with optional fields absent, **When** a guest opens the URL, **Then** only the required fields are shown — no placeholder text for missing optional fields.
|
3. **Given** a valid event link for an event with optional fields absent, **When** a guest opens the URL, **Then** only the required fields are shown — no placeholder text for missing optional fields.
|
||||||
4. **Given** a valid event with RSVPs, **When** a guest opens the event page, **Then** the names of all confirmed attendees ("attending") are listed and a total count is shown.
|
4. **Given** a valid event with RSVPs, **When** a guest opens the event page, **Then** only the total count of confirmed attendees is shown — individual names are NOT displayed to guests (names are only visible to the organizer via the organizer view).
|
||||||
5. **Given** an event page, **When** it is rendered, **Then** no external resources (CDNs, fonts, tracking scripts) are loaded — all assets are served from the app's own domain.
|
5. **Given** an event page, **When** it is rendered, **Then** no external resources (CDNs, fonts, tracking scripts) are loaded — all assets are served from the app's own domain.
|
||||||
6. **Given** a guest with no account, **When** they open the event URL, **Then** the page loads without any login, account, or access code required.
|
6. **Given** a guest with no account, **When** they open the event URL, **Then** the page loads without any login, account, or access code required.
|
||||||
|
|
||||||
@@ -70,9 +70,9 @@ A guest navigates to an event URL that no longer resolves — the event was dele
|
|||||||
|
|
||||||
### Edge Cases
|
### Edge Cases
|
||||||
|
|
||||||
- What happens when the event has no attendees yet? — Attendee list is empty; count shows 0.
|
- What happens when the event has no attendees yet? — Count shows 0.
|
||||||
- What happens when the event has been cancelled after US-18 is implemented? — Renders cancelled state with optional message; RSVP hidden. [Deferred]
|
- What happens when the event has been cancelled after US-18 is implemented? — Renders cancelled state with optional message; RSVP hidden. [Deferred]
|
||||||
- What happens when the server is temporarily unavailable? — [NEEDS EXPANSION]
|
- What happens when the server is temporarily unavailable? — The page displays a generic, friendly error message with a manual "Retry" button. No automatic retry.
|
||||||
- How does the page behave when JavaScript is disabled? — Per Q-3 resolution: the app is a SPA; JavaScript-dependent rendering is acceptable.
|
- How does the page behave when JavaScript is disabled? — Per Q-3 resolution: the app is a SPA; JavaScript-dependent rendering is acceptable.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -81,7 +81,7 @@ A guest navigates to an event URL that no longer resolves — the event was dele
|
|||||||
|
|
||||||
- **FR-001**: The event page MUST display: title, date and time, and attendee count for any valid event.
|
- **FR-001**: The event page MUST display: title, date and time, and attendee count for any valid event.
|
||||||
- **FR-002**: The event page MUST display description and location when those optional fields are set on the event.
|
- **FR-002**: The event page MUST display description and location when those optional fields are set on the event.
|
||||||
- **FR-003**: The event page MUST list the names of all confirmed attendees (those who RSVPed "attending").
|
- **FR-003**: The public event page MUST display only the count of confirmed attendees. Individual attendee names MUST NOT be shown to guests — names are only visible to the organizer (organizer view, separate user story).
|
||||||
- **FR-004**: If the event's expiry date has passed, the page MUST render a clear "this event has ended" state and MUST NOT show any RSVP actions.
|
- **FR-004**: If the event's expiry date has passed, the page MUST render a clear "this event has ended" state and MUST NOT show any RSVP actions.
|
||||||
- **FR-005**: If the event has been cancelled (US-18), the page MUST display a "cancelled" state with the cancellation message (if provided) and MUST NOT show any RSVP actions. [Deferred until US-18 is implemented]
|
- **FR-005**: If the event has been cancelled (US-18), the page MUST display a "cancelled" state with the cancellation message (if provided) and MUST NOT show any RSVP actions. [Deferred until US-18 is implemented]
|
||||||
- **FR-006**: If the event token does not match any event on the server, the page MUST display a clear "event not found" message — no partial data or error traces.
|
- **FR-006**: If the event token does not match any event on the server, the page MUST display a clear "event not found" message — no partial data or error traces.
|
||||||
@@ -90,15 +90,29 @@ A guest navigates to an event URL that no longer resolves — the event was dele
|
|||||||
|
|
||||||
### Key Entities
|
### Key Entities
|
||||||
|
|
||||||
- **Event**: Has a public event token (UUID in URL), title, optional description, date/time, optional location, expiry date, and optionally a cancelled state with message.
|
- **Event**: Has a public event token (UUID in URL), title, optional description, date/time (OffsetDateTime — displayed in the organizer's original timezone, no conversion to viewer timezone), IANA timezone name (e.g. `Europe/Berlin`, stored as separate field — required for human-readable timezone display), optional location, expiry date (LocalDate), and optionally a cancelled state with message. See `.specify/memory/research/datetime-best-practices.md` for full stack type mapping.
|
||||||
- **RSVP**: Has a guest name and attending status; confirmed attendees (status = attending) are listed on the public event page.
|
- **Note**: The IANA timezone requires a new `timezone` field on the Event entity and API schema. This impacts US-1 (Create Event) — the frontend must send the organizer's IANA zone ID alongside the OffsetDateTime.
|
||||||
|
- **RSVP**: Has a guest name and binary attending status (attending / not attending — no "maybe"). Only the count of confirmed attendees (status = attending) is exposed on the public event page. Individual names are visible only in the organizer view, sorted alphabetically by name.
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
### Measurable Outcomes
|
### Measurable Outcomes
|
||||||
|
|
||||||
- **SC-001**: A guest who opens a valid event URL can see all set event fields (title, date/time, and any optional fields) without logging in.
|
- **SC-001**: A guest who opens a valid event URL can see all set event fields (title, date/time, and any optional fields) without logging in.
|
||||||
- **SC-002**: The attendee list and count reflect all current server-side RSVPs with attending status.
|
- **SC-002**: The attendee count reflects all current server-side RSVPs with attending status. No individual names are exposed on the public event page.
|
||||||
- **SC-003**: An expired event URL renders the "ended" state — RSVP controls are absent from the DOM, not merely hidden via CSS.
|
- **SC-003**: An expired event URL renders the "ended" state — RSVP controls are absent from the DOM, not merely hidden via CSS.
|
||||||
- **SC-004**: An unknown event token URL renders a "not found" message — no event data, no server error details.
|
- **SC-004**: An unknown event token URL renders a "not found" message — no event data, no server error details.
|
||||||
- **SC-005**: No network requests to external domains are made when loading the event page.
|
- **SC-005**: No network requests to external domains are made when loading the event page.
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-03-06
|
||||||
|
|
||||||
|
- Q: What should the event page display when the server is temporarily unavailable? → A: Generic friendly error state with a manual "Retry" button; no automatic retry.
|
||||||
|
- Q: How should date/time be displayed regarding timezones? → A: Organizer timezone preserved — display the time exactly as entered by the organizer (OffsetDateTime), no conversion to viewer's local timezone. The IANA timezone name (e.g. "Europe/Berlin") MUST be displayed alongside the time. Requires a new `timezone` field on Event entity/API (impacts US-1).
|
||||||
|
- Q: What is the URL pattern for event pages? → A: `/events/:token` (e.g. `/events/a1b2c3d4-...`). Plural, matching the RESTful API resource name.
|
||||||
|
- Q: Should guest names be visible to other guests on the public event page? → A: No. Only the attendee count is shown to guests. Individual names are exclusively visible to the organizer, sorted alphabetically.
|
||||||
|
- Q: How should the loading state look while the API call is in progress? → A: Skeleton-shimmer (placeholder blocks in field shape that shimmer until data arrives).
|
||||||
|
- Q: Should the event page include OpenGraph meta tags for link previews? → A: Out of scope for US-007. Separate user story — generic app-branding OG-tags only, no event data exposed to crawlers. Noted in `.specify/memory/ideen.md`.
|
||||||
|
- Q: Should date/time formatting adapt to the viewer's browser locale? → A: Yes, browser-locale-based via `Intl.DateTimeFormat` (e.g. DE: "15. März 2026, 20:00" / EN: "March 15, 2026, 8:00 PM").
|
||||||
|
- Q: Is RSVP status binary or are there more states (e.g. "maybe")? → A: Binary — attending or not attending. No "maybe" status. Count reflects only confirmed attendees.
|
||||||
|
|||||||
225
specs/007-view-event/tasks.md
Normal file
225
specs/007-view-event/tasks.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# Tasks: View Event Landing Page
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/007-view-event/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/get-event.yaml
|
||||||
|
|
||||||
|
**Tests**: Included — constitution enforces Test-Driven Methodology (Principle II).
|
||||||
|
|
||||||
|
**Organization**: Tasks grouped by user story. US3 (cancelled event) is deferred until US-18.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US4)
|
||||||
|
- Exact file paths included in descriptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup (Cross-Cutting Schema Changes)
|
||||||
|
|
||||||
|
**Purpose**: OpenAPI contract update, database migration, and type generation — prerequisites for all backend and frontend work.
|
||||||
|
|
||||||
|
- [x] T001 Update OpenAPI spec: add `GET /events/{token}` endpoint, `GetEventResponse` schema, and `timezone` field to `CreateEventRequest`/`CreateEventResponse` in `backend/src/main/resources/openapi/api.yaml`
|
||||||
|
- [x] T002 [P] Add Liquibase changeset: `timezone VARCHAR(64) NOT NULL DEFAULT 'UTC'` column on `events` table in `backend/src/main/resources/db/changelog/`
|
||||||
|
- [x] T003 Regenerate frontend TypeScript types from updated OpenAPI spec in `frontend/src/api/schema.d.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: OpenAPI contract finalized, DB schema ready, frontend types available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Backend — Blocks All User Stories)
|
||||||
|
|
||||||
|
**Purpose**: Domain model update, new GET use case, controller endpoint, and backend tests. All user stories depend on this.
|
||||||
|
|
||||||
|
**CRITICAL**: No frontend user story work can begin until this phase is complete.
|
||||||
|
|
||||||
|
### Backend Tests (TDD — write first, verify they fail)
|
||||||
|
|
||||||
|
- [x] T004 [P] Backend unit tests for `GetEventUseCase`: test getByEventToken returns event, returns empty for unknown token, computes expired flag — in `backend/src/test/java/de/fete/`
|
||||||
|
- [x] T005 [P] Backend controller tests for `GET /events/{token}`: test 200 with full response, 200 with optional fields absent, 404 with ProblemDetail — in `backend/src/test/java/de/fete/`
|
||||||
|
- [x] T006 [P] Backend tests for timezone in Create Event flow: request validation (valid/invalid IANA zone), persistence round-trip — in `backend/src/test/java/de/fete/`
|
||||||
|
|
||||||
|
### Backend Implementation
|
||||||
|
|
||||||
|
- [x] T007 Add `timezone` field (String) to domain model in `backend/src/main/java/de/fete/domain/model/Event.java`
|
||||||
|
- [x] T008 [P] Add `timezone` column to JPA entity and update persistence mapping in `backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java` and `EventPersistenceAdapter.java`
|
||||||
|
- [x] T009 [P] Update Create Event flow to accept and validate `timezone` (must be valid IANA zone ID via `ZoneId.getAvailableZoneIds()`) in `backend/src/main/java/de/fete/application/service/EventService.java` and `EventController.java`
|
||||||
|
- [x] T010 Create `GetEventUseCase` inbound port with `getByEventToken(UUID): Optional<Event>` in `backend/src/main/java/de/fete/domain/port/in/GetEventUseCase.java`
|
||||||
|
- [x] T011 Implement `GetEventUseCase` in `backend/src/main/java/de/fete/application/service/EventService.java` — delegates to existing `findByEventToken()` repository method
|
||||||
|
- [x] T012 Implement `getEvent()` in `backend/src/main/java/de/fete/adapter/in/web/EventController.java` — maps domain Event to GetEventResponse, computes `expired` (expiryDate vs server clock) and `attendeeCount` (hardcoded 0)
|
||||||
|
|
||||||
|
**Checkpoint**: Backend complete — `GET /api/events/{token}` returns 200 or 404. All backend tests pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — View Event Details as Guest (Priority: P1) MVP
|
||||||
|
|
||||||
|
**Goal**: A guest opens a shared event link and sees all event information: title, date/time with IANA timezone, description, location, attendee count. Loading shows skeleton shimmer.
|
||||||
|
|
||||||
|
**Independent Test**: Navigate to a valid event URL, verify all fields display correctly with locale-formatted date/time.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [x] T013 [P] [US1] Unit tests for EventDetailView loaded state: renders title, date/time (locale-formatted via `Intl.DateTimeFormat`), timezone, description, location, attendee count — in `frontend/src/__tests__/EventDetailView.spec.ts`
|
||||||
|
- [x] T014 [P] [US1] Unit test for EventDetailView loading state: renders skeleton shimmer placeholders — in `frontend/src/__tests__/EventDetailView.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T015 [P] [US1] Add skeleton shimmer CSS (CSS-only gradient animation, no dependencies) in `frontend/src/assets/main.css`
|
||||||
|
- [x] T016 [US1] Create `EventDetailView.vue` with loading (skeleton shimmer) and loaded states — fetches event via `openapi-fetch` GET `/events/{token}`, formats date/time with `Intl.DateTimeFormat` using browser locale — in `frontend/src/views/EventDetailView.vue`
|
||||||
|
- [x] T017 [US1] Update router to use `EventDetailView` for `/events/:token` route in `frontend/src/router/index.ts`
|
||||||
|
- [x] T018 [P] [US1] Update `EventCreateView.vue` to send `timezone` field (auto-detected via `Intl.DateTimeFormat().resolvedOptions().timeZone`) in `frontend/src/views/EventCreateView.vue`
|
||||||
|
- [x] T019 [US1] E2E test for loaded event: navigate to valid event URL, verify all fields displayed, verify no external resource requests — in `frontend/e2e/event-view.spec.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: US1 complete — guest can view event details. Skeleton shimmer during loading. Date/time locale-formatted with timezone label.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — View Expired Event (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: A guest opens a link to an expired event. The page shows event details plus a clear "event has ended" indicator. No RSVP actions shown.
|
||||||
|
|
||||||
|
**Independent Test**: Create an event with past expiry date, navigate to its URL, verify "event has ended" state renders and no RSVP controls are present.
|
||||||
|
|
||||||
|
**Dependencies**: Requires Phase 3 (EventDetailView exists).
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [x] T020 [P] [US2] Unit test for EventDetailView expired state: renders "event has ended" indicator, RSVP controls absent from DOM — in `frontend/src/__tests__/EventDetailView.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T021 [US2] Add expired state rendering to `EventDetailView.vue`: show event details + "event has ended" banner when `expired === true`, no RSVP actions in DOM — in `frontend/src/views/EventDetailView.vue`
|
||||||
|
- [x] T022 [US2] E2E test for expired event: MSW returns event with `expired: true`, verify banner and absent RSVP controls — in `frontend/e2e/event-view.spec.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: US2 complete — expired events clearly show "ended" state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 4 — Event Not Found (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: A guest navigates to an invalid event URL. The page shows a clear "event not found" message — no partial data, no error traces.
|
||||||
|
|
||||||
|
**Independent Test**: Navigate to a URL with an unknown event token, verify "event not found" message renders.
|
||||||
|
|
||||||
|
**Dependencies**: Requires Phase 3 (EventDetailView exists). No dependency on US2.
|
||||||
|
|
||||||
|
### Tests for User Story 4
|
||||||
|
|
||||||
|
- [x] T023 [P] [US4] Unit test for EventDetailView not-found state: renders "event not found" message, no event data in DOM — in `frontend/src/__tests__/EventDetailView.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 4
|
||||||
|
|
||||||
|
- [x] T024 [US4] Add not-found state rendering to `EventDetailView.vue`: show "event not found" message when API returns 404 — in `frontend/src/views/EventDetailView.vue`
|
||||||
|
- [x] T025 [US4] E2E test for event not found: MSW returns 404 ProblemDetail, verify message and no event data — in `frontend/e2e/event-view.spec.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: US4 complete — invalid tokens show friendly not-found message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Server error edge case, final validation, and cleanup.
|
||||||
|
|
||||||
|
- [x] T026 Add server error state with manual retry button to `EventDetailView.vue`: friendly error message + "Retry" button that re-fetches — in `frontend/src/views/EventDetailView.vue`
|
||||||
|
- [x] T027 [P] Unit test for server error + retry state in `frontend/src/__tests__/EventDetailView.spec.ts`
|
||||||
|
- [x] T028 [P] E2E test for server error: MSW returns 500, verify error message and retry button functionality — in `frontend/e2e/event-view.spec.ts`
|
||||||
|
- [x] T029 Run quickstart.md validation: verify all key changes listed in quickstart.md are implemented
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies — start immediately
|
||||||
|
- **Foundational (Phase 2)**: Depends on T001 (OpenAPI spec) and T002 (migration) from Setup
|
||||||
|
- **US1 (Phase 3)**: Depends on Phase 2 completion (backend endpoint must exist)
|
||||||
|
- **US2 (Phase 4)**: Depends on Phase 3 (EventDetailView exists) — can parallelize with US4
|
||||||
|
- **US4 (Phase 5)**: Depends on Phase 3 (EventDetailView exists) — can parallelize with US2
|
||||||
|
- **Polish (Phase 6)**: Depends on Phase 3 minimum; ideally after US2 + US4
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 (Setup) ──► Phase 2 (Backend) ──► Phase 3 (US1/MVP)
|
||||||
|
│
|
||||||
|
┌────┴────┐
|
||||||
|
▼ ▼
|
||||||
|
Phase 4 Phase 5
|
||||||
|
(US2) (US4)
|
||||||
|
└────┬────┘
|
||||||
|
▼
|
||||||
|
Phase 6 (Polish)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **US1 (P1)**: Requires Phase 2 — no dependency on other stories
|
||||||
|
- **US2 (P2)**: Requires US1 (same component) — no dependency on US4
|
||||||
|
- **US4 (P2)**: Requires US1 (same component) — no dependency on US2
|
||||||
|
- **US3 (P2)**: DEFERRED until US-18 (cancel event) is implemented
|
||||||
|
|
||||||
|
### Within Each Phase
|
||||||
|
|
||||||
|
- Tests MUST be written and FAIL before implementation (TDD)
|
||||||
|
- Models/ports before services
|
||||||
|
- Services before controllers
|
||||||
|
- Backend before frontend (for the same endpoint)
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
**Phase 1**: T002 (migration) can run in parallel with T001 (OpenAPI update)
|
||||||
|
**Phase 2**: T004, T005, T006 (tests) can run in parallel. T008, T009 can run in parallel after T007.
|
||||||
|
**Phase 3**: T013, T014 (unit tests) and T015 (CSS) can run in parallel. T018 (create form timezone) is independent.
|
||||||
|
**Phase 4 + 5**: US2 and US4 can be implemented in parallel (different UI states, same file but non-conflicting sections).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: Phase 2 (Backend)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Write all backend tests in parallel (TDD):
|
||||||
|
Task T004: "Unit tests for GetEventUseCase"
|
||||||
|
Task T005: "Controller tests for GET /events/{token}"
|
||||||
|
Task T006: "Tests for timezone in Create Event flow"
|
||||||
|
|
||||||
|
# Then implement in parallel where possible:
|
||||||
|
Task T008: "Add timezone to JPA entity + persistence" # parallel
|
||||||
|
Task T009: "Update Create Event flow for timezone" # parallel
|
||||||
|
# T010-T012 are sequential (port → service → controller)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup (OpenAPI + migration + types)
|
||||||
|
2. Complete Phase 2: Backend (domain + use case + controller + tests)
|
||||||
|
3. Complete Phase 3: US1 (EventDetailView + router + tests)
|
||||||
|
4. **STOP and VALIDATE**: Guest can view event details via shared link
|
||||||
|
5. Deploy/demo if ready
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Setup + Backend → Backend ready, API testable via curl
|
||||||
|
2. Add US1 → Guest can view events (MVP!)
|
||||||
|
3. Add US2 → Expired events show "ended" state
|
||||||
|
4. Add US4 → Invalid tokens show "not found"
|
||||||
|
5. Polish → Server error handling, final validation
|
||||||
|
|
||||||
|
### Deferred Work
|
||||||
|
|
||||||
|
- **US3 (Cancelled event)**: Blocked on US-18. No tasks generated. Will require adding `cancelled` + `cancellationMessage` to GetEventResponse and a new UI state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- [P] tasks = different files, no dependencies on incomplete tasks
|
||||||
|
- [Story] label maps task to specific user story for traceability
|
||||||
|
- `attendeeCount` returns 0 until RSVP feature (US-8+) is implemented (R-3)
|
||||||
|
- `expired` is computed server-side using injected Clock bean (R-4)
|
||||||
|
- Frontend route: `/events/:token` — API path: `/api/events/{token}` (R-5)
|
||||||
|
- Skeleton shimmer is CSS-only, no additional dependencies (R-6)
|
||||||
|
- Date/time formatted via `Intl.DateTimeFormat` with browser locale (spec clarification Q7)
|
||||||
79
specs/008-rsvp/contracts/create-rsvp.yaml
Normal file
79
specs/008-rsvp/contracts/create-rsvp.yaml
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# OpenAPI contract addition for POST /events/{eventToken}/rsvps
|
||||||
|
# To be merged into backend/src/main/resources/openapi/api.yaml
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/events/{eventToken}/rsvps:
|
||||||
|
post:
|
||||||
|
operationId: createRsvp
|
||||||
|
summary: Submit an RSVP for an event
|
||||||
|
tags:
|
||||||
|
- events
|
||||||
|
parameters:
|
||||||
|
- name: eventToken
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Public event token
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CreateRsvpRequest"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: RSVP created successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CreateRsvpResponse"
|
||||||
|
"400":
|
||||||
|
description: Validation failed (e.g. blank name)
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ValidationProblemDetail"
|
||||||
|
"404":
|
||||||
|
description: Event not found
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
"409":
|
||||||
|
description: Event has expired — RSVPs no longer accepted
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
CreateRsvpRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 100
|
||||||
|
description: Guest's display name
|
||||||
|
example: "Max Mustermann"
|
||||||
|
|
||||||
|
CreateRsvpResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- rsvpToken
|
||||||
|
- name
|
||||||
|
properties:
|
||||||
|
rsvpToken:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Token identifying this RSVP (store client-side for future updates)
|
||||||
|
example: "d4e5f6a7-b8c9-0123-4567-890abcdef012"
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Guest's display name as stored
|
||||||
|
example: "Max Mustermann"
|
||||||
93
specs/008-rsvp/data-model.md
Normal file
93
specs/008-rsvp/data-model.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Data Model: RSVP to an Event (008)
|
||||||
|
|
||||||
|
**Date**: 2026-03-06
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### Rsvp (NEW)
|
||||||
|
|
||||||
|
| Field | Type | Required | Constraints | Notes |
|
||||||
|
|------------|----------------|----------|--------------------------------|------------------------------------|
|
||||||
|
| id | Long | yes | BIGSERIAL, PK | Internal only, never exposed |
|
||||||
|
| rsvpToken | RsvpToken | yes | UNIQUE, NOT NULL | Server-generated UUID, returned to client |
|
||||||
|
| eventId | Long | yes | FK -> events.id, NOT NULL | Which event this RSVP belongs to |
|
||||||
|
| name | String | yes | 1-100 chars, NOT NULL | Guest's display name |
|
||||||
|
|
||||||
|
**Notes**:
|
||||||
|
- No `attending` boolean — existence of an entry implies attendance (per spec).
|
||||||
|
- No `createdAt` — not required by the spec. Can be added later if needed (e.g. for guest list sorting in 009).
|
||||||
|
- Duplicates from different devices or cleared localStorage are accepted (privacy trade-off).
|
||||||
|
|
||||||
|
### Token Value Objects (NEW)
|
||||||
|
|
||||||
|
| Record | Field | Type | Notes |
|
||||||
|
|------------------|-------|------|-----------------------------------------------|
|
||||||
|
| `EventToken` | value | UUID | Immutable, non-null. Java record wrapping UUID |
|
||||||
|
| `OrganizerToken` | value | UUID | Immutable, non-null. Java record wrapping UUID |
|
||||||
|
| `RsvpToken` | value | UUID | Immutable, non-null. Java record wrapping UUID |
|
||||||
|
|
||||||
|
**Purpose**: Type-safe wrappers preventing mix-ups between the three token types at compile time. All generated server-side via `UUID.randomUUID()`. JPA entities continue to use raw `UUID` columns — mapping happens in the persistence adapters.
|
||||||
|
|
||||||
|
### Event (MODIFIED — token fields change type)
|
||||||
|
|
||||||
|
The Event domain model's `eventToken` and `organizerToken` fields change from raw `UUID` to their typed record wrappers. No database schema change — the JPA entity keeps raw `UUID` columns.
|
||||||
|
|
||||||
|
| Field | Old Type | New Type |
|
||||||
|
|-----------------|----------|------------------|
|
||||||
|
| eventToken | UUID | EventToken |
|
||||||
|
| organizerToken | UUID | OrganizerToken |
|
||||||
|
|
||||||
|
The `attendeeCount` was already added to the API response in 007-view-event — it now gets populated from a count query instead of returning 0.
|
||||||
|
|
||||||
|
### StoredEvent (frontend localStorage — modified)
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|----------------|--------|----------|------------------------------------|
|
||||||
|
| eventToken | string | yes | Existing |
|
||||||
|
| organizerToken | string | no | Existing (organizer flow) |
|
||||||
|
| title | string | yes | Existing |
|
||||||
|
| dateTime | string | yes | Existing |
|
||||||
|
| expiryDate | string | yes | Existing |
|
||||||
|
| rsvpToken | string | no | **NEW** — set after RSVP submission |
|
||||||
|
| rsvpName | string | no | **NEW** — guest's submitted name |
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
- `name`: required, 1-100 characters, trimmed. Blank or whitespace-only is rejected.
|
||||||
|
- `rsvpToken`: server-generated, never from client input on create.
|
||||||
|
- `eventId`: must reference an existing, non-expired event.
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
```
|
||||||
|
Event 1 <---- * Rsvp
|
||||||
|
| |
|
||||||
|
eventToken rsvpToken (unique)
|
||||||
|
(public) (returned to client)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Type Mapping (full stack)
|
||||||
|
|
||||||
|
| Concept | Java | PostgreSQL | OpenAPI | TypeScript |
|
||||||
|
|--------------|-------------------|---------------|---------------------|------------|
|
||||||
|
| RSVP ID | `Long` | `bigserial` | N/A (not exposed) | N/A |
|
||||||
|
| RSVP Token | `RsvpToken` | `uuid` | `string` `uuid` | `string` |
|
||||||
|
| Event FK | `Long` | `bigint` | N/A (path param) | N/A |
|
||||||
|
| Guest name | `String` | `varchar(100)`| `string` | `string` |
|
||||||
|
| Attendee cnt | `long` | `count(*)` | `integer` | `number` |
|
||||||
|
|
||||||
|
## Database Migration
|
||||||
|
|
||||||
|
New Liquibase changeset `003-create-rsvps-table.xml`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE rsvps (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
rsvp_token UUID NOT NULL UNIQUE,
|
||||||
|
event_id BIGINT NOT NULL REFERENCES events(id),
|
||||||
|
name VARCHAR(100) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_rsvps_event_id ON rsvps(event_id);
|
||||||
|
CREATE INDEX idx_rsvps_rsvp_token ON rsvps(rsvp_token);
|
||||||
|
```
|
||||||
114
specs/008-rsvp/plan.md
Normal file
114
specs/008-rsvp/plan.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Implementation Plan: RSVP to an Event
|
||||||
|
|
||||||
|
**Branch**: `008-rsvp` | **Date**: 2026-03-06 | **Spec**: [spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/008-rsvp/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add RSVP functionality to the event detail page. Backend: new `POST /api/events/{eventToken}/rsvps` endpoint that persists an RSVP (guest name) and returns an `rsvpToken`. Populates the existing `attendeeCount` field with real data from a count query. Rejects RSVPs on expired events (409). Frontend: fullscreen event presentation with sticky bottom bar (RSVP CTA or status), bottom sheet with RSVP form (name + submit). localStorage stores rsvpToken and name per event. No account required.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: Java 25 (backend), TypeScript 5.9 (frontend)
|
||||||
|
**Primary Dependencies**: Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
|
||||||
|
**Storage**: PostgreSQL (JPA via Spring Data, Liquibase migrations)
|
||||||
|
**Testing**: JUnit (backend), Vitest (frontend unit), Playwright + MSW (frontend E2E)
|
||||||
|
**Target Platform**: Self-hosted web application (Docker)
|
||||||
|
**Project Type**: Web service + SPA
|
||||||
|
**Performance Goals**: N/A (single-user scale, self-hosted)
|
||||||
|
**Constraints**: No external resources (CDNs, fonts, tracking), WCAG AA, privacy-first, no PII logging
|
||||||
|
**Scale/Scope**: New RSVP domain (model + service + controller + persistence), new frontend components (bottom sheet, sticky bar), modified event detail view
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Privacy by Design | PASS | No PII logged. Only guest-entered name stored. No IP logging. No tracking. Attendee names not exposed publicly (count only). Unprotected endpoint is a conscious privacy trade-off per spec. |
|
||||||
|
| II. Test-Driven Methodology | PASS | TDD enforced: backend unit + integration tests, frontend unit tests, E2E tests. Tests written before implementation. |
|
||||||
|
| III. API-First Development | PASS | OpenAPI spec updated first. New endpoint + schemas with `example:` fields. Types generated before implementation. |
|
||||||
|
| IV. Simplicity & Quality | PASS | Minimal scope: one POST endpoint, one domain entity, one bottom sheet component. No CAPTCHA, no rate limiting, no edit/withdraw (deferred). Cancelled event guard deferred to US-18. |
|
||||||
|
| V. Dependency Discipline | PASS | No new dependencies. Bottom sheet is CSS + Vue (~50 lines). No UI library. |
|
||||||
|
| VI. Accessibility | PASS | Bottom sheet uses dialog role + aria-modal. Focus trap. ESC to close. Keyboard navigable. WCAG AA contrast via design system. |
|
||||||
|
|
||||||
|
**Post-Phase-1 re-check**: All gates still pass. Three token value objects (`EventToken`, `OrganizerToken`, `RsvpToken`) introduced uniformly — justified by spec requirement for type-safe tokens. Refactoring existing Event model to use typed tokens is a mechanical change well-covered by existing tests.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/008-rsvp/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── spec.md # Feature specification
|
||||||
|
├── research.md # Phase 0: research decisions (R-1 through R-8)
|
||||||
|
├── data-model.md # Phase 1: Rsvp entity, RsvpToken value object
|
||||||
|
├── quickstart.md # Phase 1: implementation overview
|
||||||
|
├── contracts/
|
||||||
|
│ └── create-rsvp.yaml # Phase 1: POST endpoint contract
|
||||||
|
└── tasks.md # Phase 2: implementation tasks (via /speckit.tasks)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/
|
||||||
|
├── src/main/java/de/fete/
|
||||||
|
│ ├── domain/
|
||||||
|
│ │ ├── model/
|
||||||
|
│ │ │ ├── Event.java # MODIFIED: UUID → EventToken/OrganizerToken
|
||||||
|
│ │ │ ├── EventToken.java # NEW: typed token record
|
||||||
|
│ │ │ ├── OrganizerToken.java # NEW: typed token record
|
||||||
|
│ │ │ ├── Rsvp.java # NEW: RSVP domain entity
|
||||||
|
│ │ │ └── RsvpToken.java # NEW: typed token record
|
||||||
|
│ │ └── port/
|
||||||
|
│ │ ├── in/CreateRsvpUseCase.java # NEW: inbound port
|
||||||
|
│ │ └── out/RsvpRepository.java # NEW: outbound port
|
||||||
|
│ ├── application/service/
|
||||||
|
│ │ ├── EventService.java # MODIFIED: use typed tokens
|
||||||
|
│ │ └── RsvpService.java # NEW: RSVP business logic
|
||||||
|
│ ├── adapter/
|
||||||
|
│ │ ├── in/web/
|
||||||
|
│ │ │ ├── EventController.java # MODIFIED: typed tokens + attendee count + createRsvp()
|
||||||
|
│ │ │ └── GlobalExceptionHandler.java # MODIFIED: handle EventExpiredException
|
||||||
|
│ │ └── out/persistence/
|
||||||
|
│ │ ├── EventPersistenceAdapter.java # MODIFIED: map typed tokens
|
||||||
|
│ │ ├── RsvpJpaEntity.java # NEW: JPA entity
|
||||||
|
│ │ ├── RsvpJpaRepository.java # NEW: Spring Data interface
|
||||||
|
│ │ └── RsvpPersistenceAdapter.java # NEW: port implementation
|
||||||
|
├── src/main/resources/
|
||||||
|
│ ├── openapi/api.yaml # MODIFIED: add RSVP endpoint + schemas
|
||||||
|
│ └── db/changelog/
|
||||||
|
│ ├── db.changelog-master.xml # MODIFIED: include 003
|
||||||
|
│ └── 003-create-rsvps-table.xml # NEW: rsvps table
|
||||||
|
└── src/test/java/de/fete/
|
||||||
|
├── application/service/
|
||||||
|
│ ├── EventServiceTest.java # MODIFIED: use typed tokens
|
||||||
|
│ └── RsvpServiceTest.java # NEW: unit tests
|
||||||
|
└── adapter/in/web/
|
||||||
|
└── EventControllerIntegrationTest.java # MODIFIED: typed tokens + RSVP integration tests
|
||||||
|
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── api/schema.d.ts # REGENERATED from OpenAPI
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── BottomSheet.vue # NEW: reusable bottom sheet
|
||||||
|
│ │ └── RsvpBar.vue # NEW: sticky bottom bar
|
||||||
|
│ ├── views/EventDetailView.vue # MODIFIED: integrate RSVP bar + sheet
|
||||||
|
│ ├── composables/useEventStorage.ts # MODIFIED: add rsvpToken/rsvpName
|
||||||
|
│ └── assets/main.css # MODIFIED: bottom sheet + bar styles
|
||||||
|
├── src/views/__tests__/EventDetailView.spec.ts # MODIFIED: RSVP integration tests
|
||||||
|
├── src/components/__tests__/
|
||||||
|
│ ├── BottomSheet.spec.ts # NEW: unit tests
|
||||||
|
│ └── RsvpBar.spec.ts # NEW: unit tests
|
||||||
|
├── src/composables/__tests__/useEventStorage.spec.ts # MODIFIED: test new fields
|
||||||
|
└── e2e/
|
||||||
|
└── event-rsvp.spec.ts # NEW: E2E tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Extends the existing web application structure (backend + frontend). Adds a new RSVP domain following the same hexagonal architecture pattern established in 006-create-event and 007-view-event. Cross-cutting refactoring introduces typed token value objects (`EventToken`, `OrganizerToken`, `RsvpToken`) across all layers. Two new frontend components (`BottomSheet`, `RsvpBar`) are the first entries in `src/components/` — justified because they're reusable UI primitives, not view-specific markup.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations. No entries needed.
|
||||||
58
specs/008-rsvp/quickstart.md
Normal file
58
specs/008-rsvp/quickstart.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Quickstart: RSVP to an Event (008)
|
||||||
|
|
||||||
|
## What this feature adds
|
||||||
|
|
||||||
|
1. **Backend**: New `POST /api/events/{eventToken}/rsvps` endpoint that accepts an RSVP (guest name) and returns an `rsvpToken`. Populates the existing `attendeeCount` field in `GET /events/{token}` with real data.
|
||||||
|
|
||||||
|
2. **Frontend**: Bottom sheet RSVP form on the event detail page. Sticky bottom bar with CTA (or status after RSVP). localStorage persistence of RSVP data.
|
||||||
|
|
||||||
|
## Implementation order
|
||||||
|
|
||||||
|
1. **Token value objects** — Create `EventToken`, `OrganizerToken`, `RsvpToken` records. Refactor `Event` domain model and all layers (service, controller, repository, persistence adapter, tests) to use typed tokens instead of raw UUID.
|
||||||
|
2. **OpenAPI spec** — Add `CreateRsvpRequest`, `CreateRsvpResponse`, and the `POST /events/{eventToken}/rsvps` endpoint.
|
||||||
|
3. **Liquibase migration** — Create `rsvps` table (003-create-rsvps-table.xml).
|
||||||
|
4. **Domain model** — `Rsvp` entity using `RsvpToken`.
|
||||||
|
5. **Ports** — `CreateRsvpUseCase` (in), `RsvpRepository` (out).
|
||||||
|
6. **Persistence adapter** — `RsvpJpaEntity`, `RsvpJpaRepository`, `RsvpPersistenceAdapter`.
|
||||||
|
7. **Service** — `RsvpService` implementing `CreateRsvpUseCase`.
|
||||||
|
8. **Controller** — Add `createRsvp()` to `EventController`.
|
||||||
|
9. **Attendee count** — Wire `RsvpRepository.countByEventId()` into the GET event flow.
|
||||||
|
10. **Frontend composable** — Extend `useEventStorage` with `rsvpToken`/`rsvpName`.
|
||||||
|
11. **Frontend UI** — Bottom sheet component, sticky bar, RSVP form.
|
||||||
|
12. **E2E tests** — RSVP submission, expired event guard, localStorage verification.
|
||||||
|
|
||||||
|
## Key files to touch
|
||||||
|
|
||||||
|
### Backend (new)
|
||||||
|
- `domain/model/EventToken.java`
|
||||||
|
- `domain/model/OrganizerToken.java`
|
||||||
|
- `domain/model/Rsvp.java`
|
||||||
|
- `domain/model/RsvpToken.java`
|
||||||
|
- `domain/port/in/CreateRsvpUseCase.java`
|
||||||
|
- `domain/port/out/RsvpRepository.java`
|
||||||
|
- `application/service/RsvpService.java`
|
||||||
|
- `adapter/out/persistence/RsvpJpaEntity.java`
|
||||||
|
- `adapter/out/persistence/RsvpJpaRepository.java`
|
||||||
|
- `adapter/out/persistence/RsvpPersistenceAdapter.java`
|
||||||
|
- `db/changelog/003-create-rsvps-table.xml`
|
||||||
|
|
||||||
|
### Backend (modified)
|
||||||
|
- `domain/model/Event.java` — UUID → EventToken/OrganizerToken
|
||||||
|
- `application/service/EventService.java` — use typed tokens
|
||||||
|
- `adapter/in/web/EventController.java` — typed tokens + wire attendee count + createRsvp()
|
||||||
|
- `adapter/in/web/GlobalExceptionHandler.java` — handle `EventExpiredException` (409)
|
||||||
|
- `adapter/out/persistence/EventPersistenceAdapter.java` — map typed tokens
|
||||||
|
- `domain/port/out/EventRepository.java` — typed token in signature
|
||||||
|
- `openapi/api.yaml` — new endpoint + schemas
|
||||||
|
- `db/changelog/db.changelog-master.xml` — include new migration
|
||||||
|
- All existing tests — update to use typed tokens
|
||||||
|
|
||||||
|
### Frontend (new)
|
||||||
|
- `src/components/BottomSheet.vue` — reusable bottom sheet
|
||||||
|
- `src/components/RsvpBar.vue` — sticky bottom bar (CTA or status)
|
||||||
|
- `e2e/event-rsvp.spec.ts` — E2E tests
|
||||||
|
|
||||||
|
### Frontend (modified)
|
||||||
|
- `src/views/EventDetailView.vue` — integrate RSVP bar + bottom sheet
|
||||||
|
- `src/composables/useEventStorage.ts` — add rsvpToken/rsvpName fields
|
||||||
|
- `src/api/schema.d.ts` — regenerated from OpenAPI
|
||||||
157
specs/008-rsvp/research.md
Normal file
157
specs/008-rsvp/research.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# Research: RSVP to an Event (008)
|
||||||
|
|
||||||
|
**Date**: 2026-03-06 | **Status**: Complete
|
||||||
|
|
||||||
|
## R-1: RSVP Endpoint Design
|
||||||
|
|
||||||
|
**Decision**: `POST /api/events/{eventToken}/rsvps` creates an RSVP. Returns `201` with `rsvpToken` on success. Rejects with `409 Conflict` if event expired.
|
||||||
|
|
||||||
|
**Rationale**: RSVPs are a sub-resource of events — nesting under the event token is RESTful and groups operations logically. The event token in the path identifies the event; no separate event ID in the request body needed.
|
||||||
|
|
||||||
|
**Flow**:
|
||||||
|
1. `EventController` implements generated `EventsApi.createRsvp()` — same controller, same tag, sub-resource of events.
|
||||||
|
2. Controller resolves the event via `eventToken`, checks expiry.
|
||||||
|
3. New inbound port: `CreateRsvpUseCase` with `createRsvp(CreateRsvpCommand): Rsvp`.
|
||||||
|
4. `RsvpService` validates event exists + not expired, persists RSVP, returns domain model.
|
||||||
|
5. Controller maps to `CreateRsvpResponse` DTO (contains `rsvpToken`).
|
||||||
|
6. 404 if event not found, 409 if event expired, 400 for validation errors.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- `POST /api/rsvps` with eventToken in body — rejected because RSVPs are always scoped to an event. Nested resource is cleaner.
|
||||||
|
- Separate `RsvpController` — rejected because the URL is under `/events/`, so it belongs in `EventController`. One controller per resource root (KISS).
|
||||||
|
- `PUT` instead of `POST` — rejected because the client doesn't know the rsvpToken before creation.
|
||||||
|
|
||||||
|
## R-2: Token Value Objects
|
||||||
|
|
||||||
|
**Decision**: Introduce all three token types as Java records wrapping `UUID`: `EventToken`, `OrganizerToken`, and `RsvpToken`. Refactor the existing `Event` domain model and all layers (service, controller, repository, persistence adapter) to use the typed tokens instead of raw `UUID`.
|
||||||
|
|
||||||
|
**Rationale**: The spec mandates typed token records. Introducing `RsvpToken` alone while leaving the other two as raw UUIDs would create an inconsistency in the domain model. All three tokens serve the same purpose (type-safe identification) and should be modeled uniformly. The cross-cutting refactoring touches existing code but is mechanical and well-covered by existing tests.
|
||||||
|
|
||||||
|
**Implementation pattern** (same for all three):
|
||||||
|
```java
|
||||||
|
package de.fete.domain.model;
|
||||||
|
|
||||||
|
public record EventToken(UUID value) {
|
||||||
|
public EventToken {
|
||||||
|
Objects.requireNonNull(value, "eventToken must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static EventToken generate() {
|
||||||
|
return new EventToken(UUID.randomUUID());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```java
|
||||||
|
public record OrganizerToken(UUID value) { /* same pattern */ }
|
||||||
|
public record RsvpToken(UUID value) { /* same pattern */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact on existing code**:
|
||||||
|
- `Event.java`: `UUID eventToken` → `EventToken eventToken`, `UUID organizerToken` → `OrganizerToken organizerToken`
|
||||||
|
- `EventService.java`: `UUID.randomUUID()` → `EventToken.generate()` / `OrganizerToken.generate()`
|
||||||
|
- `EventController.java`: unwrap tokens at API boundary (`token.value()`)
|
||||||
|
- `EventRepository.java` / `EventPersistenceAdapter.java`: map between domain tokens and raw UUIDs for JPA
|
||||||
|
- `EventJpaEntity.java`: stays with raw `UUID` columns (JPA mapping layer)
|
||||||
|
- All existing tests: update to use typed tokens
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Use raw UUID everywhere — rejected because the spec explicitly requires typed records and they prevent mixing up token types at compile time.
|
||||||
|
- Introduce only `RsvpToken` now — rejected because it creates an inconsistency. All three should be uniform.
|
||||||
|
|
||||||
|
## R-3: Attendee Count Population
|
||||||
|
|
||||||
|
**Decision**: Populate `attendeeCount` in `GetEventResponse` by counting RSVP rows for the event. The count query lives in the `RsvpRepository` port and is called by `EventService` (or a query in `RsvpService` delegated to by `EventController`).
|
||||||
|
|
||||||
|
**Rationale**: The `attendeeCount` field already exists in the API contract (returns 0 today per R-3 in 007). Now it gets real data. Since an RSVP entry's existence implies attendance (no `attending` boolean), the count is simply `COUNT(*) WHERE event_id = ?`.
|
||||||
|
|
||||||
|
**Implementation approach**: Add `countByEventId(Long eventId)` to `RsvpRepository`. `EventService.getByEventToken()` returns the Event domain object; the controller queries the RSVP count separately and sets it on the response. This keeps Event and RSVP domains loosely coupled.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Store count on the Event entity (denormalized) — rejected because it introduces update anomalies and requires synchronization logic.
|
||||||
|
- Join query in EventRepository — rejected because it couples Event persistence to RSVP schema.
|
||||||
|
|
||||||
|
## R-4: Expired Event Guard
|
||||||
|
|
||||||
|
**Decision**: Both client and server enforce the expiry guard. Client hides the RSVP form when `expired === true` (from GetEventResponse). Server rejects `POST /rsvps` with `409 Conflict` when `event.expiryDate < today`.
|
||||||
|
|
||||||
|
**Rationale**: Defense in depth. The client check is UX (don't show a form that can't succeed). The server check is the authoritative guard (clients can be bypassed). Using `409 Conflict` rather than `400 Bad Request` because the request format is valid — it's the event state that prevents the operation.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Client-only guard — rejected because clients can be bypassed.
|
||||||
|
- `400 Bad Request` — rejected because the request body is valid; the conflict is with the event's state.
|
||||||
|
- `422 Unprocessable Entity` — acceptable but `409` better communicates "the resource state conflicts with this operation."
|
||||||
|
|
||||||
|
## R-5: localStorage Schema for RSVP
|
||||||
|
|
||||||
|
**Decision**: Extend the existing `fete:events` localStorage structure. Each `StoredEvent` entry gains optional `rsvpToken` and `rsvpName` fields.
|
||||||
|
|
||||||
|
**Rationale**: The existing `useEventStorage` composable already stores events by token. Adding RSVP data to the same entry avoids a second localStorage key and keeps event data co-located. The spec requires storing: rsvpToken, name, event token, event title, event date — the last three are already in `StoredEvent`.
|
||||||
|
|
||||||
|
**Schema change**:
|
||||||
|
```typescript
|
||||||
|
interface StoredEvent {
|
||||||
|
eventToken: string
|
||||||
|
organizerToken?: string // existing (for organizers)
|
||||||
|
title: string // existing
|
||||||
|
dateTime: string // existing
|
||||||
|
expiryDate: string // existing
|
||||||
|
rsvpToken?: string // NEW — present after RSVP
|
||||||
|
rsvpName?: string // NEW — guest's submitted name
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Separate `fete:rsvps` localStorage key — rejected because it duplicates event metadata (title, date) and complicates lookups.
|
||||||
|
- IndexedDB — rejected (over-engineering for a few KBs of data).
|
||||||
|
|
||||||
|
## R-6: Bottom Sheet UI Pattern
|
||||||
|
|
||||||
|
**Decision**: Implement the bottom sheet as a Vue component using CSS transforms and transitions. No UI library dependency.
|
||||||
|
|
||||||
|
**Rationale**: The spec requires a bottom sheet for the RSVP form. A custom implementation using `transform: translateY()` with CSS transitions is lightweight, accessible, and avoids new dependencies (Principle V). The sheet slides up from the bottom on open and back down on close.
|
||||||
|
|
||||||
|
**Key implementation details**:
|
||||||
|
- Overlay backdrop (semi-transparent) with click-to-dismiss
|
||||||
|
- `<dialog>` element or ARIA `role="dialog"` with `aria-modal="true"`
|
||||||
|
- Focus trap inside the sheet (keyboard accessibility)
|
||||||
|
- ESC key to close
|
||||||
|
- Transition: `transform 0.3s ease-out`
|
||||||
|
- Mobile: full-width, max-height ~50vh. Desktop: full-width within the 480px column.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Modal/dialog instead of bottom sheet — rejected because bottom sheets are the mobile-native pattern for contextual actions.
|
||||||
|
- Headless UI library (e.g., @headlessui/vue) — rejected because it adds a dependency for a single component. Custom implementation is ~50 lines.
|
||||||
|
|
||||||
|
## R-7: Sticky Bottom Bar
|
||||||
|
|
||||||
|
**Decision**: The sticky bar is a `position: fixed` element at the bottom of the viewport, within the content column (max 480px). It contains either the RSVP CTA button or the RSVP status text.
|
||||||
|
|
||||||
|
**Rationale**: The spec defines two states for the bar:
|
||||||
|
1. **No RSVP**: Shows CTA button (accent color, "Ich bin dabei!" or similar)
|
||||||
|
2. **Has RSVP**: Shows status text ("Du kommst!" + edit hint, though edit is out of scope)
|
||||||
|
|
||||||
|
The bar state is determined by checking localStorage for an rsvpToken for the current event.
|
||||||
|
|
||||||
|
**CSS pattern**:
|
||||||
|
```css
|
||||||
|
.sticky-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px; /* matches content column */
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
/* glass-morphism or solid surface */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- `position: sticky` at bottom of content — rejected because it only sticks within the scroll container, not the viewport. Fixed is needed for always-visible CTA.
|
||||||
|
|
||||||
|
## R-8: Cancelled Event Guard (Deferred)
|
||||||
|
|
||||||
|
**Decision**: FR-010 (cancelled event guard) is deferred per spec. The code will NOT check for cancellation status. This will be added when US-18 (cancel event) is implemented.
|
||||||
|
|
||||||
|
**Rationale**: No cancellation field exists on the Event model yet. Adding a guard for a non-existent state violates KISS (Principle IV).
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user