Skip to content

Unified Activity Feed — Feature & Testing Guide

Overview

The unified activity feed merges activity from four sources — ally goal updates, community posts, user notifications, and the user's own goal events — into a single chronological timeline. Items are fetched from the backend via the BuildUnifiedFeed interaction, sorted by timestamp, and paginated. On the Dashboard, the feed appears as the "Ally Activity" section showing the 3 most recent items. A dedicated FeedItemCard component renders each item with source-specific icons, badges, and navigation.


Feed Sources

The feed aggregates items from four sources, each with a 14-day lookback window:

SourcefeedTypeAction VerbIconBadge ColorWhat It Shows
Ally Activityally_activity"checked in on"UserCheckBlueGoal events from accepted allies' public, active goals
Community Postscommunity_post"posted in"MessageSquarePurplePosts from communities the user is a member of
Own Activityown_activity"logged progress on"TargetEmeraldThe user's own goal events (public or private), both active and completed goals
NotificationsnotificationVaries by kindActivitySlateUser feed items (generic updates), including badge unlocks

Source Limits

SourceMax Items FetchedLookback
Ally activity5014 days
Community posts5014 days
User feed items3014 days
Own goal eventsUnlimited14 days

Feed Item Structure

Every feed item, regardless of source, is normalized to a common structure:

FieldTypeDescription
idString!Prefixed by source: ally_, post_, feed_, own_
feedTypeString!ally_activity, community_post, notification, or own_activity
actorNameStringDisplay name of the user who performed the action
actorIdStringpublic_id of the acting user
actorPhotoStringPhoto URL of the acting user
actionString!Verb (e.g., "checked in on", "posted in", "logged progress on", "badge")
targetNameStringName of the goal or community acted upon
targetIdStringpublic_id of the target entity
targetTypeStringgoal or community
contentStringBody text or excerpt (truncated to 200 characters)
communityNameStringCommunity name if applicable
communityIdStringCommunity public_id if applicable
badgeKeyStringBadge action key (e.g., complete_first_goal) when feedType=notification and action=badge; null for non-badge items
timestampString!Unix timestamp as a string

ID Prefixing

Each source prefixes its IDs to prevent collisions:

  • ally_{goalEvent.id} — ally activity items
  • post_{communityPost.id} — community posts
  • feed_{userFeedItem.id} — notification/feed items
  • own_{goalEvent.id} — own goal events

Backend: BuildUnifiedFeed Interaction

Input

ParamTypeDefaultDescription
user_idStringUser's public_id (required)
limitInteger20Items per page
offsetInteger0Items to skip for pagination

Logic

  1. Find user by public_id — fail with "User not found" if missing
  2. Collect items from all four sources (14-day lookback each)
  3. Normalize each item to the common structure
  4. Sort all items by timestamp descending (newest first)
  5. Apply pagination: skip offset, take limit
  6. Calculate has_more = (offset + limit) < total_count

Output

ruby
{
  items: [{ id: "ally_123", feed_type: "ally_activity", ... }, ...],
  total_count: 47,
  has_more: true
}

Source Query Details

Ally Activity:

  • Finds accepted allies via UserAlly.where(user_id:, status: :accepted)
  • Fetches GoalEvent records from allies' goals where private: false and completed: false
  • Sets actor_name from ally user, target_name from goal name

Community Posts:

  • Finds communities via CommunityMember.where(user_id:)
  • Fetches CommunityPost records from those communities
  • Sets actor_name from post author, target_name and target_type as community

User Feed Items:

  • Fetches UserFeedItem records for the current user
  • Parses details JSON to extract goal references
  • Sets target_type: 'goal' when goal details present

Own Goal Events:

  • Fetches GoalEvent records from user's own goals (both public and private, active and completed)
  • Sets actor_name from current user, action: "logged progress on"
  • Privacy note: The user sees their own private goal events in their My Activity feed. Allies' private goal events remain hidden from the ally's feed (the ally_activity_sql still filters g.private = false).

Content Truncation

All content fields are truncated to 200 characters with ... appended if longer.


Real-Time Delivery (OBJ-2881)

An ally's goal event, a community post, or the viewer's own activity now surfaces to an open feed without pull-to-refresh, via the feedUpdate GraphQL subscription (WS /cable, keyed on user_id public_id — same authorization shape as the other two ActionCable topics, see Notifications § Real-Time Delivery for the shared authorize!/rescue Redis::BaseError pattern).

The payload is an invalidation ping, not a rendered item

Types::FeedUpdatePayloadType carries exactly two fields — source (one of ally_activity, community_post, notification, own_activity, matching feedType above) and occurred_at. It never carries a rendered feed row. On receipt, the client stages the ping and, once the viewer commits, refetches page 1 through unifiedFeed rather than inserting a client-synthesized item.

