Skip to content

Communities Deep Dive — Architecture & Implementation

For the user-facing feature overview, see Communities — Feature Guide.

This document details the component architecture, GraphQL contracts, and design patterns for the Communities feature (OBJ-70 overhaul).

Related docs:


Component Architecture

Discovery Page (Communities.vue)

File: ionic_frontend/src/views/Communities.vue

The main discovery surface. Orchestrates:

  • Header (search, create button)
  • CommunitiesHubCard — unified stat strip + Today feed + Coach nudge column (replaces the former CommunityStatsTile ×4 grid and standalone AiInsightCard)
  • CommunityAlliesSection — dedicated Allies module, gated behind allyFlowEnabled, mounted between the hub card and the Your communities rail (OBJ-1569)
  • AllyLookupModal — 460×720 ally discovery modal
  • Your communities rail (vertical stack on mobile, 2-up grid on desktop)
  • Explore section (category chips, sort, view toggle, card grid)
  • Empty / loading / error states

Key composables:

  • useCommunity() — fetches list, handles filtering, sorting, join/leave mutations
  • useAiInsights('communities') — powers the Coach nudge inside CommunitiesHubCard (via hubCoachNudge computed)
  • useUnifiedFeed(5) — fetches today's activity feed items for the hub card (limit 5)
  • useEditorialDiscovery() — behind the editorial-discovery flag; data source for the four editorial discovery rails (see Editorial Discovery Rails below)

State management:

  • communities (ref) — all communities, filtered/sorted client-side
  • myCommunities (computed) — user's joined communities
  • selectedCategory (ref) — active category chip
  • sortBy (ref) — current sort order
  • viewMode (ref) — Grid or List
  • searchQuery (ref) — live search input
  • communityInsights (computed from insightsResult) — stats (joined, postsThisWeek, partnersCount, suggestedCount)
  • hubStats (computed) — maps communityInsights to CommunitiesHubCard stat shape
  • hubCoachNudge (computed) — maps useAiInsights insight to hub card coach nudge shape
  • allyFlowEnabled (const) — isFeatureFlagEnabled('ally-request-flow-enabled'); gates CommunityAlliesSection and every ally affordance on the page
  • userAllies (computed from queries.USER_ALLIES_QUERY) — feeds CommunityAlliesSection's allies prop
  • pendingAllyRequests (computed from PENDING_ALLY_REQUESTS_QUERY) — feeds CommunityAlliesSection's pending-requests prop
  • showAllyLookup (ref) — controls AllyLookupModal open state
  • allyLookupInitialTab (ref) — controls which tab the modal opens on ('search' or 'pending')

