Skip to content

Seasonal Events — Feature Guide

Seasonal Events are platform-wide, time-boxed challenges — "New Year New Goals," "Spring Reset," "Back to School" — open to every user rather than one community's members. Admins create the event; users join, log qualifying goal activity, and earn a badge if they hit the target before the event ends.

Ships behind the seasonal_events_enabled feature flag, currently 0% rollout — see Rollout status below before raising it.


Why a separate model, not a nullable community_id

SeasonalEvent is its own aggregate root, not CommunityChallenge with community_id: null. CommunityChallenge.community_id is null: false — a community challenge has no representation of "no community" to repurpose. A seasonal event is open to every user on the platform, not scoped to a room's membership, so it needed a model with no community relationship to begin with rather than a nullable escape hatch bolted onto one that assumes it.

#status and the upcoming/active/completed scopes on SeasonalEvent are written to read identically to CommunityChallenge's — the two are meant to move together if either changes, even though they're separate classes.

Code: SeasonalEvent, SeasonalEventParticipant models (rails_api/app/models/)


For Admins: Creating Next Year's Event

Next year's "Spring Reset" is a new row, not a migration and not a frontend constant — there's no hardcoded event calendar anywhere in the codebase. An admin creates it from the admin dashboard.

Where to Find It

Admin Dashboard → Communities → Seasonal Events (/communities/seasonal-events, SeasonalEventsView.vue). The page lists every event (active, upcoming, and past — this admin list is not flag-gated, unlike the member-facing queries below) with a New event button that opens the creation form.

The Creation Form

FieldRequiredConstraints
NameYes≤100 chars
SlugYesUnique among non-deleted events
DescriptionNo≤1000 chars
Start dateYes
End dateYesMust be on or after start date
Target goal countYesInteger, >0
Badge nameYes≤50 chars
Badge iconYes≤10 chars (an emoji)

Submitting calls the createSeasonalEvent mutation (Admin::CreateSeasonalEvent interaction), which requires context[:current_user].admin and records the write through Admin::Logged for audit history. The same form (pre-filled) is used to edit an existing event via updateSeasonalEvent.

Activating / Deactivating

The list's toggle calls setSeasonalEventActive (Admin::SetSeasonalEventActive). This flips is_active rather than deleting the row — events are historical once run, and deactivation is a kill switch on new participation and progress accrual, not a retroactive denial of badges already earned. Social::JoinSeasonalEvent and Social::IncrementSeasonalEventProgress both refuse once is_active is false; Social::FinalizeSeasonalEvent deliberately does not check is_active — a deactivated-but-ended event still finalizes normally for whoever already qualified while it was live.

Code: Admin::CreateSeasonalEvent, Admin::UpdateSeasonalEvent, Admin::SetSeasonalEventActive interactions (rails_api/app/interactions/admin/); admin_dashboard/src/views/SeasonalEventsView.vue


For Members: Joining and Tracking Progress

The Banner

SeasonalEventBanner.vue renders at the top of the Communities page when a currently-active event exists and seasonal_events_enabled is on for the viewer — zero DOM otherwise, so it costs nothing while the flag is off. It shows the badge icon, event name, a one-line status ("Finish 5 goals by Apr 30 to earn Spring Reset." → "3 of 5 goals. Ends Apr 30." → "Spring Reset earned.") and a progress bar once the user has joined. The CTA reads "Join the event" before joining, "See your progress" while active, and "See the event" once the user has completed it.

The Detail Page

Tapping the banner (or an event badge, see below) routes to /events/:id (SeasonalEventView.vue, route name SeasonalEvent). It resolves the event via the public seasonalEvent(id:) query — see GraphQL surface below for exactly how that resolves. A user can join or leave from here (joinSeasonalEvent / leaveSeasonalEvent mutations), and progress updates live as qualifying goal activity is logged.

"Event ended, you didn't finish" is now a real state, distinct from "not found." A deep link to a real, ended event the caller didn't finish (or never joined) renders "{name} ended {date}." with a "See your goals" / "Browse communities" CTA depending on whether the caller participated — not the generic "That event isn't here." shown for a genuinely missing, soft-deleted, or flag-off event. status === 'upcoming' still routes to the "not here" state deliberately — there is no designed state for an event that hasn't started yet. Added by OBJ-3964 (PR #3354), after the query itself (PR #3331); both merged after this page's Phase 10 pass, which is why they're called out separately here rather than folded into the surface description above.

Code: ionic_frontend/src/views/SeasonalEventView.vue, ionic_frontend/src/composables/useSeasonalEvent.ts

Joining

Social::JoinSeasonalEvent creates a SeasonalEventParticipant (or restores a soft-deleted one from a prior leave, resetting progress_count to 0) and records a joined_seasonal_event UserAction. Guards refuse a join if the event isn't active, has already ended, or the user is already participating.

joined_seasonal_event is append-only, deliberately not once-per-user. It's in UserAction's APPEND-ONLY enum but intentionally absent from ONCE_PER_USER_ACTIONS — a user can join a different seasonal event later in the year and each join is its own row, unlike a true one-time action.


