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

8.6 KiB
Raw Permalink Blame History

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