diff --git a/.specify/memory/ideen.md b/.specify/memory/ideen.md index 8cc0496..b7c2b50 100644 --- a/.specify/memory/ideen.md +++ b/.specify/memory/ideen.md @@ -84,31 +84,12 @@ Die folgenden Punkte wurden in Diskussionen bereits geklärt und sind verbindlic * (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) @@ -137,19 +118,6 @@ Badge/Indikator bei ungelesenen Organisator-Updates, rein clientseitig via local 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. @@ -169,27 +137,11 @@ Organisator sucht Headerbild über integrierte Unsplash-Suche. * Bild lokal gespeichert + Unsplash-Attribution * Feature deaktiviert wenn kein API-Key konfiguriert -### 023 – Dark Mode -App erkennt `prefers-color-scheme` und bietet manuellen Toggle. -* Manuelle Auswahl in localStorage gespeichert -* Gilt für globales App-Chrome, nicht Event-Farbthemen -* Beide Modi WCAG AA konform - -### 024 – Event absagen -Organisator kann Event absagen (mit optionaler Nachricht, Einweg-Transition). -* RSVPs werden nach Absage abgelehnt -* Absage-Nachricht nachträglich editierbar -* Kann nicht rückgängig gemacht werden -* Wenn Organisator Event auf der Eventlistenseite löscht, muss dabei das Event abgesagt werden (nicht nur lokal entfernen) - -### 025 – Event löschen -Organisator löscht Event permanent und unwiderruflich. -* Entfernt alle zugehörigen Daten sofort -* localStorage-Eintrag wird entfernt, Redirect zu `/` -* Funktioniert in jedem Event-Status - ### 026 – 404-Seite Catch-all Route für ungültige Pfade mit "Seite nicht gefunden" und Link zur Startseite. * Folgt dem Design System (Electric Dusk + Sora) * WCAG AA konform * Verhindert leere Seiten bei Fehlnavigation + +### 027 - Update der EventListe +* Irgendwie ein update der event liste, wenn man sie betritt oder wenn man mit touch die seite nach unten zieht (hier müssen wir noch überlegen, wie wir mit den verschiedenen update fällen umgehen und wie wir das update überhaupt requesten. Ich meine sowas wie: was ist, wenn das event nicht mehr gefunden wurde?) diff --git a/frontend/e2e/cancel-event.spec.ts b/frontend/e2e/cancel-event.spec.ts index 9ba73e3..f6a831a 100644 --- a/frontend/e2e/cancel-event.spec.ts +++ b/frontend/e2e/cancel-event.spec.ts @@ -64,12 +64,14 @@ test.describe('US1: Organizer cancels event with reason', () => { await page.addInitScript(seedEvents([organizerSeed()])) await page.goto(`/events/${fullEvent.eventToken}`) - // Cancel button visible for organizer - const cancelBtn = page.getByRole('button', { name: /Cancel event/i }) - await expect(cancelBtn).toBeVisible() + // Open kebab menu, then cancel event + const kebabBtn = page.getByRole('button', { name: /Event actions/i }) + await expect(kebabBtn).toBeVisible() + await kebabBtn.click() - // Open cancel bottom sheet - await cancelBtn.click() + const cancelItem = page.getByRole('menuitem', { name: /Cancel event/i }) + await expect(cancelItem).toBeVisible() + await cancelItem.click() // Fill in reason const reasonField = page.getByLabel(/reason/i) @@ -83,8 +85,8 @@ test.describe('US1: Organizer cancels event with reason', () => { await expect(page.getByText(/This event has been cancelled/i)).toBeVisible() await expect(page.getByText('Venue closed')).toBeVisible() - // Cancel button should be gone - await expect(cancelBtn).not.toBeVisible() + // Kebab menu should be gone (event is cancelled) + await expect(kebabBtn).not.toBeVisible() }) }) @@ -118,7 +120,8 @@ test.describe('US1: Organizer cancels event without reason', () => { await page.addInitScript(seedEvents([organizerSeed()])) await page.goto(`/events/${fullEvent.eventToken}`) - await page.getByRole('button', { name: /Cancel event/i }).click() + await page.getByRole('button', { name: /Event actions/i }).click() + await page.getByRole('menuitem', { name: /Cancel event/i }).click() // Don't fill in reason, just confirm await page.getByRole('button', { name: /Confirm cancellation/i }).click() @@ -150,7 +153,8 @@ test.describe('US1: Cancel API failure', () => { await page.addInitScript(seedEvents([organizerSeed()])) await page.goto(`/events/${fullEvent.eventToken}`) - await page.getByRole('button', { name: /Cancel event/i }).click() + await page.getByRole('button', { name: /Event actions/i }).click() + await page.getByRole('menuitem', { name: /Cancel event/i }).click() await page.getByRole('button', { name: /Confirm cancellation/i }).click() // Error message in bottom sheet diff --git a/frontend/e2e/ical-download.spec.ts b/frontend/e2e/ical-download.spec.ts new file mode 100644 index 0000000..3deccdd --- /dev/null +++ b/frontend/e2e/ical-download.spec.ts @@ -0,0 +1,108 @@ +import { http, HttpResponse } from 'msw' +import { test, expect } from './msw-setup' +import type { StoredEvent } from '../src/composables/useEventStorage' + +const STORAGE_KEY = 'fete:events' + +const fullEvent = { + eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + title: 'Sommerfest am See', + description: 'Bring your own drinks!', + dateTime: '2026-07-15T18:00:00+02:00', + timezone: 'Europe/Berlin', + location: 'Stadtpark Berlin', + attendeeCount: 12, + cancelled: false, +} + +const cancelledEvent = { + ...fullEvent, + cancelled: true, + cancellationReason: 'Bad weather', +} + +function seedEvents(events: StoredEvent[]): string { + return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})` +} + +function rsvpSeed(): StoredEvent { + return { + eventToken: fullEvent.eventToken, + title: fullEvent.title, + dateTime: fullEvent.dateTime, + rsvpToken: 'd4e5f6a7-b8c9-0123-4567-890abcdef012', + rsvpName: 'Anna', + } +} + +function organizerSeed(): StoredEvent { + return { + eventToken: fullEvent.eventToken, + title: fullEvent.title, + dateTime: fullEvent.dateTime, + organizerToken: 'org-token-1234', + } +} + +test.describe('iCal download: calendar button visibility', () => { + test('calendar button visible for pre-RSVP visitor', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)), + ) + await page.goto(`/events/${fullEvent.eventToken}`) + + const calendarBtn = page.getByRole('button', { name: /add to calendar/i }) + await expect(calendarBtn).toBeVisible() + }) + + test('calendar button visible for post-RSVP attendee', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)), + ) + await page.addInitScript(seedEvents([rsvpSeed()])) + await page.goto(`/events/${fullEvent.eventToken}`) + + const calendarBtn = page.getByRole('button', { name: /add to calendar/i }) + await expect(calendarBtn).toBeVisible() + }) + + test('calendar button visible for organizer', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)), + http.get('*/api/events/:token/attendees*', () => + HttpResponse.json({ attendees: [] }), + ), + ) + await page.addInitScript(seedEvents([organizerSeed()])) + await page.goto(`/events/${fullEvent.eventToken}`) + + const calendarBtn = page.getByRole('button', { name: /add to calendar/i }) + await expect(calendarBtn).toBeVisible() + }) + + test('calendar button NOT visible for cancelled event', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(cancelledEvent)), + ) + await page.goto(`/events/${fullEvent.eventToken}`) + + const calendarBtn = page.getByRole('button', { name: /add to calendar/i }) + await expect(calendarBtn).not.toBeVisible() + }) +}) + +test.describe('iCal download: file generation', () => { + test('triggers download with correct filename', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)), + ) + await page.goto(`/events/${fullEvent.eventToken}`) + + // Intercept the download by overriding the click-link mechanism + const downloadPromise = page.waitForEvent('download') + await page.getByRole('button', { name: /add to calendar/i }).click() + const download = await downloadPromise + + expect(download.suggestedFilename()).toBe('sommerfest-am-see.ics') + }) +}) diff --git a/frontend/e2e/watch-event.spec.ts b/frontend/e2e/watch-event.spec.ts index a976e74..87d3e0c 100644 --- a/frontend/e2e/watch-event.spec.ts +++ b/frontend/e2e/watch-event.spec.ts @@ -56,7 +56,7 @@ test.describe('US1: Watch event from detail page', () => { ) await page.goto(`/events/${fullEvent.eventToken}`) - const bookmark = page.locator('.rsvp-bar__bookmark-inner') + const bookmark = page.getByLabel(/watch.*this event/i) await expect(bookmark).toBeVisible() await expect(bookmark).toHaveAttribute('aria-label', 'Watch this event') @@ -81,7 +81,7 @@ test.describe('US2: Un-watch event from detail page', () => { await page.addInitScript(seedEvents([watchSeed()])) await page.goto(`/events/${fullEvent.eventToken}`) - const bookmark = page.locator('.rsvp-bar__bookmark-inner') + const bookmark = page.getByLabel(/watch.*this event/i) await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event') await bookmark.click() @@ -105,7 +105,7 @@ test.describe('US3: Bookmark reflects attending status', () => { await page.goto(`/events/${fullEvent.eventToken}`) // Bookmark not shown for attendees — RsvpBar shows status state - const bookmark = page.locator('.rsvp-bar__bookmark-inner') + const bookmark = page.getByLabel(/watch.*this event/i) await expect(bookmark).not.toBeVisible() // Navigate to list via back link @@ -132,7 +132,7 @@ test.describe('US4: RSVP cancellation preserves watch status', () => { await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click() // Bookmark reappears in CTA state, filled because event is still stored - const bookmark = page.locator('.rsvp-bar__bookmark-inner') + const bookmark = page.getByLabel(/watch.*this event/i) await expect(bookmark).toBeVisible() await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event') @@ -151,7 +151,7 @@ test.describe('US5: No bookmark for attendees and organizers', () => { await page.addInitScript(seedEvents([rsvpSeed()])) await page.goto(`/events/${fullEvent.eventToken}`) - const bookmark = page.locator('.rsvp-bar__bookmark-inner') + const bookmark = page.getByLabel(/watch.*this event/i) await expect(bookmark).not.toBeVisible() }) @@ -162,7 +162,7 @@ test.describe('US5: No bookmark for attendees and organizers', () => { await page.addInitScript(seedEvents([organizerSeed()])) await page.goto(`/events/${fullEvent.eventToken}`) - const bookmark = page.locator('.rsvp-bar__bookmark-inner') + const bookmark = page.getByLabel(/watch.*this event/i) await expect(bookmark).not.toBeVisible() }) }) @@ -197,7 +197,7 @@ test.describe('US7: Watcher upgrades to attendee', () => { await page.goto(`/events/${fullEvent.eventToken}`) // Verify watching state — bookmark visible - const bookmark = page.locator('.rsvp-bar__bookmark-inner') + const bookmark = page.getByLabel(/watch.*this event/i) await expect(bookmark).toBeVisible() await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event') diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9d0fb1a..4c40cd1 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,6 +2,7 @@
+
@@ -16,11 +17,19 @@ const route = useRoute() diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index bdb327e..a20082e 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -317,6 +317,72 @@ input[type="datetime-local"].form-field.glass::-webkit-datetime-edit-fields-wrap to { --glow-angle: 360deg; } } +/* ── Fixed Bottom Bar Components ── */ + +/* CTA wrapper (text button, e.g. "I'm attending!", "Post an update") */ +.bar-cta { + flex: 1; + min-width: 0; + border-radius: var(--radius-button); + transition: transform 0.1s ease; +} + +.bar-cta:hover { + transform: scale(1.02); +} + +.bar-cta:active { + transform: scale(0.98); +} + +.bar-cta-btn { + display: block; + width: 100%; + padding: var(--spacing-md) var(--spacing-lg); + border-radius: calc(var(--radius-button) - 2px); + font-family: inherit; + font-size: 1rem; + font-weight: 700; + color: var(--color-text-on-gradient); + text-align: center; + border: none; + cursor: pointer; +} + + +/* Icon wrapper (e.g. calendar, bookmark buttons) */ +.bar-icon { + flex-shrink: 0; + border-radius: var(--radius-button); + transition: transform 0.1s ease; +} + +.bar-icon:hover { + transform: scale(1.02); +} + +.bar-icon:active { + transform: scale(0.98); +} + +.bar-icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: var(--spacing-md); + border-radius: calc(var(--radius-button) - 2px); + border: none; + cursor: pointer; + color: var(--color-text-on-gradient); + line-height: 0; +} + +.bar-icon-btn svg { + display: block; +} + /* Utility */ .text-center { text-align: center; diff --git a/frontend/src/components/RsvpBar.vue b/frontend/src/components/RsvpBar.vue index 71e2101..acd3eeb 100644 --- a/frontend/src/components/RsvpBar.vue +++ b/frontend/src/components/RsvpBar.vue @@ -3,20 +3,30 @@
-
- - You're attending! - +
+
+ + You're attending! + +
+
-
-
+
+
+ +
+
+ +
@@ -64,6 +84,7 @@ defineEmits<{ open: [] cancel: [] bookmark: [] + calendar: [] }>() const expanded = ref(false) @@ -111,34 +132,6 @@ watch(expanded, (isExpanded) => { gap: var(--spacing-sm); } -.rsvp-bar__cta { - flex: 1; - min-width: 0; - border-radius: var(--radius-button); - transition: transform 0.1s ease; -} - -.rsvp-bar__cta:hover { - transform: scale(1.02); -} - -.rsvp-bar__cta:active { - transform: scale(0.98); -} - -.rsvp-bar__cta-inner { - display: block; - width: 100%; - padding: var(--spacing-md) var(--spacing-lg); - border-radius: calc(var(--radius-button) - 2px); - font-family: inherit; - font-size: 1rem; - font-weight: 700; - color: var(--color-text-on-gradient); - text-align: center; - border: none; - cursor: pointer; -} .rsvp-bar__status-wrapper { display: flex; @@ -146,7 +139,14 @@ watch(expanded, (isExpanded) => { gap: var(--spacing-xs); } +.rsvp-bar__status-row { + display: flex; + gap: var(--spacing-sm); +} + .rsvp-bar__status { + flex: 1; + min-width: 0; display: flex; align-items: center; justify-content: center; @@ -225,35 +225,35 @@ watch(expanded, (isExpanded) => { transform: translateY(-4px); } -.rsvp-bar__bookmark { + +/* Calendar button — glassmorphic variant (post-RSVP status row) */ +.rsvp-bar__calendar-glass { flex-shrink: 0; - border-radius: var(--radius-button); - transition: transform 0.1s ease; -} - -.rsvp-bar__bookmark:hover { - transform: scale(1.02); -} - -.rsvp-bar__bookmark:active { - transform: scale(0.98); -} - -.rsvp-bar__bookmark-inner { display: flex; align-items: center; justify-content: center; - width: 100%; - height: 100%; padding: var(--spacing-md); - border-radius: calc(var(--radius-button) - 2px); - border: none; - cursor: pointer; + background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%); + border: 1px solid var(--color-glass-border); + backdrop-filter: blur(16px); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); color: var(--color-text-on-gradient); + cursor: pointer; line-height: 0; + transition: transform 0.1s ease, background 0.15s ease; } -.rsvp-bar__bookmark-inner svg { +.rsvp-bar__calendar-glass:hover { + transform: scale(1.02); + background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%); +} + +.rsvp-bar__calendar-glass:active { + transform: scale(0.98); +} + +.rsvp-bar__calendar-glass svg { display: block; } diff --git a/frontend/src/components/__tests__/RsvpBar.spec.ts b/frontend/src/components/__tests__/RsvpBar.spec.ts index 34bb30b..3333ba6 100644 --- a/frontend/src/components/__tests__/RsvpBar.spec.ts +++ b/frontend/src/components/__tests__/RsvpBar.spec.ts @@ -5,8 +5,8 @@ import RsvpBar from '../RsvpBar.vue' describe('RsvpBar', () => { it('renders CTA button when hasRsvp is false', () => { const wrapper = mount(RsvpBar) - expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true) - expect(wrapper.find('.rsvp-bar__cta-inner').text()).toBe("I'm attending!") + expect(wrapper.find('.bar-cta').exists()).toBe(true) + expect(wrapper.find('.bar-cta-btn').text()).toBe("I'm attending!") expect(wrapper.find('.rsvp-bar__status').exists()).toBe(false) }) @@ -14,17 +14,17 @@ describe('RsvpBar', () => { 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) + expect(wrapper.find('.bar-cta').exists()).toBe(false) }) it('emits open when CTA button is clicked', async () => { const wrapper = mount(RsvpBar) - await wrapper.find('.rsvp-bar__cta-inner').trigger('click') + await wrapper.find('.bar-cta-btn').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) + expect(wrapper.find('.bar-cta-btn').exists()).toBe(false) }) }) diff --git a/frontend/src/composables/__tests__/useIcalDownload.spec.ts b/frontend/src/composables/__tests__/useIcalDownload.spec.ts new file mode 100644 index 0000000..9bfbc13 --- /dev/null +++ b/frontend/src/composables/__tests__/useIcalDownload.spec.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest' +import { generateIcs } from '../useIcalDownload' + +describe('generateIcs', () => { + const baseEvent = { + eventToken: '550e8400-e29b-41d4-a716-446655440000', + title: 'Sommerfest am See', + dateTime: '2026-07-15T18:00:00+02:00', + location: 'Stadtpark Berlin', + description: 'Bring your own drinks', + } + + it('generates valid VCALENDAR wrapper', () => { + const ics = generateIcs(baseEvent) + expect(ics).toMatch(/^BEGIN:VCALENDAR\r\n/) + expect(ics).toMatch(/\r\nEND:VCALENDAR\r\n$/) + }) + + it('includes VERSION and PRODID', () => { + const ics = generateIcs(baseEvent) + expect(ics).toContain('VERSION:2.0\r\n') + expect(ics).toContain('PRODID:-//fete//EN\r\n') + }) + + it('generates valid VEVENT block', () => { + const ics = generateIcs(baseEvent) + expect(ics).toContain('BEGIN:VEVENT\r\n') + expect(ics).toContain('END:VEVENT\r\n') + }) + + it('sets UID from eventToken', () => { + const ics = generateIcs(baseEvent) + expect(ics).toContain('UID:550e8400-e29b-41d4-a716-446655440000@fete\r\n') + }) + + it('sets DTSTART in UTC format', () => { + const ics = generateIcs(baseEvent) + expect(ics).toContain('DTSTART:20260715T160000Z\r\n') + }) + + it('does NOT include DTEND or DURATION', () => { + const ics = generateIcs(baseEvent) + expect(ics).not.toContain('DTEND') + expect(ics).not.toContain('DURATION') + }) + + it('sets SUMMARY from title', () => { + const ics = generateIcs(baseEvent) + expect(ics).toContain('SUMMARY:Sommerfest am See\r\n') + }) + + it('sets LOCATION when present', () => { + const ics = generateIcs(baseEvent) + expect(ics).toContain('LOCATION:Stadtpark Berlin\r\n') + }) + + it('sets DESCRIPTION when present', () => { + const ics = generateIcs(baseEvent) + expect(ics).toContain('DESCRIPTION:Bring your own drinks\r\n') + }) + + it('omits LOCATION when not provided', () => { + const { location: _location, ...noLocation } = baseEvent + const ics = generateIcs(noLocation) + expect(ics).not.toContain('LOCATION') + }) + + it('omits DESCRIPTION when not provided', () => { + const { description: _description, ...noDesc } = baseEvent + const ics = generateIcs(noDesc) + expect(ics).not.toContain('DESCRIPTION') + }) + + it('includes SEQUENCE:0', () => { + const ics = generateIcs(baseEvent) + expect(ics).toContain('SEQUENCE:0\r\n') + }) + + it('includes DTSTAMP in UTC format', () => { + const ics = generateIcs(baseEvent) + expect(ics).toMatch(/DTSTAMP:\d{8}T\d{6}Z\r\n/) + }) + + it('escapes commas in text fields', () => { + const ics = generateIcs({ ...baseEvent, title: 'Hello, World' }) + expect(ics).toContain('SUMMARY:Hello\\, World\r\n') + }) + + it('escapes semicolons in text fields', () => { + const ics = generateIcs({ ...baseEvent, description: 'foo; bar' }) + expect(ics).toContain('DESCRIPTION:foo\\; bar\r\n') + }) + + it('escapes backslashes in text fields', () => { + const ics = generateIcs({ ...baseEvent, title: 'path\\to' }) + expect(ics).toContain('SUMMARY:path\\\\to\r\n') + }) + + it('escapes newlines in text fields', () => { + const ics = generateIcs({ ...baseEvent, description: 'line1\nline2' }) + expect(ics).toContain('DESCRIPTION:line1\\nline2\r\n') + }) + + it('produces deterministic output for the same input', () => { + const ics1 = generateIcs(baseEvent) + const ics2 = generateIcs(baseEvent) + // DTSTAMP changes with time, so strip it for comparison + const strip = (s: string) => s.replace(/DTSTAMP:\d{8}T\d{6}Z\r\n/, '') + expect(strip(ics1)).toBe(strip(ics2)) + }) + + it('uses CRLF line endings throughout', () => { + const ics = generateIcs(baseEvent) + const lines = ics.split('\r\n') + // Every "line" split by CRLF should not contain a bare LF + for (const line of lines) { + expect(line).not.toContain('\n') + } + }) +}) diff --git a/frontend/src/composables/useIcalDownload.ts b/frontend/src/composables/useIcalDownload.ts new file mode 100644 index 0000000..2994cf7 --- /dev/null +++ b/frontend/src/composables/useIcalDownload.ts @@ -0,0 +1,71 @@ +import { slugify } from '@/utils/slugify' + +export interface IcalEvent { + eventToken: string + title: string + dateTime: string + location?: string + description?: string +} + +function escapeText(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\n/g, '\\n') +} + +function toUtcString(isoDateTime: string): string { + const d = new Date(isoDateTime) + const pad = (n: number) => String(n).padStart(2, '0') + return ( + `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` + + `T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z` + ) +} + +export function generateIcs(event: IcalEvent): string { + const lines: string[] = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//fete//EN', + 'BEGIN:VEVENT', + `UID:${event.eventToken}@fete`, + `DTSTAMP:${toUtcString(new Date().toISOString())}`, + `DTSTART:${toUtcString(event.dateTime)}`, + `SUMMARY:${escapeText(event.title)}`, + 'SEQUENCE:0', + ] + + if (event.location) { + lines.push(`LOCATION:${escapeText(event.location)}`) + } + + if (event.description) { + lines.push(`DESCRIPTION:${escapeText(event.description)}`) + } + + lines.push('END:VEVENT', 'END:VCALENDAR', '') + + return lines.join('\r\n') +} + +export function useIcalDownload() { + function download(event: IcalEvent) { + const ics = generateIcs(event) + const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' }) + const url = URL.createObjectURL(blob) + + const filename = `${slugify(event.title) || 'event'}.ics` + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } + + return { download } +} diff --git a/frontend/src/utils/__tests__/slugify.spec.ts b/frontend/src/utils/__tests__/slugify.spec.ts new file mode 100644 index 0000000..5a7c7be --- /dev/null +++ b/frontend/src/utils/__tests__/slugify.spec.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest' +import { slugify } from '../slugify' + +describe('slugify', () => { + it('converts to lowercase', () => { + expect(slugify('Hello World')).toBe('hello-world') + }) + + it('replaces spaces with hyphens', () => { + expect(slugify('foo bar baz')).toBe('foo-bar-baz') + }) + + it('transliterates German umlauts', () => { + expect(slugify('Ärger über Öl füßen')).toBe('aerger-ueber-oel-fuessen') + }) + + it('transliterates uppercase umlauts', () => { + expect(slugify('Ä Ö Ü')).toBe('ae-oe-ue') + }) + + it('transliterates ß', () => { + expect(slugify('Straße')).toBe('strasse') + }) + + it('removes non-ASCII characters after transliteration', () => { + expect(slugify('Café résumé')).toBe('caf-rsum') + }) + + it('replaces special characters with hyphens', () => { + expect(slugify('hello@world! #test')).toBe('hello-world-test') + }) + + it('collapses consecutive hyphens', () => { + expect(slugify('foo---bar')).toBe('foo-bar') + }) + + it('trims leading and trailing hyphens', () => { + expect(slugify('--hello--')).toBe('hello') + }) + + it('truncates to 60 characters', () => { + const long = 'a'.repeat(80) + expect(slugify(long).length).toBeLessThanOrEqual(60) + }) + + it('does not break mid-word when truncating', () => { + // 60 chars of 'a' should just be 60 a's (no word boundary issue) + const result = slugify('a'.repeat(65)) + expect(result.length).toBe(60) + expect(result).toBe('a'.repeat(60)) + }) + + it('handles empty string', () => { + expect(slugify('')).toBe('') + }) + + it('handles string that becomes empty after processing', () => { + expect(slugify('!@#$%')).toBe('') + }) + + it('handles emoji', () => { + const result = slugify('Party 🎉 time') + expect(result).toBe('party-time') + }) + + it('produces Sommerfest am See example from spec', () => { + expect(slugify('Sommerfest am See')).toBe('sommerfest-am-see') + }) +}) diff --git a/frontend/src/utils/slugify.ts b/frontend/src/utils/slugify.ts new file mode 100644 index 0000000..da28552 --- /dev/null +++ b/frontend/src/utils/slugify.ts @@ -0,0 +1,28 @@ +const UMLAUT_MAP: Record = { + ä: 'ae', + ö: 'oe', + ü: 'ue', + ß: 'ss', + Ä: 'Ae', + Ö: 'Oe', + Ü: 'Ue', +} + +export function slugify(input: string): string { + return ( + input + // Transliterate German umlauts + .replace(/[äöüßÄÖÜ]/g, (ch) => UMLAUT_MAP[ch] ?? ch) + .toLowerCase() + // Remove non-ASCII characters + .replace(/[^\x20-\x7E]/g, '') + // Replace non-alphanumeric characters with hyphens + .replace(/[^a-z0-9]+/g, '-') + // Collapse consecutive hyphens + .replace(/-{2,}/g, '-') + // Trim leading/trailing hyphens + .replace(/^-|-$/g, '') + // Truncate to 60 characters + .slice(0, 60) + ) +} diff --git a/frontend/src/views/EventDetailView.vue b/frontend/src/views/EventDetailView.vue index 4f1e3b3..8483af8 100644 --- a/frontend/src/views/EventDetailView.vue +++ b/frontend/src/views/EventDetailView.vue @@ -10,6 +10,33 @@
+ + +
+ + + + +
+
+
@@ -72,11 +99,25 @@
- -
- + +
+
+
+ +
+
+ +
+
@@ -120,6 +161,7 @@ @open="sheetOpen = true" @cancel="confirmCancelOpen = true" @bookmark="handleBookmarkClick" + @calendar="handleCalendarDownload" /> @@ -163,10 +205,11 @@