trails/openspec/specs/explore/spec.md
Ullrich Schäfer 0cf87b72ab
Fix medium-severity spec drift across 17 specs
- authentication-methods: document completeAuth mode param ("redirect"|"json");
  clarify add-passkey nudge (no dismiss mechanism, disappears on passkey add)
- journal-auth: session maxAge is 30 days; terms allow-list uses /legal/ prefix
  matching (broader than fixed list of paths)
- session-notes: mark awareness isolation and UndoManager isolation as not yet
  implemented (shared instances in current code)
- activity-feed: add fan-out scenario for visibility change to public
- explore: note that ?perPage is not yet implemented (hardcoded page size)
- multi-day-routes: add per-day GPX track split scenario (splitByDays option);
  document overnight vs isDayBreak naming gap
- osm-poi-overlays: debounce is 800ms + 2000ms min interval (not 500ms);
  retry is not automatic (fires on next viewport change)
- brouter-integration: rate limit corrected to 300/hour; add segment-cache
  requirement (client caches per-pair segments)
- wahoo-route-push: OAuth state shape uses camelCase (returnTo, pushAfter
  object) not snake_case with push_after boolean
- komoot-import: document noop adapter / ConnectedServiceManager bypass;
  note four Komoot-specific routes that bypass the generic OAuth framework
- background-jobs: exponential backoff not wired (retryLimit only); add SIGINT
- connected-services: add revoked status; name ConnectionNotActiveError
- infrastructure: add INTEGRATION_SECRET and SENTRY_DSN to env var lists;
  split secret decryption scenario by workflow (cd-apps vs cd-infra)
- secret-management: correct CD decryption — cd-apps only decrypts app.env;
  cd-infra decrypts both
- transactional-emails: welcome email is async (pg-boss job); magic-link email
  includes 6-digit numeric code
- journal-route-detail: websites are https: links (not mailto:); opening_hours
  is also displayed
- local-dev-environment: add mobile app (Expo) dev commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:11:31 +02:00

