Skip to content

v4.63 — Community Discovery & Seasonal Events

A user opening Communities now sees a list ranked by what they're actually working on and who they're actually accountable to, and, at three points in the year, a platform-wide event they can join.

Summary

Before this milestone, Objectuve's community discovery surface advertised a "Relevance score (0–100)" that was a constant zero on every single response — Community#match_score didn't exist, so the field's resolver raised on every call, was silently swallowed into Sentry, and fell back to 0. Ally overlap was a hardcoded 0. The community health score that was supposed to feed ranking was 20% literal constant and cost 4–6 uncached queries per community. And whatever the server did compute was thrown away anyway: Communities.vue re-sorted the list client-side by raw member count. Worse, recommendedCommunities { members { email } } handed any signed-in user real email addresses for members of communities they hadn't joined, because the field's type delegated straight to the full UserType with no field-level authorization.

This milestone pairs two roadmap items — Community Discovery Improvements (item 41) and Seasonal Events & Challenges (item 43) — because they turned out to be one engineering problem. A ranking formula over goal category, ally overlap, and community health produces a better-ordered but static list; the one signal that makes discovery feel current, and the thing seasonal events exist to provide, is a running platform-wide event. Ten phases shipped: two backend fixes that close a live privacy leak and a broken relevance score, a real ranking service and its resolvers, two UI-SPECs, two frontends, a new SeasonalEvent model with an admin surface and three seeded events (New Year New Goals, Spring Reset, Back to School), participation/badge-award machinery, and a docs reconciliation pass.

The milestone shipped in five days but included a genuine ~66-hour stall: introducing a PostHog feature flag from CI requires the flag to already exist in PostHog, and no crew agent holds the personal API key needed to create one. Phase 3 sat fully finished — code, tests, review, rebase — waiting on that one human action. Josh created the flag directly, and the crew shipped a durable fix (a workflow_dispatch job that can create, but never enable, a flag) the same day, so Phase 7's identical wall didn't cost a second stall. Both discovery_ranking_v2 and seasonal_events_enabled ship live in PostHog at 0% rollout — this is complete build-out, not yet a launch, and nothing this milestone built is reachable by a real user today.

Goal

A user opening Communities sees a list that could only have been built for them — ordered by what they are actually working on, who they are actually accountable to, and which rooms are actually alive — and, at the three times of year when the whole internet is looking for a goal app, sees a platform-wide event they can join and bring someone into.

Scope — What Shipped

  • New backend types/methods: Types::DiscoveryMemberType (allow-listed member preview — public_id, display name, avatar only, never UserType); a real Community#match_score(user); a rebuilt, bounded Community#health_score.
  • New ranking service: Social::RankCommunities — goal-category match, an invisible ally-overlap weight, the rebuilt health score, and a seasonal-participation term, behind discovery_ranking_v2.
  • New models: SeasonalEvent and SeasonalEventParticipant (PublicRecord, acts_as_paranoid, platform-scoped — deliberately not a nullable CommunityChallenge).
  • New interactions: Social::JoinSeasonalEvent, Social::LeaveSeasonalEvent, Social::IncrementSeasonalEventProgress, Social::FinalizeSeasonalEvent — modelled on FinalizeCommunityChallenge's transaction + reprocessing-guard + at-most-once-notification guarantees.
  • New GraphQL surface: Types::SeasonalEventType, seasonalEvents/activeSeasonalEvent/activeSeasonalEvents/seasonalEvent(id:) queries, join/leave mutations, me.completedSeasonalEvents.
  • New frontend components: SeasonalEventBanner.vue, SeasonalEventView.vue (/events/:id), SeasonalBadgeTile.vue, a useSeasonalEvent composable — all behind seasonal_events_enabled.
  • New admin surface: seasonal event CRUD in admin_dashboard, following the existing CommunityCurationView.vue pattern — events are curated, never user-created.
  • Frontend behavior removed: Communities.vue's client-side re-sort — Explore's default order now renders the server's ranked output as-is; the growthRate * 2 client-side weighting is deleted.
  • New docs page: docs/features/seasonal-events.md — model, admin workflow, badge derivation, GraphQL surface as shipped.
  • New badge enum value: joined_seasonal_event, appended to UserAction (append-only), deliberately excluded from ONCE_PER_USER_ACTIONS.

Phases

PhaseNameStatusPlansHighlights
1Close the discovery member leak and the matchScore error pathShipped1Unflagged fix for a live email-exposure leak and a Sentry-flooding broken relevance score.
2Make community health cheap and realShipped1Rebuilt health_score off its 20%-constant, unbounded-query implementation.
3Social::RankCommunities and the three resolversShipped1The ranking service. Survived a real merge conflict and a ~66h PostHog-flag-creation stall — the milestone's critical path.
4UI-SPEC: the discovery pageShipped1Desi ruled ally overlap stays an invisible ranking weight, never named or counted.
5Discovery frontendShipped1Removed the client-side re-sort; server order renders as returned. Absorbed a stacked-PR auto-close when Phase 3's branch merged.
6SeasonalEvent, its admin surface, and the three eventsShipped1New platform-scoped model, modelled on CommunityChallenge but not community-bound; three events seeded.
7Participation, badge award, and the seasonal ranking termShipped1Join/progress/finalize mutations; populated the seasonal ranking term Phase 3 shipped wired-but-inert.
8UI-SPEC: seasonal event surfacesShipped1Banner, detail view, badge tile — zero fix rounds.
9Seasonal event frontendShipped1Member-facing surfaces consuming Phase 7's API; one known gap (OBJ-3964) tracked outside the milestone.
10Docs reconciliationShipped1New docs/features/seasonal-events.md; corrected a stale gotchas.md claim; filed OBJ-3984 rather than absorbing it.