Badge Derivation

The badge a qualifier earns is derived from the participant row's own event, not from BadgeCatalog. badge_name and badge_icon live as columns on SeasonalEvent itself (set by the admin at creation), not as a lookup key into a separate badge registry. SeasonalBadgeTile.vue on the Achievements page renders badgeIcon/badgeName/eventName straight through as props sourced from me.completedSeasonalEvents ([SeasonalEventParticipant!]!) — there is no BadgeCatalog::BADGE_KEYS entry for a seasonal event badge, and there never needs to be one: a new event with a new badge name/icon needs no code change, just a new SeasonalEvent row.

Finalization

Social::FinalizeSeasonalEvent runs once an event's end_date has passed and is not already processed (guarded by the event's own completion_processed_at). Inside one transaction it marks every participant with progress_count >= target_goal_count as completed_at (if not already set — Social::IncrementSeasonalEventProgress may have set it live during the event window) and stamps completion_processed_at on the event. After the transaction commits, it pushes a UserNotification (kind: :badge) to each newly-completed qualifier, gated by the participant's own notified_at marker so a retry after partial failure never double-notifies.

Code: Social::JoinSeasonalEvent, Social::LeaveSeasonalEvent, Social::IncrementSeasonalEventProgress, Social::FinalizeSeasonalEvent interactions; SeasonalBadgeTile.vue, ionic_frontend/src/views/Achievements.vue


GraphQL surface, as shipped

Three deviations from the original plan are worth calling out explicitly, because a doc that just restated the plan would be wrong here.

communityInsights.upcomingEvents is descoped — still the literal []

The original plan routed seasonal events through communityInsights.upcomingEvents. That field still exists on CommunityInsightsType but resolves to a hardcoded [] (rails_api/app/graphql/resolvers/community_queries.rb) — it was never wired to SeasonalEvent and won't be. Seasonal events reach the UI only through activeSeasonalEvent / activeSeasonalEvents / seasonalEvent(id:). Do not treat upcomingEvents as a seasonal-event entry point. Tracked as OBJ-3955.

activeSeasonalEvent's ordering does not match the UI-SPEC ruling — rollout precondition

The UI-SPEC ruled activeSeasonalEvent should pick the singular in-fact active event by latest start_date, ties broken by latest created_at, then public_id ascending. The shipped resolver (rails_api/app/graphql/resolvers/seasonal_event_queries.rb) instead runs:

ruby
SeasonalEvent.where(is_active: true).active.order(start_date: :asc).first

earliest start_date, no tiebreak at all. Two active events with the same start date resolve to an arbitrary row. Tracked as OBJ-3961; it must land before seasonal_events_enabled goes above 0% (see Rollout status).

Social::RankCommunities#primary_active_seasonal_event (the seasonal-participation ranking term — see Communities § Community Discovery Ranking) independently picks the earliest active event too, so the two agree today. If either ordering changes without the other, the banner can end up citing a different event than the one driving a community's "seasonal_event" reason chip — fix OBJ-3961 and the ranking term together, not separately.

The public seasonalEvent(id:) query

Added after Phase 7 closed (PR #3331, OBJ-3964 Task 1). Resolves by public_id for an event in any status (upcoming, active, or completed) — not just active ones, since a completed event's detail page (badge earned, event over) is still a valid destination. Resolves regardless of is_active too: deactivation is a kill switch on new participation, not a tombstone, so a retired event's detail page (and any badges already earned against it) stays reachable. Returns nil, never a GraphQL error, for an unknown id, a soft-deleted event, or when seasonal_events_enabled is off — the three cases are deliberately indistinguishable to the client, and the frontend maps that nil to the detail page's "That event isn't here" state. A non-nil result for a real, ended event the caller didn't finish drives the separate "event ended, you didn't finish" state instead (OBJ-3964 Task 2, PR #3354) — see The Detail Page above.

Code: Resolvers::SeasonalEventQueries (rails_api/app/graphql/resolvers/seasonal_event_queries.rb) — active_seasonal_event, active_seasonal_events, seasonal_event(id:)


Rollout status

seasonal_events_enabled is registered in PostHog and ionic_frontend/src/lib/featureFlags.ts's FEATURE_FLAGS, currently disabled, 0% rollout. Do not raise it above 0% until OBJ-3961 lands — at any rollout above 0%, two same-day-start active events would show different users (or the same user across the banner and a community's reason chip) different "the" active event, with no tiebreak to make it deterministic. See Feature Flags § Reference for the flag's full lifecycle entry.



Last updated: 2026-09-19 — added the Detail Page's "event ended, you didn't finish" state, distinct from "not found" (OBJ-3964 Tasks 1–2, PRs #3331/#3354). Initial page (2026-09-18) documented v4.63 Phases 6–7 as shipped on master, including the upcomingEvents descope (OBJ-3955) and the activeSeasonalEvent ordering gap blocking rollout (OBJ-3961) (OBJ-3816).

Loading…