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:
| Source | feedType | Action Verb | Icon | Badge Color | What It Shows |
|---|---|---|---|---|---|
| Ally Activity | ally_activity | "checked in on" | UserCheck | Blue | Goal events from accepted allies' public, active goals |
| Community Posts | community_post | "posted in" | MessageSquare | Purple | Posts from communities the user is a member of |
| Own Activity | own_activity | "logged progress on" | Target | Emerald | The user's own goal events (public or private), both active and completed goals |
| Notifications | notification | Varies by kind | Activity | Slate | User feed items (generic updates), including badge unlocks |
Source Limits
| Source | Max Items Fetched | Lookback |
|---|---|---|
| Ally activity | 50 | 14 days |
| Community posts | 50 | 14 days |
| User feed items | 30 | 14 days |
| Own goal events | Unlimited | 14 days |
Feed Item Structure
Every feed item, regardless of source, is normalized to a common structure:
| Field | Type | Description |
|---|---|---|
id | String! | Prefixed by source: ally_, post_, feed_, own_ |
feedType | String! | ally_activity, community_post, notification, or own_activity |
actorName | String | Display name of the user who performed the action |
actorId | String | public_id of the acting user |
actorPhoto | String | Photo URL of the acting user |
action | String! | Verb (e.g., "checked in on", "posted in", "logged progress on", "badge") |
targetName | String | Name of the goal or community acted upon |
targetId | String | public_id of the target entity |
targetType | String | goal or community |
content | String | Body text or excerpt (truncated to 200 characters) |
communityName | String | Community name if applicable |
communityId | String | Community public_id if applicable |
badgeKey | String | Badge action key (e.g., complete_first_goal) when feedType=notification and action=badge; null for non-badge items |
timestamp | String! | Unix timestamp as a string |
ID Prefixing
Each source prefixes its IDs to prevent collisions:
ally_{goalEvent.id}— ally activity itemspost_{communityPost.id}— community postsfeed_{userFeedItem.id}— notification/feed itemsown_{goalEvent.id}— own goal events
Backend: BuildUnifiedFeed Interaction
Input
| Param | Type | Default | Description |
|---|---|---|---|
user_id | String | — | User's public_id (required) |
limit | Integer | 20 | Items per page |
offset | Integer | 0 | Items to skip for pagination |
Logic
- Find user by
public_id— fail with "User not found" if missing - Collect items from all four sources (14-day lookback each)
- Normalize each item to the common structure
- Sort all items by
timestampdescending (newest first) - Apply pagination: skip
offset, takelimit - Calculate
has_more = (offset + limit) < total_count
Output
{
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
GoalEventrecords from allies' goals whereprivate: falseandcompleted: false - Sets
actor_namefrom ally user,target_namefrom goal name
Community Posts:
- Finds communities via
CommunityMember.where(user_id:) - Fetches
CommunityPostrecords from those communities - Sets
actor_namefrom post author,target_nameandtarget_typeas community
User Feed Items:
- Fetches
UserFeedItemrecords for the current user - Parses
detailsJSON to extract goal references - Sets
target_type: 'goal'when goal details present
Own Goal Events:
- Fetches
GoalEventrecords from user's own goals (both public and private, active and completed) - Sets
actor_namefrom 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_sqlstill filtersg.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:
UserFeedItem#publish_feed_update(after_create_commit,app/models/user_feed_item.rb) — the single choke point for all sixadd_*_feed_itemclass methods (encouragement, reaction, comment, badge, goal-event follow-update, follow). Every row this model creates always emitssource: 'notification', since everyUserFeedItemrow surfaces asfeedType: 'notification'inBuildUnifiedFeed#user_feed_item_sqlregardless of its ownkindenum.after_create_commit, notafter_create, so a row created inside a transaction that later rolls back never fires a phantom ping.Social::FanOutFeedUpdateJob(app/jobs/social/fan_out_feed_update_job.rb) — covers the twoBuildUnifiedFeedsources that read directly from their own tables rather than throughUserFeedItem: ally goal events and community posts. Enqueued fromGoalTracking::AddGoalEvent#enqueue_post_commit_jobsandSocial::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, mirroringBuildUnifiedFeed#ally_activity_sql'sWHEREclause exactly. A blocked ally is excluded from the recipient set via the sameUserAlly.blocked_user_ids_forlookupBuildUnifiedFeeduses.community_post— every community member except the poster (source: 'community_post'), mirroringSocial::CreateCommunityPost#notify_members's own notification recipient set. A member whose row is soft-deleted (member.userpreloadsnilunderacts_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-smtransition
Relative Time Display
| Elapsed | Display |
|---|---|
| < 60 seconds | "just now" |
| < 60 minutes | "Xm ago" |
| < 24 hours | "Xh ago" |
| < 48 hours | "yesterday" |
| Otherwise | "Xd ago" |
Navigation
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 theuseCommunitycomposable - 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 existtotal_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=falseUI 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_activityitem appears - [ ] Have an ally check in on their goal → verify
ally_activityitem appears - [ ] Post in a community → verify
community_postitem 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_QUERYwith default params - [ ] Verify items from multiple sources are returned
- [ ] Verify items are sorted by timestamp descending (newest first)
- [ ] Verify
totalCountandhasMoreare 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: truewhen more items exist - [ ] Verify
hasMore: falseon 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
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.rbFrontend Unit Tests
cd ionic_frontend
npm run test:unit -- --run -t "FeedItemCard"
npm run test:unit -- --run -t "CommunityFeedCard"GraphQL API Reference
Queries
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
| Argument | Type | Required | Default | Description |
|---|---|---|---|---|
limit | Int | No | 20 | Items per page |
offset | Int | No | 0 | Items to skip for pagination |
Known Behaviors & Edge Cases
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.
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.
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.
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.
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.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.
Actor photo is always null: The
actor_photofield is currently set tonilin the interaction for all sources. Avatar display falls back to the icon-based avatar circle.Dashboard shows legacy card format: The Dashboard's "Ally Activity" section uses
CommunityFeedCard(notFeedItemCard), transforming the data into a simplified format. The fullFeedItemCardcomponent is available for a dedicated feed page.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.Sort is timestamp descending: Items are always sorted newest-first. There is no option to change sort order.
Authentication required: The
unifiedFeedquery requires authentication. Unauthenticated requests return an authorization error.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.
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 withtabindex="0"and@keyup.enterhandlers.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).