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>
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user