The Engagement Stats system provides comprehensive analytics and reporting on alumni engagement activities across multiple dimensions and time periods. It’s accessible at /engagement_stats and consists of multiple tabs with different views and calculations.
The engagement stats system analyzes engagement activities using a sophisticated scoring system that combines:
Based on EngagementType::LEVEL_POINTS:
To prevent manipulation through repetitive low-level activities:
Rankings use a “distance” calculation balancing quality and breadth:
Distance = √((score × 1.5)² + (capped_activity_count × 1.0)²)
FiscalYear (app/models/fiscal_year.rb), shared
layer. FiscalYearHelpers and Education::AggregateScope both delegate to it;
the boundary is FiscalYear::START_MONTHFiscalYear.range is inclusive of May 31
(use with date columns / BETWEEN); FiscalYear.half_open_range returns an
exclusive June-1 end (use with >= ? AND < ?, and for timestamps, where an
inclusive end date would drop everything after midnight on May 31)Prior to August 2026 this document said “July 1 – June 30.” That was never what the code did — the boundary has been June 1 in every implementation.
Stats default to degreed alumni only. Three checkboxes allow inclusion of additional populations:
student_status: "pending" and future expected_graduation_yearPopulation selection is persisted in session and applies across all stats tabs. The filter uses SQL UNION in AlumniFilterService#apply_population_filter to combine structurally incompatible scopes. Cache keys include the population selection.
BaseService#cache_key_base)BaseService#eligible_buids is the
memoized set of BUIDs that pass the population/college/year filters and have
activity in the selected range. Everything else derives from it.engagement_activities is indexed on
(engagement_date, buid) and a fiscal year holds a few thousand rows, so it
seeds the expensive filter rather than being filtered by it.filtered_alumni or Education::AggregateScope inside a loop.
That UNION ALL (70K educations + 52K degrees, with a NOT IN) costs ~550ms per
execution. Looping it once per engagement type and once per 1,000-alumni batch
is what made Breakdown take 17s and Analytics 42s against a 15s timeout.group(...) covering all
activity types, not one query per type.Alumni records for the top 50 scorers only; scoring itself runs off
pluck(:buid, :activity_code).test/services/engagement_stats/query_efficiency_test.rb, which
fails if query count starts scaling with engagement types or population size.Purpose: High-level engagement statistics and trends
Key Metrics:
Calculations:
EngagementScoreCalculator for precise scoringPurpose: Detailed charts and advanced metrics
Features:
Performance: Set-based aggregation over the engagement rows in range (~8 queries)
Purpose: Activity breakdown by champion role and level
Display:
/public/)Data Structure:
@activity_data = {
"Champion Role Name" => {
types: [
{
name: "Activity Name",
code: "activity_code",
level: 2,
unique_buids: 150,
descriptions: [
{ description: "Specific activity", count: 50, unique_buids: 45 }
]
}
]
}
}
Purpose: Engagement analysis by demographic segments
Segments:
Purpose: Quadrant analysis based on score vs. activity count
Quadrants:
Visualization: Scatter plot with quadrant boundaries
Purpose: Ranked list of most engaged alumni
Ranking: Based on distance formula combining score and activity count Features: Detailed activity breakdowns per alumnus
Purpose: Analysis of alumni with specific activity combinations
Use Case: Understanding which activities occur together Export: CSV export functionality available
⚠️ All statistics logic MUST be in services, NOT duplicated in the controller.
The EngagementStats:: namespace provides services for each tab. The controller should only:
# ✅ Correct pattern - controller delegates to service
def load_demographics_data
service = EngagementStats::DemographicsService.new(
start_date: @start_date,
end_date: @end_date,
college: @college,
year: @year,
population: @population
)
service.call.each { |key, value| instance_variable_set("@#{key}", value) }
@last_updated = Rails.cache.read("engagement_stats_last_updated") || Time.current
end
| Service | Purpose | Cache Duration |
|---|---|---|
OverviewService |
Goal metrics, engagement counts | 4 hours |
AnalyticsService |
Charts, score distributions | 4 hours |
BreakdownService |
Activity breakdown by role/level | 1 hour |
DemographicsService |
Year/college/major breakdowns | 1 hour |
MatrixService |
Quadrant analysis scatter plot | 1 hour |
ActivityPairsService |
Activity combination analysis | 1 hour |
Key Design Decisions:
Alumni Counting (not Degrees): All services count unique alumni by BUID, not degree records. An alum with multiple degrees appears once per graduation year.
Built-in Caching: Services use BaseService#with_caching - no need for separate fast/comprehensive modes.
Tested Logic: Services have tests in test/services/engagement_stats/. Controller duplicates are NOT tested.
Degree Classification:
A% + B% codes)D%, J%, P% codesEngagementStatsController handles all tab logic with methods:
load_overview_data / load_analytics_data / etc.EngagementScoreCalculator: Core scoring logic with capsTopEngagedAlumniService: Ranking and distance calculationsAlumniFilterService: Filtering by demographicsFiscalYearHelpers: Date range calculationsalumni table with buid primary keyengagement_activities table with buid, activity_code, engagement_dateengagement_types table with code, level, champion_roletab: Active tab (overview, analytics, breakdown, etc.)start_date / end_date: Custom date rangesyear: Fiscal year selectionAlumniFilterService)Integration with AlumniFilterService supports:
TimeoutProtection aborts index at 15 seconds and
redirects with “This report is taking too long to load…”. That message names the
data set as the cause, which has been misleading every time it has fired — the
causes so far have been query shape, not volume. Treat it as a bug report.email_click ≤ 5, event_rsvp ≤ 2) and relies on
EngagementScoreCalculator for precise capping. This is a threshold on cap
strategy, not a cap on how many alumni are processed.Rails.cache.clearPOST /engagement_stats/clear_cache clears all engagement stats cachesincludes and joins/public/ directory