113 lines
8.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# explore Specification
## Purpose
Local user discovery for the Journal: a paginated directory of local users with `profile_visibility = 'public'`, plus a small "Active recently" surface, served at `/explore`. Closes the gap between "I want to follow someone on this instance" and "I have a username from outside the app." Federation of remote actor discovery is out of scope here and lives in `social-federation`.
## Requirements
### Requirement: Local user directory at `/explore`
The Journal SHALL expose a route at `/explore` that renders a paginated directory of local users with `profile_visibility = 'public'`. The directory SHALL be reachable to both signed-in users and anonymous visitors. Each row SHALL display the user's display name, handle (`@username`), follower count (accepted only — see `social-follows` spec), and a short truncated bio when present. For signed-in viewers each row SHALL also render a Follow / Requested / Unfollow button reflecting the viewer's current relationship to that user (using the same locked-account semantics as the profile page); for anonymous viewers the button SHALL NOT render.
#### Scenario: Signed-in user loads /explore
- **WHEN** a signed-in user requests `/explore`
- **THEN** the page renders a list of public local users with display name, handle, follower count, truncated bio, and a Follow / Requested / Unfollow button per row
#### Scenario: Anonymous visitor loads /explore
- **WHEN** an unauthenticated visitor requests `/explore`
- **THEN** the page renders the same list of public local users (display name, handle, follower count, truncated bio), but no Follow / Requested / Unfollow button is rendered on any row
#### Scenario: Truncated bio
- **WHEN** a user with a bio longer than 120 characters appears in the directory
- **THEN** the row shows the first 120 characters followed by an ellipsis
#### Scenario: User with no bio
- **WHEN** a user with no bio (`bio IS NULL` or empty string) appears in the directory
- **THEN** the row simply omits the bio area; the row remains valid
### Requirement: Directory is ordered by most-recent public activity
The directory SHALL be ordered by `MAX(activities.created_at) DESC` per user, considering only activities with `visibility = 'public'`. Users with no public activities SHALL sort to the end of the list, ordered among themselves by `users.created_at DESC` (newer registrations first). The ordering SHALL be deterministic — ties on the primary sort key SHALL be broken by `users.id` (descending) so pagination doesn't omit or duplicate rows.
#### Scenario: Active user appears before inactive user
- **WHEN** user A has a public activity from yesterday and user B has no public activities
- **THEN** A appears before B in the directory listing
#### Scenario: More-recently-active user appears first
- **WHEN** user A's most recent public activity is from today and user B's is from last week
- **THEN** A appears before B in the directory listing
#### Scenario: Tied recency falls back to user id
- **WHEN** users A and B share the same `MAX(activities.created_at)` (or both have no public activities)
- **THEN** the ordering between them is determined by `users.id` descending (deterministic), so neither is omitted nor duplicated when paginating
### Requirement: Excluded users
The directory SHALL exclude:
1. Users with `profile_visibility = 'private'` — they have explicitly opted out of public discovery (Mastodon-style locked accounts).
2. (Forward-compat) Users in any future banned/suspended state — when such a status column exists, it SHALL be added to the exclusion filter.
Excluded users SHALL NOT appear on `/explore` even if they have public activities and would otherwise sort to the top of the directory.
The demo persona (identified by `loadPersona().username`) is **not** excluded — its purpose is to give new users a follow target, so it appears in the directory like any other public user. The directory row SHALL render a "demo account" badge next to the display name so viewers know what they're following.
#### Scenario: Private profile is excluded from the directory
- **WHEN** user A has `profile_visibility = 'private'` and any activity history
- **THEN** A does not appear in the `/explore` directory regardless of which page is requested
#### Scenario: Demo persona appears with a demo badge
- **WHEN** the demo persona username (per `loadPersona()`) matches a row in the directory
- **THEN** the row is rendered like any other public user, with an additional small "demo account" badge next to the display name
#### Scenario: Public user with no activities is included
- **WHEN** user A has `profile_visibility = 'public'` but has never created an activity
- **THEN** A still appears in the directory (sorted toward the end by the recency rule)
### Requirement: "Active recently" sub-section
The `/explore` page SHALL render an "Active recently" sub-section at the top of the directory, listing up to N (default 5) public users who have created at least one public activity in the last 30 days, ordered by `MAX(activities.created_at) DESC`. The sub-section SHALL apply the same exclusion rules as the main directory (private profiles, future banned/suspended users — the demo persona is included like any other public user, with the same demo-badge treatment). When fewer than 1 user qualifies, the sub-section SHALL be omitted entirely (no empty header).
#### Scenario: Active-recently strip rendered with qualifying users
- **WHEN** at least one public local user (excluding private/demo) has a public activity within the last 30 days
- **THEN** the page renders the "Active recently" sub-section above the main directory, with up to 5 such users
#### Scenario: No qualifying users hides the strip
- **WHEN** no public local user (excluding private/demo) has any public activity within the last 30 days
- **THEN** the "Active recently" sub-section is not rendered at all; the main directory is the only listing on the page
#### Scenario: Strip excludes private profiles
- **WHEN** a private profile has a recent public activity
- **THEN** they are not included in the "Active recently" strip — the same private-profile exclusion as the main directory
#### Scenario: Strip includes the demo persona
- **WHEN** the demo persona has a recent public activity
- **THEN** it appears in the "Active recently" strip like any other public user, carrying the same demo-account badge as in the main directory
### Requirement: Pagination
The directory SHALL paginate via `?page=N` (1-indexed) and `?perPage=K` query parameters. Page size SHALL default to 20 per page and SHALL be capped at 100; `perPage` values outside `[1, 100]` SHALL be clamped to that range without raising an error. The response SHALL surface a "Next page" link when more rows exist past the current page, and a "Previous page" link when `page > 1`.
#### Scenario: Default pagination
- **WHEN** a visitor loads `/explore` with no query params
- **THEN** the page renders the first 20 users in the directory ordering
#### Scenario: Subsequent page
- **WHEN** a visitor loads `/explore?page=2`
- **THEN** the page renders rows 2140 in the directory ordering, with a "Previous page" link to `?page=1` and (if more rows exist) a "Next page" link to `?page=3`
#### Scenario: Out-of-range perPage is clamped
- **WHEN** a visitor loads `/explore?perPage=10000` or `/explore?perPage=0`
- **THEN** the loader clamps `perPage` to `[1, 100]` and renders normally — no 400 response
Note: the `?perPage` parameter is not yet implemented. The current loader uses a hardcoded page size (20). The `?page` parameter is supported. `perPage` support is aspirational.
#### Scenario: Page beyond the last
- **WHEN** a visitor loads `/explore?page=N` where N is past the last populated page
- **THEN** the response renders the empty list and a "Previous page" link; no 404
### Requirement: Navbar entry for signed-in users
The Journal navbar SHALL include an "Explore" entry for signed-in users, linking to `/explore`. Anonymous visitors SHALL NOT see this entry in the navbar — they reach `/explore` via the visitor home (see `journal-landing` spec). The visual treatment of the entry is governed by the navbar redesign (Stream C in `docs/information-architecture.md`); this requirement only mandates that the entry exists.
#### Scenario: Signed-in user sees the Explore entry
- **WHEN** a signed-in user loads any page
- **THEN** the navbar includes an "Explore" link to `/explore`
#### Scenario: Anonymous user does not see the Explore entry in the navbar
- **WHEN** an unauthenticated visitor loads any page
- **THEN** the navbar does not include an Explore entry; the visitor home provides the in-app path to `/explore` instead