Champion Portal Development Sub-Phase 1.8
Status: ✅ COMPLETE (December 23, 2025)
Actual Completion: Admin Dashboard & Engagement ScoringEstimated Effort: 1–2 weeks
Focus: Staff-facing metrics dashboard, activity analytics, exports, notificationsPrerequisites: Phase 1.6 complete (activity tracking infrastructure exists)
Related Documents:
- ../archive/1B-enhancements.md — Original Phase 1B spec (partially absorbed)
- ../../JOBS-TO-BE-DONE.md — Job E2: See the Big Picture
- PHASE-6.md — Advanced reporting (builds on this)
Phase 1.8 completed December 23, 2025. This phase evolved from the original staff metrics dashboard plan into a focused Admin Dashboard & Engagement Scoring System.
Admin Dashboard:
Engagement Scoring System:
Cp::EngagementScoreService — Reusable, extensible service for calculating Champion engagementStaff Metrics Dashboard, Exports, and Notifications:
/champions/stats dashboard with period filters (7d/30d/90d/all)export_champions_stats_path(format: :csv, period: ...)Cp::NotifyAdminsJob + Cp::AdminNotificationMailer#new_champion_signup)Cp::WeeklyDigestJob + Cp::AdminNotificationMailer#weekly_digest)docs/operations/SCHEDULERS.md)Testing:
Documentation:
docs/features/champion_portal/ENGAGEMENT_SCORING.md (297 lines)The items originally listed here as “deferred” (weekly digest, signup notifications, CSV export, charts/trends, district breakdown, and recognition widgets) are implemented in the current codebase. Future enhancements (more event types, deeper segmentation, scheduled reporting flexibility) can build on this in Phase 6.
Phase 1B (Metrics Foundation) was marked as “absorbed” into Phase 1.6, but Phase 1.8 completes the staff-facing layer: a metrics dashboard, exports, and notifications built on cp_activity_events.
This sub-phase includes:
/champions/stats with KPI overview, trends chart, and breakdownsAfter Phase 1.8, the Engagement Team can:
From JOBS-TO-BE-DONE.md:
Job E2: See the Big Picture
“When I’m reporting to leadership or planning strategy, I want to see Champion activity metrics at a glance, so I can demonstrate value and identify opportunities.”
Importance: 🔥 Critical
Current Satisfaction: ❌ Very Low
Opportunity Score: 🎯 High
Current Gap:
The cp_activity_events table is being populated with:
login — Champion logged inprofile_edit — Champion updated profileprofile_view — Champion viewed another profiledirectory_search — Champion searched directorymessage_thread_started — Champion started a conversationmessage_sent — Champion sent a messageBefore Phase 1.8, staff had no visibility into this data (the /champions/stats page was a placeholder).
Staff visibility is now provided via the /champions/stats dashboard and admin digest/notification emails.
| Sub-Phase | Name | Est. Time |
|---|---|---|
| 1.8.1 | Signup Notification Emails | 0.5 days |
| 1.8.2 | Staff Metrics Dashboard | 2–3 days |
| 1.8.3 | District/City Breakdown | 1–2 days |
| 1.8.4 | Exports & Weekly Digest | 1–2 days |
| 1.8.5 | Recognition Features | 1 day |
| 1.8.6 | Invite Flow | 1 day |
| 1.8.7 | Empty District Messaging | 0.5 days |
| 1.8.8 | Archive 1A Update | 0.25 days |
Goal: Notify portal admins and admins immediately when a new Champion signs up.
Deliverables:
Cp::AdminNotificationMailer with new_champion_signup actionEmail Content:
Subject: New Champion Signup: [First Last] — [Needs Verification | Auto-Verified]
Hi [Admin Name],
A new Champion has signed up for the Alumni Champions Portal.
📋 Champion Details:
• Name: [First Last]
• Email: [email@example.com]
• Location: [City, State] ([District Name])
• Signed Up: [Date/Time]
• Sign Up Method: [Email | Google SSO]
🔗 Alumni Match Status:
[IF AUTO-VERIFIED via email match:]
✅ Automatically Verified
• Matched to: [Alumni Name] (BUID: [BUID])
• Match Type: Email address match
• No action required
[IF PENDING VERIFICATION via name match:]
⏳ Needs Verification
• Pending Match: [Alumni Name] (BUID: [BUID])
• Match Type: Name-based (requires staff approval)
• Action: Review and approve in Verification Queue
[IF NO MATCH:]
❓ No Alumni Match Found
• Action: Search manually in Verification Queue
[View in Verification Queue →]
---
You received this email because you are a portal admin for Alumni Lookup.
Implementation:
# app/mailers/cp/admin_notification_mailer.rb
module Cp
class AdminNotificationMailer < ApplicationMailer
def new_champion_signup(champion)
@champion = champion
@admins = User.where(role: [:admin, :portal_admin])
# Determine verification status
@status = if champion.champion_verified?
:auto_verified
elsif champion.pending_buid.present?
:pending_verification
else
:no_match
end
mail(
to: @admins.pluck(:email),
subject: "New Champion Signup: #{champion.display_name} — #{status_label(@status)}"
)
end
private
def status_label(status)
case status
when :auto_verified then "Auto-Verified"
when :pending_verification then "Needs Verification"
else "No Match Found"
end
end
end
end
Trigger Point:
Hook into the email confirmation flow (after Champion confirms email, not on initial form submission):
# In Cp::ConfirmationsController or via callback
after_action :notify_admins_of_new_signup, only: [:show], if: :champion_just_confirmed?
def notify_admins_of_new_signup
Cp::NotifyAdminsJob.perform_later(@champion.id)
end
Tests:
Goal: Replace the “Coming Soon” placeholder with a comprehensive metrics dashboard.
Deliverables:
Champions::StatsController with real activity datacp_activity_events dataDashboard Layout:
┌─────────────────────────────────────────────────────────────────────────┐
│ Champion Portal Statistics [7d ▼] [Export CSV] │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Total │ │ Active │ │ Directory │ │ Messages │ │
│ │ Champions │ │ This Period │ │ Searches │ │ Sent │ │
│ │ 127 │ │ 89 │ │ 156 │ │ 23 │ │
│ │ +5 vs prev │ │ 70% rate │ │ +12% ↑ │ │ +8% ↑ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
├─────────────────────────────────────────────────────────────────────────┤
│ Activity Trend │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ╭──╮ │ │
│ │ ╭╯ ╰╮ ╭──╮ │ │
│ │ ──╯ ╰──╯ ╰── │ │
│ │ Mon Tue Wed Thu Fri Sat Sun │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ [Logins] [Searches] [Messages] [Profile Views] │
├─────────────────────────────────────────────────────────────────────────┤
│ Recent Activity │ Quick Stats │
│ • Sarah J. updated profile — 2h ago │ New This Month: 12 │
│ • Mike T. searched directory — 3h ago │ Verification Rate: 94% │
│ • Lisa R. sent message to Tom — 5h ago │ Profile Completion: 78% │
│ • John D. logged in — 6h ago │ Avg Sessions/Week: 2.3 │
└─────────────────────────────────────────────────────────────────────────┘
Metrics Cards (from cp_activity_events):
| Metric | Query |
|---|---|
| Total Champions | Cp::Champion.verified.count |
| Active This Period | Cp::ActivityEvent.where(occurred_at: period).select(:cp_champion_id).distinct.count |
| Directory Searches | Cp::ActivityEvent.where(event_type: 'directory_search', occurred_at: period).count |
| Messages Sent | Cp::ActivityEvent.where(event_type: 'message_sent', occurred_at: period).count |
| Profile Views | Cp::ActivityEvent.where(event_type: 'profile_view', occurred_at: period).count |
| Logins | Cp::ActivityEvent.where(event_type: 'login', occurred_at: period).count |
Date Range Selector:
# params[:period] = '7d' | '30d' | '90d' | 'all'
def period_range
case params[:period]
when '7d' then 7.days.ago..Time.current
when '30d' then 30.days.ago..Time.current
when '90d' then 90.days.ago..Time.current
else nil # all time
end
end
Activity Trend Chart:
Use Chart.js (already in project via importmap). Line chart showing daily activity count by type.
# Controller prepares chart data
@chart_data = {
labels: (period_start.to_date..Date.current).map(&:to_s),
datasets: [
{
label: 'Logins',
data: daily_counts_for('login'),
borderColor: '#3B82F6'
},
{
label: 'Searches',
data: daily_counts_for('directory_search'),
borderColor: '#10B981'
},
{
label: 'Messages',
data: daily_counts_for('message_sent'),
borderColor: '#F59E0B'
}
]
}
Recent Activity Feed:
Query cp_activity_events and display human-readable actions:
@recent_activity = Cp::ActivityEvent
.includes(:cp_champion)
.order(occurred_at: :desc)
.limit(10)
# View helper
def activity_description(event)
champion = event.cp_champion.display_name
case event.event_type
when 'login' then "#{champion} logged in"
when 'profile_edit' then "#{champion} updated their profile"
when 'profile_view'
target = Cp::Champion.find_by(id: event.metadata['target_champion_id'])&.display_name || 'another Champion'
"#{champion} viewed #{target}'s profile"
when 'directory_search' then "#{champion} searched the directory"
when 'message_sent' then "#{champion} sent a message"
when 'message_thread_started' then "#{champion} started a conversation"
end
end
Service Class:
# app/services/cp/metrics_service.rb
module Cp
class MetricsService
def initialize(period: nil)
@period = period
end
def overview
{
total_champions: Cp::Champion.verified.count,
active_champions: active_champion_count,
directory_searches: event_count('directory_search'),
messages_sent: event_count('message_sent'),
profile_views: event_count('profile_view'),
logins: event_count('login')
}
end
def daily_activity(event_type)
scope = Cp::ActivityEvent.where(event_type: event_type)
scope = scope.where(occurred_at: @period) if @period
scope.group("DATE(occurred_at)")
.order("DATE(occurred_at)")
.count
end
def recent_activity(limit: 10)
Cp::ActivityEvent
.includes(:cp_champion)
.order(occurred_at: :desc)
.limit(limit)
end
private
def active_champion_count
scope = Cp::ActivityEvent.select(:cp_champion_id).distinct
scope = scope.where(occurred_at: @period) if @period
scope.count
end
def event_count(event_type)
scope = Cp::ActivityEvent.where(event_type: event_type)
scope = scope.where(occurred_at: @period) if @period
scope.count
end
end
end
Acceptance Test:
/champions/stats → see real metrics (no “Coming Soon”)Goal: Show Champion distribution and activity by district/city.
Deliverables:
District Breakdown Table:
| District | Champions | Active (30d) | Searches | Messages | Activity Level |
|---|---|---|---|---|---|
| Nashville | 34 | 28 (82%) | 89 | 12 | 🟢 High |
| Atlanta | 18 | 12 (67%) | 45 | 8 | 🟡 Medium |
| Dallas | 12 | 5 (42%) | 18 | 2 | 🟠 Low |
| Portland | 1 | 1 (100%) | 3 | 0 | 🔵 New |
Query:
def district_breakdown
District.joins(:cp_champions)
.where(cp_champions: { verification_status: :champion_verified })
.group('districts.id', 'districts.name', 'districts.state')
.select(
'districts.id',
'districts.name',
'districts.state',
'COUNT(DISTINCT cp_champions.id) as champion_count'
)
.order('champion_count DESC')
.limit(25)
.map do |district|
{
id: district.id,
name: district.name,
state: district.state,
champion_count: district.champion_count,
active_count: active_in_district(district.id),
search_count: searches_in_district(district.id),
message_count: messages_in_district(district.id)
}
end
end
def active_in_district(district_id)
Cp::ActivityEvent
.joins(:cp_champion)
.where(cp_champions: { district_id: district_id })
.where(occurred_at: 30.days.ago..Time.current)
.select(:cp_champion_id)
.distinct
.count
end
Activity Level Indicators:
| Level | Criteria |
|---|---|
| 🟢 High | > 70% active in 30d |
| 🟡 Medium | 40-70% active |
| 🟠 Low | < 40% active |
| 🔵 New | District added in last 30d |
Goal: Enable metric exports and automated email summaries.
Deliverables:
CSV Export:
Export button on /champions/stats page. Generates CSV with:
Metric,Value,Period
Total Champions,127,All Time
Verified This Month,12,December 2025
Active Champions (30d),89,Last 30 Days
Directory Searches (30d),156,Last 30 Days
Messages Sent (30d),23,Last 30 Days
...
District,State,Champions,Active (30d),Searches,Messages
Nashville,TN,34,28,89,12
Atlanta,GA,18,12,45,8
...
Controller:
def export
@service = Cp::MetricsService.new(period: period_range)
respond_to do |format|
format.csv do
send_data Cp::MetricsCsvExporter.new(@service).generate,
filename: "champion-metrics-#{Date.current}.csv",
type: 'text/csv'
end
end
end
Weekly Digest Email:
Subject: Champion Portal Weekly Summary — Dec 16-22, 2025
Hi [Name],
Here's this week's Champion Portal activity:
📊 Key Metrics
• 5 new Champions verified (127 total)
• 89 Champions active this week (70% engagement rate)
• 156 directory searches (+12% vs last week)
• 23 messages sent between Champions
🏙️ Most Active Districts
1. Nashville, TN (34 active Champions)
2. Atlanta, GA (18 active)
3. Dallas, TX (12 active)
🌟 Highlights
• First Champion verified in Portland, OR
• Highest daily activity: Tuesday (47 logins)
• Top Connector: Sarah J. (12 messages sent)
📈 Trends
• Signups: ↑ 15% vs previous week
• Engagement: ↑ 8% vs previous week
View full dashboard: https://alumnilookup.com/champions/stats
---
You received this email because you are a portal admin for Alumni Lookup.
To unsubscribe, update your notification preferences in Settings.
Scheduled Job:
# app/jobs/cp/weekly_digest_job.rb
module Cp
class WeeklyDigestJob < ApplicationJob
queue_as :default
def perform
recipients = User.where(role: [:admin, :portal_admin])
recipients.each do |user|
Cp::AdminNotificationMailer.weekly_digest(user).deliver_later
end
end
end
end
# config/initializers/good_job.rb or sidekiq-scheduler
# Run every Monday at 8:00 AM Central
GoodJob::Cron.set('weekly_champion_digest', cron: '0 8 * * 1', class: 'Cp::WeeklyDigestJob')
Goal: Surface recognition opportunities to encourage Champion engagement.
Per JOBS-TO-BE-DONE.md Job C10 (Feel Recognized):
“When I’ve been active in the community, I want recognition of my contributions, so I feel valued and motivated to continue.”
Deliverables:
Top Connectors (Last 30 Days):
def top_connectors(limit: 5)
Cp::ActivityEvent
.where(event_type: 'message_sent')
.where(occurred_at: 30.days.ago..Time.current)
.group(:cp_champion_id)
.order('COUNT(*) DESC')
.limit(limit)
.count
.map do |champion_id, count|
champion = Cp::Champion.find(champion_id)
{ champion: champion, message_count: count }
end
end
Most Viewed Profiles:
def most_viewed_profiles(limit: 5)
Cp::ActivityEvent
.where(event_type: 'profile_view')
.where(occurred_at: 30.days.ago..Time.current)
.where("metadata->>'target_champion_id' IS NOT NULL")
.group("metadata->>'target_champion_id'")
.order('COUNT(*) DESC')
.limit(limit)
.count
.map do |champion_id, count|
champion = Cp::Champion.find_by(id: champion_id)
{ champion: champion, view_count: count } if champion
end.compact
end
Dashboard Widget:
┌─────────────────────────────────────────┐
│ 🏆 Recognition │
├─────────────────────────────────────────┤
│ Top Connectors (30d) │
│ 1. Sarah J. — 12 messages │
│ 2. Mike T. — 9 messages │
│ 3. Lisa R. — 7 messages │
├─────────────────────────────────────────┤
│ Most Viewed Profiles │
│ 1. John D. — 24 views │
│ 2. Emily K. — 18 views │
│ 3. Tom S. — 15 views │
├─────────────────────────────────────────┤
│ District Leaders │
│ Nashville: Sarah J. (28 activities) │
│ Atlanta: Mike T. (19 activities) │
│ Dallas: Lisa R. (12 activities) │
└─────────────────────────────────────────┘
Future Consideration (Phase 6):
Goal: Enable Champions to invite other Belmont alumni to join the Champion Portal.
Per JOBS-TO-BE-DONE.md — supports C9 (Feel Like I Belong) and overall community growth.
Deliverables:
invite_sent activity eventInvite Link Format:
https://alumnichampions.com/signup?ref=[champion_id]
Invite Page UI:
┌─────────────────────────────────────────────────────────────────────────┐
│ 🎓 Invite a Fellow Bruin │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Know a Belmont alum who'd make a great Champion? │
│ Invite them to join the community! │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Option 1: Share Your Link │ │
│ │ │ │
│ │ https://alumnichampions.com/signup?ref=abc123 [Copy Link] │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Option 2: Send an Email Invite │ │
│ │ │ │
│ │ Email: [_________________________] │ │
│ │ │ │
│ │ Personal note (optional): │ │
│ │ [_____________________________________________] │ │
│ │ │ │
│ │ [Send Invite] │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Invite Email Template:
Subject: [Champion Name] invites you to join Belmont Alumni Champions
Hi there,
[Champion Name], a fellow Belmont alum, thinks you'd be a great addition
to the Alumni Champions community!
[IF PERSONAL NOTE:]
They said: "[Personal note]"
Alumni Champions is a community of engaged Belmont graduates who:
• Connect with fellow Bruins in their city
• Host and attend local alumni gatherings
• Support prospective and current students
• Stay connected to the Belmont family
Join [Champion Name] and [X] other Champions:
[Join Now →]
Once a Bruin, always a Bruin. 🐻
---
This invitation was sent by [Champion Name] ([City]).
Database:
# Track invites sent (optional table, or use activity_events)
# For MVP, just use activity_events with metadata
Cp::ActivityRecorder.record(champion, :invite_sent, {
method: 'email', # or 'link_copy'
recipient_email: email, # if email invite
invite_code: code
})
Acceptance Test:
Goal: Improve dashboard UX when a Champion is the only one (or one of few) in their district.
Current State:
Better Experience:
Dashboard Section When Empty:
┌─────────────────────────────────────────────────────────────────────────┐
│ 🌟 Alumni in [District Name] │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ You're the first Champion in [District Name]! 🎉 │
│ │
│ Help grow the community by inviting Belmont alumni you know │
│ in the area to join. │
│ │
│ [Invite a Fellow Bruin →] │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ Nearby Champions: │
│ • [Nearby District 1] — 3 Champions │
│ • [Nearby District 2] — 1 Champion │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Logic:
district_champions_count == 0district_champions_count < 3 with “Be part of building this community”Deliverables:
Goal: Update the Phase 1A (Quick Connect) archive document to reflect that it was superseded by Phase 1.7 (In-App Messaging).
Current State:
Update Required:
Deliverables:
/champions/statscp_activity_eventstest/mailers/cp/admin_notification_mailer_test.rb)new_champion_signup sends to all portal admins and adminstest/services/cp/metrics_service_test.rb)test/controllers/champions/stats_controller_test.rb)test/jobs/cp/weekly_digest_job_test.rb)After completing Phase 1.8:
app/controllers/champions/roadmap_controller.rb with 1.8 sub-phase| Question | Decision | Notes |
|---|---|---|
| Dashboard location | /champions/stats in Lookup Portal |
Staff already use Lookup Portal for Champion management |
| Notification recipients | All portal_admin + admin users | Configurable per-user in future |
| Weekly digest schedule | Monday 8:00 AM Central | Configurable in future |
| Chart library | Chart.js | Already in project via importmap |
| Real-time vs cached | Cached with 5-min refresh | Real-time would require ActionCable; defer to Phase 6 |
app/mailers/cp/admin_notification_mailer.rbapp/views/cp/admin_notification_mailer/new_champion_signup.html.erbapp/views/cp/admin_notification_mailer/new_champion_signup.text.erbapp/views/cp/admin_notification_mailer/weekly_digest.html.erbapp/views/cp/admin_notification_mailer/weekly_digest.text.erbapp/services/cp/metrics_service.rbapp/services/cp/metrics_csv_exporter.rbapp/jobs/cp/weekly_digest_job.rbapp/jobs/cp/notify_admins_job.rbtest/mailers/cp/admin_notification_mailer_test.rbtest/services/cp/metrics_service_test.rbtest/jobs/cp/weekly_digest_job_test.rbapp/controllers/champions/stats_controller.rb — Replace placeholder with real metricsapp/views/champions/stats/index.html.erb — Full dashboard UIapp/controllers/cp/confirmations_controller.rb — Trigger signup notificationconfig/routes.rb — Add export route