FEATURE: Add seven new metrics to Discourse Rewind reports (#26)

## Summary
Adds seven new metrics to the Discourse Rewind plugin, significantly expanding the types of insights users can get about their annual
activity. These new reports cover temporal patterns, community engagement, plugin integrations, and content analysis.

## New Reports

### 1. Time of Day Activity (`time-of-day-activity`)
- Analyzes user activity by hour in their timezone
- Determines personality type: "early bird" (6-9am), "night owl" (10pm-2am), or "balanced"
- Aggregates posts, chat messages, and page views

### 2. New User Interactions (`new-user-interactions`)
- Tracks veteran mentorship and community building behavior
- Measures likes, replies, and mentions to users who joined this year
- Shows unique new users interacted with and total engagement

### 3. Chat Usage (`chat-usage`)
- Total messages and average message length
- Top 5 favorite channels with message counts
- DM statistics (message count, unique conversations)
- Reactions received on messages

### 4. AI Usage (`ai-usage`)
- Integrates with `discourse-ai` plugin
- Total requests, tokens consumed (request/response breakdown)
- Top 5 most used features and AI models
- Success rate calculation

### 5. Favorite GIFs (`favorite-gifs`)
- Extracts GIFs from posts and chat messages
- Ranks by engagement score (usage × 10 + likes + reactions)
- Supports Giphy, Tenor, and direct GIF URLs
- Shows top 5 GIFs with usage statistics

### 6. Assignments (`assignments`)
- Integrates with `discourse-assign` plugin
- Tracks assignments received and given
- Shows completion rate and pending assignments

### 7. Invites (`invites`)
- Total invites sent and redemption rate
- Impact metrics: invitee posts, topics, and likes created
- Most active invitee identification
- Average trust level of invitees

## Technical Details
- All reports extend `BaseReport` class
- Include proper enablement checks for plugin dependencies
- Use efficient database queries with proper joins and aggregations
- Return `nil` when no relevant data exists

Co-authored-by: Martin Brennan <mjrbrennan@gmail.com>
This commit is contained in:
Rafael dos Santos Silva 2025-10-30 11:18:35 -03:00 committed by GitHub
parent ea11f40e61
commit 93b37e4069
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 624 additions and 3 deletions

View File

@ -0,0 +1,72 @@
# frozen_string_literal: true
# AI usage statistics from discourse-ai plugin
# Shows total usage, favorite features, token consumption, etc.
module DiscourseRewind
module Action
class AiUsage < BaseReport
def call
return if !enabled?
base_query = AiApiAuditLog.where(user_id: user.id).where(created_at: date)
# Get aggregated stats in a single query
stats =
base_query.select(
"COUNT(*) as total_requests",
"COALESCE(SUM(request_tokens), 0) as total_request_tokens",
"COALESCE(SUM(response_tokens), 0) as total_response_tokens",
"COUNT(CASE WHEN response_tokens > 0 THEN 1 END) as successful_requests",
).take
return if stats.total_requests == 0
total_tokens = stats.total_request_tokens + stats.total_response_tokens
success_rate =
(
if stats.total_requests > 0
(stats.successful_requests.to_f / stats.total_requests * 100).round(1)
else
0
end
)
# Most used features (top 5)
feature_usage =
base_query
.group(:feature_name)
.order("COUNT(*) DESC")
.limit(5)
.pluck(:feature_name, Arel.sql("COUNT(*)"))
.to_h
# Most used AI model (top 5)
model_usage =
base_query
.where.not(language_model: nil)
.group(:language_model)
.order("COUNT(*) DESC")
.limit(5)
.pluck(:language_model, Arel.sql("COUNT(*)"))
.to_h
{
data: {
total_requests: stats.total_requests,
total_tokens: total_tokens,
request_tokens: stats.total_request_tokens,
response_tokens: stats.total_response_tokens,
feature_usage: feature_usage,
model_usage: model_usage,
success_rate: success_rate,
},
identifier: "ai-usage",
}
end
def enabled?
defined?(AiApiAuditLog) && SiteSetting.discourse_ai_enabled
end
end
end
end

View File

@ -0,0 +1,62 @@
# frozen_string_literal: true
# Assignment statistics using discourse-assign plugin data
# Shows how many assignments, completed, pending, etc.
module DiscourseRewind
module Action
class Assignments < BaseReport
def call
return if !enabled?
# Assignments made to the user
assignments_scope =
Assignment
.where(assigned_to_id: user.id, assigned_to_type: "User")
.where(created_at: date)
total_assigned = assignments_scope.count
# Completed assignments (topics that were assigned and then closed or unassigned)
completed_count =
assignments_scope
.joins(:topic)
.where(
"topics.closed = true OR assignments.active = false OR assignments.updated_at > assignments.created_at",
)
.distinct
.count
# Currently pending (still open and assigned)
pending_count =
Assignment
.where(assigned_to_id: user.id, assigned_to_type: "User", active: true)
.joins(:topic)
.where(topics: { closed: false })
.count
# Assignments made by the user to others
assigned_by_user =
Assignment
.where(assigned_by_user_id: user.id)
.where(created_at: date)
.count
{
data: {
total_assigned: total_assigned,
completed: completed_count,
pending: pending_count,
assigned_by_user: assigned_by_user,
completion_rate:
total_assigned > 0 ? (completed_count.to_f / total_assigned * 100).round(1) : 0,
},
identifier: "assignments",
}
end
def enabled?
defined?(Assignment) && SiteSetting.assign_enabled
end
end
end
end

View File

@ -0,0 +1,82 @@
# frozen_string_literal: true
# Chat usage statistics
# Shows message counts, favorite channels, DM activity, etc.
module DiscourseRewind
module Action
class ChatUsage < BaseReport
def call
return if !enabled?
messages =
Chat::Message.where(user_id: user.id).where(created_at: date).where(deleted_at: nil)
total_messages = messages.count
return if total_messages == 0
# Get favorite channels (public channels)
channel_usage =
messages
.joins(:chat_channel)
.where(chat_channels: { type: "CategoryChannel" })
.group("chat_channels.id", "chat_channels.name")
.count
.sort_by { |_, count| -count }
.first(5)
.map do |(id, name), count|
{ channel_id: id, channel_name: name, message_count: count }
end
# DM statistics
dm_message_count =
messages.joins(:chat_channel).where(chat_channels: { type: "DirectMessageChannel" }).count
# Unique DM conversations
unique_dm_channels =
messages
.joins(:chat_channel)
.where(chat_channels: { type: "DirectMessageChannel" })
.distinct
.count(:chat_channel_id)
# Messages with reactions received
messages_with_reactions =
Chat::MessageReaction
.joins(:chat_message)
.where(chat_messages: { user_id: user.id })
.where(chat_messages: { created_at: date })
.distinct
.count(:chat_message_id)
# Total reactions received
total_reactions_received =
Chat::MessageReaction
.joins(:chat_message)
.where(chat_messages: { user_id: user.id })
.where(chat_messages: { created_at: date })
.count
# Average message length
avg_message_length =
messages.where("LENGTH(message) > 0").average("LENGTH(message)")&.to_f&.round(1) || 0
{
data: {
total_messages: total_messages,
favorite_channels: channel_usage,
dm_message_count: dm_message_count,
unique_dm_channels: unique_dm_channels,
messages_with_reactions: messages_with_reactions,
total_reactions_received: total_reactions_received,
avg_message_length: avg_message_length,
},
identifier: "chat-usage",
}
end
def enabled?
SiteSetting.chat_enabled
end
end
end
end

View File

@ -0,0 +1,122 @@
# frozen_string_literal: true
# Find the user's most used GIFs in posts and chat
# Ranks by usage count and engagement (likes/reactions)
module DiscourseRewind
module Action
class FavoriteGifs < BaseReport
GIF_URL_PATTERN =
%r{
https?://[^\s]+\.(?:gif|gifv)
|
https?://(?!(?:developers|support|blog)\.) (?:[^/\s]+\.)?giphy\.com/(?!dashboard\b)[^\s]+
|
https?://(?!(?:support)\.) (?:[^/\s]+\.)?tenor\.com/(?!gifapi\b)[^\s]+
}ix
MAX_RESULTS = 5
def call
gif_data = {}
# Get GIFs from posts
post_gifs = extract_gifs_from_posts
post_gifs.each do |url, data|
gif_data[url] ||= { url: url, usage_count: 0, likes: 0, reactions: 0 }
gif_data[url][:usage_count] += data[:count]
gif_data[url][:likes] += data[:likes]
end
# Get GIFs from chat messages if chat is enabled
if SiteSetting.chat_enabled
chat_gifs = extract_gifs_from_chat
chat_gifs.each do |url, data|
gif_data[url] ||= { url: url, usage_count: 0, likes: 0, reactions: 0 }
gif_data[url][:usage_count] += data[:count]
gif_data[url][:reactions] += data[:reactions]
end
end
return if gif_data.empty?
# Sort by engagement score (usage * 10 + likes + reactions)
sorted_gifs =
gif_data
.values
.sort_by { |gif| -(gif[:usage_count] * 10 + gif[:likes] + gif[:reactions]) }
.first(MAX_RESULTS)
{
data: {
favorite_gifs: sorted_gifs,
total_gif_usage: gif_data.values.sum { |g| g[:usage_count] },
},
identifier: "favorite-gifs",
}
end
private
def extract_gifs_from_posts
gif_usage = {}
posts =
Post
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.where("raw ~* ?", gif_sql_pattern)
.select(:id, :raw, :like_count)
posts.each do |post|
gif_urls = post.raw.scan(GIF_URL_PATTERN).uniq.select { |url| content_gif_url?(url) }
gif_urls.each do |url|
gif_usage[url] ||= { count: 0, likes: 0 }
gif_usage[url][:count] += 1
gif_usage[url][:likes] += post.like_count || 0
end
end
gif_usage
end
def extract_gifs_from_chat
gif_usage = {}
messages =
Chat::Message
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.where("message ~* ?", gif_sql_pattern)
.select(:id, :message)
messages.each do |message|
gif_urls =
message.message.scan(GIF_URL_PATTERN).uniq.select { |url| content_gif_url?(url) }
gif_urls.each do |url|
gif_usage[url] ||= { count: 0, reactions: 0 }
gif_usage[url][:count] += 1
# Count reactions on this message
reaction_count = Chat::MessageReaction.where(chat_message_id: message.id).count
gif_usage[url][:reactions] += reaction_count
end
end
gif_usage
end
def gif_sql_pattern
@gif_sql_pattern ||= GIF_URL_PATTERN.source.gsub(/\s+/, "")
end
def content_gif_url?(url)
return true if url.match?(/\.(gif|gifv)(?:\?|$)/i)
return true if url.match?(%r{giphy\.com/(?:gifs?|media|embed|stickers|clips)}i)
return true if url.match?(%r{tenor\.com/(?:view|watch|embed|gif)}i)
false
end
end
end
end

View File

@ -0,0 +1,85 @@
# frozen_string_literal: true
# Invite statistics
# Shows how many users this user invited and the impact of those invitees
module DiscourseRewind
module Action
class Invites < BaseReport
def call
# Get all invites created by this user in the date range
invites = Invite.where(invited_by_id: user.id).where(created_at: date)
total_invites = invites.count
return if total_invites == 0
# Redeemed invites (users who actually joined)
redeemed_count = invites.where.not(redeemed_at: nil).count
# Get the users who were invited (via InvitedUser or redeemed invites)
invited_user_ids = InvitedUser.where(invite: invites).pluck(:user_id).compact
invited_users = User.where(id: invited_user_ids)
# Calculate impact of invitees
invitee_post_count =
Post
.where(user_id: invited_user_ids)
.where(created_at: date)
.where(deleted_at: nil)
.count
invitee_topic_count =
Topic
.where(user_id: invited_user_ids)
.where(created_at: date)
.where(deleted_at: nil)
.count
invitee_like_count =
UserAction
.where(user_id: invited_user_ids)
.where(action_type: UserAction::LIKE)
.where(created_at: date)
.count
# Calculate average trust level of invitees
avg_trust_level = invited_users.average(:trust_level)&.to_f&.round(1) || 0
# Most active invitee
most_active_invitee = nil
if invited_user_ids.any?
most_active_id =
Post
.where(user_id: invited_user_ids)
.where(created_at: date)
.where(deleted_at: nil)
.group(:user_id)
.count
.max_by { |_, count| count }
&.first
if most_active_id
most_active_user = User.find_by(id: most_active_id)
most_active_invitee =
BasicUserSerializer.new(most_active_user, root: false).as_json if most_active_user
end
end
{
data: {
total_invites: total_invites,
redeemed_count: redeemed_count,
redemption_rate:
total_invites > 0 ? (redeemed_count.to_f / total_invites * 100).round(1) : 0,
invitee_post_count: invitee_post_count,
invitee_topic_count: invitee_topic_count,
invitee_like_count: invitee_like_count,
avg_trust_level: avg_trust_level,
most_active_invitee: most_active_invitee,
},
identifier: "invites",
}
end
end
end
end

View File

@ -0,0 +1,85 @@
# frozen_string_literal: true
# Tracks how much this user interacted with new users (created this year)
# Shows veteran mentorship and community building behavior
module DiscourseRewind
module Action
class NewUserInteractions < BaseReport
def call
year_start = Date.new(date.first.year, 1, 1)
# Find users who created accounts this year
new_user_ids =
User
.real
.where("created_at >= ? AND created_at <= ?", year_start, date.last)
.where("id != ?", user.id)
.pluck(:id)
return if new_user_ids.empty?
# Count likes given to new users
likes_scope =
UserAction.where(
acting_user_id: user.id,
user_id: new_user_ids,
action_type: UserAction::WAS_LIKED,
).where(created_at: date)
likes_given = likes_scope.count
liked_user_ids = likes_scope.distinct.pluck(:user_id)
# Count replies to new users' posts
replies_scope =
Post
.joins(
"INNER JOIN posts AS parent_posts ON posts.reply_to_post_number = parent_posts.post_number AND posts.topic_id = parent_posts.topic_id",
)
.where(posts: { user_id: user.id, deleted_at: nil, created_at: date })
.where("parent_posts.user_id": new_user_ids)
replies_to_new_users = replies_scope.count
replied_user_ids = replies_scope.distinct.pluck("parent_posts.user_id")
# Count topics created by user that new users participated in
topics_with_new_users =
Topic
.joins(:posts)
.where(topics: { user_id: user.id, deleted_at: nil })
.where(posts: { user_id: new_user_ids, deleted_at: nil })
.where(topics: { created_at: date })
.distinct
.count
# Count direct messages/mentions to new users
mentions_scope =
Post
.joins(
"INNER JOIN user_actions ON user_actions.target_post_id = posts.id AND user_actions.action_type = #{UserAction::MENTION}",
)
.where(posts: { user_id: user.id, deleted_at: nil, created_at: date })
.where(user_actions: { user_id: new_user_ids })
mentions_to_new_users = mentions_scope.distinct.count
mentioned_user_ids = mentions_scope.distinct.pluck("user_actions.user_id")
# Unique new users interacted with
unique_new_users = (liked_user_ids + replied_user_ids + mentioned_user_ids).uniq.count
total_interactions = likes_given + replies_to_new_users + mentions_to_new_users
return if total_interactions == 0
{
data: {
total_interactions: total_interactions,
likes_given: likes_given,
replies_to_new_users: replies_to_new_users,
mentions_to_new_users: mentions_to_new_users,
topics_with_new_users: topics_with_new_users,
unique_new_users: unique_new_users,
new_users_count: new_user_ids.count,
},
identifier: "new-user-interactions",
}
end
end
end
end

View File

@ -0,0 +1,106 @@
# frozen_string_literal: true
# Time of day activity analysis
# Shows when a user is most active (considering their timezone)
# Determines if they are a night owl or early bird
module DiscourseRewind
module Action
class TimeOfDayActivity < BaseReport
EARLY_BIRD_THRESHOLD = 6..9
NIGHT_OWL_THRESHOLD_PM = 22..23
NIGHT_OWL_THRESHOLD_AM = 0..2
def call
# Get activity by hour of day (in user's timezone)
activity_by_hour = get_activity_by_hour
return if activity_by_hour.empty?
total_activities = activity_by_hour.values.sum
most_active_hour = activity_by_hour.max_by { |_, count| count }&.first
personality = determine_personality(activity_by_hour, total_activities)
{
data: {
activity_by_hour: activity_by_hour,
most_active_hour: most_active_hour,
personality: personality,
total_activities: total_activities,
},
identifier: "time-of-day-activity",
}
end
private
def get_activity_by_hour
# Get user timezone offset
user_timezone = user.user_option&.timezone || "UTC"
quoted_timezone = ActiveRecord::Base.connection.quote(user_timezone)
hour_extract_sql =
Arel.sql(
"EXTRACT(HOUR FROM created_at AT TIME ZONE 'UTC' AT TIME ZONE #{quoted_timezone})::integer",
)
# Initialize hash with all hours
activity = (0..23).to_h { |hour| [hour, 0] }
# Posts created
post_hours =
Post
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.pluck(hour_extract_sql)
.tally
# User visits (page views)
visit_hours =
UserHistory
.where(acting_user_id: user.id)
.where(created_at: date)
.where(action: UserHistory.actions[:page_view])
.pluck(hour_extract_sql)
.tally
# Chat messages if chat is enabled
if SiteSetting.chat_enabled
chat_hours =
Chat::Message
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.pluck(hour_extract_sql)
.tally
chat_hours.each { |hour, count| activity[hour] += count }
end
post_hours.each { |hour, count| activity[hour] += count }
visit_hours.each { |hour, count| activity[hour] += count }
activity
end
def determine_personality(activity_by_hour, total_activities)
return nil if total_activities == 0
early_bird_activity = EARLY_BIRD_THRESHOLD.sum { |hour| activity_by_hour[hour] || 0 }
night_owl_activity =
NIGHT_OWL_THRESHOLD_PM.sum { |hour| activity_by_hour[hour] || 0 } +
NIGHT_OWL_THRESHOLD_AM.sum { |hour| activity_by_hour[hour] || 0 }
early_bird_percentage = (early_bird_activity.to_f / total_activities * 100).round(1)
night_owl_percentage = (night_owl_activity.to_f / total_activities * 100).round(1)
if early_bird_percentage > 20
{ type: "early_bird", percentage: early_bird_percentage }
elsif night_owl_percentage > 20
{ type: "night_owl", percentage: night_owl_percentage }
else
{ type: "balanced", percentage: 0 }
end
end
end
end
end

View File

@ -31,6 +31,13 @@ module DiscourseRewind
Action::BestTopics,
Action::BestPosts,
Action::ActivityCalendar,
Action::TimeOfDayActivity,
Action::NewUserInteractions,
Action::ChatUsage,
Action::AiUsage,
Action::FavoriteGifs,
Action::Assignments,
Action::Invites,
]
model :year

View File

@ -35,10 +35,10 @@ RSpec.describe(DiscourseRewind::FetchReports) do
before { freeze_time DateTime.parse("2021-12-22") }
it "returns the cached reports" do
expect(result.reports.length).to eq(9)
expect(result.reports.length).to eq(16)
allow(DiscourseRewind::Action::TopWords).to receive(:call)
expect(result.reports.length).to eq(9)
expect(result.reports.length).to eq(16)
expect(DiscourseRewind::Action::TopWords).to_not have_received(:call)
end
end
@ -51,7 +51,7 @@ RSpec.describe(DiscourseRewind::FetchReports) do
it "returns the reports" do
allow(DiscourseRewind::Action::TopWords).to receive(:call)
expect(result.reports.length).to eq(9)
expect(result.reports.length).to eq(16)
expect(DiscourseRewind::Action::TopWords).to have_received(:call)
end
end