Compare commits
42 Commits
9e48debca7
...
0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | ||
|
|
6e655597d7 | ||
|
|
e10b88ee5f | ||
|
|
465fc2178f | ||
|
|
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,10 +7,10 @@ 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@v4
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 25
|
java-version: 25
|
||||||
@@ -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,10 @@ The following skills are available and should be used for their respective purpo
|
|||||||
- The loop runner is `ralph.sh`. Each run lives in its own directory under `.ralph/`.
|
- 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)
|
||||||
|
|
||||||
|
## 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>
|
||||||
|
|||||||
@@ -3,9 +3,24 @@ 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.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.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.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.GetEventUseCase;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.DateTimeException;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.UUID;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.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 +30,35 @@ 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 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,
|
||||||
|
Clock clock) {
|
||||||
this.createEventUseCase = createEventUseCase;
|
this.createEventUseCase = createEventUseCase;
|
||||||
|
this.getEventUseCase = getEventUseCase;
|
||||||
|
this.createRsvpUseCase = createRsvpUseCase;
|
||||||
|
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
|
||||||
|
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 +66,55 @@ 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<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,10 @@
|
|||||||
package de.fete.adapter.in.web;
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
|
import de.fete.application.service.EventExpiredException;
|
||||||
|
import de.fete.application.service.EventNotFoundException;
|
||||||
|
import de.fete.application.service.ExpiryDateBeforeEventException;
|
||||||
import de.fete.application.service.ExpiryDateInPastException;
|
import de.fete.application.service.ExpiryDateInPastException;
|
||||||
|
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 +48,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 +74,45 @@ 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 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,14 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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,56 @@
|
|||||||
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
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.out.EventRepository;
|
||||||
|
import de.fete.domain.port.out.RsvpRepository;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/** Application service implementing RSVP creation. */
|
||||||
|
@Service
|
||||||
|
public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseCase {
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.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,13 @@
|
|||||||
|
package de.fete.domain.port.out;
|
||||||
|
|
||||||
|
import de.fete.domain.model.Rsvp;
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
@@ -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,80 @@ 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}:
|
||||||
|
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 +118,7 @@ components:
|
|||||||
required:
|
required:
|
||||||
- title
|
- title
|
||||||
- dateTime
|
- dateTime
|
||||||
|
- timezone
|
||||||
- expiryDate
|
- expiryDate
|
||||||
properties:
|
properties:
|
||||||
title:
|
title:
|
||||||
@@ -58,6 +133,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 +153,7 @@ components:
|
|||||||
- organizerToken
|
- organizerToken
|
||||||
- title
|
- title
|
||||||
- dateTime
|
- dateTime
|
||||||
|
- timezone
|
||||||
- expiryDate
|
- expiryDate
|
||||||
properties:
|
properties:
|
||||||
eventToken:
|
eventToken:
|
||||||
@@ -93,11 +173,89 @@ 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"
|
||||||
|
|
||||||
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,421 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,137 @@
|
|||||||
|
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.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.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
250
frontend/e2e/home-events.spec.ts
Normal file
250
frontend/e2e/home-events.spec.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
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('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()
|
||||||
|
})
|
||||||
|
})
|
||||||
222
frontend/package-lock.json
generated
222
frontend/package-lock.json
generated
@@ -26,14 +26,14 @@
|
|||||||
"@vue/tsconfig": "^0.9.0",
|
"@vue/tsconfig": "^0.9.0",
|
||||||
"eslint": "^10.0.2",
|
"eslint": "^10.0.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-oxlint": "~1.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"
|
||||||
],
|
],
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -38,14 +38,14 @@
|
|||||||
"@vue/tsconfig": "^0.9.0",
|
"@vue/tsconfig": "^0.9.0",
|
||||||
"eslint": "^10.0.2",
|
"eslint": "^10.0.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-oxlint": "~1.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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
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>
|
||||||
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>
|
||||||
172
frontend/src/components/EventCard.vue
Normal file
172
frontend/src/components/EventCard.vue
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<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">{{ relativeTime }}</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'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
delete: [eventToken: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
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>
|
||||||
79
frontend/src/components/EventList.vue
Normal file
79
frontend/src/components/EventList.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<div class="event-list" role="list" aria-label="Your events">
|
||||||
|
<div v-for="event in sortedEvents" :key="event.eventToken" role="listitem">
|
||||||
|
<EventCard
|
||||||
|
:event-token="event.eventToken"
|
||||||
|
:title="event.title"
|
||||||
|
:relative-time="formatRelativeTime(event.dateTime)"
|
||||||
|
:is-past="isPast(event.dateTime)"
|
||||||
|
:event-role="getRole(event)"
|
||||||
|
@delete="requestDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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 { formatRelativeTime } from '../composables/useRelativeTime'
|
||||||
|
import EventCard from './EventCard.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 isPast(dateTime: string): boolean {
|
||||||
|
return new Date(dateTime) < new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRole(event: StoredEvent): 'organizer' | 'attendee' | undefined {
|
||||||
|
if (event.organizerToken) return 'organizer'
|
||||||
|
if (event.rsvpToken) return 'attendee'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedEvents = computed(() => {
|
||||||
|
const valid = getStoredEvents().filter(isValidStoredEvent)
|
||||||
|
const now = new Date()
|
||||||
|
const upcoming = valid.filter((e) => new Date(e.dateTime) >= now)
|
||||||
|
const past = valid.filter((e) => new Date(e.dateTime) < now)
|
||||||
|
upcoming.sort((a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime())
|
||||||
|
past.sort((a, b) => new Date(b.dateTime).getTime() - new Date(a.dateTime).getTime())
|
||||||
|
return [...upcoming, ...past]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.event-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>
|
||||||
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
76
frontend/src/components/__tests__/EventCard.spec.ts
Normal file
76
frontend/src/components/__tests__/EventCard.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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']])
|
||||||
|
})
|
||||||
|
})
|
||||||
79
frontend/src/components/__tests__/EventList.spec.ts
Normal file
79
frontend/src/components/__tests__/EventList.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } 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 />' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockEvents = [
|
||||||
|
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2025-01-01T10:00:00Z', expiryDate: '' },
|
||||||
|
{ eventToken: 'future-1', title: 'Future Event', dateTime: '2027-06-15T10:00:00Z', expiryDate: '' },
|
||||||
|
{ eventToken: 'future-2', title: 'Soon Event', dateTime: '2027-01-01T10:00:00Z', 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.startsWith('2025')) return '1 year ago'
|
||||||
|
if (dateTime.includes('06-15')) return 'in 1 year'
|
||||||
|
return 'in 10 months'
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
function mountList() {
|
||||||
|
return mount(EventList, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EventList', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date('2026-03-08T12:00:00Z'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all valid events', () => {
|
||||||
|
const wrapper = mountList()
|
||||||
|
const cards = wrapper.findAll('.event-card')
|
||||||
|
expect(cards).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts upcoming events before past events', () => {
|
||||||
|
const wrapper = mountList()
|
||||||
|
const titles = wrapper.findAll('.event-card__title').map((el) => el.text())
|
||||||
|
// Upcoming events first (sorted ascending), then past events
|
||||||
|
expect(titles[0]).toBe('Soon Event')
|
||||||
|
expect(titles[1]).toBe('Future Event')
|
||||||
|
expect(titles[2]).toBe('Past Event')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks past events with isPast class', () => {
|
||||||
|
const wrapper = mountList()
|
||||||
|
const cards = wrapper.findAll('.event-card')
|
||||||
|
expect(cards).toHaveLength(3)
|
||||||
|
// Last card should be past
|
||||||
|
expect(cards[2]!.classes()).toContain('event-card--past')
|
||||||
|
// First two should not be past
|
||||||
|
expect(cards[0]!.classes()).not.toContain('event-card--past')
|
||||||
|
expect(cards[1]!.classes()).not.toContain('event-card--past')
|
||||||
|
})
|
||||||
|
})
|
||||||
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
317
frontend/src/views/EventDetailView.vue
Normal file
317
frontend/src/views/EventDetailView.vue
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
<template>
|
||||||
|
<main class="detail">
|
||||||
|
<header class="detail__header">
|
||||||
|
<RouterLink to="/" class="detail__back" aria-label="Back to home">←</RouterLink>
|
||||||
|
<span class="detail__brand">fete</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="state === 'loading'" class="detail__card" 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__card">
|
||||||
|
<h1 class="detail__title">{{ event.title }}</h1>
|
||||||
|
|
||||||
|
<dl class="detail__fields">
|
||||||
|
<div class="detail__field">
|
||||||
|
<dt class="detail__label">Date & Time</dt>
|
||||||
|
<dd class="detail__value">{{ formattedDateTime }}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="event.description" class="detail__field">
|
||||||
|
<dt class="detail__label">Description</dt>
|
||||||
|
<dd class="detail__value">{{ event.description }}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="event.location" class="detail__field">
|
||||||
|
<dt class="detail__label">Location</dt>
|
||||||
|
<dd class="detail__value">{{ event.location }}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail__field">
|
||||||
|
<dt class="detail__label">Attendees</dt>
|
||||||
|
<dd class="detail__value">{{ event.attendeeCount }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
|
||||||
|
This event has ended.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Not found state -->
|
||||||
|
<div v-else-if="state === 'not-found'" class="detail__card detail__card--center" role="status">
|
||||||
|
<p class="detail__message">Event not found.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-else-if="state === 'error'" class="detail__card detail__card--center" role="alert">
|
||||||
|
<p class="detail__message">Something went wrong.</p>
|
||||||
|
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
|
||||||
|
</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 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 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
|
||||||
|
isOrganizer.value = !!getOrganizerToken(event.value.eventToken)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchEvent)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-2xl);
|
||||||
|
padding-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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__card {
|
||||||
|
background: var(--color-card);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__card--center {
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__value {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__banner {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__banner--expired {
|
||||||
|
background: #fff3e0;
|
||||||
|
color: #e65100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__message {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton sizes */
|
||||||
|
.skeleton--title {
|
||||||
|
height: 1.6rem;
|
||||||
|
width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton--line {
|
||||||
|
height: 1rem;
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton--short {
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
</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' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
368
frontend/src/views/__tests__/EventDetailView.spec.ts
Normal file
368
frontend/src/views/__tests__/EventDetailView.spec.ts
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
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__value')[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()
|
||||||
|
})
|
||||||
|
|
||||||
|
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).
|
||||||
@@ -5,11 +5,25 @@
|
|||||||
**Status**: Draft
|
**Status**: Draft
|
||||||
**Source**: Migrated from spec/userstories.md
|
**Source**: Migrated from spec/userstories.md
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-03-06
|
||||||
|
|
||||||
|
- Q: How should the server deduplicate RSVPs from the same device without accounts? → A: Server generates an `rsvpToken` (UUID) on first RSVP, returns it to the client. Client stores it in localStorage per event. On re-RSVP, client sends the `rsvpToken` to update instead of create. No global device identifier — the token is event-scoped. If localStorage is lost (cleared or different device), a duplicate entry is accepted as a privacy trade-off.
|
||||||
|
- Q: Should typed token value objects be used in the backend? → A: Yes. Backend: The three token types (EventToken, OrganizerToken, RsvpToken) MUST be modeled as distinct Java record types wrapping UUID, not passed as raw UUID values. Frontend: No branded types — plain string variables with clear naming (eventToken, rsvpToken) are sufficient given TypeScript's structural typing and OpenAPI codegen.
|
||||||
|
- Q: How should the RSVP interaction be presented on the event page? → A: Fullscreen event presentation (gradient background, later Unsplash). Title prominent at top, key facts below (description, date, attendee count) — spacious layout. Sticky bottom bar with RSVP CTA. Tap opens a bottom sheet with the RSVP form. After RSVP, the bar shows status ("Du kommst!" + edit option) instead of the CTA.
|
||||||
|
- Q: How does the RSVP form handle declining? → A: There is no explicit "not attending" button. The bottom sheet only offers the attending flow (name + submit). To not attend, the guest simply closes the sheet. Withdrawing an existing RSVP (DELETE with rsvpToken) is out of scope — deferred to a separate edit-RSVP spec.
|
||||||
|
- Q: Should the attendee name list be publicly visible on the event page? → A: No. Only the attendee count is shown publicly. The full name list is visible only to the organizer (via organizer link). This maximizes guest privacy.
|
||||||
|
- Q: Should the RSVP endpoint have spam/abuse protection? → A: No. The RSVP endpoint is intentionally unprotected — risk is consciously accepted as a privacy trade-off consistent with the no-account, no-tracking philosophy. Protection measures can be retrofitted in a separate spec if real-world abuse occurs. KISS.
|
||||||
|
- Q: How is the attendee count delivered and updated? → A: As a new `attendeeCount` field in the existing Event response (no separate endpoint). Loaded once on page load, no polling or WebSocket. After the guest's own RSVP submission, the count is optimistically incremented (+1) client-side. KISS.
|
||||||
|
- Q: What determines the RSVP cutoff? → A: The event date itself. No separate expiry field. After the event date has passed, RSVPs are blocked (form hidden, server rejects).
|
||||||
|
- Q: Should the RSVP entity have an `attending` boolean field? → A: No. The server only stores attending RSVPs — existence of an entry implies attendance. No `attending` boolean needed. Deletion of entries (withdrawal) is deferred to the edit-RSVP spec.
|
||||||
|
|
||||||
## User Scenarios & Testing
|
## User Scenarios & Testing
|
||||||
|
|
||||||
### User Story 1 - Submit an RSVP (Priority: P1)
|
### User Story 1 - Submit an RSVP (Priority: P1)
|
||||||
|
|
||||||
A guest opens an active event page and indicates whether they will attend. If attending, they must provide their name. If not attending, the name is optional. The RSVP is sent to the server and persisted. The guest's choice, name, event token, title, and date are saved in localStorage.
|
A guest opens an active event page, which presents the event fullscreen (gradient background, title prominent at top, key facts below including attendee count). A sticky bottom bar shows an RSVP call-to-action. Tapping opens a bottom sheet with the RSVP form: name field + submit. The RSVP is sent to the server and persisted. The server returns an rsvpToken which, along with the name, event token, title, and date, is saved in localStorage. After submission, the bottom sheet closes and the sticky bar shows the guest's RSVP status. To not attend, the guest simply closes the sheet — no server request.
|
||||||
|
|
||||||
**Why this priority**: Core interactive feature of the app. Without it, guests cannot communicate attendance, and the attendee list (US-2) has no data.
|
**Why this priority**: Core interactive feature of the app. Without it, guests cannot communicate attendance, and the attendee list (US-2) has no data.
|
||||||
|
|
||||||
@@ -17,30 +31,15 @@ A guest opens an active event page and indicates whether they will attend. If at
|
|||||||
|
|
||||||
**Acceptance Scenarios**:
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
1. **Given** a guest is on an active event page, **When** they select "I'm attending" and enter their name, **Then** the RSVP is submitted to the server, persisted, and the attendee list reflects the new entry.
|
1. **Given** a guest is on an active event page, **When** they tap the RSVP CTA, enter their name, and submit, **Then** the RSVP is submitted to the server, persisted, and the attendee count updates.
|
||||||
2. **Given** a guest is on an active event page, **When** they select "I'm attending" but leave the name blank, **Then** the form is not submitted and a validation message indicating the name is required is shown.
|
2. **Given** a guest has opened the bottom sheet, **When** they leave the name blank and try to submit, **Then** the form is not submitted and a validation message is shown.
|
||||||
3. **Given** a guest is on an active event page, **When** they select "I'm not attending" without entering a name, **Then** the RSVP is submitted successfully (name is optional for non-attendees).
|
3. **Given** a guest has opened the bottom sheet, **When** they close it without submitting, **Then** no server request is made and no state changes.
|
||||||
4. **Given** a guest submits an RSVP (attending or not), **When** the submission succeeds, **Then** the guest's RSVP choice, name, event token, event title, and event date are stored in localStorage on this device.
|
4. **Given** a guest submits an RSVP, **When** the submission succeeds, **Then** the rsvpToken, name, event token, event title, and event date are stored in localStorage on this device.
|
||||||
5. **Given** a guest submits an RSVP, **When** the submission succeeds, **Then** no account, login, or personal data beyond the optionally entered name is required.
|
5. **Given** a guest submits an RSVP, **When** the submission succeeds, **Then** no account, login, or personal data beyond the entered name is required.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### User Story 2 - Re-RSVP from the Same Device (Priority: P2)
|
### User Story 2 - RSVP Blocked on Expired or Cancelled Events (Priority: P2)
|
||||||
|
|
||||||
A returning guest on the same device opens an event page where they previously submitted an RSVP. The form pre-fills with their prior choice and name. Re-submitting updates the existing RSVP rather than creating a duplicate.
|
|
||||||
|
|
||||||
**Why this priority**: Prevents duplicate entries and provides a better UX for guests who want to change their mind. Depends on Story 1 populating localStorage.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by RSVPing once, then reloading the event page and verifying the form is pre-filled and a second submission updates rather than duplicates the server-side record.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a guest has previously submitted an RSVP on this device, **When** they open the same event page again, **Then** the RSVP form is pre-filled with their previous choice and name.
|
|
||||||
2. **Given** a guest has a prior RSVP pre-filled, **When** they change their selection and re-submit, **Then** the existing server-side RSVP entry is updated and no duplicate entry is created.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - RSVP Blocked on Expired or Cancelled Events (Priority: P2)
|
|
||||||
|
|
||||||
A guest attempts to RSVP to an event that has already expired or has been cancelled. The RSVP form is not shown and the server rejects any submission attempts.
|
A guest attempts to RSVP to an event that has already expired or has been cancelled. The RSVP form is not shown and the server rejects any submission attempts.
|
||||||
|
|
||||||
@@ -58,37 +57,40 @@ A guest attempts to RSVP to an event that has already expired or has been cancel
|
|||||||
|
|
||||||
### Edge Cases
|
### Edge Cases
|
||||||
|
|
||||||
- What happens when a guest RSVPs on two different devices? Each device stores its own localStorage entry; the server holds both RSVPs as separate entries (no deduplication across devices — acceptable per design, consistent with the no-account model).
|
- What happens when a guest RSVPs on two different devices? Each device stores its own localStorage entry; the server holds both RSVPs as separate entries (no deduplication across devices — accepted privacy trade-off).
|
||||||
- What happens when the server is unreachable during RSVP submission? The submission fails; localStorage is not updated (no optimistic write). The guest sees an error and can retry.
|
- What happens when the server is unreachable during RSVP submission? The submission fails; localStorage is not updated (no optimistic write). The guest sees an error and can retry.
|
||||||
- What happens if localStorage is cleared after RSVPing? The form no longer pre-fills and the guest can re-submit; the server will create a new RSVP entry rather than update the old one.
|
- What happens if localStorage is cleared after RSVPing? The sticky bar shows the CTA again (as if no prior RSVP). A new submission creates a duplicate server-side entry — accepted privacy trade-off.
|
||||||
|
- What about spam/abuse on the unprotected RSVP endpoint? Risk is consciously accepted (KISS, privacy-first). No rate limiting, no honeypot, no CAPTCHA. Can be retrofitted in a future spec if real-world abuse occurs.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Functional Requirements
|
### Functional Requirements
|
||||||
|
|
||||||
- **FR-001**: The RSVP form MUST offer exactly two choices: "I'm attending" and "I'm not attending".
|
- **FR-001**: The RSVP bottom sheet MUST offer an attending flow only: name field (required, max 100 characters) + submit. There is no explicit "not attending" option — the guest simply closes the sheet.
|
||||||
- **FR-002**: When the guest selects "I'm attending", the name field MUST be required; submission MUST be blocked if the name is blank.
|
- **FR-002**: Submission MUST be blocked if the name is blank or exceeds 100 characters.
|
||||||
- **FR-003**: When the guest selects "I'm not attending", the name field MUST be optional; submission MUST succeed without a name.
|
- **FR-003**: If a prior RSVP for this event exists in localStorage (rsvpToken present), the sticky bottom bar MUST show the guest's RSVP status instead of the initial CTA. Editing the RSVP (name change, withdrawal) is out of scope for this spec.
|
||||||
- **FR-004**: On successful RSVP submission, the server MUST persist the RSVP associated with the event.
|
- **FR-004**: On successful attending RSVP submission, the server MUST persist the RSVP associated with the event and return an rsvpToken.
|
||||||
- **FR-005**: On successful RSVP submission, the client MUST store the guest's RSVP choice and name in localStorage, keyed by event token.
|
- **FR-005**: On successful RSVP submission, the client MUST store the guest's name and the server-returned rsvpToken in localStorage, keyed by event token.
|
||||||
- **FR-006**: On successful RSVP submission, the client MUST store the event token, event title, and event date in localStorage (to support the local event overview, US-7).
|
- **FR-006**: On successful RSVP submission, the client MUST store the event token, event title, and event date in localStorage (to support the local event overview, US-7).
|
||||||
- **FR-007**: If a prior RSVP for this event exists in localStorage, the form MUST pre-fill with the stored choice and name on page load.
|
- **FR-013**: The event page MUST present the event fullscreen with a sticky bottom bar containing the RSVP call-to-action. Tapping the CTA MUST open a bottom sheet with the RSVP form.
|
||||||
- **FR-008**: Re-submitting an RSVP from a device that has an existing server-side entry for this event MUST update the existing entry, not create a new one.
|
- **FR-014**: After successful RSVP submission, the bottom sheet MUST close and the sticky bar MUST transition to showing the RSVP status.
|
||||||
- **FR-009**: The RSVP form MUST NOT be shown and the server MUST reject RSVP submissions after the event's expiry date has passed.
|
- **FR-009**: The RSVP form MUST NOT be shown and the server MUST reject RSVP submissions after the event's expiry date has passed.
|
||||||
- **FR-010**: The RSVP form MUST NOT be shown and the server MUST reject RSVP submissions if the event has been cancelled [enforcement deferred until US-18 is implemented].
|
- **FR-010**: The RSVP form MUST NOT be shown and the server MUST reject RSVP submissions if the event has been cancelled [enforcement deferred until US-18 is implemented].
|
||||||
- **FR-011**: RSVP submission MUST NOT require an account, login, or any personal data beyond the optionally entered name.
|
- **FR-011**: RSVP submission MUST NOT require an account, login, or any personal data beyond the entered name.
|
||||||
- **FR-012**: No personal data or IP address MUST be logged on the server when processing an RSVP.
|
- **FR-012**: No personal data or IP address MUST be logged on the server when processing an RSVP.
|
||||||
|
- **FR-015**: The event page MUST show only the attendee count publicly. The full attendee name list is out of scope (see 009-guest-list).
|
||||||
|
|
||||||
### Key Entities
|
### Key Entities
|
||||||
|
|
||||||
- **RSVP**: Represents a guest's attendance declaration. Attributes: event token reference, attending status (boolean), optional name, creation/update timestamp. The server-side identity key for deduplication is the combination of event token and a device-bound identifier [NEEDS EXPANSION: deduplication mechanism to be defined during implementation].
|
- **RSVP**: Represents a guest's attendance declaration. Attributes: rsvpToken (server-generated UUID, returned to client), event reference, name (required), creation timestamp. Existence of an entry implies attendance — no `attending` boolean. The rsvpToken is returned to the client for future use (editing/withdrawal in a later spec). Duplicates from lost localStorage or different devices are accepted as a privacy trade-off.
|
||||||
|
- **RsvpToken**: A server-generated, event-scoped UUID identifying a single RSVP entry. Modeled as a distinct Java record type (alongside EventToken and OrganizerToken). Stored client-side in localStorage per event.
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
### Measurable Outcomes
|
### Measurable Outcomes
|
||||||
|
|
||||||
- **SC-001**: A guest can submit an RSVP (attending with name, or not attending without name) from the event page without an account.
|
- **SC-001**: A guest can submit an RSVP (name + submit) from the event page without an account.
|
||||||
- **SC-002**: Submitting an RSVP from the same device twice results in exactly one server-side RSVP entry for that guest (no duplicates).
|
- **SC-002**: After submitting, the sticky bar shows the guest's RSVP status (not the CTA).
|
||||||
- **SC-003**: After submitting an RSVP, the local event overview (US-7) can display the event without a server request (event token, title, and date are in localStorage).
|
- **SC-003**: After submitting an RSVP, the local event overview (US-7) can display the event without a server request (event token, title, and date are in localStorage).
|
||||||
- **SC-004**: The RSVP form is not shown on expired events, and direct server submissions for expired events are rejected.
|
- **SC-004**: The RSVP form is not shown on expired events, and direct server submissions for expired events are rejected.
|
||||||
- **SC-005**: No name, IP address, or personal data beyond the submitted name is stored or logged by the server in connection with an RSVP.
|
- **SC-005**: No name, IP address, or personal data beyond the submitted name is stored or logged by the server in connection with an RSVP.
|
||||||
|
|||||||
190
specs/008-rsvp/tasks.md
Normal file
190
specs/008-rsvp/tasks.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
# Tasks: RSVP to an Event
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/008-rsvp/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, data-model.md, contracts/create-rsvp.yaml, research.md, quickstart.md
|
||||||
|
|
||||||
|
**Tests**: Included — constitution mandates Test-Driven Methodology (tests before implementation).
|
||||||
|
|
||||||
|
**Organization**: Tasks grouped by user story for independent implementation and testing.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
- Exact file paths included in descriptions
|
||||||
|
|
||||||
|
## Phase 1: Setup
|
||||||
|
|
||||||
|
**Purpose**: OpenAPI spec and database migration — shared infrastructure for all user stories
|
||||||
|
|
||||||
|
- [x] T001 Update OpenAPI spec with RSVP endpoint, request/response schemas, and `attendeeCount` population in `backend/src/main/resources/openapi/api.yaml`
|
||||||
|
- [x] T002 [P] Create Liquibase migration for rsvps table in `backend/src/main/resources/db/changelog/003-create-rsvps-table.xml`
|
||||||
|
- [x] T003 [P] Include new migration in `backend/src/main/resources/db/changelog/db.changelog-master.xml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Token value objects and cross-cutting refactoring — MUST complete before user stories
|
||||||
|
|
||||||
|
**Why blocking**: All new RSVP code uses typed tokens. Existing code must be refactored first to avoid mixing raw UUID and typed tokens.
|
||||||
|
|
||||||
|
- [x] T004 [P] Create `EventToken` record in `backend/src/main/java/de/fete/domain/model/EventToken.java`
|
||||||
|
- [x] T005 [P] Create `OrganizerToken` record in `backend/src/main/java/de/fete/domain/model/OrganizerToken.java`
|
||||||
|
- [x] T006 [P] Create `RsvpToken` record in `backend/src/main/java/de/fete/domain/model/RsvpToken.java`
|
||||||
|
- [x] T007 Refactor `Event` domain model to use `EventToken`/`OrganizerToken` in `backend/src/main/java/de/fete/domain/model/Event.java`
|
||||||
|
- [x] T008 Refactor `EventRepository` port to use typed tokens in `backend/src/main/java/de/fete/domain/port/out/EventRepository.java`
|
||||||
|
- [x] T009 Refactor `EventPersistenceAdapter` to map typed tokens in `backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java`
|
||||||
|
- [x] T010 Refactor `EventService` to use typed tokens in `backend/src/main/java/de/fete/application/service/EventService.java`
|
||||||
|
- [x] T011 Refactor `EventController` to unwrap/wrap typed tokens at API boundary in `backend/src/main/java/de/fete/adapter/in/web/EventController.java`
|
||||||
|
- [x] T012 Update `EventServiceTest` to use typed tokens in `backend/src/test/java/de/fete/application/service/EventServiceTest.java`
|
||||||
|
- [x] T013 Update `EventControllerIntegrationTest` to use typed tokens in `backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java`
|
||||||
|
- [x] T014 Verify all existing tests pass after token refactoring (`cd backend && ./mvnw test`)
|
||||||
|
|
||||||
|
**Checkpoint**: All existing tests green with typed tokens. New RSVP domain work can begin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — Submit an RSVP (Priority: P1) MVP
|
||||||
|
|
||||||
|
**Goal**: A guest can open an active event page, tap the RSVP CTA, enter their name, submit, and see confirmation. Server persists the RSVP and returns an rsvpToken. localStorage stores RSVP data. Attendee count is populated from real data.
|
||||||
|
|
||||||
|
**Independent Test**: Open an event page, submit an RSVP with a name, verify attendee count updates, verify localStorage contains rsvpToken and name.
|
||||||
|
|
||||||
|
### Backend Tests for US1
|
||||||
|
|
||||||
|
- [x] T015 [P] [US1] Write unit tests for `RsvpService` (create RSVP, validation, event-not-found) in `backend/src/test/java/de/fete/application/service/RsvpServiceTest.java`
|
||||||
|
- [x] T016 [P] [US1] Write integration tests for `POST /events/{eventToken}/rsvps` (201 success, 400 validation, 404 not found) in `backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java`
|
||||||
|
|
||||||
|
### Backend Implementation for US1
|
||||||
|
|
||||||
|
- [x] T017 [P] [US1] Create `Rsvp` domain entity in `backend/src/main/java/de/fete/domain/model/Rsvp.java`
|
||||||
|
- [x] T018 [P] [US1] Create `CreateRsvpUseCase` inbound port in `backend/src/main/java/de/fete/domain/port/in/CreateRsvpUseCase.java`
|
||||||
|
- [x] T019 [P] [US1] Create `RsvpRepository` outbound port with `save()` and `countByEventId()` in `backend/src/main/java/de/fete/domain/port/out/RsvpRepository.java`
|
||||||
|
- [x] T020 [P] [US1] Create `RsvpJpaEntity` in `backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaEntity.java`
|
||||||
|
- [x] T021 [P] [US1] Create `RsvpJpaRepository` (Spring Data) in `backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaRepository.java`
|
||||||
|
- [x] T022 [US1] Implement `RsvpPersistenceAdapter` in `backend/src/main/java/de/fete/adapter/out/persistence/RsvpPersistenceAdapter.java`
|
||||||
|
- [x] T023 [US1] Implement `RsvpService` (create RSVP logic, validate event exists) in `backend/src/main/java/de/fete/application/service/RsvpService.java`
|
||||||
|
- [x] T024 [US1] Add `createRsvp()` method to `EventController` in `backend/src/main/java/de/fete/adapter/in/web/EventController.java`
|
||||||
|
- [x] T025 [US1] Wire attendee count: add `countByEventId()` call to GET event flow, populate `attendeeCount` in response in `backend/src/main/java/de/fete/adapter/in/web/EventController.java`
|
||||||
|
- [x] T026 [US1] Verify backend tests pass (`cd backend && ./mvnw test`)
|
||||||
|
|
||||||
|
### Frontend Tests for US1
|
||||||
|
|
||||||
|
- [ ] T027 [P] [US1] Write unit tests for `BottomSheet` component in `frontend/src/components/__tests__/BottomSheet.spec.ts`
|
||||||
|
- [ ] T028 [P] [US1] Write unit tests for `RsvpBar` component in `frontend/src/components/__tests__/RsvpBar.spec.ts`
|
||||||
|
- [ ] T029 [P] [US1] Update unit tests for `useEventStorage` composable (rsvpToken/rsvpName fields) in `frontend/src/composables/__tests__/useEventStorage.spec.ts`
|
||||||
|
|
||||||
|
### Frontend Implementation for US1
|
||||||
|
|
||||||
|
- [ ] T030 [US1] Regenerate TypeScript types from updated OpenAPI spec (`frontend/src/api/schema.d.ts`)
|
||||||
|
- [ ] T031 [P] [US1] Extend `useEventStorage` composable with `rsvpToken` and `rsvpName` fields in `frontend/src/composables/useEventStorage.ts`
|
||||||
|
- [ ] T032 [P] [US1] Create `BottomSheet.vue` component (slide-up, backdrop, focus trap, ESC close, aria-modal) in `frontend/src/components/BottomSheet.vue`
|
||||||
|
- [ ] T033 [P] [US1] Create `RsvpBar.vue` sticky bottom bar (CTA state + status state) in `frontend/src/components/RsvpBar.vue`
|
||||||
|
- [ ] T034 [US1] Integrate `RsvpBar` + `BottomSheet` + RSVP form into `EventDetailView`, including error state when server is unreachable, in `frontend/src/views/EventDetailView.vue`
|
||||||
|
- [ ] T035 [US1] Add bottom sheet and sticky bar styles to `frontend/src/assets/main.css`
|
||||||
|
- [ ] T036 [US1] Update `EventDetailView` unit tests for RSVP integration in `frontend/src/views/__tests__/EventDetailView.spec.ts`
|
||||||
|
|
||||||
|
### E2E Tests for US1
|
||||||
|
|
||||||
|
- [ ] T037 [US1] Write E2E tests: RSVP submission flow, localStorage verification, attendee count update in `frontend/e2e/event-rsvp.spec.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: US1 complete — guest can submit RSVP, see confirmation, attendee count populated. All backend + frontend tests green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — RSVP Blocked on Expired Events (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Expired events hide the RSVP form and the server rejects RSVP submissions with 409 Conflict.
|
||||||
|
|
||||||
|
**Independent Test**: Attempt to RSVP to an expired event — verify form is hidden client-side and server returns 409.
|
||||||
|
|
||||||
|
### Tests for US2
|
||||||
|
|
||||||
|
- [ ] T038 [P] [US2] Write backend test for expired event rejection (409) in `backend/src/test/java/de/fete/application/service/RsvpServiceTest.java`
|
||||||
|
- [ ] T039 [P] [US2] Write integration test for `POST /events/{eventToken}/rsvps` returning 409 on expired event in `backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java`
|
||||||
|
|
||||||
|
### Implementation for US2
|
||||||
|
|
||||||
|
- [ ] T040 [US2] Add expiry check to `RsvpService.createRsvp()` — throw `EventExpiredException` when event date has passed in `backend/src/main/java/de/fete/application/service/RsvpService.java`
|
||||||
|
- [ ] T041 [US2] Handle `EventExpiredException` in `GlobalExceptionHandler` — return 409 Conflict in `backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java`
|
||||||
|
- [ ] T042 [US2] Hide RSVP bar/form on expired events in `EventDetailView` (check `expired` field from API response) in `frontend/src/views/EventDetailView.vue`
|
||||||
|
- [ ] T043 [US2] Write E2E test for expired event: verify RSVP form hidden, direct API call returns 409 in `frontend/e2e/event-rsvp.spec.ts`
|
||||||
|
- [ ] T044 [US2] Verify all tests pass (`cd backend && ./mvnw test && cd ../frontend && npm run test:unit`)
|
||||||
|
|
||||||
|
**Checkpoint**: US2 complete — expired events block RSVPs client-side and server-side.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Final verification across all stories
|
||||||
|
|
||||||
|
- [ ] T045 Run full backend verify (`cd backend && ./mvnw verify`)
|
||||||
|
- [ ] T046 Run frontend build and type-check (`cd frontend && npm run build`)
|
||||||
|
- [ ] T047 Run all E2E tests (`cd frontend && npx playwright test`)
|
||||||
|
- [ ] T048 Visual verification of RSVP flow using `browser-interactive-testing` skill
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies — can start immediately
|
||||||
|
- **Foundational (Phase 2)**: Depends on T001 (OpenAPI spec) for schema awareness; T002-T003 (migration) independent
|
||||||
|
- **US1 (Phase 3)**: Depends on Phase 2 completion (typed tokens in place)
|
||||||
|
- **US2 (Phase 4)**: Depends on Phase 3 (RSVP creation must exist before expiry guard)
|
||||||
|
- **Polish (Phase 5)**: Depends on all user stories complete
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1 (P1)**: Can start after Phase 2 — no dependency on US2
|
||||||
|
- **US2 (P2)**: Depends on US1 (the RSVP endpoint and form must exist before adding the expiry guard)
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Tests MUST be written first and FAIL before implementation
|
||||||
|
- Domain model/ports before persistence adapters
|
||||||
|
- Persistence before services
|
||||||
|
- Services before controllers
|
||||||
|
- Backend before frontend (API must exist for frontend to consume)
|
||||||
|
- Frontend components before view integration
|
||||||
|
- Unit tests before E2E tests
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
**Phase 1**: T002 and T003 can run in parallel with T001
|
||||||
|
**Phase 2**: T004, T005, T006 in parallel; then T007-T013 sequentially (refactoring chain)
|
||||||
|
**Phase 3 Backend**: T015+T016 (tests) in parallel; T017+T018+T019+T020+T021 (domain/ports/JPA) in parallel; then T022→T023→T024→T025 sequential
|
||||||
|
**Phase 3 Frontend**: T027+T028+T029 (tests) in parallel; T031+T032+T033 in parallel; then T034→T035→T036→T037 sequential
|
||||||
|
**Phase 4**: T038+T039 (tests) in parallel; then T040→T041→T042→T043→T044 sequential
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup (OpenAPI + migration)
|
||||||
|
2. Complete Phase 2: Foundational (token value objects + refactoring)
|
||||||
|
3. Complete Phase 3: User Story 1 (full RSVP flow)
|
||||||
|
4. **STOP and VALIDATE**: Guest can submit RSVP, see confirmation, attendee count works
|
||||||
|
5. Deploy/demo if ready
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Setup + Foundational → Token refactoring complete, schema ready
|
||||||
|
2. Add US1 → Full RSVP flow works → Deploy/Demo (MVP!)
|
||||||
|
3. Add US2 → Expired events guarded → Deploy/Demo
|
||||||
|
4. Polish → All tests green, visual verification done
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Cancelled event guard (FR-010) is deferred until US-18 — NOT included in tasks
|
||||||
|
- No CAPTCHA/rate-limiting per spec (KISS, privacy-first)
|
||||||
|
- RSVP editing/withdrawal deferred to separate edit-RSVP spec
|
||||||
|
- Frontend uses plain `string` for tokens (no branded types) per clarification
|
||||||
|
- Backend uses typed records (`EventToken`, `OrganizerToken`, `RsvpToken`) per clarification
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# Feature Specification: Manage Guest List as Organizer
|
|
||||||
|
|
||||||
**Feature**: `009-guest-list`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - View and manage RSVPs (Priority: P1)
|
|
||||||
|
|
||||||
As an event organizer, I want to view all RSVPs for my event and remove individual entries if needed, so that I have an accurate overview of attendance and can moderate erroneous or spam entries.
|
|
||||||
|
|
||||||
The organizer view is accessible from the event page when a valid organizer token for that event is present in localStorage. When no organizer token is present, no organizer-specific UI is shown. The organizer can see each RSVP entry with name and attending status, and can permanently delete any entry.
|
|
||||||
|
|
||||||
**Why this priority**: Core organizer capability — without it, the organizer has no way to manage erroneous or spam RSVP entries. The public attendee list is only trustworthy if the organizer can moderate it.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by creating an event (obtaining an organizer token), submitting several RSVPs via the RSVP form, then opening the organizer view to verify the list is displayed and deletion works.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an organizer token for an event is present in localStorage, **When** the organizer opens the event page, **Then** an organizer view link or section is visible that is not shown to guests without the token.
|
|
||||||
2. **Given** the organizer view is open, **When** the page loads, **Then** all RSVPs for the event are listed, each showing the entry's name and attending status.
|
|
||||||
3. **Given** the organizer view is open with at least one RSVP entry, **When** the organizer deletes an entry, **Then** the entry is permanently removed from the server and immediately disappears from the attendee list on the public event page.
|
|
||||||
4. **Given** a visitor without an organizer token in localStorage opens the event page, **When** the page renders, **Then** no organizer-specific UI (link, button, or organizer view) is visible.
|
|
||||||
5. **Given** the organizer view is open, **When** the organizer attempts to access it via a guessable URL without the organizer token in localStorage, **Then** organizer access is denied — it requires the organizer token established during event creation.
|
|
||||||
6. **Given** the organizer has a valid organizer token in localStorage, **When** the organizer accesses the organizer view, **Then** no additional authentication step beyond the localStorage token is required.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens when the organizer token is present in localStorage but the event no longer exists on the server?
|
|
||||||
- How does the system handle a deletion request when the organizer token is invalid or has been cleared from localStorage mid-session?
|
|
||||||
- What if all RSVPs are deleted — does the organizer view show an empty state?
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: System MUST display the organizer view (guest list management) only when a valid organizer token for that event is present in localStorage.
|
|
||||||
- **FR-002**: System MUST hide all organizer-specific UI (links, buttons, organizer view) from visitors who do not have the organizer token in localStorage.
|
|
||||||
- **FR-003**: Organizer view MUST list all RSVPs for the event, showing each entry's name and attending status.
|
|
||||||
- **FR-004**: Organizer MUST be able to permanently delete any individual RSVP entry from the organizer view.
|
|
||||||
- **FR-005**: System MUST reflect RSVP deletions immediately on the public event page — the attendee list must update without delay.
|
|
||||||
- **FR-006**: Organizer view MUST NOT be accessible via a guessable URL — access requires the organizer token stored in localStorage during event creation (US-1).
|
|
||||||
- **FR-007**: System MUST NOT require any additional authentication step beyond the presence of the organizer token in localStorage.
|
|
||||||
- **FR-008**: Server MUST reject RSVP deletion requests that do not include a valid organizer token.
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **RSVP**: An entry submitted by a guest (US-3). Attributes: event association, guest name, attending status. The organizer can delete individual entries.
|
|
||||||
- **Organizer token**: A secret UUID stored in localStorage on the device where the event was created (US-1). Grants organizer access to the guest list management view.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: An organizer can view the complete guest list for their event from the event page without navigating away to a separate URL.
|
|
||||||
- **SC-002**: An organizer can delete any RSVP entry and the deletion is reflected on the public event page within the same page interaction (no reload required).
|
|
||||||
- **SC-003**: A visitor without the organizer token in localStorage sees no organizer UI at all — zero organizer-specific elements rendered.
|
|
||||||
- **SC-004**: The organizer view is not reachable by guessing or constructing a URL — it requires the in-memory/localStorage token to render.
|
|
||||||
- **SC-005**: No additional login, account, or authentication beyond the organizer token is required to manage the guest list.
|
|
||||||
35
specs/009-list-events/checklists/requirements.md
Normal file
35
specs/009-list-events/checklists/requirements.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Event List on Home Page
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-08
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All items pass validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||||
|
- Assumptions section documents that no backend changes are needed — this is a frontend-only feature using existing localStorage data.
|
||||||
99
specs/009-list-events/data-model.md
Normal file
99
specs/009-list-events/data-model.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Data Model: Event List on Home Page
|
||||||
|
|
||||||
|
**Feature**: 009-list-events | **Date**: 2026-03-08
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### StoredEvent (existing — no changes)
|
||||||
|
|
||||||
|
The `StoredEvent` interface in `frontend/src/composables/useEventStorage.ts` already contains all fields needed for the event list feature.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface StoredEvent {
|
||||||
|
eventToken: string // Required — UUID, used for navigation
|
||||||
|
organizerToken?: string // Present if user created this event
|
||||||
|
title: string // Required — displayed on card
|
||||||
|
dateTime: string // Required — ISO 8601, used for sorting + relative time
|
||||||
|
expiryDate: string // Stored but not displayed in list view
|
||||||
|
rsvpToken?: string // Present if user RSVP'd to this event
|
||||||
|
rsvpName?: string // User's name at RSVP time
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation Rules
|
||||||
|
|
||||||
|
An event entry is considered **valid** for display if all of:
|
||||||
|
- `eventToken` is a non-empty string
|
||||||
|
- `title` is a non-empty string
|
||||||
|
- `dateTime` is a non-empty string that parses to a valid `Date`
|
||||||
|
|
||||||
|
Invalid entries are silently excluded from the list (FR-010).
|
||||||
|
|
||||||
|
### Derived Properties (computed at render time)
|
||||||
|
|
||||||
|
| Property | Derivation |
|
||||||
|
|----------|-----------|
|
||||||
|
| `isPast` | `new Date(dateTime) < new Date()` |
|
||||||
|
| `isOrganizer` | `organizerToken !== undefined` |
|
||||||
|
| `isAttendee` | `rsvpToken !== undefined && organizerToken === undefined` |
|
||||||
|
| `relativeTime` | `Intl.RelativeTimeFormat` applied to `dateTime` vs now |
|
||||||
|
| `detailRoute` | `/events/${eventToken}` |
|
||||||
|
|
||||||
|
### Sorting Order
|
||||||
|
|
||||||
|
1. **Upcoming events** (`dateTime >= now`): ascending by `dateTime` (soonest first)
|
||||||
|
2. **Past events** (`dateTime < now`): descending by `dateTime` (most recently passed first)
|
||||||
|
|
||||||
|
### Composable Extension
|
||||||
|
|
||||||
|
The `useEventStorage` composable needs one new function:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function removeEvent(eventToken: string): void {
|
||||||
|
const events = readEvents().filter((e) => e.eventToken !== eventToken)
|
||||||
|
writeEvents(events)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returned alongside existing functions from `useEventStorage()`.
|
||||||
|
|
||||||
|
## State Transitions
|
||||||
|
|
||||||
|
```
|
||||||
|
localStorage read
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Parse JSON ──(error)──► empty array
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Validate entries ──(invalid)──► silently excluded
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Split: upcoming / past
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Sort each group
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Concatenate ──► rendered list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove Event Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User taps delete icon / swipes left
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ConfirmDialog opens
|
||||||
|
│
|
||||||
|
┌────┴────┐
|
||||||
|
│ Cancel │ Confirm
|
||||||
|
│ │ │
|
||||||
|
│ ▼ ▼
|
||||||
|
│ removeEvent(token)
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ Event removed from localStorage
|
||||||
|
│ List re-renders (event disappears)
|
||||||
|
└────────────────────────────────┘
|
||||||
|
```
|
||||||
86
specs/009-list-events/plan.md
Normal file
86
specs/009-list-events/plan.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Implementation Plan: Event List on Home Page
|
||||||
|
|
||||||
|
**Branch**: `009-list-events` | **Date**: 2026-03-08 | **Spec**: `specs/009-list-events/spec.md`
|
||||||
|
**Input**: Feature specification from `/specs/009-list-events/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Transform the home page from a static empty-state placeholder into a dynamic event list that shows all events stored in the browser's localStorage. Each event card displays title, relative time, and role indicator (organizer/attendee). Events are sorted chronologically (upcoming first), past events appear faded, and users can remove events via delete icon or swipe gesture. A FAB provides persistent access to event creation.
|
||||||
|
|
||||||
|
This is a **frontend-only** feature — no backend or API changes required. The existing `useEventStorage` composable already provides all necessary data access.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5.9, Vue 3.5
|
||||||
|
**Primary Dependencies**: Vue 3, Vue Router 5, Vite
|
||||||
|
**Storage**: Browser localStorage via `useEventStorage` composable
|
||||||
|
**Testing**: Vitest (unit), Playwright + MSW (E2E)
|
||||||
|
**Target Platform**: Mobile-first PWA (centered 480px column on desktop)
|
||||||
|
**Project Type**: Web application (frontend-only changes)
|
||||||
|
**Performance Goals**: Event list renders within 1 second (SC-001) — trivial given localStorage read
|
||||||
|
**Constraints**: No external dependencies, no tracking, WCAG AA, keyboard navigable
|
||||||
|
**Scale/Scope**: Typically <50 events in localStorage; no pagination needed
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Privacy by Design | ✅ PASS | Purely client-side. No data leaves the browser. No analytics. |
|
||||||
|
| II. Test-Driven Methodology | ✅ PASS | Unit tests for composable, E2E for each user story. TDD enforced. |
|
||||||
|
| III. API-First Development | ✅ N/A | No API changes — this feature reads only from localStorage. |
|
||||||
|
| IV. Simplicity & Quality | ✅ PASS | Minimal approach: extend existing composable + new components. No over-engineering. |
|
||||||
|
| V. Dependency Discipline | ✅ PASS | No new dependencies. Swipe gesture implemented with native Touch API. Relative time via built-in `Intl.RelativeTimeFormat`. |
|
||||||
|
| VI. Accessibility | ✅ PASS | Semantic list markup, ARIA labels, keyboard navigation, WCAG AA contrast on faded past events. |
|
||||||
|
|
||||||
|
**Gate result: PASS** — no violations.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/009-list-events/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── spec.md # Feature specification
|
||||||
|
├── research.md # Phase 0 output
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks command)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── composables/
|
||||||
|
│ │ ├── useEventStorage.ts # MODIFY: add removeEvent()
|
||||||
|
│ │ ├── useRelativeTime.ts # NEW: Intl.RelativeTimeFormat wrapper
|
||||||
|
│ │ └── __tests__/
|
||||||
|
│ │ ├── useEventStorage.spec.ts # MODIFY: add removeEvent tests
|
||||||
|
│ │ └── useRelativeTime.spec.ts # NEW: relative time formatting tests
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── EventCard.vue # NEW: individual event list item
|
||||||
|
│ │ ├── EventList.vue # NEW: sorted event list container
|
||||||
|
│ │ ├── EmptyState.vue # NEW: extracted empty state
|
||||||
|
│ │ ├── CreateEventFab.vue # NEW: floating action button
|
||||||
|
│ │ ├── ConfirmDialog.vue # NEW: reusable confirmation prompt
|
||||||
|
│ │ └── __tests__/
|
||||||
|
│ │ ├── EventCard.spec.ts # NEW
|
||||||
|
│ │ ├── EventList.spec.ts # NEW
|
||||||
|
│ │ ├── EmptyState.spec.ts # NEW
|
||||||
|
│ │ └── ConfirmDialog.spec.ts # NEW
|
||||||
|
│ ├── views/
|
||||||
|
│ │ └── HomeView.vue # MODIFY: compose list/empty/fab
|
||||||
|
│ └── assets/
|
||||||
|
│ └── main.css # MODIFY: add event card, faded, fab styles
|
||||||
|
└── e2e/
|
||||||
|
└── home-events.spec.ts # NEW: E2E tests for all user stories
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Frontend-only changes. New components in `components/`, composable extensions in `composables/`, styles in existing `main.css`. No backend changes.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations — this section is intentionally empty.
|
||||||
110
specs/009-list-events/research.md
Normal file
110
specs/009-list-events/research.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Research: Event List on Home Page
|
||||||
|
|
||||||
|
**Feature**: 009-list-events | **Date**: 2026-03-08
|
||||||
|
|
||||||
|
## Research Questions
|
||||||
|
|
||||||
|
### 1. Relative Time Formatting with `Intl.RelativeTimeFormat`
|
||||||
|
|
||||||
|
**Decision**: Use the built-in `Intl.RelativeTimeFormat` API directly — no library needed.
|
||||||
|
|
||||||
|
**Rationale**: The API is supported in all modern browsers (97%+ coverage). It handles locale-aware output natively (e.g., "in 3 days", "vor 2 Tagen" for German). The spec requires exactly this (FR-002).
|
||||||
|
|
||||||
|
**Implementation approach**: Create a `useRelativeTime` composable that:
|
||||||
|
1. Takes a date string (ISO 8601) and computes the difference from `now`
|
||||||
|
2. Selects the appropriate unit (seconds → minutes → hours → days → weeks → months → years)
|
||||||
|
3. Returns a formatted string via `Intl.RelativeTimeFormat(navigator.language, { numeric: 'auto' })`
|
||||||
|
4. Exposes a reactive `label` that updates (optional — can be static since the list re-reads on mount)
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- `date-fns/formatDistance`: Would add a dependency for something the platform already does. Rejected per Principle V.
|
||||||
|
- `dayjs/relativeTime`: Same reasoning — unnecessary dependency.
|
||||||
|
|
||||||
|
### 2. Swipe-to-Delete Gesture (FR-006b)
|
||||||
|
|
||||||
|
**Decision**: Implement with native Touch API (`touchstart`, `touchmove`, `touchend`) — no gesture library.
|
||||||
|
|
||||||
|
**Rationale**: The gesture is simple (horizontal swipe on a single element). A library like Hammer.js or @vueuse/gesture would be overkill for one swipe direction on one component type. Per Principle V, dependencies must provide substantial value.
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
1. Track `touchstart` X position on the event card
|
||||||
|
2. On `touchmove`, calculate delta-X; if leftward and exceeds threshold (~80px), reveal delete action
|
||||||
|
3. On `touchend`, either snap back or trigger confirmation
|
||||||
|
4. CSS `transform: translateX()` with `transition` for smooth animation
|
||||||
|
5. Desktop users use the visible delete icon (no swipe needed)
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- `@vueuse/gesture`: Wraps Hammer.js, adds ~15KB. Rejected — too heavy for one gesture.
|
||||||
|
- CSS `scroll-snap` trick: Clever but brittle and poor accessibility. Rejected.
|
||||||
|
|
||||||
|
### 3. Past Event Visual Fading (FR-009)
|
||||||
|
|
||||||
|
**Decision**: Use CSS `opacity` reduction + `filter: saturate()` for faded appearance.
|
||||||
|
|
||||||
|
**Rationale**: The spec says "subtle reduction in contrast and saturation" — not a blunt grey-out. Combining `opacity: 0.6` with `filter: saturate(0.5)` achieves this while keeping text readable. Must verify WCAG AA contrast on the faded state.
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- Add a `.event-card--past` modifier class
|
||||||
|
- Apply `opacity: 0.55; filter: saturate(0.4)` (tune exact values for WCAG AA)
|
||||||
|
- Keep `pointer-events: auto` and normal hover/focus styles so the card remains interactive
|
||||||
|
- The card still navigates to the event detail page on click
|
||||||
|
|
||||||
|
**Contrast verification**: The card text (`#1C1C1E` on `#FFFFFF`) has a contrast ratio of ~17:1. At `opacity: 0.55`, effective contrast drops to ~9:1, which still passes WCAG AA (4.5:1 minimum). Safe.
|
||||||
|
|
||||||
|
### 4. Confirmation Dialog (FR-007)
|
||||||
|
|
||||||
|
**Decision**: Custom modal component (reusing the existing `BottomSheet.vue` pattern) rather than `window.confirm()`.
|
||||||
|
|
||||||
|
**Rationale**: `window.confirm()` is blocking, non-stylable, and inconsistent across browsers. A custom dialog matches the app's design system and provides a better UX. The existing `BottomSheet.vue` already handles teleportation, focus trapping, and Escape-key dismissal — the confirm dialog can reuse this or follow the same pattern.
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- Create a `ConfirmDialog.vue` component
|
||||||
|
- Props: `open`, `title`, `message`, `confirmLabel`, `cancelLabel`
|
||||||
|
- Emits: `confirm`, `cancel`
|
||||||
|
- Uses the same teleport-to-body pattern as `BottomSheet.vue`
|
||||||
|
- Focus trapping and keyboard navigation (Tab, Escape, Enter)
|
||||||
|
|
||||||
|
### 5. localStorage Validation (FR-010)
|
||||||
|
|
||||||
|
**Decision**: Validate entries during read — filter out invalid events silently.
|
||||||
|
|
||||||
|
**Rationale**: The spec says "silently excluded from the list." The `readEvents()` function already handles parse errors with a try/catch. We need to add field-level validation: an event is valid only if it has `eventToken`, `title`, and `dateTime` (all non-empty strings).
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- Add a `isValidStoredEvent(e: unknown): e is StoredEvent` type guard
|
||||||
|
- Apply it in `getStoredEvents()` as a filter
|
||||||
|
- Invalid entries remain in localStorage (no destructive cleanup) but are not displayed
|
||||||
|
|
||||||
|
### 6. FAB Placement (FR-011)
|
||||||
|
|
||||||
|
**Decision**: Fixed-position button at bottom-right with safe-area padding.
|
||||||
|
|
||||||
|
**Rationale**: Standard Material Design pattern for primary actions. The existing `RsvpBar.vue` already uses `padding-bottom: env(safe-area-inset-bottom)` for mobile notch avoidance — reuse the same approach.
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- `position: fixed; bottom: calc(1.2rem + env(safe-area-inset-bottom)); right: 1.2rem`
|
||||||
|
- Circular button with `+` icon, accent color background
|
||||||
|
- `z-index` above content, shadow for elevation
|
||||||
|
- Navigates to `/create` on click
|
||||||
|
|
||||||
|
### 7. Event Sorting (FR-004)
|
||||||
|
|
||||||
|
**Decision**: Sort in-memory after reading from localStorage.
|
||||||
|
|
||||||
|
**Rationale**: The list is small (<100 events typically). Sorting on every render is negligible. Sort by `dateTime` ascending (nearest upcoming first), then past events after.
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- Split events into `upcoming` (dateTime >= now) and `past` (dateTime < now)
|
||||||
|
- Sort upcoming ascending (soonest first), past descending (most recent past first)
|
||||||
|
- Concatenate: `[...upcoming, ...past]`
|
||||||
|
|
||||||
|
### 8. Role Distinction (FR-008 / US-5)
|
||||||
|
|
||||||
|
**Decision**: Small badge/label on the event card indicating "Organizer" or "Attendee."
|
||||||
|
|
||||||
|
**Rationale**: The data is already available — `organizerToken` present means organizer, `rsvpToken` present (without `organizerToken`) means attendee. A subtle text badge is sufficient; no need for icons or colors.
|
||||||
|
|
||||||
|
**Implementation approach**:
|
||||||
|
- If `organizerToken` is set → show "Organizer" badge (accent-colored)
|
||||||
|
- If `rsvpToken` is set (no `organizerToken`) → show "Attendee" badge (muted)
|
||||||
|
- If neither → show no badge (edge case: event stored but no role — could happen with manual localStorage manipulation)
|
||||||
145
specs/009-list-events/spec.md
Normal file
145
specs/009-list-events/spec.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Feature Specification: Event List on Home Page
|
||||||
|
|
||||||
|
**Feature Branch**: `009-list-events`
|
||||||
|
**Created**: 2026-03-08
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "man kann auf der hauptseite eine liste an events sehen, sofern sie im localstorage gespeichert sind"
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - View My Events (Priority: P1)
|
||||||
|
|
||||||
|
As a returning user, I want to see a list of events I have previously created or interacted with (RSVP'd to) on the home page, so I can quickly navigate back to them without needing to remember or bookmark individual event links.
|
||||||
|
|
||||||
|
The home page displays all events stored in the browser's local storage. Each event entry shows the event title and date/time. Tapping an event navigates to its detail page.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core value of the feature — without the list, the home page remains a dead end for returning users.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by creating an event (or simulating localStorage entries), returning to the home page, and verifying all stored events appear in a list with correct titles and dates.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the user has 3 events stored in localStorage, **When** they visit the home page, **Then** all 3 events are displayed in a list showing title and date/time for each.
|
||||||
|
2. **Given** the user has events stored in localStorage, **When** they tap on an event in the list, **Then** they are navigated to the event detail page (`/events/:eventToken`).
|
||||||
|
3. **Given** the user has events stored in localStorage, **When** they visit the home page, **Then** events are sorted by date/time (nearest upcoming event first, past events last).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Empty State (Priority: P2)
|
||||||
|
|
||||||
|
As a new user with no stored events, I see an inviting empty state on the home page that encourages me to create my first event or explains how to get started.
|
||||||
|
|
||||||
|
**Why this priority**: First-time users need clear guidance. The empty state is the first impression for new users.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by clearing localStorage and visiting the home page — the empty state message and "Create Event" call-to-action should be visible.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** no events are stored in localStorage, **When** the user visits the home page, **Then** an empty state message is displayed (e.g., "No events yet") with a prominent "Create Event" button.
|
||||||
|
2. **Given** the user has at least one event stored, **When** they visit the home page, **Then** the empty state message is not shown — the event list is displayed instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Remove Event from List (Priority: P3)
|
||||||
|
|
||||||
|
As a user, I want to remove an event from my personal list so I can keep my home page tidy and only show events I still care about.
|
||||||
|
|
||||||
|
**Why this priority**: Housekeeping capability. Without removal, the list grows indefinitely and becomes cluttered over time.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by having multiple events in localStorage, removing one from the list, and verifying it disappears from the home page while the others remain.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the user has events in their list, **When** they tap the delete icon on an event card, **Then** a confirmation prompt appears asking if they are sure.
|
||||||
|
1b. **Given** the user has events in their list, **When** they swipe an event card to the left, **Then** a confirmation prompt appears asking if they are sure.
|
||||||
|
2. **Given** the confirmation prompt is shown, **When** the user confirms removal, **Then** the event is removed from localStorage and disappears from the list immediately.
|
||||||
|
3. **Given** the confirmation prompt is shown, **When** the user cancels, **Then** the event remains in the list unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 4 - Past Events Appear Faded (Priority: P2)
|
||||||
|
|
||||||
|
As a user, I want events whose date/time has passed to appear visually faded or muted in the list, so I can immediately focus on upcoming events without past events cluttering my attention.
|
||||||
|
|
||||||
|
The fading should feel modern and polished — not a blunt grey-out, but a subtle reduction in contrast and saturation that makes past events recede visually while remaining readable and tappable.
|
||||||
|
|
||||||
|
**Why this priority**: Without this, past and upcoming events look identical, making the list harder to scan. This is essential for usability once a user has accumulated several events.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by having both future and past events in localStorage and verifying that past events display with reduced visual prominence while remaining interactive.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the user has a past event (dateTime before now) in localStorage, **When** they view the home page, **Then** the event appears with reduced visual prominence (muted colors, lower contrast) compared to upcoming events.
|
||||||
|
2. **Given** the user has a past event in the list, **When** they tap on it, **Then** it still navigates to the event detail page — it remains fully interactive.
|
||||||
|
3. **Given** the user has both past and upcoming events, **When** they view the home page, **Then** upcoming events appear first (full visual prominence), followed by past events (faded), creating a clear visual hierarchy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 5 - Visual Distinction for Event Roles (Priority: P3)
|
||||||
|
|
||||||
|
As a user, I want to see at a glance whether I am the organizer of an event or just an attendee, so I can quickly identify my responsibilities.
|
||||||
|
|
||||||
|
**Why this priority**: Nice-to-have clarity. The data is already available in localStorage (presence of `organizerToken`), so surfacing it improves usability at low effort.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by having both created events (with organizerToken) and RSVP'd events (with rsvpToken) in localStorage, and verifying they display different visual indicators.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the user has a created event (organizerToken present) in localStorage, **When** they view the home page, **Then** the event shows a visual indicator marking them as the organizer (e.g., a badge or label).
|
||||||
|
2. **Given** the user has an event with an RSVP (rsvpToken present, no organizerToken) in localStorage, **When** they view the home page, **Then** the event shows a visual indicator marking them as an attendee.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when localStorage data is corrupted or contains invalid entries? Events with missing required fields (eventToken, title, dateTime) are silently excluded from the list.
|
||||||
|
- What happens when localStorage is unavailable (e.g., private browsing with storage disabled)? The empty state is shown with the "Create Event" button — the app remains functional.
|
||||||
|
- What happens when an event's date/time has passed? The event remains in the list but appears visually faded.
|
||||||
|
- What happens when the user has a very large number of stored events (e.g., 50+)? The list scrolls naturally. No pagination is needed at this scale since localStorage entries are lightweight.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST display a list of all events stored in the browser's local storage on the home page.
|
||||||
|
- **FR-002**: Each event entry MUST show the event title and the event date/time displayed as a relative time label (e.g., "in 3 days", "yesterday") using `Intl.RelativeTimeFormat`.
|
||||||
|
- **FR-003**: Each event entry MUST be tappable/clickable and navigate to the event detail page (`/events/:eventToken`).
|
||||||
|
- **FR-004**: Events MUST be sorted by date/time with nearest upcoming events first and past events last.
|
||||||
|
- **FR-005**: System MUST display an empty state with a "Create Event" call-to-action when no events are stored.
|
||||||
|
- **FR-006a**: Users MUST be able to remove individual events from their local list via a visible delete icon on each event card (primary mechanism, implemented first).
|
||||||
|
- **FR-006b**: Users MUST be able to remove individual events via swipe-to-delete gesture (secondary mechanism, implemented separately after FR-006a).
|
||||||
|
- **FR-007**: System MUST show a confirmation prompt before removing an event from the list.
|
||||||
|
- **FR-008**: System MUST visually distinguish events where the user is the organizer from events where the user is an attendee.
|
||||||
|
- **FR-009**: System MUST display past events (dateTime before current time) with reduced visual prominence — muted colors and lower contrast — while keeping them readable and interactive.
|
||||||
|
- **FR-010**: System MUST gracefully handle corrupted or incomplete localStorage entries by excluding invalid events from the list.
|
||||||
|
- **FR-011**: The "Create Event" button MUST remain accessible on the home page even when events are listed, implemented as a Floating Action Button (FAB) fixed at the bottom-right corner.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Stored Event**: A locally persisted reference to an event the user has interacted with. Contains: event token (unique identifier for navigation), title, date/time, expiry date, and optionally an organizer token (if created by this user) or RSVP token and name (if the user RSVP'd).
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Users can see all their stored events on the home page within 1 second of page load.
|
||||||
|
- **SC-002**: Users can navigate from the home page to any event detail page in a single tap/click.
|
||||||
|
- **SC-003**: Users can remove an unwanted event from their list in under 3 seconds (including confirmation).
|
||||||
|
- **SC-004**: New users (no stored events) see a clear call-to-action to create their first event.
|
||||||
|
- **SC-005**: Users can distinguish their role (organizer vs. attendee) for each event at a glance without opening the event.
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-03-08
|
||||||
|
|
||||||
|
- Q: How does the user trigger event removal? → A: Two mechanisms — visible delete icon on each event card (primary, implemented first) and swipe-to-delete gesture (secondary, implemented separately after).
|
||||||
|
- Q: Placement of "Create Event" button when events exist? → A: Floating Action Button (FAB) fixed at bottom-right corner.
|
||||||
|
- Q: Date/time display format in event list? → A: Relative time labels ("in 3 days", "yesterday") via Intl.RelativeTimeFormat.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The existing `useEventStorage` composable and `StoredEvent` interface provide all necessary data for the event list (no backend API calls needed for listing).
|
||||||
|
- The event list is purely client-side — there is no server-side "my events" endpoint. Privacy is preserved because events are only known to the user's browser.
|
||||||
|
- The event list uses `Intl.RelativeTimeFormat` for relative time labels (FR-002), while the event detail view uses `Intl.DateTimeFormat` for absolute date/time display. Both use the browser's locale (`navigator.language`).
|
||||||
|
- The "Create Event" flow (spec 006) already saves events to localStorage, so no changes to event creation are needed.
|
||||||
|
- The RSVP flow (spec 008) already saves RSVP data to localStorage, so no changes to RSVP are needed.
|
||||||
215
specs/009-list-events/tasks.md
Normal file
215
specs/009-list-events/tasks.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# Tasks: Event List on Home Page
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/009-list-events/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, research.md, data-model.md
|
||||||
|
|
||||||
|
**Tests**: Unit tests (Vitest) and E2E tests (Playwright) are included per constitution (Principle II: Test-Driven Methodology).
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||||
|
|
||||||
|
## 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, US3)
|
||||||
|
- Include exact file paths in descriptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Composable extensions and utility functions shared across all user stories
|
||||||
|
|
||||||
|
- [x] T000 Rename router param `:token` to `:eventToken` in `frontend/src/router/index.ts` and update all references in `EventDetailView.vue`, `EventStubView.vue`, and their test files (consistency with `StoredEvent.eventToken` field name)
|
||||||
|
- [x] T001 Add `isValidStoredEvent` type guard and validation filter to `frontend/src/composables/useEventStorage.ts` (FR-010)
|
||||||
|
- [x] T002 Add `removeEvent(eventToken: string)` function to `frontend/src/composables/useEventStorage.ts` (needed by US3)
|
||||||
|
- [x] T003 [P] Create `useRelativeTime` composable in `frontend/src/composables/useRelativeTime.ts` (Intl.RelativeTimeFormat wrapper, FR-002)
|
||||||
|
- [x] T004 [P] Add unit tests for `isValidStoredEvent` and `removeEvent` in `frontend/src/composables/__tests__/useEventStorage.spec.ts`
|
||||||
|
- [x] T005 [P] Create unit tests for `useRelativeTime` in `frontend/src/composables/__tests__/useRelativeTime.spec.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: Composable layer complete — all shared logic tested and available for components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Story 1 — View My Events (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Home page shows all stored events in a sorted list with title and relative time. Tapping navigates to event detail.
|
||||||
|
|
||||||
|
**Independent Test**: Simulate localStorage entries, visit home page, verify all events appear sorted with correct titles and relative times. Tap an event and verify navigation to `/events/:eventToken`.
|
||||||
|
|
||||||
|
### Unit Tests for User Story 1
|
||||||
|
|
||||||
|
- [x] T006 [P] [US1] Create unit tests for EventCard component in `frontend/src/components/__tests__/EventCard.spec.ts` — include test cases for `isPast` prop (faded styling) and role badge rendering (organizer vs. attendee)
|
||||||
|
- [x] T007 [P] [US1] Create unit tests for EventList component in `frontend/src/components/__tests__/EventList.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T008 [P] [US1] Create `EventCard.vue` component in `frontend/src/components/EventCard.vue` — displays title, relative time, role badge; emits click for navigation
|
||||||
|
- [x] T009 [US1] Create `EventList.vue` component in `frontend/src/components/EventList.vue` — reads events from composable, validates, sorts (upcoming asc, past desc), renders EventCard list
|
||||||
|
- [x] T010 [US1] Refactor `HomeView.vue` in `frontend/src/views/HomeView.vue` — integrate EventList, conditionally show list when events exist
|
||||||
|
- [x] T011 [US1] Add event card and list styles to `frontend/src/assets/main.css`
|
||||||
|
|
||||||
|
### E2E Tests for User Story 1
|
||||||
|
|
||||||
|
- [x] T012 [US1] Create E2E test file `frontend/e2e/home-events.spec.ts` — tests: events displayed with title and relative time, sorted correctly, click navigates to detail page
|
||||||
|
|
||||||
|
**Checkpoint**: MVP complete — returning users see their events and can navigate to details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 2 — Empty State (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: New users with no stored events see an inviting empty state with a "Create Event" call-to-action.
|
||||||
|
|
||||||
|
**Independent Test**: Clear localStorage, visit home page, verify empty state message and "Create Event" button are visible.
|
||||||
|
|
||||||
|
### Unit Tests for User Story 2
|
||||||
|
|
||||||
|
- [x] T013 [P] [US2] Create unit tests for EmptyState component in `frontend/src/components/__tests__/EmptyState.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T014 [US2] Create `EmptyState.vue` component in `frontend/src/components/EmptyState.vue` — shows message and "Create Event" RouterLink
|
||||||
|
- [x] T015 [US2] Update `HomeView.vue` in `frontend/src/views/HomeView.vue` — show EmptyState when no valid events, show EventList otherwise
|
||||||
|
- [x] T016 [US2] Add empty state styles to `frontend/src/assets/main.css`
|
||||||
|
|
||||||
|
### E2E Tests for User Story 2
|
||||||
|
|
||||||
|
- [x] T017 [US2] Add E2E tests to `frontend/e2e/home-events.spec.ts` — tests: empty state shown when no events, hidden when events exist
|
||||||
|
|
||||||
|
**Checkpoint**: Home page handles both new and returning users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 4 — Past Events Appear Faded (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Events whose date/time has passed appear with reduced visual prominence (muted colors, lower contrast) while remaining interactive.
|
||||||
|
|
||||||
|
**Independent Test**: Have both future and past events in localStorage, verify past events display faded while remaining clickable.
|
||||||
|
|
||||||
|
### Implementation for User Story 4
|
||||||
|
|
||||||
|
- [x] T018 [US4] Add `.event-card--past` modifier class with `opacity: 0.6; filter: saturate(0.5)` to `frontend/src/components/EventCard.vue` or `frontend/src/assets/main.css`
|
||||||
|
- [x] T019 [US4] Pass `isPast` computed property to EventCard in `EventList.vue` and apply modifier class in `frontend/src/components/EventCard.vue`
|
||||||
|
|
||||||
|
### E2E Tests for User Story 4
|
||||||
|
|
||||||
|
- [x] T020 [US4] Add E2E tests to `frontend/e2e/home-events.spec.ts` — tests: past events have faded class, upcoming events do not, past events remain clickable
|
||||||
|
|
||||||
|
**Checkpoint**: Visual hierarchy distinguishes upcoming from past events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 — Remove Event from List (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Users can remove events from their local list via delete icon (and later swipe) with confirmation.
|
||||||
|
|
||||||
|
**Independent Test**: Have multiple events, remove one via delete icon, verify it disappears while others remain.
|
||||||
|
|
||||||
|
### Unit Tests for User Story 3
|
||||||
|
|
||||||
|
- [x] T021 [P] [US3] Create unit tests for ConfirmDialog component in `frontend/src/components/__tests__/ConfirmDialog.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T022 [US3] Create `ConfirmDialog.vue` component in `frontend/src/components/ConfirmDialog.vue` — teleport-to-body modal with confirm/cancel, focus trapping, Escape key
|
||||||
|
- [x] T023 [US3] Add delete icon button to `EventCard.vue` in `frontend/src/components/EventCard.vue` — emits `delete` event with eventToken (FR-006a)
|
||||||
|
- [x] T024 [US3] Wire delete flow in `EventList.vue` in `frontend/src/components/EventList.vue` — listen for delete event, show ConfirmDialog, call `removeEvent()` on confirm
|
||||||
|
- [x] T025 [US3] Add delete icon and confirm dialog styles to `frontend/src/assets/main.css`
|
||||||
|
|
||||||
|
### E2E Tests for User Story 3
|
||||||
|
|
||||||
|
- [x] T026 [US3] Add E2E tests to `frontend/e2e/home-events.spec.ts` — tests: delete icon visible, confirmation dialog appears, confirm removes event, cancel keeps event
|
||||||
|
|
||||||
|
**Checkpoint**: Users can manage their event list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: User Story 5 — Visual Distinction for Event Roles (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Events show a badge indicating whether the user is the organizer or an attendee.
|
||||||
|
|
||||||
|
**Independent Test**: Have events with organizerToken and rsvpToken in localStorage, verify different badges displayed.
|
||||||
|
|
||||||
|
### Implementation for User Story 5
|
||||||
|
|
||||||
|
- [x] T027 [US5] Add role badge (Organizer/Attendee) to `EventCard.vue` in `frontend/src/components/EventCard.vue` — derive from organizerToken/rsvpToken presence
|
||||||
|
- [x] T028 [US5] Add role badge styles to `frontend/src/assets/main.css`
|
||||||
|
|
||||||
|
### E2E Tests for User Story 5
|
||||||
|
|
||||||
|
- [x] T029 [US5] Add E2E tests to `frontend/e2e/home-events.spec.ts` — tests: organizer badge shown for events with organizerToken, attendee badge for events with rsvpToken only
|
||||||
|
|
||||||
|
**Checkpoint**: Role distinction visible at a glance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: FAB, swipe gesture, accessibility, and final polish
|
||||||
|
|
||||||
|
- [x] T030 Create `CreateEventFab.vue` in `frontend/src/components/CreateEventFab.vue` — fixed FAB at bottom-right, navigates to `/create` (FR-011)
|
||||||
|
- [x] T031 Add FAB to `HomeView.vue` in `frontend/src/views/HomeView.vue` — visible when events exist (empty state has its own CTA)
|
||||||
|
- [x] T032 Add FAB styles to `frontend/src/assets/main.css`
|
||||||
|
- [x] T033 Implement swipe-to-delete gesture on EventCard in `frontend/src/components/EventCard.vue` — native Touch API (FR-006b)
|
||||||
|
- [x] T034 Accessibility review: verify ARIA labels, keyboard navigation (Tab/Enter/Escape), focus trapping in ConfirmDialog, WCAG AA contrast on faded cards
|
||||||
|
- [x] T035 Add E2E tests for FAB to `frontend/e2e/home-events.spec.ts` — tests: FAB visible when events exist, navigates to create page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Phase 1 (Setup)**: No dependencies — start immediately
|
||||||
|
- **Phase 2 (US1)**: Depends on T001, T003 (validation + relative time composable)
|
||||||
|
- **Phase 3 (US2)**: Depends on T001 (validation); can run in parallel with US1
|
||||||
|
- **Phase 4 (US4)**: Depends on Phase 2 completion (EventCard must exist)
|
||||||
|
- **Phase 5 (US3)**: Depends on Phase 2 completion (EventList must exist) + T002 (removeEvent)
|
||||||
|
- **Phase 6 (US5)**: Depends on Phase 2 completion (EventCard must exist)
|
||||||
|
- **Phase 7 (Polish)**: Depends on Phases 2–6 completion
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1 (P1)**: Depends only on Phase 1 — no other story dependencies
|
||||||
|
- **US2 (P2)**: Depends only on Phase 1 — independent of US1 but shares HomeView
|
||||||
|
- **US4 (P2)**: Depends on US1 (extends EventCard with past styling)
|
||||||
|
- **US3 (P3)**: Depends on US1 (extends EventList with delete flow)
|
||||||
|
- **US5 (P3)**: Depends on US1 (extends EventCard with role badge)
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- T003 + T004 + T005 can all run in parallel (different files)
|
||||||
|
- T006 + T007 can run in parallel (different test files)
|
||||||
|
- T008 can run in parallel with T006/T007 (component vs test files)
|
||||||
|
- US4, US5 can start in parallel once US1 is done (both extend EventCard independently)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup composables
|
||||||
|
2. Complete Phase 2: US1 — EventCard, EventList, HomeView refactor
|
||||||
|
3. **STOP and VALIDATE**: Test the event list end-to-end
|
||||||
|
4. Deploy/demo if ready
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Phase 1 → Composable layer ready
|
||||||
|
2. Phase 2 (US1) → Event list works → MVP!
|
||||||
|
3. Phase 3 (US2) → Empty state for new users
|
||||||
|
4. Phase 4 (US4) → Past events faded
|
||||||
|
5. Phase 5 (US3) → Remove events from list
|
||||||
|
6. Phase 6 (US5) → Role badges
|
||||||
|
7. Phase 7 → FAB, swipe, accessibility polish
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- [P] tasks = different files, no dependencies
|
||||||
|
- [Story] label maps task to specific user story for traceability
|
||||||
|
- This is a **frontend-only** feature — no backend changes needed
|
||||||
|
- All data comes from existing `useEventStorage` composable (localStorage)
|
||||||
|
- E2E tests consolidated in single file `home-events.spec.ts` with separate `describe` blocks per story
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# Feature Specification: Edit Event Details as Organizer
|
|
||||||
|
|
||||||
**Feature**: `010-edit-event`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - Edit event details (Priority: P1)
|
|
||||||
|
|
||||||
The event organizer wants to update the details of an event they created — title, description, date/time, location, or expiry date — so that guests always see accurate and up-to-date information if something changes.
|
|
||||||
|
|
||||||
**Why this priority**: Core organizer capability. Edits are expected for any real-world event (date changes, venue updates). Without this, the organizer has no recourse when details change after creation.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by creating an event (US-1), navigating to the edit form, submitting changed values, and verifying the event page reflects the updates.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an organizer on the event page with a valid organizer token in localStorage, **When** they navigate to the edit form, **Then** the form is pre-filled with the current event values (title, description, date/time, location, expiry date).
|
|
||||||
2. **Given** the edit form is pre-filled, **When** the organizer modifies one or more fields and submits, **Then** the changes are persisted server-side and the organizer is returned to the event page which reflects the updated details.
|
|
||||||
3. **Given** the organizer is on the event page without an organizer token in localStorage, **When** they attempt to access the edit UI, **Then** no edit option is shown and the server rejects any update request.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Expiry date future-date validation (Priority: P2)
|
|
||||||
|
|
||||||
When editing, the organizer must set the expiry date to a future date. Setting it to today or a past date is rejected, and the organizer is directed to the delete feature (US-19) if they want to remove the event immediately.
|
|
||||||
|
|
||||||
**Why this priority**: Enforces the invariant that an event's expiry date is always in the future. Prevents the expiry date field from being misused as an implicit deletion mechanism, keeping the model clean.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by submitting the edit form with a past or current date in the expiry date field and verifying the rejection response and validation message.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the edit form is open, **When** the organizer sets the expiry date to today or a past date and submits, **Then** the submission is rejected with a clear validation message directing the organizer to use the delete feature (US-19) instead.
|
|
||||||
2. **Given** the edit form is open, **When** the organizer sets the expiry date to a date in the future and submits, **Then** the change is accepted and persisted.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Organizer token authentication (Priority: P2)
|
|
||||||
|
|
||||||
If the organizer token is absent or invalid, neither the edit UI is shown nor the edit request is accepted server-side.
|
|
||||||
|
|
||||||
**Why this priority**: Security constraint — the organizer token is the sole authentication mechanism. Both client and server must enforce it independently.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by attempting an edit request with a missing or wrong organizer token and verifying a 403/401 response and no UI exposure.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a visitor without an organizer token in localStorage for the event, **When** they view the event page, **Then** no edit option or link is shown.
|
|
||||||
2. **Given** an edit request is submitted with an absent or invalid organizer token, **When** the server processes the request, **Then** it rejects the request and the event data is unchanged.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens when the organizer submits the edit form with no changes? The server accepts the submission (idempotent update) and the organizer is returned to the event page.
|
|
||||||
- What happens if the title field is left empty? Submission is rejected with a validation message — title is required.
|
|
||||||
- What happens if the event has expired before the organizer submits the edit form? The server rejects the edit — editing an expired event is not permitted.
|
|
||||||
- How does the system handle concurrent edits (e.g. organizer edits from two devices simultaneously)? [NEEDS EXPANSION — last-write-wins is the simplest strategy]
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: System MUST allow the organizer to edit: title (required), description (optional), date and time (required), location (optional), expiry date (required).
|
|
||||||
- **FR-002**: System MUST pre-fill the edit form with the current stored values for all editable fields.
|
|
||||||
- **FR-003**: System MUST reject an edit submission where the expiry date is today or in the past, and MUST return a validation message directing the organizer to use the delete feature (US-19).
|
|
||||||
- **FR-004**: System MUST persist all submitted changes server-side upon successful validation.
|
|
||||||
- **FR-005**: System MUST redirect the organizer to the event page after a successful edit, with the updated details visible.
|
|
||||||
- **FR-006**: System MUST NOT expose the edit UI to any visitor who does not have a valid organizer token for the event in localStorage.
|
|
||||||
- **FR-007**: System MUST reject any edit request where the organizer token is absent or does not match the event's stored organizer token.
|
|
||||||
- **FR-008**: No account or additional authentication step beyond the organizer token is required to edit an event.
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **Event**: Mutable entity with fields: title, description, date/time, location, expiry date. Identified externally by its event token. Updated via the organizer token.
|
|
||||||
- **Organizer token**: Secret UUID stored in localStorage on the device where the event was created. Required to authenticate all organizer operations including editing.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: An organizer can update any editable field and see the change reflected on the public event page without a page reload after redirect.
|
|
||||||
- **SC-002**: Any attempt to set the expiry date to a non-future date is rejected with a user-visible validation message before the server persists the change.
|
|
||||||
- **SC-003**: No edit operation is possible — client or server — without a valid organizer token.
|
|
||||||
- **SC-004**: The edit form is never shown to a visitor who does not hold the organizer token for the event.
|
|
||||||
- **SC-005**: Visual highlighting of changed fields for guests is deferred to US-9 (Highlight changed event details); this story covers only the server-side persistence and organizer UX of editing.
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# Feature Specification: Bookmark an Event
|
|
||||||
|
|
||||||
**Feature**: `011-bookmark-event`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
> **Note on directory naming**: The migration task list labeled this directory `us-06-calendar-export`, but US-6 in userstories.md is "Bookmark an event". Calendar export is US-8. The directory was created with the correct name reflecting the actual story content.
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - Bookmark an event without RSVP (Priority: P1)
|
|
||||||
|
|
||||||
A guest who has opened an event page wants to save it for later without committing to attendance. They activate a "Remember" / "Follow" action on the event page. The event token, title, and date are stored in localStorage — no server request is made. The bookmark persists across browser sessions on the same device. The guest can unfollow by activating the action again.
|
|
||||||
|
|
||||||
**Why this priority**: Core bookmarking capability — without this, the entire feature has no value.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by visiting an event page, activating the bookmark action, closing the browser, and reopening to verify persistence. Delivers value by enabling the local event overview (US-7) to display bookmarked events.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a guest has opened an event page, **When** they activate the "Remember" / "Follow" action, **Then** the event token, title, and date are stored in localStorage and no server request is made.
|
|
||||||
2. **Given** a guest has bookmarked an event, **When** they close and reopen the browser, **Then** the bookmark is still present in localStorage.
|
|
||||||
3. **Given** a guest has bookmarked an event, **When** they activate the bookmark action again, **Then** the bookmark is removed from localStorage ("unfollow") without any server contact.
|
|
||||||
4. **Given** a guest has bookmarked an event, **When** the event page is loaded again, **Then** the bookmark action reflects the current bookmarked state.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Bookmark is independent of RSVP state (Priority: P2)
|
|
||||||
|
|
||||||
A guest who has already RSVPed to an event on their device can still explicitly bookmark or un-bookmark the event. The bookmark state is tracked separately from RSVP state.
|
|
||||||
|
|
||||||
**Why this priority**: Important for correctness but not the primary use case. The primary scenario is the undecided guest who wants to remember without committing.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by RSVPing to an event and then toggling the bookmark action — both states persist independently in localStorage.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a guest has RSVPed "attending" on this device, **When** they activate the bookmark action, **Then** the bookmark is stored independently and the RSVP state is unaffected.
|
|
||||||
2. **Given** a guest has bookmarked an event and RSVPed, **When** they remove the bookmark, **Then** the RSVP state is unaffected.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Bookmark available on expired events (Priority: P2)
|
|
||||||
|
|
||||||
A guest who is viewing an event that has passed its expiry date can still bookmark it, so it remains visible in their local event overview (US-7).
|
|
||||||
|
|
||||||
**Why this priority**: Edge case that ensures continuity of the local overview even for past events.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by visiting an expired event page and verifying the bookmark action is present and functional.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an event has passed its expiry date, **When** a guest views the event page, **Then** the bookmark action is still shown and functional.
|
|
||||||
2. **Given** a guest bookmarks an expired event, **When** they view their local event overview (US-7), **Then** the event appears in the list marked as ended.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens when the event title or date changes after bookmarking? Locally cached title and date may become stale if the organizer edits the event — this is an accepted trade-off. Cached values are refreshed when the guest next visits the event page.
|
|
||||||
- How does the app handle localStorage being unavailable (e.g. private browsing in some browsers)? [NEEDS EXPANSION]
|
|
||||||
- What happens if the guest bookmarks the same event from multiple devices? Each device maintains its own independent bookmark — no server-side sync.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: The event page MUST display a "Remember" / "Follow" action accessible to any visitor holding the event link.
|
|
||||||
- **FR-002**: Activating the bookmark action MUST store the event token, event title, and event date in localStorage with no server request.
|
|
||||||
- **FR-003**: The bookmark MUST persist across browser sessions on the same device.
|
|
||||||
- **FR-004**: A second activation of the bookmark action MUST remove the bookmark ("unfollow") with no server contact.
|
|
||||||
- **FR-005**: The bookmark state MUST be independent of the RSVP state — both can coexist for the same event on the same device.
|
|
||||||
- **FR-006**: The bookmark action MUST remain available on event pages where the event has expired.
|
|
||||||
- **FR-007**: No personal data, IP address, or identifier MUST be transmitted to the server when bookmarking or un-bookmarking.
|
|
||||||
- **FR-008**: The bookmark action MUST reflect the current state (bookmarked / not bookmarked) when the event page loads.
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **Bookmark record** (localStorage): Stored per event token. Contains: event token, event title, event date. Indicates the guest has explicitly bookmarked this event without RSVPing. Independent of the RSVP record.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: A guest can bookmark an event and find it in their local overview (US-7) without any server contact at any point in the bookmark flow.
|
|
||||||
- **SC-002**: Bookmarking and un-bookmarking produce no network requests (verifiable via browser DevTools).
|
|
||||||
- **SC-003**: A bookmark persists after browser restart on the same device.
|
|
||||||
- **SC-004**: The bookmark state is correctly reflected on the event page across multiple sessions.
|
|
||||||
- **SC-005**: Guests with existing RSVPs on this device can independently toggle the bookmark without affecting their RSVP state.
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
# Feature Specification: Local Event Overview List
|
|
||||||
|
|
||||||
**Feature**: `012-local-event-overview`
|
|
||||||
**Created**: 2026-03-06
|
|
||||||
**Status**: Draft
|
|
||||||
**Source**: Migrated from spec/userstories.md
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### User Story 1 - View tracked events on the root page (Priority: P1)
|
|
||||||
|
|
||||||
A user who has created, bookmarked, or RSVPed to events on this device opens the root page (`/`) and sees a list of all those events, each with the event title, date, and their relationship to the event (organizer / attending / not attending / bookmarked only). Each entry is a link to the event page. The list is rendered entirely from localStorage — no server request is required. If no events are tracked locally, an empty state is shown.
|
|
||||||
|
|
||||||
**Why this priority**: This is the core feature — the reason the overview exists. Without this story, no other scenario is meaningful.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by seeding localStorage with event entries (simulating US-1/US-3/US-6 data) and loading the root page. Delivers value by allowing users to navigate back to any tracked event without the original link.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the user has created an event from this device (organizer token in localStorage), **When** they navigate to `/`, **Then** the event appears in the list with title, date, and relationship "organizer"
|
|
||||||
2. **Given** the user has RSVPed "attending" to an event from this device, **When** they navigate to `/`, **Then** the event appears in the list with title, date, and relationship "attending"
|
|
||||||
3. **Given** the user has RSVPed "not attending" to an event from this device, **When** they navigate to `/`, **Then** the event appears in the list with title, date, and relationship "not attending"
|
|
||||||
4. **Given** the user has bookmarked an event from this device (US-6), **When** they navigate to `/`, **Then** the event appears in the list with title, date, and relationship "bookmarked only"
|
|
||||||
5. **Given** no events are tracked in localStorage, **When** the user navigates to `/`, **Then** an empty state message is shown — not an error
|
|
||||||
6. **Given** the list is rendered, **When** the user clicks an entry, **Then** they are navigated directly to the event page for that entry
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Visually distinguish past events (Priority: P2)
|
|
||||||
|
|
||||||
Events whose date has passed are still shown in the list but rendered with a visual distinction (e.g. marked as "ended"), so the user can differentiate between upcoming and past events at a glance.
|
|
||||||
|
|
||||||
**Why this priority**: Useful for UX clarity but the overview is still functional without this distinction. Past events in the list are still navigable.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by placing a past-dated event entry in localStorage and loading the root page. The entry should appear visually distinct from current events.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an event in localStorage has a date in the past, **When** the user views the overview, **Then** the entry is visually distinguished (e.g. marked "ended") compared to upcoming events
|
|
||||||
2. **Given** an event in localStorage has a date in the future, **When** the user views the overview, **Then** the entry is rendered without any "ended" indicator
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Remove an entry from the local list (Priority: P2)
|
|
||||||
|
|
||||||
The user can remove individual entries from the local overview. The behavior depends on the entry type: for bookmarked-only events the bookmark is removed; for RSVPed events the local record is removed (server-side RSVP unaffected); for organizer-created events the organizer token and event data are removed from localStorage, with a confirmation warning that organizer access on this device will be lost.
|
|
||||||
|
|
||||||
**Why this priority**: Important for list hygiene but not required for the core navigation use case.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by placing entries of each type in localStorage and verifying removal behavior from the overview UI.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a bookmarked-only event is in the local list, **When** the user removes that entry, **Then** the bookmark is removed from localStorage and the entry disappears from the list
|
|
||||||
2. **Given** an RSVPed event is in the local list, **When** the user removes that entry, **Then** the local RSVP record is removed from localStorage; the server-side RSVP is unaffected
|
|
||||||
3. **Given** an organizer-created event is in the local list, **When** the user initiates removal, **Then** a confirmation warning is shown explaining that organizer access on this device will be revoked
|
|
||||||
4. **Given** the organizer confirms removal, **Then** the organizer token and event metadata are removed from localStorage and the entry disappears from the list
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 4 - Handle a deleted event when navigating from the overview (Priority: P2)
|
|
||||||
|
|
||||||
If the user clicks an entry in the local overview and the server responds that the event no longer exists (deleted per US-12 automatic cleanup or US-19 organizer deletion), the app displays an "event no longer exists" message and offers to remove the entry from the local list.
|
|
||||||
|
|
||||||
**Why this priority**: Edge case that improves consistency and prevents stale entries from accumulating, but not core to the overview's primary purpose.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by navigating to an event whose token does not exist on the server. The app should display a "no longer exists" message and offer removal.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an entry exists in the local overview, **When** the user navigates to that event and the server returns "event not found", **Then** the app displays an "event no longer exists" message and offers to remove the entry from the local list
|
|
||||||
2. **Given** the user confirms removal of the stale entry, **Then** the entry is removed from localStorage and the user is returned to the overview
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens if localStorage is unavailable or disabled in the browser? The overview cannot render — [NEEDS EXPANSION: define fallback message or behavior]
|
|
||||||
- What happens if the same event appears under multiple localStorage keys (e.g. both RSVPed and organizer)? [NEEDS EXPANSION: define de-duplication or priority rule for relationship label]
|
|
||||||
- What happens if an event's locally cached title or date is stale (organizer edited via US-5)? Stale values are displayed until the user next visits the event page — this is an accepted trade-off per the story notes.
|
|
||||||
- What happens when the user has a very large number of tracked events? [NEEDS EXPANSION: pagination or truncation strategy]
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: The root page (`/`) MUST display the local event overview list below a project header/branding section
|
|
||||||
- **FR-002**: The list MUST include any event for which an organizer token, RSVP record, or bookmark exists in localStorage for this device
|
|
||||||
- **FR-003**: Each list entry MUST show at minimum: event title, event date, and the user's relationship to the event (organizer / attending / not attending / bookmarked only)
|
|
||||||
- **FR-004**: Each list entry MUST be a link that navigates directly to the corresponding event page
|
|
||||||
- **FR-005**: The list MUST be populated entirely from localStorage — no server request is made to render the overview
|
|
||||||
- **FR-006**: Events whose date has passed MUST be visually distinguished (e.g. marked "ended") from upcoming events
|
|
||||||
- **FR-007**: An individual entry MUST be removable from the list; removal behavior depends on entry type (bookmark removal, local RSVP record removal, or organizer token removal)
|
|
||||||
- **FR-008**: Removing an organizer-created event entry MUST require a confirmation warning explaining that organizer access on this device will be revoked
|
|
||||||
- **FR-009**: No personal data or event data MUST be transmitted to the server when viewing or interacting with the overview
|
|
||||||
- **FR-010**: If no events are tracked locally, an empty state MUST be shown — not an error or blank screen
|
|
||||||
- **FR-011**: When navigating from the overview to an event that the server reports as deleted, the app MUST display an "event no longer exists" message and offer to remove the stale entry from the local list
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **LocalEventEntry**: A localStorage-stored record representing one tracked event. Contains at minimum: event token, event title, event date, and relationship type (organizer / attending / not attending / bookmarked). May also contain: organizer token (for organizer entries), RSVP choice and name (for RSVP entries).
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: A user with events tracked in localStorage can navigate to `/` and see all tracked events without any server request being made
|
|
||||||
- **SC-002**: Each event entry links correctly to its event page
|
|
||||||
- **SC-003**: Past events are visually distinguishable from upcoming events in the list
|
|
||||||
- **SC-004**: An entry can be removed from the list, and the corresponding localStorage key is cleaned up correctly for each entry type
|
|
||||||
- **SC-005**: The empty state is shown when no events are tracked in localStorage — no blank page or error state
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user