This was a deliberate design decision, not an oversight: a full UnifiedFeedItem payload would have to duplicate the viewer-scoped filtering BuildUnifiedFeed's SQL already does (blocked users, demo-vs-real community visibility) in a second place — the ping-and-refetch design keeps that logic in exactly one place. See Filtering boundary below.

Two trigger paths — both matter

BuildUnifiedFeed unions four sources, and a live ping is wired at each of them:

  1. UserFeedItem#publish_feed_update (after_create_commit, app/models/user_feed_item.rb) — the single choke point for all six add_*_feed_item class methods (encouragement, reaction, comment, badge, goal-event follow-update, follow). Every row this model creates always emits source: 'notification', since every UserFeedItem row surfaces as feedType: 'notification' in BuildUnifiedFeed#user_feed_item_sql regardless of its own kind enum. after_create_commit, not after_create, so a row created inside a transaction that later rolls back never fires a phantom ping.
  2. Social::FanOutFeedUpdateJob (app/jobs/social/fan_out_feed_update_job.rb) — covers the two BuildUnifiedFeed sources that read directly from their own tables rather than through UserFeedItem: ally goal events and community posts. Enqueued from GoalTracking::AddGoalEvent#enqueue_post_commit_jobs and Social::CreateCommunityPost#notify_members, never called inline — a goal event by a user with 50 accepted allies is 50 Redis publishes, and that cost never belongs on the originating mutation's request/response cycle.

Both paths call the same publish choke point, Subscriptions::FeedUpdate.trigger (app/graphql/subscriptions/feed_update.rb), so there is exactly one place that builds a feedUpdate payload and wraps the broadcast in rescue Redis::BaseError.

Fan-out recipient rules

Social::FanOutFeedUpdateJob computes two distinct recipient sets:

  • ally_goal_event — the actor always (source: 'own_activity', for a second signed-in device), plus every accepted ally (source: 'ally_activity') — but only when the goal is non-private and non-completed, mirroring BuildUnifiedFeed#ally_activity_sql's WHERE clause exactly. A blocked ally is excluded from the recipient set via the same UserAlly.blocked_user_ids_for lookup BuildUnifiedFeed uses.
  • community_post — every community member except the poster (source: 'community_post'), mirroring Social::CreateCommunityPost#notify_members's own notification recipient set. A member whose row is soft-deleted (member.user preloads nil under acts_as_paranoid) is skipped rather than raising.

Filtering boundary (A3) — read this before touching either file

Recipient filtering inside FanOutFeedUpdateJob is a volume optimization, not a security boundary. The payload is an invalidation ping; the client always refetches through BuildUnifiedFeed, and that query — not the fan-out job — is what actually enforces blocked-user exclusion and demo-vs-real community visibility (see visible_community_ids and blocked_user_ids in Social::BuildUnifiedFeed). A ping delivered to someone who shouldn't see the item produces an empty-handed refetch, never a leak — the worst case is a wasted round-trip, not exposed data.

