Skip to content

Communities — Feature Guide

Related docs:

Overview

Communities are interest-based groups where users share goals, post updates, and support each other. The Communities feature includes two main surfaces:

  • /communities — Discovery page with search, filtering, stats, and personalized recommendations
  • /community/:community_id — Individual community page with tabbed interface (Feed, Members, Goals, About)

Communities List Page

The /communities route displays an organized discovery experience centered on helping users find communities aligned with their goals.

  • Headline: "Communities"
  • Subtitle: "Join one — or start your own."
  • Search input: Live filter on community name/description (desktop inline; mobile shown below header row)
  • Create button: Opens the community creation modal

Ally actions (find, review, manage) no longer live in the header — they moved into the dedicated Allies section below the hub card.

Communities Hub Card

Directly below the header, a unified card (CommunitiesHubCard) replaces the former separate stats grid and AI insight card. It has three regions:

Stat strip (4-up)

A horizontal strip shows your community engagement at a glance:

StatMeaning
JoinedCommunities you're a member of
Posts this wkYour posts in communities this week
AlliesYour total accepted allies (not scoped to community membership)
SuggestedCommunity suggestions available for you

On mobile (< 1024 px) the strip collapses to 2×2; on desktop it's 4-across.

The Allies tile is tappable when the ally-request-flow-enabled feature flag is on — tapping it routes to the allies list, where each ally who's also an accountability partner shows a Partner indicator (a Pending indicator marks an outstanding partner request). When the flag is off, the tile stays inert so a tap never bounces the user to /dashboard. The other three tiles are never tappable.

Today feed column (left)

