Files
fete/specs/009-list-events/research.md
nitrix e56998b17c
All checks were successful
CI / backend-test (push) Successful in 57s
CI / frontend-test (push) Successful in 22s
CI / frontend-e2e (push) Successful in 1m4s
CI / build-and-publish (push) Has been skipped
Add event list feature (009-list-events)
Enable users to see all their saved events on the home screen, sorted
by date with upcoming events first. Key capabilities:

- EventCard with title, relative time display, and organizer/attendee
  role badge
- Sortable EventList with past-event visual distinction (faded style)
- Empty state when no events are stored
- Swipe-to-delete gesture with confirmation dialog
- Floating action button for quick event creation
- Rename router param :token → :eventToken across all views
- useRelativeTime composable (Intl.RelativeTimeFormat)
- useEventStorage: add validation, removeEvent(), reactive versioning
- Full E2E and unit test coverage for all new components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:53:55 +01:00

6.0 KiB

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)