Communities — Feature Guide
Related docs:
- Communities Page Overhaul PRD — original product rationale and design exploration
- Community Cold-Start PRD — original product rationale and design exploration
- Stock Photos for Goals & Communities PRD — original product rationale and design exploration
- Ally Social Graph — the allies list the Allies tile routes to, and the ally request/accept flow
- Accountability Partners — the elevated partner state the Allies tile's Partner indicator surfaces
- Community Highlights — the four verified marks rendered below the hero, their exact thresholds, and the six removed schema fields
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.
Header
- 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:
| Stat | Meaning |
|---|---|
| Joined | Communities you're a member of |
| Posts this wk | Your posts in communities this week |
| Allies | Your total accepted allies (not scoped to community membership) |
| Suggested | Community 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:
| State | What 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 allies | Dashed 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 / error | Skeleton 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:
| Tab | Content |
|---|---|
| Search | Live 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. |
| Suggested | Users 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. |
| Pending | Incoming 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:
| Rail | Heading | Framing |
|---|---|---|
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):
| Rail | Empty headline | CTA |
|---|---|---|
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:
+ Joinbutton - 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
FOUNDINGpill on discovery cards (CommunityCardV2) - A primary-gradient
Foundingchip 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 nouser_idargument and is reachable signed-out.communityInsights.suggestedCommunities- The Explore page's
communitiesresolver (added Phase 5) — falls back to the full unranked candidate set rather than emptying the browse surface if ranking returns nothing.RankCommunitiesreturns[]outright for ago_it_alone+privacy_controls_v1user, 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:
| Signal | Weight | Source |
|---|---|---|
| Goal-category match | 35 | The user's own goal categories vs. the community's goal_category_id |
| Size normalization | 10 | members_count normalized against Community::CLUB_MEMBER_THRESHOLD |
| Ally overlap | 25 | Count of the user's accepted allies who are members, capped at 5 |
| Community health | 20 | The Phase 2 health snapshot (Community#health_score, see Discovery threshold context above) |
| Seasonal participation | 10 | Fraction 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::CommunityReasonType — kind, label, disclosable), chosen by fixed precedence — goal_category → seasonal_event → community_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
| Scenario | Headline | Body |
|---|---|---|
| Search with no matches | Nothing matches that search yet. | Try a different word, or browse a category. |
| Category with zero communities | No communities in {Category} yet. | Be the first one through the door. |
| No communities in system | Communities are how Enkidu turns goals into pacts. | Start one — your future allies are out there. |
| Network error | Couldn'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_communitymutation - 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 todayif 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:
| Stat | Icon | Meaning |
|---|---|---|
| TOTAL PROGRESS | TrendingUp | Your aggregate progress across shared goals |
| ACTIVITY | Zap | Total community check-ins |
| ACHIEVEMENTS | Award | Completed goals in the community |
| DISCUSSIONS | MessageSquare | Total 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 partnerCTA
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:
| Key | Value |
|---|---|
| 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 postCTA - 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 communitiesCommunityInsightsType.suggested_count— Withdiscovery_ranking_v2off, countsCommunitySuggestionrows (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 rankedsuggestedCommunitiesresult — 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.
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 joinedCommunityInsightsType.posts_this_week— User's posts in communities this weekCommunity.guidelines— Ordered list of community rules (parsed as newline-separated strings)Community.founder— User object for the community creatorCommunity.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.teamId—public_idof the owning Team, ornullfor a non-Team community.CommunityType.isDefaultForTeam/isDefault— whether this is the team's default landing room (isDefaultis an aliasCommunityTypeexposes for it — the Teams frontend'sTeamRoomtype and every sub-community mutation/query response are frozen on the shorter name).CommunityType.joinPolicy—"open"or"request". Determines whetherjoinSubCommunitysucceeds or is refused.CommunityType.icon— display icon key:sunrise,book,leaf,mountain, orusers.CommunityType.leadName— the room's lead (its admin-roleCommunityMember) first name, ornull. Batch-loaded once per team per request (context[:community_lead_names]) to avoid an N+1 acrossteamHome.rooms[]/teamSettings.subCommunities[].CommunityType.isGated—truewhenjoinPolicyis"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 genericCommunitiesQuery), first exercised on the Teams surface in v4.19 (OBJ-1830) to splitteamHome.rooms[]into "Your rooms" vs. "Discover" onManageRoomsSheet.vue.falsewhen unauthenticated. Resolved with onemembers.exists?check per room — see GraphQL Reference § teamHome — known issue for the N+1 this incurs (OBJ-1851, non-blocking, bounded byTeam::MAX_SUB_COMMUNITIES = 10).CommunityType.checkInsPerWeek— check-ins logged by members in the trailing 7 days. Always0in 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 (nullwhen 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-communitiesviarouter.replace(notrouter.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, andcommunityBadgesresolvers, plus posting (Social::CreateCommunityPost). - OBJ-2310 (PR #2206) — closed the two gaps OBJ-2302 missed: the
communityGoalsresolver was never wired to the guard, andCommunityType#goals/#members/#feedItems/#badgeshad 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:
| Category | Token | HSL |
|---|---|---|
| Personal Development | category-personal | hsl(276 62% 50%) |
| Health | category-health | hsl(147 70% 27%) |
| Career Development | category-career | hsl(215 45% 37%) |
| Wealth Creation | category-wealth | hsl(174 80% 25%) |
| Wellbeing | category-wellbeing | hsl(250 58% 52%) |
| Other | category-other | hsl(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:
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=truequery parameter - The
community-create-ally-stepfeature 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:
mutation InviteAllyToCommunity($communityPublicId: ID!, $allyPublicId: ID!) {
inviteAllyToCommunity(communityPublicId: $communityPublicId, allyPublicId: $allyPublicId) {
success
alreadyInvited
errors
}
}Arguments:
| Argument | Type | Required | Description |
|---|---|---|---|
communityPublicId | ID | Yes | Public ID of the community |
allyPublicId | ID | Yes | Public ID of the ally to invite |
Return:
| Field | Type | Description |
|---|---|---|
success | Boolean | Whether the mutation completed without error |
alreadyInvited | Boolean | Whether 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 %sParameters: inviter's full name, community name.
Details payload:
{
"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:
| Event | Fired when | Payload |
|---|---|---|
community_ally_invite_prompted | Interstitial mounts (once per session) | (none) |
community_ally_invite_sent | Successful mutation response (success: true) | { allyPublicId } |
community_ally_invite_skipped | User 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-pressedto indicate selection - Posts announce with author, timestamp, and type
- Embedded cards announce their resource type (e.g., "Achievement: 30-Day Meditation Challenge")
- Stats tiles announce as
- Tap targets: All buttons and cards ≥44px on mobile
- Motion: Respects
prefers-reduced-motionon entry animations and hover transitions
Joining & Leaving
Joining a Community
- Click
+ Joinon a card or the hero - Button enters loading state (
Joining…) - On success: button changes to
Joined ✓and you become a member - Triggers the
join_first_communitybadge check - Toast: "You're in. Welcome."
Leaving a Community
- From the community detail page, click the
Joined ✓button - 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."
- Click
Step awayto confirm - On success: membership is removed and you return to the detail page as a non-member
- 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
- Members only: Non-members don't see the composer
- Click the composer placeholder: "Share a goal, a question, or a moment."
- A modal opens with:
- Text body textarea
- Optional embedded item picker (goal / achievement / milestone)
Post/Cancelbuttons
- Click
Postto submit - On success: post appears at the top of the feed (most recent sort)
- Toast: "Posted. Eyes will find it."
Empty States & Error Handling
| Scenario | Headline | Body | CTA |
|---|---|---|---|
| No communities joined (Your Communities) | Find your first community. | Communities help you stay accountable when willpower wobbles. | Browse communities below → |
| Exploring, search matches nothing | Nothing matches that search yet. | Try a different word, or browse a category. | Clear search |
| Exploring, category has zero | No 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 error | Couldn't load communities. | Network glitch. Try again in a moment. | Try again |
| Community loading fails | Couldn'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):
| Event | Trigger | Metadata | Purpose |
|---|---|---|---|
community_ally_invite_prompted | Composable mount (display invite flow) | — | Baseline: measure how many users see the ally invite prompt |
community_ally_invite_sent | Successful mutation response (success: true) | — | Conversion: track completed invitations per community |
community_ally_invite_skipped | User clicks skip/dismiss action | — | Drop-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).