From 3d7efb14f7650f96849e57de057927963ed35448 Mon Sep 17 00:00:00 2001 From: nitrix Date: Fri, 13 Mar 2026 21:39:31 +0100 Subject: [PATCH 1/7] Add iCal download feature spec and clean up implemented ideas Co-Authored-By: Claude Opus 4.6 --- .specify/memory/ideen.md | 54 +-------- .../checklists/requirements.md | 36 ++++++ specs/019-ical-download/plan.md | 95 +++++++++++++++ specs/019-ical-download/spec.md | 80 +++++++++++++ specs/019-ical-download/tasks.md | 109 ++++++++++++++++++ 5 files changed, 323 insertions(+), 51 deletions(-) create mode 100644 specs/019-ical-download/checklists/requirements.md create mode 100644 specs/019-ical-download/plan.md create mode 100644 specs/019-ical-download/spec.md create mode 100644 specs/019-ical-download/tasks.md 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/specs/019-ical-download/checklists/requirements.md b/specs/019-ical-download/checklists/requirements.md new file mode 100644 index 0000000..e761b16 --- /dev/null +++ b/specs/019-ical-download/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: iCal Download + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-13 +**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. Spec mentions "RFC 5545" and "Blob" which are standard/format references, not implementation details. +- FR-004 (SEQUENCE number) depends on whether the backend exposes an update counter or timestamp — documented as assumption. +- Spec is ready for `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/019-ical-download/plan.md b/specs/019-ical-download/plan.md new file mode 100644 index 0000000..97d4831 --- /dev/null +++ b/specs/019-ical-download/plan.md @@ -0,0 +1,95 @@ +# Implementation Plan: iCal Download + +**Branch**: `019-ical-download` | **Date**: 2026-03-13 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/019-ical-download/spec.md` + +## Summary + +Add a calendar download button to the event detail page that generates RFC 5545-compliant `.ics` files client-side. The button appears in the RsvpBar for all non-organizer users (not shown for cancelled events). No backend changes are required — all event data is already available in the frontend after fetching event details. + +## Technical Context + +**Language/Version**: TypeScript 5.x, Vue 3 (Composition API) +**Primary Dependencies**: None new — uses existing Vue 3, openapi-fetch stack. iCal generation is hand-rolled (RFC 5545 is simple enough; no library needed). +**Storage**: N/A (no persistence; generates file on demand) +**Testing**: Vitest (unit tests for iCal generation + slug utility), Playwright + MSW (E2E for button behavior) +**Target Platform**: PWA, mobile-first (320px–768px), all modern browsers +**Project Type**: Web application (frontend-only change) +**Performance Goals**: Instant download (< 50ms generation time, all client-side) +**Constraints**: No external dependencies, no backend changes, UTF-8 encoded output +**Scale/Scope**: 1 new composable, 1 utility, modifications to 2 existing components + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Privacy by Design | ✅ PASS | Client-side only, no data sent to external services, no tracking | +| II. Test-Driven Methodology | ✅ PLAN | Unit tests for iCal generation + slug utility, E2E for button UX | +| III. API-First Development | ✅ N/A | No new API endpoints — uses existing `GetEventResponse` data | +| IV. Simplicity & Quality | ✅ PLAN | Hand-rolled iCal (no library for ~40 lines of format code), minimal changes to existing components | +| V. Dependency Discipline | ✅ PASS | Zero new dependencies | +| VI. Accessibility | ✅ PLAN | Aria labels on calendar button, keyboard navigable, WCAG AA contrast | + +**Gate result**: PASS — no violations. + +## Project Structure + +### Documentation (this feature) + +```text +specs/019-ical-download/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +└── tasks.md # Phase 2 output (via /speckit.tasks) +``` + +### Source Code (repository root) + +```text +frontend/src/ +├── composables/ +│ └── useIcalDownload.ts # NEW: iCal generation + download trigger +├── utils/ +│ └── slugify.ts # NEW: ASCII slug for filename +├── components/ +│ └── RsvpBar.vue # MODIFIED: add calendar button (2 visual states) +└── views/ + └── EventDetailView.vue # MODIFIED: pass event data, handle calendar emit +``` + +**Structure Decision**: Frontend-only changes. New composable for iCal logic (consistent with project pattern: `useEventStorage`, `useRelativeTime`). Slug utility in `utils/` since it's a pure function with no Vue reactivity. + +## Key Design Decisions + +### D1: No iCal library + +**Decision**: Hand-roll iCal generation (~40 lines). + +**Rationale**: RFC 5545 VEVENT with 8–10 properties is trivial. Adding a library (e.g., `ical-generator`, `ics`) would violate Principle V (dependency discipline) — we'd use < 5% of its features. + +### D2: Calendar button visual states + +Per FR-006, the calendar button has 2 visual contexts: + +| State | Layout | Button Style | +|-------|--------|-------------| +| Before RSVP | Row: [bookmark] [CTA] [calendar] | glow-border + glass-inner (matches bookmark) | +| After RSVP | Row: [status-bar (flex)] [calendar (fixed)] | glassmorphic bar style (matches status bar) | + +The button is not shown for cancelled events (RsvpBar remains hidden when `event.cancelled`). + +### D3: UID format + +**Decision**: `{eventToken}@fete` — stable across re-downloads, enables calendar deduplication per FR-003. + +### D4: SEQUENCE strategy + +**Decision**: Always `0`. Per FR-004, a proper version counter requires backend changes (future scope). + +## Complexity Tracking + +No constitution violations to justify. diff --git a/specs/019-ical-download/spec.md b/specs/019-ical-download/spec.md new file mode 100644 index 0000000..748f831 --- /dev/null +++ b/specs/019-ical-download/spec.md @@ -0,0 +1,80 @@ +# Feature Specification: iCal Download + +**Feature Branch**: `019-ical-download` +**Created**: 2026-03-13 +**Status**: Draft +**Input**: User description: "Add iCal (.ics) calendar download button to event detail page" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Download event as calendar file (Priority: P1) + +As a user viewing an event, I want to add it to my personal calendar so I don't forget the date and time. + +The user taps a calendar icon button in the bottom action bar. The browser downloads a `.ics` file containing the current event details. The user opens the file in their preferred calendar app (Apple Calendar, Google Calendar, Outlook, Thunderbird, etc.) and the event appears in their calendar. + +**Why this priority**: Core value of the feature — getting the event into the user's calendar. + +**Independent Test**: Can be fully tested by viewing an event, tapping the calendar button, and verifying the downloaded .ics file opens correctly in a calendar app. + +**Acceptance Scenarios**: + +1. **Given** a user views an active event (as attendee, pre-RSVP visitor, or organizer), **When** they tap the calendar icon in the action bar, **Then** a valid `.ics` file is downloaded containing the event's title, date/time, location, and description. +2. **Given** the event is cancelled, **When** the user views the event detail page, **Then** the calendar button is NOT shown. +3. **Given** the downloaded `.ics` file, **When** opened in any major calendar app, **Then** the event is created without errors. +4. **Given** the user downloads the `.ics` file multiple times, **Then** the generated file is identical each time (deterministic output from the same event data). + +--- + +### Edge Cases + +- What happens when the event has no location set? The `.ics` file omits the location field. +- What happens when the event has no description? The `.ics` file omits the description field. +- What happens when event title or description contains special characters (umlauts, emoji, newlines)? The `.ics` file uses proper UTF-8 encoding and RFC 5545 text escaping. +- What happens on a browser that blocks Blob downloads? The download should work via standard browser download mechanisms; no special fallback is needed since all modern browsers support Blob downloads. + +## Clarifications + +### Session 2026-03-13 + +- Q: Event hat kein Endzeit-Feld — wie soll DTEND in der .ics gehandhabt werden? → A: Kein DTEND/DURATION — nur DTSTART (punktuelles Ereignis gemäß RFC 5545). +- Q: Dateiname-Sanitierung bei Sonderzeichen, Umlauten, langen Titeln? → A: Slugify — ASCII-Transliteration (ä→ae etc.), Leerzeichen→Bindestrich, max 60 Zeichen. +- Q: Gibt es ein Konzept von "aktualisierten" .ics-Dateien? → A: Nein. Jeder Download erzeugt die gleiche Datei aus den aktuellen Event-Daten. Kein Update-Mechanismus. +- Q: Button bei abgesagten Events? → A: Nein, kein Button wenn Event cancelled. +- Q: Nur für Attendees oder auch für Organisatoren? → A: Alle Rollen — Attendee, Besucher ohne RSVP, Organisator. Button an gleicher Position. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST generate a valid `.ics` file (RFC 5545 VEVENT) client-side without requiring backend changes. +- **FR-002**: The `.ics` file MUST contain: UID, DTSTAMP, DTSTART, SUMMARY. DTEND and DURATION MUST NOT be included (the event has no end time; RFC 5545 treats a VEVENT with only DTSTART as a point-in-time event). It MUST include LOCATION and DESCRIPTION when those fields are present on the event. +- **FR-003**: The UID MUST be derived from the event token (e.g. `{eventToken}@fete`) to produce a stable identifier for the calendar entry. +- **FR-004**: The `.ics` file MUST include a SEQUENCE number of `0`. +- **FR-006**: The calendar icon button MUST appear in the bottom action bar for all users (attendees, pre-RSVP visitors, and organizers), adapting its visual style to match the surrounding elements: + - **Before RSVP (attendee)**: Button order (left to right): bookmark, "I'm attending!" CTA, calendar. The calendar button MUST use the same glow-border + glass-inner style as the bookmark button. + - **After RSVP (attendee)**: The calendar button MUST appear to the right of the "You're attending!" status bar. It MUST use the same glassmorphic bar style (gradient background, glass border, backdrop blur) as the status bar — not the glow-border style. Layout: "You're attending!" status (flex), calendar icon button (fixed width). + - **Organizer**: The calendar button MUST appear in the same fixed bottom position. Styling TBD (consistent with existing organizer UI). +- **FR-007**: The calendar button MUST NOT be shown when the event is cancelled. +- **FR-008**: The downloaded file MUST use UTF-8 encoding and the `text/calendar` MIME type. +- **FR-009**: The filename MUST be human-readable, derived from the event title using ASCII slugification (e.g. `Sommerfest am See` → `sommerfest-am-see.ics`). Rules: lowercase, umlauts transliterated (ä→ae, ü→ue, ö→oe, ß→ss), non-ASCII characters removed, spaces/special chars replaced with hyphens, consecutive hyphens collapsed, max 60 characters before `.ics` extension. + +### Key Entities + +- **iCal Event (VEVENT)**: A calendar entry generated from fete event data. Key attributes: UID (from event token), SUMMARY (title), DTSTART (date/time, no DTEND — point-in-time event), LOCATION, DESCRIPTION, SEQUENCE, DTSTAMP. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Any user (attendee, visitor, organizer) can download a calendar file with 1 tap. +- **SC-002**: Downloaded `.ics` files import successfully into Apple Calendar, Google Calendar, and Outlook without errors. +- **SC-003**: The calendar button does not disrupt the existing bottom bar layout or interaction patterns on devices from 320px to 768px width. + +## Assumptions + +- The `.ics` file generation is entirely client-side (no new backend endpoints needed), since all required event data is already available in the frontend after fetching event details. +- The generated `.ics` file is deterministic: same event data always produces the same output. There is no concept of "updated" files — each download is a fresh snapshot of the current event data. +- SEQUENCE is always `0`. +- The calendar button is visible for all user roles (attendee, visitor, organizer) on active events. Not shown for cancelled events. +- All date/time values in the `.ics` file use UTC format (Z suffix) since the event times are already stored in UTC. diff --git a/specs/019-ical-download/tasks.md b/specs/019-ical-download/tasks.md new file mode 100644 index 0000000..8129d79 --- /dev/null +++ b/specs/019-ical-download/tasks.md @@ -0,0 +1,109 @@ +# Tasks: iCal Download + +**Input**: Design documents from `/specs/019-ical-download/` +**Prerequisites**: plan.md, spec.md + +**Tests**: TDD is mandated by the project constitution. Tests are written first and must fail before implementation. + +**Organization**: Single user story (US1). Foundational phase covers the two pure utility modules; US1 phase covers component integration. + +## 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) +- Include exact file paths in descriptions + +--- + +## Phase 1: Foundational (Pure Utilities) + +**Purpose**: iCal generation and slug utility — pure functions with no UI dependencies + +### Tests (RED) + +- [x] T001 [P] Write unit tests for slugify (umlaut transliteration, special chars, max length, empty input) in `frontend/src/utils/__tests__/slugify.spec.ts` +- [x] T002 [P] Write unit tests for iCal generation (required fields, optional LOCATION/DESCRIPTION omission, UTF-8 text escaping, UID format, deterministic output, DTSTAMP) in `frontend/src/composables/__tests__/useIcalDownload.spec.ts` + +### Implementation (GREEN) + +- [x] T003 Implement slugify utility in `frontend/src/utils/slugify.ts` — ASCII transliteration (ä→ae, ü→ue, ö→oe, ß→ss), lowercase, non-ASCII removal, hyphens for spaces/special chars, collapse consecutive hyphens, max 60 chars +- [x] T004 Implement `generateIcs()` function and `useIcalDownload()` composable in `frontend/src/composables/useIcalDownload.ts` — RFC 5545 VEVENT with UID (`{eventToken}@fete`), DTSTAMP, DTSTART (UTC), SUMMARY, SEQUENCE:0, optional LOCATION/DESCRIPTION, Blob download with `text/calendar` MIME type, slugified filename + +**Checkpoint**: `npm run test:unit` passes — both utilities work in isolation + +--- + +## Phase 2: User Story 1 — Download event as calendar file (Priority: P1) 🎯 MVP + +**Goal**: Calendar icon button in the bottom action bar for all user roles (attendee pre-RSVP, attendee post-RSVP, organizer). Tap triggers `.ics` download. Not shown for cancelled events. + +**Independent Test**: View any active event → tap calendar button → `.ics` file downloads → opens in calendar app. + +### Tests (RED) + +- [x] T005 Write E2E test for calendar download button in `frontend/e2e/ical-download.spec.ts` — verify button visible for pre-RSVP visitor, post-RSVP attendee, and organizer; verify button NOT visible for cancelled event; verify download triggers with correct filename + +### Implementation (GREEN) + +- [x] T006 [US1] Add calendar button and `calendar` emit to RsvpBar in `frontend/src/components/RsvpBar.vue` — pre-RSVP state: glow-border + glass-inner icon button after CTA; post-RSVP state: glassmorphic icon button right of status bar +- [x] T007 [US1] Add calendar button for organizer view in `frontend/src/views/EventDetailView.vue` — fixed bottom position next to existing "Cancel event" button, consistent glassmorphic styling +- [x] T008 [US1] Wire calendar download handler in `frontend/src/views/EventDetailView.vue` — import `useIcalDownload`, call on `@calendar` emit from RsvpBar and on organizer button click, pass event data + +**Checkpoint**: All acceptance scenarios pass — any user on an active event can download a valid `.ics` file with 1 tap + +--- + +## Phase 3: Polish & Cross-Cutting Concerns + +- [ ] T009 Verify calendar button layout does not disrupt existing RsvpBar on 320px–768px viewports (visual check via `browser-interactive-testing` skill) + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Foundational (Phase 1)**: No dependencies — can start immediately +- **US1 (Phase 2)**: Depends on Phase 1 completion (T003, T004 must be done) +- **Polish (Phase 3)**: Depends on Phase 2 completion + +### Within Phases + +- T001 and T002 are parallel (different files) +- T003 before T004 (`useIcalDownload` imports `slugify`) +- T005 can be written before T006–T008 (TDD: test fails first) +- T006 and T007 are parallel (different files) +- T008 depends on T006 and T007 (wires them together) + +### Parallel Opportunities + +```text +# Phase 1 tests (parallel): +T001: slugify unit tests +T002: iCal generation unit tests + +# Phase 2 implementation (parallel after T005): +T006: RsvpBar calendar button +T007: Organizer calendar button +``` + +--- + +## Implementation Strategy + +### MVP (Single Pass) + +1. Complete Phase 1: Write tests → implement slugify → implement iCal generation +2. Complete Phase 2: Write E2E → add buttons to RsvpBar + organizer view → wire handler +3. Complete Phase 3: Visual verification +4. **DONE**: Single user story, single deliverable + +--- + +## Notes + +- No backend changes required — all client-side +- Zero new dependencies — hand-rolled iCal generation +- `generateIcs()` must be a pure function (deterministic, no side effects) for easy testing +- `useIcalDownload()` wraps `generateIcs()` + Blob download trigger +- Calendar SVG icon: use a calendar outline matching the existing date/time meta icon style -- 2.49.1 From d4a1f0dc232f17f355254eece92efb383cb6c209 Mon Sep 17 00:00:00 2001 From: nitrix Date: Fri, 13 Mar 2026 21:39:41 +0100 Subject: [PATCH 2/7] Add slugify utility for filename sanitization Co-Authored-By: Claude Opus 4.6 --- frontend/src/utils/__tests__/slugify.spec.ts | 69 ++++++++++++++++++++ frontend/src/utils/slugify.ts | 28 ++++++++ 2 files changed, 97 insertions(+) create mode 100644 frontend/src/utils/__tests__/slugify.spec.ts create mode 100644 frontend/src/utils/slugify.ts 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) + ) +} -- 2.49.1 From 75e65484037cc4543ccdb94834c160a913f60b75 Mon Sep 17 00:00:00 2001 From: nitrix Date: Fri, 13 Mar 2026 21:39:54 +0100 Subject: [PATCH 3/7] Add iCal download composable with RFC 5545 VEVENT generation Co-Authored-By: Claude Opus 4.6 --- .../__tests__/useIcalDownload.spec.ts | 120 ++++++++++++++++++ frontend/src/composables/useIcalDownload.ts | 71 +++++++++++ 2 files changed, 191 insertions(+) create mode 100644 frontend/src/composables/__tests__/useIcalDownload.spec.ts create mode 100644 frontend/src/composables/useIcalDownload.ts 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 } +} -- 2.49.1 From 9483e9b1f7883bf23e2a34a297138b5d0effece5 Mon Sep 17 00:00:00 2001 From: nitrix Date: Fri, 13 Mar 2026 21:40:12 +0100 Subject: [PATCH 4/7] Extract shared bar component CSS and add calendar button to RsvpBar Co-Authored-By: Claude Opus 4.6 --- frontend/src/assets/main.css | 66 +++++++++ frontend/src/components/RsvpBar.vue | 138 +++++++++--------- .../src/components/__tests__/RsvpBar.spec.ts | 10 +- 3 files changed, 140 insertions(+), 74 deletions(-) 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) }) }) -- 2.49.1 From 7817ad182b26cd0aebc380a15c2feeeba5f6f727 Mon Sep 17 00:00:00 2001 From: nitrix Date: Fri, 13 Mar 2026 21:40:22 +0100 Subject: [PATCH 5/7] Unify header as fixed top bar with action slot Co-Authored-By: Claude Opus 4.6 --- frontend/src/App.vue | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) 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() -- 2.49.1 From 92372b6a594077d6e2964e435cf442a6822857e6 Mon Sep 17 00:00:00 2001 From: nitrix Date: Fri, 13 Mar 2026 21:40:40 +0100 Subject: [PATCH 6/7] Add organizer kebab menu, bottom bar, and iCal download integration Co-Authored-By: Claude Opus 4.6 --- frontend/src/views/EventDetailView.vue | 181 +++++++++++++++--- .../views/__tests__/EventDetailView.spec.ts | 24 ++- 2 files changed, 172 insertions(+), 33 deletions(-) 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 @@