This means a future change that "optimizes" the fan-out job by loosening its recipient computation (or a future contributor who assumes the job's recipient list is itself the access-control layer) is safe from a data-exposure standpoint but should not be read as the enforcement point. If feedUpdate is ever extended to carry a rendered item instead of a ping, this boundary stops holding and the filtering logic would need to move — or be duplicated — into the fan-out job itself.

Frontend: staged arrival, not an in-place insert

useUnifiedFeed.ts stages incoming feedUpdate pings (pendingPings) rather than re-rendering the list immediately — a row appearing under the viewer's thumb mid-scroll is a dark pattern the UI-SPEC for this phase explicitly ruled out. FeedLiveArrivalPill.vue surfaces the staged count ("See 3 new updates") and, on tap, calls commit(), which resets to page 1, refetches, and clears the staged pings — the same "invalidation ping → refetch page 1" flow described above, just gated on an explicit tap instead of firing automatically mid-scroll. A reconnect catch-up ping (source unknowable) stages as a sourceless entry and renders the pill as "See what's new" rather than a specific count, and is never filtered out by the active tab.

Full interaction/motion spec (auto-apply at scroll-top, reduced-motion behavior, reconnect and error states, aria-live timing): .planning/phases/v4.45-phase-3-feed-live-updates/UI-SPEC.md.


Frontend: FeedItemCard Component

Props

Accepts a single item prop matching the FeedItem interface (see feed item structure above).

Visual Layout

┌──────────────────────────────────────────┐
│  [Avatar]  ActorName action TargetName   │
│            Content excerpt (2 lines)...  │
│            Community Badge · 2h ago  Ally │
└──────────────────────────────────────────┘
  • Avatar circle (9x9): Source-colored background with Lucide icon
  • Action line: Bold actor name + action verb + bold target name (clickable)
  • Content: 2-line clamp, muted text
  • Meta footer: Community badge (if applicable) + relative timestamp + type badge
  • Hover: shadow-sm transition

Relative Time Display

ElapsedDisplay
< 60 seconds"just now"
< 60 minutes"Xm ago"
< 24 hours"Xh ago"
< 48 hours"yesterday"
Otherwise"Xd ago"

Clicking the target name calls navigateToTarget():

  • targetType === 'goal' → routes to /goal/{targetId}
  • targetType === 'community' → routes to /communities/{targetId}

Dashboard Integration

"Ally Activity" Section

The Dashboard renders the feed in the active user state (3+ goals) using the CommunityFeedCard component (not FeedItemCard — the Dashboard uses a legacy card format):

  • Fetches via useAllyActivityFeed(userId, limit: 5) from the useCommunity composable
  • Maps raw ally feed data to a simplified card format
  • Shows the first 3 items
  • "See more" button navigates to /communities

Empty State

When no ally activity exists:

  • Shows a card with "No ally activity yet" message
  • Suggests joining communities to connect with allies

Pagination

Strategy

Offset-based pagination (not cursor-based):

  • limit: Number of items per page (default 20)
  • offset: Number of items to skip (default 0)
  • has_more: Boolean indicating more pages exist
  • total_count: Total items across all sources

Example

Page 1: offset=0,  limit=20 → items[0..19],  has_more=true
Page 2: offset=20, limit=20 → items[20..39], has_more=true
Page 3: offset=40, limit=20 → items[40..46], has_more=false

UI Components

FeedItemCard

  • Source-colored avatar with icon
  • Action text with bold actor/target names
  • 2-line content excerpt
  • Community badge + relative time + type badge
  • Click-to-navigate on target name

CommunityFeedCard (Dashboard variant)

  • Author avatar with name and type badge
  • Content paragraph
  • Progress bar (for milestone items)
  • Like/comment/share action buttons with counts
  • Comments preview section

Testing the Feature

Manual Testing Checklist

1. Feed Loads on Dashboard

  • [ ] Sign in as a user with 3+ goals
  • [ ] Navigate to Dashboard
  • [ ] Verify "Ally Activity" section appears
  • [ ] Verify feed items display (if allies exist with recent activity)
  • [ ] Verify empty state shows if no ally activity

2. Feed Item Types

  • [ ] Create a goal event on your own goal → verify own_activity item appears
  • [ ] Have an ally check in on their goal → verify ally_activity item appears
  • [ ] Post in a community → verify community_post item appears
  • [ ] Verify each type shows correct icon, badge color, and action verb

3. Feed Item Content

  • [ ] Verify actor name shows correctly
  • [ ] Verify target name shows correctly
  • [ ] Verify content is truncated at 200 characters with "..."
  • [ ] Verify timestamp shows relative time (e.g., "2h ago")

4. Navigation from Feed

  • [ ] Click a goal target name → verify navigation to /goal/{id}
  • [ ] Click a community target name → verify navigation to /communities/{id}
  • [ ] Verify back button returns to previous page

5. Unified Feed Query (Direct)

  • [ ] Execute UNIFIED_FEED_QUERY with default params
  • [ ] Verify items from multiple sources are returned
  • [ ] Verify items are sorted by timestamp descending (newest first)
  • [ ] Verify totalCount and hasMore are correct

6. Pagination

  • [ ] Request with limit: 5, offset: 0 → verify 5 items returned
  • [ ] Request with limit: 5, offset: 5 → verify next 5 items
  • [ ] Verify hasMore: true when more items exist
  • [ ] Verify hasMore: false on the last page
  • [ ] Verify items don't repeat across pages

7. ID Prefixing

  • [ ] Verify ally items have IDs starting with ally_
  • [ ] Verify community posts have IDs starting with post_
  • [ ] Verify own events have IDs starting with own_
  • [ ] Verify feed items have IDs starting with feed_

8. 14-Day Lookback

  • [ ] Create a goal event 15 days ago
  • [ ] Verify it does NOT appear in the feed
  • [ ] Create a goal event today
  • [ ] Verify it DOES appear in the feed

9. Privacy Filtering

  • [ ] Set a goal to private
  • [ ] Log an event on the private goal
  • [ ] Verify the event does NOT appear in allies' feeds
  • [ ] Set goal back to public → verify events from public goals appear

10. Ally Activity

  • [ ] Accept an ally request
  • [ ] Have the ally log a goal event
  • [ ] Verify the event appears in your feed as ally_activity
  • [ ] Remove the ally → verify their events no longer appear

11. Community Post Activity

  • [ ] Join a community
  • [ ] Have another member post in the community
  • [ ] Verify the post appears in your feed as community_post
  • [ ] Leave the community → verify community posts no longer appear

12. Empty State

  • [ ] Create a new user with no allies, no communities, no goals
  • [ ] Navigate to Dashboard → verify empty state message
  • [ ] Verify feed section is gracefully hidden or shows placeholder

13. Feed Card Visual States

  • [ ] Verify blue avatar/badge for ally activity
  • [ ] Verify purple avatar/badge for community posts
  • [ ] Verify emerald avatar/badge for own activity
  • [ ] Verify slate avatar/badge for notifications
  • [ ] Verify hover shadow effect on cards

Backend Unit Tests

bash
cd rails_api

# Interaction specs — core feed building logic
bundle exec rspec spec/interactions/social/build_unified_feed_spec.rb

# Model specs — feed item models
bundle exec rspec spec/models/user_feed_item_spec.rb
bundle exec rspec spec/models/community_feed_item_spec.rb

Frontend Unit Tests

bash
cd ionic_frontend

npm run test:unit -- --run -t "FeedItemCard"
npm run test:unit -- --run -t "CommunityFeedCard"

GraphQL API Reference

Queries

graphql
query UnifiedFeedQuery($limit: Int, $offset: Int) {
  unifiedFeed(limit: $limit, offset: $offset) {
    items {
      id
      feedType        # "ally_activity" | "community_post" | "own_activity" | "notification"
      actorName       # Display name of the acting user
      actorId         # public_id of the acting user
      actorPhoto      # Photo URL (nullable)
      action          # "checked in on" | "posted in" | "logged progress on" | "badge" | varies
      targetName      # Goal or community name
      targetId        # public_id of target
      targetType      # "goal" | "community"
      content         # Body text (truncated to 200 chars)
      communityName   # Community name (nullable)
      communityId     # Community public_id (nullable)
      badgeKey        # Badge action key (e.g. "complete_first_goal") when action="badge"; null otherwise
      timestamp       # Unix timestamp as string
    }
    totalCount        # Total items across all sources
    hasMore           # Whether more pages exist
  }
}

query AllyActivityFeedQuery($userId: ID!, $limit: Int) {
  allyActivityFeed(userId: $userId, limit: $limit) {
    id allyId allyName allyPhoto action target
    communityId communityName timestamp
  }
}

Arguments

ArgumentTypeRequiredDefaultDescription
limitIntNo20Items per page
offsetIntNo0Items to skip for pagination

Known Behaviors & Edge Cases

  1. 14-day lookback window: All four sources are filtered to the last 14 days. Users who haven't been active in 2+ weeks will see an empty feed. There is no configurable lookback.

  2. Offset-based pagination: The feed uses offset pagination, not cursor pagination. This means items added between page loads could cause duplicates or missed items. Acceptable for the current scale.

  3. Content truncation at 200 chars: Goal event content and community post content are truncated server-side to 200 characters. The frontend does additional 2-line clamping via CSS.

  4. Private goal filtering: Only events from public, non-completed goals appear in ally activity. Private goals are excluded at the query level. Toggling a goal to private immediately removes its events from allies' feeds.

  5. Ally relationship required: Ally activity only shows for users with accepted ally connections (UserAlly.status: :accepted). Pending or rejected requests don't contribute to the feed.

  6. Community membership required: Community posts only appear for communities the user is a member of. Following a community without joining does NOT include its posts.

  7. Actor photo is always null: The actor_photo field is currently set to nil in the interaction for all sources. Avatar display falls back to the icon-based avatar circle.

  8. Dashboard shows legacy card format: The Dashboard's "Ally Activity" section uses CommunityFeedCard (not FeedItemCard), transforming the data into a simplified format. The full FeedItemCard component is available for a dedicated feed page.

  9. ID collision prevention: Each source prefixes IDs (ally_, post_, feed_, own_) to prevent collisions when items from different database tables share the same integer ID.

  10. Sort is timestamp descending: Items are always sorted newest-first. There is no option to change sort order.

  11. Authentication required: The unifiedFeed query requires authentication. Unauthenticated requests return an authorization error.

  12. Completed goals now included in own activity: Own goal events from both active and completed goals appear in the user's My Activity tab. This allows users to see their full history in their own feed. Ally activity still excludes completed goals.

  13. Badge feed items now named and navigable: Badge items in the "All" tab render with the badge's full name, image, rarity tint, and description. Tapping a badge feed item navigates to /achievements?badge=<badgeKey>, opening the badge detail modal. Badge items are keyboard-accessible with tabindex="0" and @keyup.enter handlers.

  14. Private goal filtering for own activity: Own activity now includes the user's private goal events. This does not affect ally activity — other users' private goal events remain hidden from ally feeds. This change makes "My Activity" truly comprehensive for the user's own view of their progress.

Last updated: 2026-08-24 — added § Real-Time Delivery: the feedUpdate subscription, invalidation-ping design, fan-out recipient rules, and the A3 filtering boundary (Phase 3, OBJ-2881).

Loading…