Key Decisions

  • mutualAllies stays permanently 0 — an invisible ranking weight, never a disclosed count or name. Every publicly discoverable community has a readable member roster, so a "3 people you know are here" count resolves to names in one tap. No consent surface exists for membership disclosure, and private_mode/go_it_alone? cover a different preference. Desi's Phase 4 ruling, pinned by a regression spec.
  • Seasonal events are a new platform-scoped model, not a nullable CommunityChallenge. CommunityChallenge#community_id is null: false at both the DB and model level, with every existing scope/resolver/association assuming a community present. Making it nullable would need a null guard at every one of those call sites. SeasonalEvent/SeasonalEventParticipant mirror the challenge shape without touching it.
  • Seasonal badges derive from the participant's own row, not a new BadgeCatalog enum value. The catalog is a frozen, explicitly append-only 21-entry array keyed to UserAction's enum and mirrored 1:1 in frontend code by contract — adding one enum value per event per year is unbounded schema growth for what is really one badge with a date on it. badge_name/badge_icon live on the seasonal_events row instead, exactly as community-challenge badges already do.
  • community_insights.upcomingEvents stays descoped, not deferred. The originally planned path — serving a SeasonalEvent through CommunityEventType — contradicts that type's own null: false community fields and would error at execution time. Nothing shipped or planned consumes it; the gap in CommunityEvent's own read path (unrelated to this milestone) is tracked separately as OBJ-3955.
  • A discovery-safe member type is a separate allow-listed GraphQL type, not a conditional on UserType. A conditional fails open — whoever adds a field to UserType next quarter has no reason to know a discovery resolver reaches it. Types::DiscoveryMemberType fails closed, pinned by a schema-level regression spec.

Requirements Coverage

7 / 7 requirements satisfied (from MILESTONE-AUDIT.md — the ROADMAP's own 7 milestone-level Success criteria, used as the requirements axis in the absence of a numbered REQUIREMENTS.md).

CategoryCountStatus
Discovery ranking (member-leak fix, matchScore, server-side order, ranking inputs)4All satisfied
Seasonal events (model/admin/badges)2All satisfied
Discovery privacy (unchanged flag-on/flag-off)1Satisfied

Outcomes

  • recommendedCommunities can no longer leak a member's email address — the response type is structurally incapable of reaching Types::UserType, pinned by a regression spec.
  • The advertised community relevance score is realmatchScore returns a deterministic, non-zero value and no longer floods Sentry with swallowed exceptions on every request.
  • Discovery order is decided once, on the server, and rendered as returned — the client-side re-sort that discarded the server's answer is gone.
  • A platform-wide seasonal event can be curated by an admin and joined by a user, with a durable, at-most-once-awarded badge — infrastructure that didn't exist before this milestone.
  • Nothing above is live for a real user yet. Both discovery_ranking_v2 and seasonal_events_enabled ship at 0% PostHog rollout; seasonal_events_enabled additionally cannot be raised past 0% until OBJ-3961's ordering fix lands.

Tech Debt

  • (Phase 7, OBJ-3961) active_seasonal_event's resolver orders by earliest start_date with no tiebreak, against a UI-SPEC ruling of latest-start_date-with-tiebreaks — harmless at 0% rollout, but gates raising the flag until fixed.
  • (Phase 9, OBJ-3964) The seasonal event detail view can't distinguish "you didn't finish this event" from "no such event" for a deep link to an ended event, because Phase 7 shipped no public single-event lookup at close. Task 1 (the public seasonalEvent(id:) query) has since merged (PR #3331); two tasks remain queued.
  • (pre-existing, OBJ-3955) communityInsights.upcomingEvents remains a permanent literal [] — the CommunityEvent read path for real community events was never written, independent of and untouched by this milestone.
  • (pre-existing, found in Phase 10, OBJ-3984) A communities-deep-dive.md staleness around the CommunityCardV2 Joined-vs-Open CTA, filed rather than absorbed into the docs-reconciliation diff.
  • 30769ab095 — Phase 1: discovery member leak + matchScore fix (PR #3199)
  • 15f4ad18ed — Phase 2: community health score rebuild (PR #3244)
  • 49ea7954b — Phase 3: Social::RankCommunities and the three resolvers (PR #3255)
  • ca9d26f14a — Phase 4: discovery page UI-SPEC (PR #3200)
  • c957f9818 — Phase 5: discovery frontend, server-order consumption (PR #3330)
  • 11ed35dab7 — Phase 6: SeasonalEvent model, admin surface, three seeded events (PR #3216)
  • 6b89aedb7 — Phase 7: seasonal participation, badge award, ranking term (PR #3321)
  • 014e4d18f5 — Phase 8: seasonal event surfaces UI-SPEC (PR #3235)
  • 4f89b7b3e — Phase 9: seasonal event frontend (PR #3328)
  • eef2d390c — Phase 10: docs reconciliation (PR #3347)

Last updated: 2026-09-19

Loading…