Shows up to 4 activity items from your communities, updated in real time:

  • LIVE indicator (pulsing green dot) appears when items exist
  • Each row: actor avatar → action verb → target name, with an optional excerpt and community label
  • Clicking a row navigates to the post's community or the linked goal
  • Quiet day empty state: "Quiet morning — No posts in your communities yet. Start the day — you'll set the tone." with "Post an update" and "Browse communities" CTAs
  • "Open feed →" link in the column header navigates to /feed (the feed's default All tab)
  • @media (prefers-reduced-motion: reduce) removes the pulse animation

Coach nudge column (right)

Labeled "For you". Shows a single Coach nudge card (purple gradient) driven by useAiInsights('communities') — a personalized insight message plus a CTA that scrolls to the Explore section. Replaces the former standalone AI insight card. When there's no Coach insight, the column renders nothing.

Ally actions (find, review pending requests, see all allies) were removed from this column — they now live in the dedicated Allies section below the hub card, so ally actions appear in exactly one place on the page.

Allies section

Immediately below the hub card, a dedicated Allies section (CommunityAlliesSection) consolidates every ally entry point on the Communities page. It's gated behind the ally-request-flow-enabled feature flag — when the flag is off, the section (and every ally affordance on the page) is absent.

Since v4.47 wave 2 (OBJ-3019), the section header carries a "Read the guide" link (CommunityAlliesSection.vue:83-97) to helpUrl('allies') — the guide's Feed & social page. It's the first live consumer of helpUrl() outside the achievements XP chart; native opens it via Browser.open per useHelpLink, web opens a new tab.

States:

StateWhat it shows
Has allies"Your allies" + "{n} people keeping you accountable." (singular: "1 person keeping you accountable."), an overlapping avatar stack with a See all link (→ your Allies page), and a primary Add an ally button that opens the Ally Lookup Modal on the Search tab
Pending requests (count > 0)An accent-tinted strip prepended above the default body: "{FirstName} wants to be your ally." (plus "N more requests waiting" when there's more than one) and a Review button that opens the Ally Lookup Modal on the Pending tab
Zero alliesDashed card: "Add your first ally." / "Two people stay accountable better than one. Find someone who'll keep you honest." with a single Find your first ally button. No avatar stack and no feed link — an empty ally feed would be a dead end
Loading / errorSkeleton shimmer while allies are first-loading; on error, the title stays with "Couldn't load your allies." and the Add an ally button still works

Feed navigation: the section's See ally activity → link navigates to /feed?filter=ally_activity, which opens the activity feed directly on its Allies tab. This is separate from the hub card's generic "Open feed →" link, which lands on the default All tab.

Find Allies (AllyLookupModal)

A 460 × 720 modal (capped at 90 vh) for finding and inviting allies. Opens from the Allies section's Add an ally / Find your first ally button (Search tab) or its Review button (Pending tab).

The modal has three tabs:

TabContent
SearchLive search by name or @username (≥ 2 chars triggers results). Below the threshold: recent searches (session-only, up to 5) + "Share your ally link" invite card.
SuggestedUsers with overlapping community membership, ranked by shared-community count. Each row shows a reason string (e.g., "Both in Marathon in 16 weeks"). Powered by the suggestedAllies GraphQL query / Social::FindSuggestedAllies backend interaction.
PendingIncoming ally requests (grouped as "Incoming requests") and sent requests (grouped as "You sent"). The Pending tab shows a badge dot (brand orange #f28529) when there are incoming requests.

Ally request flow: Tapping a user in Search or Suggested fires sendAllyRequest mutation → optimistic status flip (row shows "Pending") → ally_request_sent PostHog event. The requester must confirm before the connection becomes active.

ARIA: role="dialog", aria-modal="true", aria-labelledby="ally-lookup-heading", full tablist/tab/tabpanel pattern. ESC closes via ion-modal :keyboard-close="true".

Your Communities Rail

Shows the communities you've joined:

  • Desktop: 2-column grid layout
  • Mobile: Vertical stack (full-width rows)
  • Each card displays: community name, active member count, post count, and a chevron link
  • "See all →" link at the top-right
  • Empty state: If you haven't joined any communities yet, a card prompts: "Find your first community. Communities help you stay accountable when willpower wobbles." with a link to browse below.

Editorial Discovery Rails

Behind the editorial-discovery feature flag (registered, default off — see Feature Flags), four human-curated rails render between the Your Communities rail and Explore:

RailHeadingFraming
active_now"Where people show up""Check-ins most days, not just week one."
just_started"Fresh starts""New communities finding their first regulars."
most_members"The big rooms""The largest communities on Objectuve."
Founding"Founding spots open""Under five members each. Join early and you set the tone."

The three editorial rails carry a "PICKED BY US" eyebrow — this is the app's first human editorial voice on an otherwise-algorithmic discovery surface. Each editorial rail shows up to 8 cards; the founding rail shows up to 12. Cards reuse CommunityCardV2 and the page's existing join/navigate handlers — joining or opening a community from a rail works exactly like joining from Explore.

Curation reaches these rails through an admin-only, audited write path (setCommunityEditorialSlot mutation) that assigns a community to one of the three slots and an ordering position. The admin dashboard's Communities curation screen (/communities/curation) calls this mutation — see Admin Dashboard § Communities curation for the operator workflow.

Discoverability floor applies here too: a community must already be past the 5-member discovery threshold (see Discovery threshold below) to appear in an editorial rail, even if an admin has assigned it a slot. A slotted-but-tiny community silently won't render until it crosses the floor.

Empty states (each rail renders its own, styled as an intentional "nothing here yet" panel, never a blank gap):

RailEmpty headlineCTA
active_now"No picks here yet.""See what's active →"
just_started"No picks here yet.""See what's new →"
most_members"No picks here yet.""Sort by most members →"
Founding"Every community's found its footing." / "Nothing under five members right now. Start one and be the first.""Create community"

If all three editorial rails are empty at once (the day-one state, before any admin assigns a slot), they collapse into a single shared panel — "We haven't picked yet." / "Nothing curated so far. Explore has every community we've got." with a "Browse all communities →" CTA — instead of stacking three near-identical empty panels. The Founding rail always renders on its own, including its own empty state; it never disappears.

There is no "See all" affordance on any rail in this release — the destination route doesn't exist yet.

Explore Section

Displays available communities with filtering and sorting:

Category Chips

Click a category chip to filter by that interest:

  • All (shows all communities)
  • Personal Development, Health, Career Development, Wealth Creation, Wellbeing, etc.
  • Each chip shows a count in parentheses (e.g., Health (12))
  • The selected chip highlights in the primary color

Sort & View Controls

  • Sort dropdown: Recommended (default), Trending, Most members, Most active, Newest
  • View toggle: Grid (default) or List

Community Card (CommunityCardV2)

Each community card displays on a category-colored surface with a subtle diagonal-stripe overlay:

┌─────────────────────────────────────┐
│ PERSONAL DEVELOPMENT    ★ FEATURED  │  ← category eyebrow + featured badge
│ ┌──┐                                │
│ │HB│ Habit Builders                 │  ← avatar + name
│ └──┘ Daily routines, streaks, and   │
│      accountability for the long    │
│      game.                          │  ← description (2-line clamp)
│ ┌────────────┬────────────┐         │
│ │  1,248     │    142     │  312    │  ← stats row
│ │  MEMBERS   │  ACTIVE ↑8 │  GOALS  │
│ └────────────┴────────────┴─────────┘
│ [+ Join]                            │  ← CTA bottom-right
└─────────────────────────────────────┘

Card states:

  • Not joined: + Join button
  • Joining: Joining… (spinner, disabled)
  • Joined: Joined ✓ (muted style; click to leave)

Card features:

  • Colored surface per category (Personal Development = purple, Health = teal, Career = navy, Wealth = teal-green, Wellbeing = soft purple)
  • Featured badge (gold star) appears for featured communities
  • Founding badge (✦ FOUNDING) appears for communities within 30 days of reaching the 5-member discovery threshold. Uses frosted glass treatment. Stacks below the Featured badge when both are present.
  • Growth rate indicator (e.g., ↑8%) appears on the "Active" stat
  • Card elevates on hover (desktop) with a subtle lift animation

Discovery threshold (private formation phase)

Communities with fewer than 5 members are hidden from the discovery page and category filters. They remain accessible via direct link at any member count.

Once a community crosses the 5-member threshold it becomes discoverable and appears in all listings. For the following 30 days it is tagged as Founding — a status displayed as:

  • A frosted-glass FOUNDING pill on discovery cards (CommunityCardV2)
  • A primary-gradient Founding chip below the member-count stat on the community detail stats row (CommunityStatsTile)

Creator-progress callout: Community creators (admins) see a progress banner on the community detail page while membership is below the threshold. The banner shows a gradient progress bar, the current count, remaining needed, and a Share link button that triggers the native share sheet (or clipboard copy). The callout disappears automatically once the community becomes discoverable.

Community Discovery Ranking

Behind the discovery_ranking_v2 feature flag (see Feature Flags § Reference), Social::RankCommunities (rails_api/app/interactions/social/rank_communities.rb) scores each candidate community and reorders discovery surfaces by real signal instead of the placeholder member-count ordering this replaced. This is the Social:: namespace — there is no Discovery:: namespace anywhere in this codebase.

Four surfaces share the same ranking engine:

  • recommendedCommunities — communities suggested for the user, not yet joined.
  • trendingCommunities — ranked only when a signed-in user is present to score against; falls back to today's 14-day join-count ordering when signed out or the flag is off, since the query has no user_id argument and is reachable signed-out.
  • communityInsights.suggestedCommunities
  • The Explore page's communities resolver (added Phase 5) — falls back to the full unranked candidate set rather than emptying the browse surface if ranking returns nothing. RankCommunities returns [] outright for a go_it_alone + privacy_controls_v1 user, which is correct on a recommendation surface (recommendedCommunities) and would be wrong here — Explore must never tell a privacy-conscious user there are no communities at all.

What feeds the score:

SignalWeightSource
Goal-category match35The user's own goal categories vs. the community's goal_category_id
Size normalization10members_count normalized against Community::CLUB_MEMBER_THRESHOLD
Ally overlap25Count of the user's accepted allies who are members, capped at 5
Community health20The Phase 2 health snapshot (Community#health_score, see Discovery threshold context above)
Seasonal participation10Fraction of the community's members active in a currently-running seasonal event, capped at 3 members — stays zero unless seasonal_events_enabled is also on, so flag-off ranking is byte-identical to the pre-seasonal-events score

What the score does NOT use: raw member count alone (the pre-v4.63 placeholder logic this replaces), any global "trending" heuristic, and nothing from BadgeCatalog — badge data plays no part in community ranking.

Discovery reason chip. Each ranked community carries a single reason (Types::CommunityReasonTypekind, label, disclosable), chosen by fixed precedence — goal_categoryseasonal_eventcommunity_health — never by the raw score. null when no disclosable signal applies: no chip is better than a misleading one. See Discovery privacy constraints below for why ally_overlap is never a returned reason even though it's the second-heaviest scoring input.

The client no longer re-sorts under the default sort. ionic_frontend/src/views/Communities.vue's sortBy computed passes the array through unchanged for "Recommended" (the default) — server order (Social::RankCommunities' order when the flag is on, today's scope order when it's off) is the rendered order. The explicit overrides (Trending, Most members, Most active, Newest) still re-sort client-side; only "Recommended" is server-order-as-rendered.

Discovery privacy constraints

Two access constraints guard every discovery surface above. Both are deliberate — widening either one reopens a real information leak, not a hypothetical one.

(a) Discovery member previews use a narrow, allow-listed type — never UserType. SuggestedCommunityType#members is typed [DiscoveryMemberType], never [UserType]. Types::DiscoveryMemberType (rails_api/app/graphql/types/discovery_member_type.rb) exposes exactly publicId, displayName, and avatarUrl — nothing else. This exists because Types::UserType exposes email (and every other user field) with no field-level authorization, and recommendedCommunities was handing a member preview built on it to any signed-in caller for a room they hadn't joined. Don't widen this type or repoint members at UserType. rails_api/spec/graphql/types/suggested_community_type_spec.rb fails the instant either happens — that's the guard a future PR adding "just one more field" to the discovery preview needs to see fail before merging.

(b) recommendedCommunities(userId:) and communityInsights(userId:) check the argument against current_user. Both resolvers return UNAUTHORIZED unless the caller is an admin or userId matches their own public_id (OBJ-3943, rails_api/app/graphql/resolvers/community_queries.rb). This closed a pre-existing IDOR: either query previously trusted the caller-supplied userId outright, so any signed-in user could pull another user's discovery insights or recommendations by public ID alone. Guarded by rails_api/spec/requests/graphql/community_insights_recommended_communities_idor_spec.rb.

Empty & Loading States

ScenarioHeadlineBody
Search with no matchesNothing matches that search yet.Try a different word, or browse a category.
Category with zero communitiesNo communities in {Category} yet.Be the first one through the door.
No communities in systemCommunities are how Enkidu turns goals into pacts.Start one — your future allies are out there.
Network errorCouldn't load communities.Network glitch. Try again in a moment.

Community Detail Page

The /community/:community_id page is the main interface for viewing and interacting with a community.

Top Bar

  • Back link: "← Back to communities"
  • Status pills: Category badge (e.g., Personal Development) and membership status (e.g., Member ✓)
  • Action icons: Open in new tab, Share

Hero Section

A full-width card with the community's category-colored surface:

┌─────────────────────────────────────────────┐
│ PERSONAL DEVELOPMENT       ● 24 posts today │
│ ┌──┐                                        │
│ │HB│ Habit Builders                         │
│ └──┘ Daily routines, streaks, and           │
│      accountability for the long game.      │
│ 👥 1,248     ★ 312 goals                    │
│ [+ Join community]  [⌐ Notify me]          │
└─────────────────────────────────────────────┘

Hero features:

  • Community avatar (initials or logo)
  • Name, description, member count, goal count
  • Join button (non-members): Opens the join flow
  • Notify me button (non-members): Follows the community without joining; uses the existing follow_community mutation
  • Joined ✓ button (members): Click to leave; shows a confirm dialog ("Step away from {Community}? You'll lose your partner overlap.")
  • Posted today indicator (top-right): Shows ● {N} posts today if activity occurred. No longer replaced by a Featured pill — that mark moved to the highlights row below the hero (see Community Highlights)

Challenge Card (Admins Only)

Between the hero and stats row, a pinned challenge card appears (if a challenge is active or upcoming):

  • Shows the current active challenge (or next upcoming one)
  • Badge icon, challenge name, dates, and member count
  • Members: Join button, or progress bar + "Joined ✓" if already participating
  • Non-members: Disabled join button with a "Join the community first" hint
  • Admins: If no challenge is running, the card shows "No challenge running. Start one →" CTA
  • For full details on creating and joining challenges, see Community Challenges

Stats Row

A 4-up grid (2×2 on mobile) showing community and user metrics:

StatIconMeaning
TOTAL PROGRESSTrendingUpYour aggregate progress across shared goals
ACTIVITYZapTotal community check-ins
ACHIEVEMENTSAwardCompleted goals in the community
DISCUSSIONSMessageSquareTotal feed posts

If you have no shared goals in the community, the "Total Progress" tile hides and the desktop grid collapses to 3-up.

Member Rollup (Sidebar on Desktop / Inline on Mobile)

Shows member avatars with quick stats:

  • Avatar stack (max 5 visible)
  • "+X more" count badge if >5 members
  • Count line: {n} members · {m} active today
  • See all members → link (navigates to the Members tab)
  • Empty state: If only the founder exists: "Just you and the silence. Invite a friend." with an Invite a partner CTA

Sage Card (Sidebar on Desktop / Inline on Mobile)

An AI insight card powered by the same Sage logic as the communities list:

  • Example: "Sarah and Mike are on similar morning routines. Want to introduce yourself?"
  • Default CTA: Introduce yourself (opens the feed composer with a templated intro)
  • Users can dismiss for 24 hours

Guidelines Panel (Sidebar on Desktop / About Tab on Mobile)

Community rules and guidelines:

  • Eyebrow: "COMMUNITY GUIDELINES"
  • Introduction line: Optional intro text (italic, muted)
  • Ordered list: 1–5 guidelines
  • Empty state: "This community runs on common sense. Be a good ally."
  • Overflow: If >5 guidelines, show first 3 with a Show all → toggle

Meta Panel (Sidebar on Desktop / About Tab on Mobile)

Key metadata about the community:

KeyValue
CATEGORY(e.g., Personal Development)
MEMBERS(e.g., 1,248)
FOUNDED(formatted as "March 2024")
FOUNDER(links to founder's profile)

Tabs

Four tabs organize the community content:

Feed Tab

  • Composer (members only): "Share a goal, a question, or a moment."
    • Avatar disc on the left
    • Click to open a modal composer with text body, optional embedded item picker, Post/Cancel buttons
    • Placeholder copy is the brand-voice line above
  • Empty feed state: "No posts yet — go first." with a + Make the first post CTA
  • Sort dropdown (top-right): Most recent (default), Most liked, Most commented
  • Post item:
    • Header: Avatar + author name + timestamp + post-type chip (ACHIEVEMENT, GOAL, MILESTONE, QUESTION, MOMENT) + overflow menu
    • Body: Text content + optional embedded card (achievement = gold bar, goal = primary bar, milestone = coach bar)
    • Footer: Like, comment, share buttons with counts
  • Pinned posts stick to the top regardless of sort

Members Tab

  • Avatar stack at the top (reuses the member rollup component)
  • Member list/grid below
  • Search to filter members
  • Click a member to view their profile in the community (public goals, recent activity, join date)

Goals Tab

  • Aggregate progress card at the top: community-wide progress percentage, active vs. completed goal counts. Reads "Not tracked" (never a fabricated 0%) if no goal in the community has a progress signal yet, and adds an "Averaged across N of M goals" caption when only some do.
  • Goals grid below: 2–3 columns on desktop, stacked on mobile
  • Each goal card shows: owner, title, category badge, progress, days left, encouragement count. A goal with no habit rate, no roadmap, and no target amount shows "Not tracked" and a dashed empty rail instead of a filled bar — never a 0% fill for a goal nobody's measuring.
  • Sorting: Most recent, Highest progress (untracked goals always sort last, never treated as 0), Most popular (encouragements)

Key decision: no legend on community goal progress (OBJ-3561). This card used to color its progress bar by a threshold — four bands cut at 75/50/25% with no product concept behind them. Those bands are removed, not relabelled or legended: the bar's fill is now the goal's category tint, the same convention the dashboard's goal card uses, so colour here means "what category is this," not "how worried should I be." If this feed ever needs a categorical read on a goal (ahead / on track / behind), the pattern to reuse is the labelled Pace Indicator — words carry judgment, colour carries identity. Full rationale: docs/ui-specs/honest-goal-progress.md § Amendment 1. The not-tracked/tracked/loading progress states above are the same shared derivation described in Goals — Progress Tracking, extended to this card by the same change.

About Tab

  • Full description of the community
  • Community guidelines (desktop shows inline; mobile shows the panel from the sidebar, expanded)
  • Meta panel (desktop shows inline; mobile shows the panel from the sidebar, expanded)
  • Milestones (visual cards for member count, activity, goals completed, age)

GraphQL Fields

The Communities feature depends on these fields:

New Fields (OBJ-70)

  • CommunityType.user_progress_percent — The current user's aggregate progress across shared goals in this community (percentage, 0–100)
  • CommunityInsightsType.partners_count — Count of accepted accountability partners in the user's joined communities
  • CommunityInsightsType.suggested_count — With discovery_ranking_v2 off, counts CommunitySuggestion rows (the user's own submitted community requests) — a noun collision with the field's name that predates v4.63. With the flag on (v4.63 Phase 3, OBJ-3809), it stops returning that count and instead counts the same ranked suggestedCommunities result — the field finally counts what its name says: communities suggested to the user, not requests submitted by them. See Community Discovery Ranking above.

New Query: suggestedAllies

Added in PR #918. Powers the Suggested tab in AllyLookupModal.

graphql
query SuggestedAllies($limit: Int) {
  suggestedAllies(limit: $limit) {
    publicId
    firstName
    lastName
    username
    photo { imageUrl }
    allyStatus
    reason        # e.g. "Both in Marathon in 16 weeks · 2 shared communities"
    mutualCount   # number of shared communities
  }
}

Backed by Social::FindSuggestedAllies interaction. Suggestions are co-members of the caller's communities, ranked by shared-community count, capped at 25. Excludes existing allies, blocked users, and the caller. See Architecture in the deep-dive for the full schema.

Existing Fields (Reused)

  • CommunityInsightsType.communities_joined — Communities the user has joined
  • CommunityInsightsType.posts_this_week — User's posts in communities this week
  • Community.guidelines — Ordered list of community rules (parsed as newline-separated strings)
  • Community.founder — User object for the community creator
  • Community.created_at — Community creation timestamp (formatted as "Month YYYY")
  • Community.is_featured — Boolean flag for featured badge display

Team Sub-Community Fields (Teams V1)

Introduced in Phase 3 (OBJ-1092). A Team sub-community ("room") is a Community scoped to a Team via team_id — same model, same feed/goals/members machinery, plus these Team-specific fields:

  • CommunityType.teamIdpublic_id of the owning Team, or null for a non-Team community.
  • CommunityType.isDefaultForTeam / isDefault — whether this is the team's default landing room (isDefault is an alias CommunityType exposes for it — the Teams frontend's TeamRoom type and every sub-community mutation/query response are frozen on the shorter name).
  • CommunityType.joinPolicy"open" or "request". Determines whether joinSubCommunity succeeds or is refused.
  • CommunityType.icon — display icon key: sunrise, book, leaf, mountain, or users.
  • CommunityType.leadName — the room's lead (its admin-role CommunityMember) first name, or null. Batch-loaded once per team per request (context[:community_lead_names]) to avoid an N+1 across teamHome.rooms[] / teamSettings.subCommunities[].
  • CommunityType.isGatedtrue when joinPolicy is "request" (joining requires the lead to approve — no approval workflow exists yet in V1).
  • CommunityType.isMember — whether the current viewer belongs to this room. Pre-existing field (originally for the generic CommunitiesQuery), first exercised on the Teams surface in v4.19 (OBJ-1830) to split teamHome.rooms[] into "Your rooms" vs. "Discover" on ManageRoomsSheet.vue. false when unauthenticated. Resolved with one members.exists? check per room — see GraphQL Reference § teamHome — known issue for the N+1 this incurs (OBJ-1851, non-blocking, bounded by Team::MAX_SUB_COMMUNITIES = 10).
  • CommunityType.checkInsPerWeek — check-ins logged by members in the trailing 7 days. Always 0 in Phase 3; no check-in tracking model exists yet for sub-communities.
  • CommunityType.archivedAt / archiveDaysLeft — when the 30-day read-only archive window began, and days remaining in it (null when not archived).

See GraphQL Reference § Teams — Sub-Communities, Membership & Invites for the full mutation/query surface, and Data Models for the Team/TeamMembership/Community relationships.

Team Room Feed (v4.35 Phase 1)

A team room ("sub-community") is a Community scoped to a Team — see above — and since v4.35 Phase 1 (OBJ-1825) it has a real feed. On the Team Settings → Rooms tab, a member taps a room's ⋮ menu → Open feed to land on it.

No new route or view. The room feed reuses the existing /communities/:publicId route (ionic_frontend/src/views/Community.vue), gated into an additive team-room mode whenever community.teamId is present. With teamId null (the public/free-community case), the view renders exactly as it did before this phase — team-room mode is strictly additive, never a fork. In team-room mode the view:

  • Swaps the generic community header for TeamRoomHeader.vue — room icon, name, "Led by {leadName}," member count, and a status chip (DEFAULT / GATED / ARCHIVED).
  • Suppresses public-growth chrome that doesn't belong in a private team room: the discoverability nudge, public join/follow, ally invite, challenges tabs, category chip, and the About tab.
  • Routes "Back" deterministically to /team-settings?tab=sub-communities via router.replace (not router.back(), so a hard reload or deep link still lands in the right place).

Membership gates the entry point, not the feed's own query. Whether a member sees "Open feed" at all is decided in TeamSubCommunitiesTab.vue's ⋮ menu, from isMember on TEAM_SETTINGS_QUERY's subCommunities selection — never from COMMUNITY_QUERY, which 403s a non-member on a team room by design (see below). A non-member sees Join on an open room, or nothing on a gated one; never a feed entry that would 403. The full authorization contract — including the archived-room write boundary and the admin-can-manage-but-not-read consequence — is documented once, centrally, at Architecture → Community Room Authorization. Read that doc before adding any new mutation that touches a CommunityPost, comment, or reaction on a team room.

Archived rooms are read-only, not deleted. archivedAt is a plain column, not a soft-delete marker — the record and its feed stay live for a 30-day retention window. In team-room mode the composer and post actions (reactions, comments) are suppressed client-side, and the server independently refuses createCommunityPost against an archived room (see the authorization doc). The banner reads: "Archived. Posts and goals stay readable until {date}." An empty archived feed reads: "Nothing was posted here. This room closed before anyone shared. It's kept until {date}."

Non-member on a members-only room gets a distinct 403 state, not a blank screen or a generic error: "This one's members-only. Ask whoever runs it to add you, and it'll show up in your rooms."

Editing a room (OBJ-1824, Teams V1.1 backlog)

An owner/admin can edit a non-archived room's name, description, joinPolicy, icon, and lead via the updateSubCommunity mutation (Teams::UpdateSubCommunity), reachable from ⋮ → Edit room on Team Settings → Rooms (renamed from "Sub-Communities" in v4.47 wave 2, OBJ-3019 — see TeamSettingsView.vue:243-247). Editing a room's lead reassigns the admin-role CommunityMember: the current admin is demoted to member, and the incoming lead is promoted to admin — or, if the incoming lead has no CommunityMember row on that room yet, one is created and set to admin (Teams::UpdateSubCommunity#reassign_lead!), in the same transaction as the field update. This is the same lead_is_team_member? guard updateSubCommunity shares with room creation, restricted to users already on the team — see Room Access Boundary below. A room always ends with exactly one admin; omitting the lead field leaves the current lead untouched. Full write-up (design deviations, grace-period handling): phase-7-teams.md § Room edit wired.

Room Access Boundary (CommunityAccessPolicy)

A Team sub-community with team_id present (a "room") is gated by CommunityAccessPolicy (rails_api/app/services/community_access_policy.rb): reading its feed, members, badges, or goals — or posting to it — requires an actual CommunityMember record for that specific room. This applies whether the read reaches the room through a top-level query (community, communityFeed, communityMembers, communityBadges, communityGoals) or nested on CommunityType (e.g. teamHome.rooms { goals members feedItems badges }, teamSettings.subCommunities { goals members feedItems badges }). Seated team membership alone is not enough — a user who is on the team but hasn't joined a gated room gets FORBIDDEN.

Seated team membership does grant room metadata, independent of this policy: name, description, memberCount, leadName, icon, isGated, isMember. This is exactly what teamHome.rooms and teamSettings.subCommunities return for a non-member — the boundary is metadata-vs-content, not room-vs-no-room.

Admins bypass the check. Communities with team_id nil (public/free communities) are entirely unaffected — this policy only gates team-scoped rooms.

A query denial raises a GraphQL FORBIDDEN error (GraphQL::ExecutionError, extensions: { code: 'FORBIDDEN' }) rather than returning null/[], so a caller can't confuse "no access" with "empty room." Posting (Social::CreateCommunityPost) denies the same way at the interaction layer, failing with code: :forbidden.

Shipped in two passes — the guard was added resolver-by-resolver first, and a nested-field bypass slipped through that pass:

  • OBJ-2302 (PR #2199) — guarded the community, communityFeed, communityMembers, and communityBadges resolvers, plus posting (Social::CreateCommunityPost).
  • OBJ-2310 (PR #2206) — closed the two gaps OBJ-2302 missed: the communityGoals resolver was never wired to the guard, and CommunityType#goals/#members/#feedItems/#badges had no resolver-level guard at all, so any nested read (e.g. teamHome.rooms { goals }) reached the same data regardless of the top-level fix.

See GraphQL Reference § community and its four sibling entries (communityBadges, communityFeed, communityGoals, communityMembers) for the query-level contract.


Design System

Category Palette

Each community category has a dedicated surface color used on cards and the hero:

CategoryTokenHSL
Personal Developmentcategory-personalhsl(276 62% 50%)
Healthcategory-healthhsl(147 70% 27%)
Career Developmentcategory-careerhsl(215 45% 37%)
Wealth Creationcategory-wealthhsl(174 80% 25%)
Wellbeingcategory-wellbeinghsl(250 58% 52%)
Othercategory-otherhsl(222 20% 45%)

Each token is a single, mode-independent value — light and dark render the same hue (OBJ-2231 removed the six html.dark overrides, which lifted the ramp toward failing white-text contrast in dark mode with no visible benefit on an opaque surface).

Foreground text: Pure white (text-white) on all category surfaces. Worst-case contrast against the corrected ramp is 4.77:1 (category-other), clearing the 4.5:1 WCAG AA floor in both modes.

Diagonal Stripe Overlay

A subtle repeating diagonal-stripe utility class (.community-stripe) decorates category-colored surfaces:

css
background-image:
  repeating-linear-gradient(
    135deg,
    rgba(255, 255, 255, 0.06) 0,
    rgba(255, 255, 255, 0.06) 1px,
    transparent 1px,
    transparent 14px
  );

This is purely decorative (no DOM impact).


Community creation with ally invite

After creating a community, members see a guided post-create interstitial prompting them to invite their allies to the community. This drives early adoption and surfaces the ally network as a growth lever within community spaces.

Post-create interstitial flow

When a user completes the community creation form and is redirected to the new community detail page, the interstitial mounts if:

  • The URL includes ?justCreated=true query parameter
  • The community-create-ally-step feature flag is enabled for the user

The interstitial surfaces the user's accepted allies as an invitable list. Tapping an ally sends an invite (one-tap). The user can skip or dismiss at any time. After any invite is sent, the interstitial auto-dismisses after 2.5 seconds (or 3.5 seconds with prefers-reduced-motion).

Feature flag

The feature is controlled by the PostHog feature flag community-create-ally-step. For local development, use the DEV override key objectuve_community_create_ally_step_override.

Inviting allies to a community

The inviteAllyToCommunity mutation sends a structured invitation.

GraphQL mutation:

graphql
mutation InviteAllyToCommunity($communityPublicId: ID!, $allyPublicId: ID!) {
  inviteAllyToCommunity(communityPublicId: $communityPublicId, allyPublicId: $allyPublicId) {
    success
    alreadyInvited
    errors
  }
}

Arguments:

ArgumentTypeRequiredDescription
communityPublicIdIDYesPublic ID of the community
allyPublicIdIDYesPublic ID of the ally to invite

Return:

FieldTypeDescription
successBooleanWhether the mutation completed without error
alreadyInvitedBooleanWhether the ally had already been invited to this community in the last 7 days
errors[String]Error messages, if any

Idempotency: The mutation is idempotent over a 7-day window. If the same ally is invited to the same community within 7 days, the request returns success: true and alreadyInvited: true without creating a duplicate notification.

Notifications

Inviting an ally triggers a push notification with the notification kind community_invite (enum value 19).

Notification template:

%s invited you to join %s

Parameters: inviter's full name, community name.

Details payload:

json
{
  "community_public_id": "<public_id>",
  "inviter_public_id": "<public_id>"
}

Deep link: The notification deep-links to /communities/<community_public_id> on tap.

Analytics

Three events track the ally-invite interstitial engagement:

EventFired whenPayload
community_ally_invite_promptedInterstitial mounts (once per session)(none)
community_ally_invite_sentSuccessful mutation response (success: true){ allyPublicId }
community_ally_invite_skippedUser taps Skip or Dismiss without sending invites(none)

Combine these events to measure interstitial adoption: (sent / (sent + skipped)) as the conversion rate.


Responsive Design

Desktop (≥1024px)

  • Full 3-column layout: sidebar menu (fixed left), main content (max-w-7xl centered), community sidebar (320px, sticky)
  • Stats grid: 4-up
  • Your communities: 2-column grid
  • Explore cards: 3-column grid
  • Member rollup, Sage, guidelines, meta: sidebar panel (sticky top-6)

Mobile (375–767px)

  • Single-column layout
  • Stats grid: 2×2
  • Your communities: vertical stack
  • Explore cards: full-width stack
  • Sidebar sections (member rollup, Sage, guidelines, meta) stack inline between the tabs and feed; guidelines and meta move under the About tab to save space

Tablet (768–1023px)

  • Hybrid layout: sidebar menu (fixed left), main content with narrower community sidebar (280px)
  • Stats grid: 4-up (tiles may shrink)
  • Explore cards: 2-up
  • Your communities: 2-up

Accessibility

  • WCAG AA contrast verified per category surface (white text passes AA on all colors)
  • Focus management: All buttons, chips, and cards have visible focus rings
  • Keyboard navigation: Tab through all interactive elements; Enter/Space to activate; Escape to close modals
  • Screen readers:
    • Stats tiles announce as role="group" with full labels (e.g., "Joined: 2 communities")
    • Category chips have aria-pressed to indicate selection
    • Posts announce with author, timestamp, and type
    • Embedded cards announce their resource type (e.g., "Achievement: 30-Day Meditation Challenge")
  • Tap targets: All buttons and cards ≥44px on mobile
  • Motion: Respects prefers-reduced-motion on entry animations and hover transitions

Joining & Leaving

Joining a Community

  1. Click + Join on a card or the hero
  2. Button enters loading state (Joining…)
  3. On success: button changes to Joined ✓ and you become a member
  4. Triggers the join_first_community badge check
  5. Toast: "You're in. Welcome."

Leaving a Community

  1. From the community detail page, click the Joined ✓ button
  2. A confirm dialog appears: "Step away from {Community}? You'll lose your partner overlap and your posts will stay. You can come back any time."
  3. Click Step away to confirm
  4. On success: membership is removed and you return to the detail page as a non-member
  5. Toast: "Stepped back. Come around any time."

Following Without Joining

  • Click ⌐ Notify me (non-members) to follow a community and receive updates without full membership
  • Button changes to Notified ✓
  • Toast: "You'll hear from us when something good happens here."
  • Click again to unfollow
  • Toast: "Quieted. We'll stay out of your inbox."

Posting to a Community Feed

  1. Members only: Non-members don't see the composer
  2. Click the composer placeholder: "Share a goal, a question, or a moment."
  3. A modal opens with:
    • Text body textarea
    • Optional embedded item picker (goal / achievement / milestone)
    • Post / Cancel buttons
  4. Click Post to submit
  5. On success: post appears at the top of the feed (most recent sort)
  6. Toast: "Posted. Eyes will find it."

Empty States & Error Handling

ScenarioHeadlineBodyCTA
No communities joined (Your Communities)Find your first community.Communities help you stay accountable when willpower wobbles.Browse communities below →
Exploring, search matches nothingNothing matches that search yet.Try a different word, or browse a category.Clear search
Exploring, category has zeroNo communities in {Category} yet.Be the first one through the door.Create community
Cold start (zero communities exist)Communities are how Enkidu turns goals into pacts.Start one — your future allies are out there.Create the first community
Network errorCouldn't load communities.Network glitch. Try again in a moment.Try again
Community loading failsCouldn't load this community.Network glitch. Try again in a moment.Try again
Feed empty (no posts yet)No posts yet — go first.Drop a goal, ask a question, share something small. People follow lights.+ Make the first post
Only founder (member rollup)Just you and the silence. Invite a friend.Invite a partner

Testing

Coverage moved from Cypress to Playwright smoke specs under ionic_frontend/tests/smoke-playwright/specs/communities/:

  • 07-communities.spec.ts — communities page loads (search input or heading present, no error state)
  • communities-overhaul.spec.ts (OBJ-70) — communities list header copy, stats tiles, community detail hero with category surface, "Back to communities" link, category filter chips

Analytics

PostHog Event Schema

The Communities feature fires analytics events tracked in PostHog for funnel analysis, cohort targeting, and conversion measurement. All events are captured via posthog.capture() in the frontend and paired with persistent UserAction records in the backend (for the admin funnel and cohort splits).

Ally Invitation Events

When a user invites an existing ally to join a community (Phase 76, useCommunityAllyInvite composable):

EventTriggerMetadataPurpose
community_ally_invite_promptedComposable mount (display invite flow)Baseline: measure how many users see the ally invite prompt
community_ally_invite_sentSuccessful mutation response (success: true)Conversion: track completed invitations per community
community_ally_invite_skippedUser clicks skip/dismiss actionDrop-off: measure abandonment from the invite flow

Implementation: Events are fired by trackOnboardingEvent() from src/lib/onboardingAnalytics.ts. This function dual-writes to PostHog (client-side) and the backend recordUserAction mutation (persistent record). Fire-and-forget contract: rejections breadcrumb to Sentry under the onboarding category and do not throw — a failed analytics call must never block the user from advancing.

Metadata: The event structure carries optional slideId and variantId fields for future variant analysis; invite-specific payloads may add ally count or invitation status (e.g., alreadyInvited: true).

Last updated: 2026-09-18 — documented v4.63 community discovery ranking (Social::RankCommunities, the four ranked surfaces, the reason chip) and the two discovery privacy constraints (the DiscoveryMemberType allow-list, the userId IDOR fix); corrected the stale suggested_count description (OBJ-3816).

Prior update: 2026-09-02 — corrected the stale "replaced by ★ FEATURED badge" claim in the Posted today indicator bullet (v4.50 Phase 4 removed that replacement behavior; featured now renders in the highlights row) and cross-linked Community Highlights (OBJ-3264).

Prior update: 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, html.dark overrides removed.

Prior update: 2026-08-08 — documented the team room feed (v4.35 Phase 1, OBJ-1825): Community.vue's additive team-room mode, the membership-gated ⋮ entry point, and the archived-room read-only behavior; cross-linked the new Community Room Authorization doc.

Prior update: 2026-08-08 — documented room editing (OBJ-1824, updateSubCommunity/Teams::UpdateSubCommunity), including the lead-reassignment membership grant when the incoming lead has no CommunityMember row yet.

Prior update: 2026-08-07 — documented the editorial discovery rails behind the editorial-discovery flag: the three curated rails, the Founding spots open rail, their empty/collapsed-empty states, and the caps/floor they inherit from editorialCommunities/formingCommunities (OBJ-2191); noted the admin curation screen now calls the assignment mutation (OBJ-2238).

Loading…