AllyLookupModal open paths (both driven by CommunityAlliesSection's emits):

  • openAllyLookup() — section's add-ally emit ("Add an ally" / "Find your first ally") → opens on 'search'
  • openPendingAllyRequests() — section's review-requests emit ("Review" on the pending strip) → opens on 'pending'

Feed navigation: the hub card's open-feed emit routes to /feed; the Allies section's view-ally-feed emit routes to /feed?filter=ally_activity (see CommunityAlliesSection below).

Layout:

  • Desktop: max-w-5xl centered container; Explore grid grid-cols-3
  • Mobile: full-width; Explore grid full-width stacked; Your communities uses flex flex-col
  • Tablet: intermediate breakpoints via Tailwind's sm: and lg: utilities

Editorial Discovery Rails (EditorialCarousel.vue, FoundingMemberOpportunitiesSection.vue)

Files:

  • ionic_frontend/src/components/communities/EditorialCarousel.vue (+ .stories.ts)
  • ionic_frontend/src/components/communities/FoundingMemberOpportunitiesSection.vue (+ .stories.ts)

Both live under components/communities/ (plural — the discovery-surface directory, alongside CommunityCardV2.vue), not components/community/ (singular — the single-community detail directory). Behind the editorial-discovery flag; see Feature Flags for lifecycle state.

EditorialCarousel.vue props:

  • editorialSlot ('active_now' | 'just_started' | 'most_members') — selects the heading/framing/empty-state copy from an internal lookup table
  • communities (Community[]) — the slot's fetched communities; the component slices to 8 for display
  • loading (boolean) — renders 4 skeleton CommunityCardV2 placeholders
  • joining (string | null) — the publicId of a community currently mid-join, forwarded to the matching card

EditorialCarousel.vue emits: community-click, join, empty-cta (no see-all — omitted this release).

FoundingMemberOpportunitiesSection.vue props: communities (Community[], sliced to 12), loading, joining. Emits: community-click, join, empty-create.

Neither component fetches its own data or owns join/navigate logic — Communities.vue wires both to useEditorialDiscovery()'s output and to the page's existing navigateToCommunity / handleJoin / isJoining.

All-empty collapse: when activeNow, justStarted, and mostMembers are all empty, Communities.vue renders one shared "We haven't picked yet." panel instead of the three EditorialCarousel instances (each of which would otherwise render its own near-identical empty state back to back). FoundingMemberOpportunitiesSection is not part of this collapse — it always renders, including its own empty state, since it draws from a structurally different, always-populated-eventually query (formingCommunities, not admin-gated).

Community Detail Page (Community.vue)

File: ionic_frontend/src/views/Community.vue

The main detail surface. Orchestrates:

  • Top bar (back link, status pills, share/open buttons)
  • Hero card (CommunityHero)
  • Stats row (CommunityStatsRow)
  • Tabbed interface (Feed, Members, Goals, About)
  • Sidebar (desktop) or inline sections (mobile): member rollup, Sage, guidelines, meta

Key composables:

  • useCommunity(communityId) — fetches community details, handles join/leave/follow mutations
  • useAiInsights() — Sage card
  • useMediaQuery() — sticky sidebar behavior

Layout:

  • Desktop: grid grid-cols-[minmax(0,1fr)_320px] gap-4 — left column for hero/stats/tabs, right sidebar (sticky)
  • Mobile: stacked vertically with full-width sections; tabs sticky-top
  • Sidebar height clamped to viewport with overflow-y-auto max-h-[calc(100vh-3rem)]

Hero Card (CommunityHero.vue)

File: ionic_frontend/src/components/community/CommunityHero.vue

The colored banner with join/leave state machine.

Props:

  • community (CommunityType) — full community object
  • isLoading (boolean) — skeleton state
  • isJoining (boolean) — button in-flight
  • isNotifying (boolean) — secondary button in-flight
  • joined (boolean) — membership state
  • notifying (boolean) — follow state (without join)

Surface:

  • bg-category-{slug} community-stripe — category-colored with stripe overlay
  • White text throughout

States:

  • Non-member: + Join community (primary) + ⌐ Notify me (secondary glass)
  • Joining: Joining… (spinner, disabled)
  • Member: Joined ✓ (muted glass; click opens leave-confirm modal)
  • Notifying: Notified ✓ (secondary glass; click unfollows)
  • Posted today: ● {N} posts today pill top-right (or ★ FEATURED if applicable)

Leave confirm modal:

  • Headline: "Step away from {Community}?"
  • Body: "You'll lose your partner overlap and your posts will stay. You can come back any time."
  • Buttons: [Cancel] [Step away]
  • Traps focus; Escape cancels; click-outside cancels

Stats Row (CommunityStatsRow.vue)

File: ionic_frontend/src/components/community/CommunityStatsRow.vue

A 4-up (desktop) or 2×2 (mobile) grid of metrics.

Props:

  • totalProgress (number, nullable) — user's aggregate goal progress (%)
  • activity (number) — total check-ins
  • achievements (number) — completed goals
  • discussions (number) — feed posts

States:

  • totalProgress === null → tile hides on desktop (grid collapses to 3-up); on mobile stays 2×2 with 0
  • Loading skeleton for each tile
  • Error state shows with tooltip on hover

Responsive:

  • Desktop: grid grid-cols-4 (or grid-cols-3 if totalProgress is null)
  • Mobile: grid grid-cols-2 gap-3 (always 2×2, even with null progress)

Communities Hub Card (CommunitiesHubCard.vue)

File: ionic_frontend/src/components/communities/CommunitiesHubCard.vue

Unified card that replaced the former CommunityStatsTile ×4 grid + standalone AiInsightCard. Has three regions: a 4-up stat strip, a Today feed column, and a Coach nudge column.

Props:

PropTypeDefaultDescription
statsHubStatsrequired{ joined, postsThisWeek, partners, suggested } — all nullable
feedItemsHubFeedItem[]requiredActivity items for the Today feed
coachNudgeCoachNudgePayload | nullnull{ message, ctaLabel? } from useAiInsights
loadingbooleanfalseSkeleton / loading state
maxFeedRowsnumber4Clamps displayed feed items
quietTitlestring'Quiet morning'Empty-feed headline
quietMessagestring'No posts in your communities yet…'Empty-feed body
alliesTappablebooleanfalseMakes the Allies tile a focusable <button> with a chevron affordance; renders as an inert <div> when false. Callers gate this on isFeatureFlagEnabled('ally-request-flow-enabled') so a tap never bounces to /dashboard when the flag is off.

Emits:

EventPayloadFired when
feed-item-clickHubFeedItemUser taps a feed row
post-update"Post an update" CTA in quiet state
browse-communities"Browse communities" CTA in quiet state
coach-ctaCoach nudge CTA button
open-alliesUser taps the Allies tile (only fires when alliesTappable is true); Communities.vue handles it with router.push({ name: 'Allies' })
open-feed"Open feed →" link in the Today feed header → Communities.vue routes to /feed

Stat strip colors:

  • Joined / Posts this wk → brand blue #177bbb
  • Allies → green #10b77f
  • Suggested → purple #a855f7

Today feed: Live indicator uses a CSS pulse animation (hub-pulse, 2.2 s ease-out); animation disabled with @media (prefers-reduced-motion: reduce). Avatars fall back to initials via initialsFor() with a stable hue derived from the actor name.

"For you" column (Coach-only, OBJ-1569): Renders the Coach nudge only. The allyNudge prop, ally-accept/find-allies emits, and the empty-nudge "Add an ally" fallback were removed — ally actions moved to CommunityAlliesSection. When there's no coachNudge, the column renders nothing.

Accessibility: aria-labelledby="hub-stats-heading" (sr-only heading); icon containers are aria-hidden.

CommunityAlliesSection (CommunityAlliesSection.vue)

File: ionic_frontend/src/components/communities/CommunityAlliesSection.vue

New in OBJ-1569. Consolidates every ally entry point on the Communities page — previously split across the header's My allies/Find allies buttons and CommunitiesHubCard's ally-nudge column — into one labeled <section>, mounted between CommunitiesHubCard and the Your communities rail. Gated by Communities.vue's v-if="allyFlowEnabled"; when the flag is off, no ally affordance renders anywhere on the page.

Props:

PropTypeDescription
alliesAlly[]Drives the has-allies/zero-state branch, the plural/singular subtitle, and the avatar stack ([] → zero state)
pendingRequestsPendingRequest[]Drives the pending strip ([] → no strip)
loadingbooleanSkeleton state
errorbooleanShows the inline "Couldn't load your allies." message without blanking the section

Emits:

EventFired whenHandler in Communities.vue
add-ally"Add an ally" / "Find your first ally"openAllyLookup() → opens AllyLookupModal on 'search'
review-requests"Review" on the pending stripopenPendingAllyRequests() → opens AllyLookupModal on 'pending'
view-ally-feed"See ally activity →" (has-allies state only)routes to /feed?filter=ally_activity
see-allAvatar-stack "See all" linkroutes to { name: 'Allies' }

States: default (has allies), single-pending, multi-pending, zero, loading, error — see Communities § Allies section for the user-facing description of each.

Accessibility: <section aria-labelledby="allies-section-heading"> with a visible <h2>; avatar-stack link carries aria-label="See all your allies"; the pending strip is an aria-live="polite" region; tap targets ≥44px; avatar-initial gradients use a darkened ramp (hsl(H 60% 46%)→34%, vs. the hub card's lighter ramp) for AA contrast.

AllyLookupModal (AllyLookupModal.vue)

File: ionic_frontend/src/components/allies/AllyLookupModal.vue

A 460 × 720 (max 90 vh) modal for finding and inviting allies. Wraps ion-modal with backdrop-dismiss and keyboard-close.

Props:

PropTypeDescription
isOpenbooleanControls visibility
inviteUrlstring?Ally invite link (defaults to 'objectuve.com')
initialTab'search' | 'suggested' | 'pending'?Which tab to open on; defaults to 'search'

Emits: close, request-sent (with publicId)

Tabs:

  • Search — powered by useAllySearch composable. Live debounced search (≥ 2 chars). Below threshold: recent searches (session ref, ≤ 5) + invite link card. Tab label updates to Results · N when results exist.
  • SuggestedSUGGESTED_ALLIES_QUERY (cache-and-network). SuggestedRow extends AllySearchResult with reason: string | null and mutualCount: number. Reason shown below each row in violet.
  • PendingPENDING_ALLY_REQUESTS_QUERY (cache-and-network). Incoming requests → PendingAllyRequestRow; outgoing placeholder ref (not yet server-exposed). Badge dot shown on tab when incomingRequests.length > 0.

On open: watch(isOpen) resets activeTab to props.initialTab ?? 'search', clears search, focuses the search input, and refetches pending requests.

Send request: SEND_ALLY_REQUEST_MUTATIONflipToPending() (optimistic status mutation on Apollo result objects) → ally_request_sent PostHog event.

ARIA: role="dialog", aria-modal="true", aria-labelledby="ally-lookup-heading", tabindex="-1" on container; full tablist/tab/tabpanel pattern.

AllyPartnerBadge (AllyPartnerBadge.vue)

File: ionic_frontend/src/components/allies/AllyPartnerBadge.vue

A small, non-interactive indicator rendered inline after an ally's name on the allies list (AlliesPage.vue), showing whether that ally is also an accountability partner.

Props:

PropTypeDescription
status'active' | 'pending' | 'none'Sourced from ally.partnerStatus ?? 'none'

States:

  • active — gold gradient chip, HeartHandshake icon, visible text "Partner" (screen reader hears "accountability Partner")
  • pending — neutral chip (slate-600 light / slate-300 dark for AA contrast), Clock icon, visible text "Pending" (screen reader hears "Partner request Pending")
  • none — renders nothing

State is conveyed via icon + text together, never color alone. AlliesPage.vue fetches the underlying partnerStatus field via USER_ALLIES_WITH_PARTNER_STATUS_QUERY — see Accountability Partners § GraphQL API for the field definition.

Community Card V2 (CommunityCardV2.vue)

File: ionic_frontend/src/components/communities/CommunityCardV2.vue

The new discovery card. Supersedes the now-deleted EnhancedCommunityCard.

Props:

  • community (CommunityType) — full community object
  • joined (boolean) — membership state
  • joining (boolean) — join in-flight
  • loading (boolean) — skeleton state

Surface:

  • bg-category-{slug} community-stripe — category-colored with stripe
  • relative overflow-hidden rounded-2xl p-5 min-h-[260px]
  • White text (text-category-{slug}-foreground)
  • Decorative faint label top-right (rotated, semi-transparent)

Sections:

  1. Top eyebrow: Category name uppercase + optional ★ FEATURED pill
  2. Avatar + name: w-10 h-10 rounded initials disc + bold community name
  3. Description: 2-line clamp, text-white/85
  4. Stats row: 3-up [{N} MEMBERS] [{N} ACTIVE ↑X%] [{N} GOALS]
  5. Join button: Primary (white) or Joined ✓ (muted glass)

States:

  • Hover (desktop): Lift (-translate-y-1), shadow increase, decorative label rotate
  • Joined: Button shows Joined ✓; click toggles to non-member state
  • Loading: LoadingSkeletonCard wrapper with min-h-[260px]
  • Motion-reduced: Drop hover lift and label rotate transitions

Category → Token Mapping:

ts
// src/helpers/communityCategory.ts
export const categoryToToken = (name?: string) => {
  switch (name?.toLowerCase()) {
    case 'personal development': return 'personal'
    case 'health': return 'health'
    case 'career development': return 'career'
    case 'wealth creation': return 'wealth'
    case 'wellbeing': return 'wellbeing'
    default: return 'other'
  }
}

Computed surface class (dynamic class binding):

ts
const surfaceClass = computed(() => {
  const slug = categoryToToken(community.category)
  return `bg-category-${slug}`
})

Category Chip (CommunityCategoryChip.vue)

File: ionic_frontend/src/components/communities/CommunityCategoryChip.vue

A filter chip with optional count.

Props:

  • label (string) — category name or "All"
  • count (number, optional) — count badge
  • selected (boolean) — active state
  • disabled (boolean) — grayed out

Styling:

  • Default: bg-card border border-border rounded-full px-3 py-1.5 text-xs font-medium
  • Selected: bg-primary text-primary-foreground border-transparent shadow-sm
  • Count: opacity-70 ml-1 (softer than label)

My Community Card (MyCommunityCard.vue)

File: ionic_frontend/src/components/communities/MyCommunityCard.vue

Horizontal card for the "Your Communities" rail.

Props:

  • community (CommunityType)
  • activeCount (number)
  • postCount (number)

Structure (horizontal row):

┌─────────────────────────────────────┐
│ ┌──┐ Community Name   318 · 214  › │
│ │HB│                                 │
│ └──┘                                 │
└─────────────────────────────────────┘
  • Avatar disc (category-tinted bg)
  • Name + metadata line ({activeCount} active · {postCount} posts)
  • Chevron-right at end

Member Rollup (CommunityMemberRollup.vue)

File: ionic_frontend/src/components/community/CommunityMemberRollup.vue

Avatar stack with count.

Props:

  • members (UserType[]) — top 5 members in recency order
  • totalCount (number) — total member count
  • activeCount (number) — active today

Structure:

◯ ◯ ◯ ◯ ◯  +1,243 more
1,248 members · 142 active today
[ See all members → ]
  • Avatar stack: 5 max, -ml-2 overlap, w-8 h-8 rounded-full, ring-2 ring-card
  • "+X more" count circle (6th position, muted bg)
  • Count line below
  • "See all members →" link (navigates to Members tab)

Empty state:

  • Show only founder avatar
  • Copy: "Just you and the silence. Invite a friend."
  • "Invite a partner" CTA (existing share flow)

Feed Composer (CommunityFeedComposer.vue)

File: ionic_frontend/src/components/community/CommunityFeedComposer.vue

Textarea + optional embedded item picker (members only).

Props:

  • community (CommunityType)
  • disabled (boolean)

Structure:

  • Avatar disc (user's own avatar)
  • Click to open modal with text body + item picker + Post/Cancel buttons
  • Placeholder: "Share a goal, a question, or a moment."

Modal:

  • Textarea for text body
  • Optional embedded item picker (goal / achievement / milestone)
  • [Post] button (sends createCommunityPost mutation)
  • [Cancel] button
  • Focus trap; Escape cancels

Feed & Feed Card (CommunityFeed.vue, CommunityFeedCard.vue)

File: ionic_frontend/src/components/community/CommunityFeed.vue + CommunityFeedCard.vue

Displays posts with composer.

Post structure:

  • Header: avatar + name (clickable to profile) + timestamp + type chip (ACHIEVEMENT / GOAL / MILESTONE / QUESTION / MOMENT) + overflow menu (Report, Mute)
  • Body: text + optional embedded card
  • Footer: like / comment / share counts (interactive buttons)

Embedded cards (by post type):

  • Achievement: Award icon + gold bar (bg-gold/10 border-l-2 border-gold)
  • Goal: Target icon + primary bar (bg-primary/10 border-l-2 border-primary)
  • Milestone: Sparkles icon + coach bar (bg-coach/10 border-l-2 border-coach)

Sorting:

  • Most recent (default)
  • Most liked
  • Most commented

Empty state:

  • "No posts yet — go first."
  • "Drop a goal, ask a question, share something small. People follow lights."
  • + Make the first post CTA (opens composer)

Guidelines Panel (CommunityGuidelinesPanel.vue)

File: ionic_frontend/src/components/community/CommunityGuidelinesPanel.vue

Community rules display.

Props:

  • guidelines (string) — newline-separated rules or empty string

Structure:

  • Eyebrow: "COMMUNITY GUIDELINES"
  • Intro line (italic, optional)
  • Ordered list (<ol class="list-decimal list-inside">)

States:

  • Empty: "This community runs on common sense. Be a good ally." (centered italic)
  • Long (>5): Show first 3 + "Show all →" toggle

Parsing:

ts
const rules = guidelines
  .split('\n')
  .filter(line => line.trim())
  .slice(0, collapsed ? 3 : undefined)

Meta Panel (CommunityMetaPanel.vue)

File: ionic_frontend/src/components/community/CommunityMetaPanel.vue

Key-value metadata grid.

Props:

  • community (CommunityType)

Grid layout:

grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2

Rows:

  • CATEGORY: Community category name
  • MEMBERS: Total member count
  • FOUNDED: Formatted date (e.g., "March 2024") from created_at
  • FOUNDER: Founder name (linked to /users/{founderId})

Empty values: Show em-dash ()


GraphQL Schema & Queries

Query: Communities List

graphql
query CommunitiesList($first: Int, $after: String, $search: String, $category: String, $sort: CommunitySortEnum) {
  communities(first: $first, after: $after, search: $search, category: $category, sort: $sort) {
    edges { node { id name category isFeatured ... } }
    pageInfo { hasNextPage endCursor }
  }
  communityInsights(userId: $currentUserId) {
    joinedCount
    postsThisWeek
    partnersCount      # NEW
    suggestedCount     # NEW
  }
}

Query: Community Detail

graphql
query CommunityDetail($id: ID!) {
  community(id: $id) {
    id
    name
    description
    category
    avatar
    createdAt
    isFeatured
    memberCount
    activeCount      # today
    goalCount
    postCount        # total
    postsToday       # NEW (optional; for hero indicator)
    guidelines
    founder { id name avatar }
    user_progress_percent  # NEW; user's aggregate goal progress in this community
    members(first: 5) {
      edges { node { id name avatar } }
      totalCount
    }
  }
}

New Type: CommunityInsightsType

graphql
type CommunityInsightsType {
  communities_joined: Int # existing
  posts_this_week: Int    # existing
  partners_count: Int     # NEW; count of accepted accountability partners
  suggested_count: Int    # NEW; from community_suggestions query
}

New Field: CommunityType.user_progress_percent

graphql
type CommunityType {
  # ... existing fields
  user_progress_percent: Float  # NEW; null if user has no shared goals
}

Computation:

  • Fetches user's goals where community_goals.community_id = self.id
  • Aggregates progress across those shared goals
  • Returns average(progress_percent) or null if count = 0

N+1 safety:

  • Scoped via Goldiloader's has_many :goals association with :preload_scope

New Query: suggestedAllies (PR #918)

graphql
query SuggestedAllies($limit: Int) {
  suggestedAllies(limit: $limit) {
    publicId
    firstName
    lastName
    username
    photo { imageUrl }
    allyStatus
    reason
    mutualCount
  }
}

Backed by Social::FindSuggestedAllies interaction. Default limit: 10, max: 25.

New Type: AllySuggestionType (PR #918)

graphql
type AllySuggestionType {
  publicId: ID!
  firstName: String!
  lastName: String
  username: String
  photo: PhotoType
  allyStatus: String!   # "NONE" at suggestion time
  reason: String        # e.g. "Both in Marathon in 16 weeks · 2 shared communities"
  mutualCount: Int!     # number of shared communities
}

Query: editorialCommunities / formingCommunities (OBJ-2191)

graphql
query EditorialCommunitiesQuery($slot: String!) {
  editorialCommunities(slot: $slot) { id publicId name description imageUrl coverImage category isVerified isFeatured isFounding memberCount activeMembers totalGoals }
}

query FormingCommunitiesQuery {
  formingCommunities { id publicId name description imageUrl coverImage category isVerified isFeatured isFounding memberCount activeMembers totalGoals }
}

editorialCommunities(slot:) (rails_api/app/graphql/types/query_type.rb:446) — unauthenticated, no require_auth!. Resolves to Community.discoverable.visible_to(current_user).where(editorial_slot: slot).where.not(editorial_position: nil).order(editorial_position: :asc, editorial_updated_at: :desc).limit(8) (rails_api/app/graphql/resolvers/community_queries.rb). slot must be one of Community::EDITORIAL_SLOTS (active_now, just_started, most_membersrails_api/app/models/community.rb:25); an unrecognized slot returns [] without raising. A community only appears once it (a) has an editorial_slot/editorial_position assigned via the admin-only setCommunityEditorialSlot mutation, and (b) is past the .discoverable floor (members_count >= 5, Community::DISCOVERY_THRESHOLD) — a slotted community below that floor is filtered out silently, not an error state.

formingCommunities (rails_api/app/graphql/types/query_type.rb:453) — requires authentication (require_auth!). Resolves to communities the caller does not admin, with members_count between 0 and 4, newest first, capped at 12.

Mutation: setCommunityEditorialSlot (admin-only, OBJ-2189)

graphql
mutation SetCommunityEditorialSlot($communityId: ID!, $slot: String, $position: Int) {
  setCommunityEditorialSlot(communityId: $communityId, slot: $slot, position: $position) {
    community { id editorialSlot editorialPosition }
    errors
  }
}

Backed by Admin::SetCommunityEditorialSlot (rails_api/app/interactions/admin/set_community_editorial_slot.rb), wrapped in Admin::Logged for audit trail. require_admin!-gated; passing slot: null clears the community's slot/position/editorial_updated_at. Assigning a position already held by another community in the same slot bumps that community out (editorial_slot/editorial_position/editorial_updated_at all cleared) inside the same transaction — intentional, there is no swap/shift affordance. CommunityType.editorialSlot (rails_api/app/graphql/types/community_type.rb:107) / editorialPosition (rails_api/app/graphql/types/community_type.rb:109) expose the current assignment.

Called by the admin Communities curation screen (admin_dashboard/src/views/CommunityCurationView.vue, /communities/curation, OBJ-2238) — see Admin Dashboard Guide § Communities curation for the operator workflow.

Query: curatableCommunities (admin-only, OBJ-2236)

graphql
query CuratableCommunitiesQuery {
  curatableCommunities {
    publicId
    name
    category
    memberCount
    editorialSlot
    editorialPosition
    isFeatured
  }
}

Backs the admin Communities curation screen's table. Resolvers::AdminQueries#curatable_communities (rails_api/app/graphql/resolvers/admin_queries.rb:229-238), require_admin!-gated. Unlike editorialCommunities, which stays behind the consumer-facing .discoverable floor by design (see above), curatableCommunities widens to members_count >= 5 OR editorial_slot IS NOT NULL, .visible_to(current_user), ordered by editorial_slot, editorial_position, then name:

ruby
Community
  .where(arel_table[:members_count].gteq(Community::DISCOVERY_THRESHOLD)
         .or(arel_table[:editorial_slot].not_eq(nil)))
  .visible_to(current_user)
  .includes(:goal_category)
  .order(:editorial_slot, :editorial_position, :name)

Why the widening exists — the orphan case. Before this widening (OBJ-2236, Phase 2 Task 2-1), the admin resolver reused the same .discoverable scope as the consumer query. A community slotted at 6 members that later dropped below 5 kept its editorial_slot/editorial_position in the database but vanished from both editorialCommunities (consumer, correctly — the floor is a real product rule) and curatable_communities (admin, incorrectly — no admin could see it, clear it, or even know a rail position was silently held hostage). The OR editorial_slot IS NOT NULL clause makes any sub-threshold slot-holder visible and clearable in the admin list while editorialCommunities continues to hide it from consumers unchanged — editorialCommunities's own spec suite was asserted to pass unmodified by this change, confirming the consumer-side floor was never touched.

Mutation: setCommunityFeatured (admin-only, OBJ-2236)

graphql
mutation SetCommunityFeaturedMutation($communityId: ID!, $featured: Boolean!) {
  setCommunityFeatured(communityId: $communityId, featured: $featured) {
    community { publicId isFeatured }
    errors
  }
}

Backed by Admin::SetCommunityFeatured (rails_api/app/interactions/admin/set_community_featured.rb), mirroring Admin::SetCommunityEditorialSlot's admin gate and Admin::Logged audit contract (action_type: 'set_community_featured') but with no collision/transaction logic — is_featured is a single boolean column with no slot/position semantics to protect.

is_featured is orthogonal to editorial_slot/editorial_position — a deliberate product decision (Option A: separate write path, no consolidation), not an oversight. The is_featured column is exposed as CommunityType.isFeatured (rails_api/app/graphql/types/community_type.rb:31) and read directly by CommunityCardV2.vue:47 (v-if="community.isFeatured") to render the "FEATURED" badge wherever community cards appear across the app; editorial_slot/editorial_position drive placement in the three /communities editorial rails instead. A community can carry either, both, or neither independently — there is no rule linking them. Before this mutation shipped, is_featured had no write path at all in this application (Rails db:seed / direct DB writes were the only way to set it); this is its first-ever admin-facing write path.

Existing Mutations (Unchanged)

  • joinCommunity(communityId: ID!) -> Community
  • leaveCommunity(communityId: ID!) -> Community
  • followCommunity(communityId: ID!) -> Community — creates follow without join
  • unfollowCommunity(communityId: ID!) -> Community
  • createCommunityPost(communityId: ID!, body: String, itemId: ID, itemType: String) -> Post

Design Tokens

All tokens live in design_system/css/tokens.css and are exposed via design_system/tailwind-tokens.cjs.

CSS Variables (:root)

css
/* Community category surface palette — one value, both modes (OBJ-2231) */
--category-personal: 276 62% 50%;
--category-personal-fg: 0 0% 100%;
--category-health: 147 70% 27%;
--category-health-fg: 0 0% 100%;
--category-career: 215 45% 37%;
--category-career-fg: 0 0% 100%;
--category-wealth: 174 80% 25%;
--category-wealth-fg: 0 0% 100%;
--category-wellbeing: 250 58% 52%;
--category-wellbeing-fg: 0 0% 100%;
--category-other: 222 20% 45%;
--category-other-fg: 0 0% 100%;

There's no .dark override for these six tokens — the surface is opaque, so a mode-specific lift bought no visible benefit while pushing white-text contrast below the WCAG AA floor in dark mode. OBJ-2231 measured worst-case white-on-stripe contrast at 4.77:1 (category-other), clearing 4.5:1 in both modes.

Tailwind Extension

js
// tailwind.config.js
extend: {
  colors: {
    category: {
      personal: {
        DEFAULT: 'hsl(var(--category-personal))',
        foreground: 'hsl(var(--category-personal-fg))',
      },
      health: {
        DEFAULT: 'hsl(var(--category-health))',
        foreground: 'hsl(var(--category-health-fg))',
      },
      career: {
        DEFAULT: 'hsl(var(--category-career))',
        foreground: 'hsl(var(--category-career-fg))',
      },
      wealth: {
        DEFAULT: 'hsl(var(--category-wealth))',
        foreground: 'hsl(var(--category-wealth-fg))',
      },
      wellbeing: {
        DEFAULT: 'hsl(var(--category-wellbeing))',
        foreground: 'hsl(var(--category-wellbeing-fg))',
      },
      other: {
        DEFAULT: 'hsl(var(--category-other))',
        foreground: 'hsl(var(--category-other-fg))',
      },
    },
  },
}

Utility Classes

css
@layer utilities {
  .community-stripe {
    background-image:
      repeating-linear-gradient(
        135deg,
        rgba(255, 255, 255, 0.06) 0,
        rgba(255, 255, 255, 0.06) 1px,
        transparent 1px,
        transparent 14px
      );
  }
  .community-stripe-soft {
    background-image:
      repeating-linear-gradient(
        135deg,
        rgba(255, 255, 255, 0.04) 0,
        rgba(255, 255, 255, 0.04) 1px,
        transparent 1px,
        transparent 18px
      );
  }
}

Composables

useAiInsights

ts
// Communities.vue calls it as:
const { insight, fetchInsight } = useAiInsights('communities')

// On mount:
fetchInsight({
  page: 'communities',
  joinedCommunityCount: myCommunities.value.length,
  postsThisWeek: communityInsights.value?.postsThisWeek ?? 0,
})

Returns:

  • insight — card data (message, ctaLabel)
  • fetchInsight(params) — trigger fetch with context
  • loading — boolean

The insight is surfaced via hubCoachNudge computed → :coach-nudge prop on CommunitiesHubCard. The former standalone AiInsightCard component is no longer used on this page.

useUnifiedFeed

ts
const { items: hubFeedItems, loading: hubFeedLoading } = useUnifiedFeed(5)

Fetches today's activity items from the user's joined communities, capped at the provided limit. Powers the Today feed column of CommunitiesHubCard.

useCommunity

ts
const {
  community,
  joined,
  joining,
  notifying,
  handleJoin,
  handleLeave,
  handleNotify,
  loading,
} = useCommunity(communityId)

Returns all community data and mutations for join/leave/follow.

useEditorialDiscovery

File: ionic_frontend/src/composables/useEditorialDiscovery.ts

ts
const {
  flagEnabled,
  activeNow,
  justStarted,
  mostMembers,
  formingCommunities,
  editorialLoading,
} = useEditorialDiscovery()

Ships exactly these six values — no ally-gated suggestedCommunities/recommendedCommunities/gateReason/personalCarouselsGated from the pre-launch reference version; that half never shipped on this branch. flagEnabled reads editorial-discovery through the typed useFeatureFlag() composable (see Feature Flags § Reading flags safely), not the raw PostHog client. The three editorial queries and formingCommunities are all gated enabled: flagEnabled, so nothing fetches while the flag is off.

useAllySearch

ts
const { search, results, loading, error } = useAllySearch()
// search(query: string) — debounced, triggers when query.length >= 2

Used by AllyLookupModal for the Search tab. Results are AllySearchResult[] with publicId, firstName, lastName, username, photo, allyStatus.


Backend: Social::FindSuggestedAllies

File: rails_api/app/interactions/social/find_suggested_allies.rb

Interaction that powers the suggestedAllies GraphQL query. Suggests co-members of the caller's communities as potential allies.

Inputs:

  • user (User, required) — the requesting user
  • limit (Integer, default 10, max 25)

Algorithm:

  1. Collect community_ids from CommunityMember.where(user: user)
  2. Early return [] if the user has no communities
  3. Build excluded_user_ids — self + blocked-by-them + blocked-by-me + any existing UserAlly records (all statuses)
  4. Query CommunityMember for co-members in the same communities, grouped by user_id, ordered by COUNT(*) DESC, limited to cap
  5. For each candidate user, look up the most recently joined shared community to produce a reason string
  6. Return { user, reason, mutual_count, ally_status: 'NONE' }

Reason string format:

  • Single shared community: "Both in {community_name}"
  • Multiple: "Both in {community_name} · {N} shared communities"

N+1 note: Uses CommunityMember filtered .count calls without preloads — intentional, as counter_cache is disabled on these associations (counter_cache_enable = false).

Missing spec (deferred): A spec for blocked_by_me exclusion (users the caller has blocked) is not yet written. Filed as a follow-up.


Known Limitations & Follow-ups

  1. CommunityFeed.vue composerRef naming: The ref pointing to CommunityFeedComposer has the same name (composerRef) as the parent Community.vue's ref to CommunityFeed. Not a bug (CI passes), but confusing for future readers. Post-ship: rename internal ref to feedComposerRef (separate issue).

  2. Sage CTA on feed cards: The composer defineExpose gap means clicking the Sage insight's "Introduce yourself" CTA on the detail page doesn't open the composer. Codi flagged as post-ship follow-up (separate issue).

  3. Screenshots: Desktop and mobile screenshots in docs/assets/communities/ were regenerated from the live app on 2026-05-31 (light mode: communities-list-{mobile,desktop}.png, community-detail-{mobile,desktop}.png). Dark-mode variants remain an optional follow-up.

  4. blocked_by_me spec missing (find_suggested_allies_spec.rb): The spec covers 'excludes users who blocked the caller' but not the reverse. Deferred — file a separate issue.

  5. App.vue dev helper (window.__openOnboardingWizard, lines 130–138): A drive-by debug shortcut unrelated to communities. DEV-gated; no production risk. Should move to a focused onboarding PR.

  6. Apollo cache mutation in AllyLookupModal (flipToPending): The function mutates Apollo result objects directly rather than using cache.modify(). Works in practice (queries use cache-and-network and refetch on next open) but is technically incorrect. Deferred.

  7. Redundant plucks in find_suggested_allies.rb (blocked_by_me / blocked_by_them): UserAlly.where(user: user).pluck(:ally_id) in existing already covers blocked_by_me. Two extra queries fire on every call. Deferred cleanup.


Testing

Unit Tests

  • Component props/slots for every new component (CommunityCardV2, CommunityHero, CommunityStatsRow, etc.)
  • Category token helper (categoryToToken → correct enum case folding)

Visual Regression (Storybook)

  • All variants per component story brief
  • Light + dark mode on each variant
  • Mobile viewport for responsive components

E2E (Cypress)

  • Load communities list
  • Search and category filtering
  • Join/leave with confirm dialog
  • Post to feed and sort
  • Dismiss Sage insight
  • Share community link
  • Switch tabs and see members

Last updated: 2026-08-11 — corrected the bg-category-* token ramp to clear WCAG AA white-text contrast in both modes (OBJ-2231): one mode-independent HSL value per token, .dark overrides removed.

Prior update: 2026-08-07 — documented EditorialCarousel.vue/FoundingMemberOpportunitiesSection.vue, useEditorialDiscovery(), the editorialCommunities/formingCommunities queries, the admin-only setCommunityEditorialSlot mutation (OBJ-2191), the widened curatableCommunities query and orphan-slot fix, and the setCommunityFeatured mutation (OBJ-2236/OBJ-2238).

Loading…