Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5effd054ec | ||
|
|
d9f9e0c461 | ||
|
|
26a77d4c69 | ||
|
|
09ee91ad67 | ||
|
|
f4560c2292 | ||
|
|
bdbac80063 | ||
|
|
1cdd3fd708 | ||
|
|
972cc6e5d9 | ||
|
|
a600db4d06 | ||
|
|
93b37e4069 | ||
|
|
ea11f40e61 | ||
|
|
cdf6f0d0e7 | ||
|
|
09ac87205c | ||
|
|
ca316389b5 | ||
|
|
4b621412ba | ||
|
|
139688358b | ||
|
|
5e2acd9c2d | ||
|
|
ffdd50cc65 | ||
|
|
0b7de95705 | ||
|
|
e090efa809 | ||
|
|
6da3c9388d | ||
|
|
b3f205375a | ||
|
|
824d4a426b | ||
|
|
6a7efb7f0c | ||
|
|
8b1943fb9a | ||
|
|
c7a8c3d30b | ||
|
|
a59d6c7931 | ||
|
|
141140a410 |
@@ -0,0 +1,3 @@
|
|||||||
|
< 3.6.0.beta1-dev: ca316389b51afeb17a697efaff041e425a0deac6
|
||||||
|
< 3.5.0.beta5-dev: cbcbbd2e3316b06c91998c7a0fd493d89ab6d2a5
|
||||||
|
< 3.5.0.beta1-dev: b3f205375a2f12287aa3338d8adc1afa98c4bfbb
|
||||||
|
|||||||
+67
-52
@@ -1,80 +1,95 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
activesupport (7.1.3.3)
|
activesupport (8.0.3)
|
||||||
base64
|
base64
|
||||||
|
benchmark (>= 0.3)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||||
connection_pool (>= 2.2.5)
|
connection_pool (>= 2.2.5)
|
||||||
drb
|
drb
|
||||||
i18n (>= 1.6, < 2)
|
i18n (>= 1.6, < 2)
|
||||||
|
logger (>= 1.4.2)
|
||||||
minitest (>= 5.1)
|
minitest (>= 5.1)
|
||||||
mutex_m
|
securerandom (>= 0.3)
|
||||||
tzinfo (~> 2.0)
|
tzinfo (~> 2.0, >= 2.0.5)
|
||||||
ast (2.4.2)
|
uri (>= 0.13.1)
|
||||||
base64 (0.2.0)
|
ast (2.4.3)
|
||||||
bigdecimal (3.1.8)
|
base64 (0.3.0)
|
||||||
concurrent-ruby (1.2.3)
|
benchmark (0.4.1)
|
||||||
connection_pool (2.4.1)
|
bigdecimal (3.3.0)
|
||||||
drb (2.2.1)
|
concurrent-ruby (1.3.5)
|
||||||
i18n (1.14.5)
|
connection_pool (2.5.4)
|
||||||
|
drb (2.2.3)
|
||||||
|
i18n (1.14.7)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
json (2.7.2)
|
json (2.15.1)
|
||||||
language_server-protocol (3.17.0.3)
|
language_server-protocol (3.17.0.5)
|
||||||
minitest (5.23.1)
|
lint_roller (1.1.0)
|
||||||
mutex_m (0.2.0)
|
logger (1.7.0)
|
||||||
parallel (1.24.0)
|
minitest (5.26.0)
|
||||||
parser (3.3.1.0)
|
parallel (1.27.0)
|
||||||
|
parser (3.3.9.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
racc
|
racc
|
||||||
prettier_print (1.2.1)
|
prettier_print (1.2.1)
|
||||||
racc (1.8.0)
|
prism (1.5.1)
|
||||||
rack (3.0.11)
|
racc (1.8.1)
|
||||||
|
rack (3.2.3)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
regexp_parser (2.9.2)
|
regexp_parser (2.11.3)
|
||||||
rexml (3.3.9)
|
rubocop (1.81.1)
|
||||||
rubocop (1.64.0)
|
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (>= 3.17.0)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
|
lint_roller (~> 1.1.0)
|
||||||
parallel (~> 1.10)
|
parallel (~> 1.10)
|
||||||
parser (>= 3.3.0.2)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 1.8, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rexml (>= 3.2.5, < 4.0)
|
rubocop-ast (>= 1.47.1, < 2.0)
|
||||||
rubocop-ast (>= 1.31.1, < 2.0)
|
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 3.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.31.3)
|
rubocop-ast (1.47.1)
|
||||||
parser (>= 3.3.1.0)
|
parser (>= 3.3.7.2)
|
||||||
rubocop-capybara (2.20.0)
|
prism (~> 1.4)
|
||||||
rubocop (~> 1.41)
|
rubocop-capybara (2.22.1)
|
||||||
rubocop-discourse (3.8.0)
|
lint_roller (~> 1.1)
|
||||||
|
rubocop (~> 1.72, >= 1.72.1)
|
||||||
|
rubocop-discourse (3.13.3)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
rubocop (>= 1.59.0)
|
lint_roller (>= 1.1.0)
|
||||||
rubocop-capybara (>= 2.0.0)
|
rubocop (>= 1.73.2)
|
||||||
rubocop-factory_bot (>= 2.0.0)
|
rubocop-capybara (>= 2.22.0)
|
||||||
rubocop-rails (>= 2.25.0)
|
rubocop-factory_bot (>= 2.27.0)
|
||||||
rubocop-rspec (>= 2.25.0)
|
rubocop-rails (>= 2.30.3)
|
||||||
rubocop-factory_bot (2.25.1)
|
rubocop-rspec (>= 3.0.1)
|
||||||
rubocop (~> 1.41)
|
rubocop-rspec_rails (>= 2.31.0)
|
||||||
rubocop-rails (2.25.0)
|
rubocop-factory_bot (2.27.1)
|
||||||
|
lint_roller (~> 1.1)
|
||||||
|
rubocop (~> 1.72, >= 1.72.1)
|
||||||
|
rubocop-rails (2.33.4)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
|
lint_roller (~> 1.1)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 1.33.0, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop-ast (>= 1.31.1, < 2.0)
|
rubocop-ast (>= 1.44.0, < 2.0)
|
||||||
rubocop-rspec (2.29.2)
|
rubocop-rspec (3.7.0)
|
||||||
rubocop (~> 1.40)
|
lint_roller (~> 1.1)
|
||||||
rubocop-capybara (~> 2.17)
|
rubocop (~> 1.72, >= 1.72.1)
|
||||||
rubocop-factory_bot (~> 2.22)
|
rubocop-rspec_rails (2.31.0)
|
||||||
rubocop-rspec_rails (~> 2.28)
|
lint_roller (~> 1.1)
|
||||||
rubocop-rspec_rails (2.28.3)
|
rubocop (~> 1.72, >= 1.72.1)
|
||||||
rubocop (~> 1.40)
|
rubocop-rspec (~> 3.5)
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
syntax_tree (6.2.0)
|
securerandom (0.4.1)
|
||||||
|
syntax_tree (6.3.0)
|
||||||
prettier_print (>= 1.2.0)
|
prettier_print (>= 1.2.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
unicode-display_width (2.5.0)
|
unicode-display_width (3.2.0)
|
||||||
|
unicode-emoji (~> 4.1)
|
||||||
|
unicode-emoji (4.1.0)
|
||||||
|
uri (1.0.4)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
ruby
|
ruby
|
||||||
@@ -84,4 +99,4 @@ DEPENDENCIES
|
|||||||
syntax_tree
|
syntax_tree
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.5.10
|
2.7.2
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
# DiscourseRewind
|
# DiscourseRewind
|
||||||
|
|
||||||
Display stats on your last year of Discourse usage.
|
## DEPRECATED
|
||||||
|
|
||||||
## Usage
|
This plugin has been moved into Discourse core as at https://github.com/discourse/discourse/pull/36409/
|
||||||
|
|
||||||
Navigate to `/my/activity/rewind`
|
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ module ::DiscourseRewind
|
|||||||
requires_login
|
requires_login
|
||||||
|
|
||||||
def show
|
def show
|
||||||
DiscourseRewind::Rewind::Fetch.call(service_params) do
|
DiscourseRewind::FetchReports.call(service_params) do
|
||||||
on_model_not_found(:year) { raise Discourse::NotFound }
|
on_model_not_found(:year) do
|
||||||
on_model_not_found(:user) { raise Discourse::NotFound }
|
raise Discourse::NotFound.new(nil, custom_message: "discourse_rewind.invalid_year")
|
||||||
on_success do |reports:|
|
|
||||||
@reports = reports
|
|
||||||
render json: MultiJson.dump(reports), status: 200
|
|
||||||
end
|
end
|
||||||
|
on_model_not_found(:reports) do
|
||||||
|
raise Discourse::NotFound.new(nil, custom_message: "discourse_rewind.report_failed")
|
||||||
|
end
|
||||||
|
on_success { |reports:| render json: MultiJson.dump(reports), status: :ok }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# For a GitHub like calendar
|
||||||
|
# https://docs.github.com/assets/cb-35216/mw-1440/images/help/profile/contributions-graph.webp
|
||||||
|
module DiscourseRewind
|
||||||
|
module Action
|
||||||
|
class ActivityCalendar < BaseReport
|
||||||
|
FakeData = {
|
||||||
|
data:
|
||||||
|
(Date.new(2024, 1, 1)..Date.new(2024, 12, 31)).map do |date|
|
||||||
|
{ date:, post_count: rand(0..20), visited: false }
|
||||||
|
end,
|
||||||
|
identifier: "activity-calendar",
|
||||||
|
}
|
||||||
|
|
||||||
|
def call
|
||||||
|
return FakeData if Rails.env.development?
|
||||||
|
|
||||||
|
calendar =
|
||||||
|
Post
|
||||||
|
.unscoped
|
||||||
|
.joins(<<~SQL)
|
||||||
|
RIGHT JOIN
|
||||||
|
generate_series('#{date.first}', '#{date.last}', '1 day'::interval) ON
|
||||||
|
posts.created_at::date = generate_series::date AND
|
||||||
|
posts.user_id = #{user.id} AND
|
||||||
|
posts.deleted_at IS NULL
|
||||||
|
SQL
|
||||||
|
.joins(
|
||||||
|
"LEFT JOIN user_visits ON generate_series::date = visited_at AND user_visits.user_id = #{user.id}",
|
||||||
|
)
|
||||||
|
.select(
|
||||||
|
"generate_series::date as date, count(posts.id) as post_count, COUNT(visited_at) > 0 as visited",
|
||||||
|
)
|
||||||
|
.group("generate_series, user_visits.id")
|
||||||
|
.order("generate_series")
|
||||||
|
.map { |row| { date: row.date, post_count: row.post_count, visited: row.visited } }
|
||||||
|
|
||||||
|
{ data: calendar, identifier: "activity-calendar" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# AI usage statistics from discourse-ai plugin
|
||||||
|
# Shows total usage, favorite features, token consumption, etc.
|
||||||
|
# Uses AiApiRequestStat for efficient aggregation queries
|
||||||
|
module DiscourseRewind
|
||||||
|
module Action
|
||||||
|
class AiUsage < BaseReport
|
||||||
|
FakeData = {
|
||||||
|
data: {
|
||||||
|
total_requests: 247,
|
||||||
|
total_tokens: 156_890,
|
||||||
|
request_tokens: 45_230,
|
||||||
|
response_tokens: 111_660,
|
||||||
|
feature_usage: {
|
||||||
|
"chat_composer_helper" => 89,
|
||||||
|
"post_summarizer" => 56,
|
||||||
|
"semantic_search" => 42,
|
||||||
|
"topic_gist" => 38,
|
||||||
|
"similar_topics" => 22,
|
||||||
|
},
|
||||||
|
model_usage: {
|
||||||
|
"gpt-4" => 123,
|
||||||
|
"claude-3-5-sonnet" => 89,
|
||||||
|
"gpt-3.5-turbo" => 35,
|
||||||
|
},
|
||||||
|
success_rate: 94.7,
|
||||||
|
},
|
||||||
|
identifier: "ai-usage",
|
||||||
|
}
|
||||||
|
|
||||||
|
def call
|
||||||
|
return FakeData if Rails.env.development?
|
||||||
|
return if !enabled?
|
||||||
|
|
||||||
|
base_query = AiApiRequestStat.where(user_id: user.id).where(bucket_date: date)
|
||||||
|
|
||||||
|
# Get aggregated stats in a single query
|
||||||
|
stats =
|
||||||
|
base_query.select(
|
||||||
|
"COALESCE(SUM(usage_count), 0) as total_requests",
|
||||||
|
"COALESCE(SUM(request_tokens), 0) as total_request_tokens",
|
||||||
|
"COALESCE(SUM(response_tokens), 0) as total_response_tokens",
|
||||||
|
"COALESCE(SUM(CASE WHEN response_tokens > 0 THEN usage_count ELSE 0 END), 0) 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("SUM(usage_count) DESC")
|
||||||
|
.limit(5)
|
||||||
|
.pluck(:feature_name, Arel.sql("SUM(usage_count)"))
|
||||||
|
.to_h
|
||||||
|
|
||||||
|
# Most used AI model (top 5)
|
||||||
|
model_usage =
|
||||||
|
base_query
|
||||||
|
.where.not(language_model: nil)
|
||||||
|
.group(:language_model)
|
||||||
|
.order("SUM(usage_count) DESC")
|
||||||
|
.limit(5)
|
||||||
|
.pluck(:language_model, Arel.sql("SUM(usage_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?(AiApiRequestStat) && SiteSetting.discourse_ai_enabled
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# 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
|
||||||
|
FakeData = {
|
||||||
|
data: {
|
||||||
|
total_assigned: 24,
|
||||||
|
completed: 18,
|
||||||
|
pending: 6,
|
||||||
|
assigned_by_user: 15,
|
||||||
|
completion_rate: 75.0,
|
||||||
|
},
|
||||||
|
identifier: "assignments",
|
||||||
|
}
|
||||||
|
|
||||||
|
def call
|
||||||
|
return FakeData if Rails.env.development?
|
||||||
|
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
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscourseRewind
|
||||||
|
module Action
|
||||||
|
class BaseReport < Service::ActionBase
|
||||||
|
option :user
|
||||||
|
option :date
|
||||||
|
|
||||||
|
def call
|
||||||
|
raise NotImplementedError
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.enabled?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscourseRewind
|
||||||
|
module Action
|
||||||
|
class BestPosts < BaseReport
|
||||||
|
FakeData = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
post_number: 5,
|
||||||
|
topic_id: 42,
|
||||||
|
like_count: 23,
|
||||||
|
reply_count: 8,
|
||||||
|
excerpt: "This is a great explanation of how ActiveRecord works under the hood...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
post_number: 12,
|
||||||
|
topic_id: 89,
|
||||||
|
like_count: 19,
|
||||||
|
reply_count: 5,
|
||||||
|
excerpt:
|
||||||
|
"Here's a comprehensive guide to testing Rails applications with RSpec and system tests...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
post_number: 3,
|
||||||
|
topic_id: 156,
|
||||||
|
like_count: 15,
|
||||||
|
reply_count: 12,
|
||||||
|
excerpt:
|
||||||
|
"The key to understanding PostgreSQL performance is looking at your query plans...",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
identifier: "best-posts",
|
||||||
|
}
|
||||||
|
|
||||||
|
def call
|
||||||
|
return FakeData if Rails.env.development?
|
||||||
|
best_posts =
|
||||||
|
Post
|
||||||
|
.where(user_id: user.id)
|
||||||
|
.where(created_at: date)
|
||||||
|
.where(deleted_at: nil)
|
||||||
|
.where("post_number > 1")
|
||||||
|
.order("like_count DESC NULLS LAST, created_at ASC")
|
||||||
|
.limit(3)
|
||||||
|
.select(:post_number, :topic_id, :like_count, :reply_count, :raw, :cooked)
|
||||||
|
.map do |post|
|
||||||
|
{
|
||||||
|
post_number: post.post_number,
|
||||||
|
topic_id: post.topic_id,
|
||||||
|
like_count: post.like_count,
|
||||||
|
reply_count: post.reply_count,
|
||||||
|
excerpt:
|
||||||
|
post.excerpt(200, { strip_links: true, remap_emoji: true, keep_images: true }),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
{ data: best_posts, identifier: "best-posts" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscourseRewind
|
||||||
|
module Action
|
||||||
|
class BestTopics < BaseReport
|
||||||
|
FakeData = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
topic_id: 1,
|
||||||
|
title: "How to get started with Rails",
|
||||||
|
excerpt: "A comprehensive guide to getting started with Ruby on Rails...",
|
||||||
|
yearly_score: 42.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic_id: 2,
|
||||||
|
title: "Best practices for database optimization",
|
||||||
|
excerpt: "Learn how to optimize your database queries for better performance...",
|
||||||
|
yearly_score: 38.2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic_id: 3,
|
||||||
|
title: "Understanding ActiveRecord associations",
|
||||||
|
excerpt: "Deep dive into has_many, belongs_to, and other associations...",
|
||||||
|
yearly_score: 35.7,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
identifier: "best-topics",
|
||||||
|
}
|
||||||
|
|
||||||
|
def call
|
||||||
|
return FakeData if Rails.env.development?
|
||||||
|
best_topics =
|
||||||
|
TopTopic
|
||||||
|
.includes(:topic)
|
||||||
|
.references(:topic)
|
||||||
|
.where(topic: { deleted_at: nil, created_at: date, user_id: user.id })
|
||||||
|
.order("yearly_score DESC NULLS LAST")
|
||||||
|
.limit(3)
|
||||||
|
.pluck(:topic_id, :title, :excerpt, :yearly_score)
|
||||||
|
.map do |topic_id, title, excerpt, yearly_score|
|
||||||
|
{ topic_id: topic_id, title: title, excerpt: excerpt, yearly_score: yearly_score }
|
||||||
|
end
|
||||||
|
|
||||||
|
{ data: best_topics, identifier: "best-topics" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Chat usage statistics
|
||||||
|
# Shows message counts, favorite channels, DM activity, etc.
|
||||||
|
module DiscourseRewind
|
||||||
|
module Action
|
||||||
|
class ChatUsage < BaseReport
|
||||||
|
FakeData = {
|
||||||
|
data: {
|
||||||
|
total_messages: 342,
|
||||||
|
favorite_channels: [
|
||||||
|
{ channel_id: 1, channel_name: "general", message_count: 156 },
|
||||||
|
{ channel_id: 2, channel_name: "tech-talk", message_count: 89 },
|
||||||
|
{ channel_id: 3, channel_name: "random", message_count: 45 },
|
||||||
|
{ channel_id: 4, channel_name: "dev", message_count: 32 },
|
||||||
|
{ channel_id: 5, channel_name: "announcements", message_count: 12 },
|
||||||
|
],
|
||||||
|
dm_message_count: 87,
|
||||||
|
unique_dm_channels: 12,
|
||||||
|
messages_with_reactions: 42,
|
||||||
|
total_reactions_received: 156,
|
||||||
|
avg_message_length: 78.5,
|
||||||
|
},
|
||||||
|
identifier: "chat-usage",
|
||||||
|
}
|
||||||
|
|
||||||
|
def call
|
||||||
|
return FakeData if Rails.env.development?
|
||||||
|
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
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
FakeData = {
|
||||||
|
data: {
|
||||||
|
favorite_gifs: [
|
||||||
|
{
|
||||||
|
url: "https://media.giphy.com/media/111ebonMs90YLu/giphy.gif",
|
||||||
|
usage_count: 12,
|
||||||
|
likes: 45,
|
||||||
|
reactions: 23,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif",
|
||||||
|
usage_count: 8,
|
||||||
|
likes: 32,
|
||||||
|
reactions: 18,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://media1.tenor.com/m/XnODae53zvYAAAAd/joke-stickfigure.gif",
|
||||||
|
usage_count: 7,
|
||||||
|
likes: 28,
|
||||||
|
reactions: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://c.tenor.com/tX_T48A14BwAAAAd/khaby-really.gif",
|
||||||
|
usage_count: 5,
|
||||||
|
likes: 20,
|
||||||
|
reactions: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://media.giphy.com/media/3oriO0OEd9QIDdllqo/giphy.gif",
|
||||||
|
usage_count: 4,
|
||||||
|
likes: 15,
|
||||||
|
reactions: 8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total_gif_usage: 36,
|
||||||
|
},
|
||||||
|
identifier: "favorite-gifs",
|
||||||
|
}
|
||||||
|
|
||||||
|
def call
|
||||||
|
return FakeData if Rails.env.development?
|
||||||
|
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
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Forum Best Friend Forever ranking
|
||||||
|
# Score is informative only, do not show in UI
|
||||||
|
module DiscourseRewind
|
||||||
|
module Action
|
||||||
|
class Fbff < BaseReport
|
||||||
|
MAX_SUMMARY_RESULTS = 50
|
||||||
|
LIKE_SCORE = 1
|
||||||
|
REPLY_SCORE = 10
|
||||||
|
|
||||||
|
FakeData = {
|
||||||
|
data: {
|
||||||
|
fbff: {
|
||||||
|
id: 2,
|
||||||
|
username: "codingpal",
|
||||||
|
name: "Coding Pal",
|
||||||
|
avatar_template: "/letter_avatar_proxy/v4/letter/c/3be4f8/{size}.png",
|
||||||
|
},
|
||||||
|
yourself: {
|
||||||
|
id: 1,
|
||||||
|
username: "you",
|
||||||
|
name: "You",
|
||||||
|
avatar_template: "/letter_avatar_proxy/v4/letter/y/f05b48/{size}.png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
identifier: "fbff",
|
||||||
|
}
|
||||||
|
|
||||||
|
def call
|
||||||
|
return FakeData if Rails.env.development?
|
||||||
|
most_liked_users =
|
||||||
|
like_query(date)
|
||||||
|
.where(acting_user_id: user.id)
|
||||||
|
.group(:user_id)
|
||||||
|
.order("COUNT(*) DESC")
|
||||||
|
.limit(MAX_SUMMARY_RESULTS)
|
||||||
|
.pluck("user_actions.user_id, COUNT(*)")
|
||||||
|
.map { |user_id, count| { user_id => count } }
|
||||||
|
.reduce({}, :merge)
|
||||||
|
|
||||||
|
most_liked_by_users =
|
||||||
|
like_query(date)
|
||||||
|
.where(user: user)
|
||||||
|
.group(:acting_user_id)
|
||||||
|
.order("COUNT(*) DESC")
|
||||||
|
.limit(MAX_SUMMARY_RESULTS)
|
||||||
|
.pluck("acting_user_id, COUNT(*)")
|
||||||
|
.map { |acting_user_id, count| { acting_user_id => count } }
|
||||||
|
.reduce({}, :merge)
|
||||||
|
|
||||||
|
users_who_most_replied_me =
|
||||||
|
post_query(user, date)
|
||||||
|
.where(posts: { user_id: user.id })
|
||||||
|
.group("replies.user_id")
|
||||||
|
.order("COUNT(*) DESC")
|
||||||
|
.limit(MAX_SUMMARY_RESULTS)
|
||||||
|
.pluck("replies.user_id, COUNT(*)")
|
||||||
|
.map { |user_id, count| { user_id => count } }
|
||||||
|
.reduce({}, :merge)
|
||||||
|
|
||||||
|
users_i_most_replied =
|
||||||
|
post_query(user, date)
|
||||||
|
.where("replies.user_id = ?", user.id)
|
||||||
|
.group("posts.user_id")
|
||||||
|
.order("COUNT(*) DESC")
|
||||||
|
.limit(MAX_SUMMARY_RESULTS)
|
||||||
|
.pluck("posts.user_id, COUNT(*)")
|
||||||
|
.map { |user_id, count| { user_id => count } }
|
||||||
|
.reduce({}, :merge)
|
||||||
|
|
||||||
|
#TODO include chat?
|
||||||
|
|
||||||
|
fbffs = [
|
||||||
|
apply_score(most_liked_users, LIKE_SCORE),
|
||||||
|
apply_score(most_liked_by_users, LIKE_SCORE),
|
||||||
|
apply_score(users_who_most_replied_me, REPLY_SCORE),
|
||||||
|
apply_score(users_i_most_replied, REPLY_SCORE),
|
||||||
|
]
|
||||||
|
|
||||||
|
fbff_id =
|
||||||
|
fbffs
|
||||||
|
.flatten
|
||||||
|
.inject { |h1, h2| h1.merge(h2) { |_, v1, v2| v1 + v2 } }
|
||||||
|
&.sort_by { |_, v| -v }
|
||||||
|
&.first
|
||||||
|
&.first
|
||||||
|
|
||||||
|
return if !fbff_id
|
||||||
|
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
fbff: BasicUserSerializer.new(User.find(fbff_id), root: false).as_json,
|
||||||
|
yourself: BasicUserSerializer.new(user, root: false).as_json,
|
||||||
|
},
|
||||||
|
identifier: "fbff",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def post_query(user, date)
|
||||||
|
Post
|
||||||
|
.joins(:topic)
|
||||||
|
.includes(:topic)
|
||||||
|
.where(
|
||||||
|
"posts.post_type IN (?)",
|
||||||
|
Topic.visible_post_types(user, include_moderator_actions: false),
|
||||||
|
)
|
||||||
|
.joins(
|
||||||
|
"INNER JOIN posts replies ON posts.topic_id = replies.topic_id AND posts.reply_to_post_number = replies.post_number",
|
||||||
|
)
|
||||||
|
.joins(
|
||||||
|
"INNER JOIN topics ON replies.topic_id = topics.id AND topics.archetype <> 'private_message'",
|
||||||
|
)
|
||||||
|
.joins(
|
||||||
|
"AND replies.post_type IN (#{Topic.visible_post_types(user, include_moderator_actions: false).join(",")})",
|
||||||
|
)
|
||||||
|
.where("replies.created_at BETWEEN ? AND ?", date.first, date.last)
|
||||||
|
.where("replies.user_id <> posts.user_id")
|
||||||
|
end
|
||||||
|
|
||||||
|
def like_query(date)
|
||||||
|
UserAction
|
||||||
|
.joins(:target_topic, :target_post)
|
||||||
|
.where(created_at: date)
|
||||||
|
.where(action_type: UserAction::WAS_LIKED)
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_score(users, score)
|
||||||
|
users.map { |user_id, count| { user_id => count * score } }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# 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
|
||||||
|
FakeData = {
|
||||||
|
data: {
|
||||||
|
total_invites: 18,
|
||||||
|
redeemed_count: 12,
|
||||||
|
redemption_rate: 66.7,
|
||||||
|
invitee_post_count: 145,
|
||||||
|
invitee_topic_count: 23,
|
||||||
|
invitee_like_count: 89,
|
||||||
|
avg_trust_level: 1.8,
|
||||||
|
most_active_invitee: {
|
||||||
|
id: 42,
|
||||||
|
username: "newbie_123",
|
||||||
|
name: "New User",
|
||||||
|
avatar_template: "/letter_avatar_proxy/v4/letter/n/8c91d9/{size}.png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
identifier: "invites",
|
||||||
|
}
|
||||||
|
|
||||||
|
def call
|
||||||
|
return FakeData if Rails.env.development?
|
||||||
|
# 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("redemption_count > 0").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
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Topics visited grouped by category
|
||||||
|
module DiscourseRewind
|
||||||
|
module Action
|
||||||
|
class MostViewedCategories < BaseReport
|
||||||
|
FakeData = {
|
||||||
|
data: [
|
||||||
|
{ category_id: 1, name: "cats" },
|
||||||
|
{ category_id: 2, name: "dogs" },
|
||||||
|
{ category_id: 3, name: "countries" },
|
||||||
|
{ category_id: 4, name: "management" },
|
||||||
|
],
|
||||||
|
identifier: "most-viewed-categories",
|
||||||
|
}
|
||||||
|
def call
|
||||||
|
return FakeData if Rails.env.development?
|
||||||
|
|
||||||
|
most_viewed_categories =
|
||||||
|
TopicViewItem
|
||||||
|
.joins(:topic)
|
||||||
|
.joins("INNER JOIN categories ON categories.id = topics.category_id")
|
||||||
|
.where(
|
||||||
|
user: user,
|
||||||
|
viewed_at: date,
|
||||||
|
categories: {
|
||||||
|
id: user.guardian.allowed_category_ids,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.group("categories.id, categories.name")
|
||||||
|
.order("COUNT(*) DESC")
|
||||||
|
.limit(4)
|
||||||
|
.pluck("categories.id, categories.name")
|
||||||
|
.map { |category_id, name| { category_id: category_id, name: name } }
|
||||||
|
|
||||||
|
{ data: most_viewed_categories, identifier: "most-viewed-categories" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Topics visited grouped by tag
|
||||||
|
module DiscourseRewind
|
||||||
|
module Action
|
||||||
|
class MostViewedTags < BaseReport
|
||||||
|
FakeData = {
|
||||||
|
data: [
|
||||||
|
{ tag_id: 1, name: "cats" },
|
||||||
|
{ tag_id: 2, name: "dogs" },
|
||||||
|
{ tag_id: 3, name: "countries" },
|
||||||
|
{ tag_id: 4, name: "management" },
|
||||||
|
],
|
||||||
|
identifier: "most-viewed-tags",
|
||||||
|
}
|
||||||
|
|
||||||
|
def call
|
||||||
|
return FakeData if Rails.env.development?
|
||||||
|
|
||||||
|
most_viewed_tags =
|
||||||
|
TopicViewItem
|
||||||
|
.joins(:topic)
|
||||||
|
.joins("INNER JOIN topic_tags ON topic_tags.topic_id = topics.id")
|
||||||
|
.joins("INNER JOIN tags ON tags.id = topic_tags.tag_id")
|
||||||
|
.where(user: user, viewed_at: date, tags: { id: Tag.visible(user.guardian).pluck(:id) })
|
||||||
|
.group("tags.id, tags.name")
|
||||||
|
.order("COUNT(DISTINCT topic_views.topic_id) DESC")
|
||||||
|
.limit(4)
|
||||||
|
.pluck("tags.id, tags.name")
|
||||||
|
.map { |tag_id, name| { tag_id: tag_id, name: name } }
|
||||||
|
|
||||||
|
{ data: most_viewed_tags, identifier: "most-viewed-tags" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 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
|
||||||
|
FakeData = {
|
||||||
|
data: {
|
||||||
|
total_interactions: 127,
|
||||||
|
likes_given: 45,
|
||||||
|
replies_to_new_users: 62,
|
||||||
|
mentions_to_new_users: 20,
|
||||||
|
topics_with_new_users: 8,
|
||||||
|
unique_new_users: 24,
|
||||||
|
new_users_count: 156,
|
||||||
|
},
|
||||||
|
identifier: "new-user-interactions",
|
||||||
|
}
|
||||||
|
|
||||||
|
def call
|
||||||
|
return FakeData if Rails.env.development?
|
||||||
|
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
|
||||||
|
liked_user_ids =
|
||||||
|
UserAction
|
||||||
|
.where(
|
||||||
|
acting_user_id: user.id,
|
||||||
|
user_id: new_user_ids,
|
||||||
|
action_type: UserAction::WAS_LIKED,
|
||||||
|
)
|
||||||
|
.where(created_at: date)
|
||||||
|
.distinct
|
||||||
|
.pluck(:user_id)
|
||||||
|
|
||||||
|
# Count replies to new users' posts
|
||||||
|
replied_user_ids =
|
||||||
|
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)
|
||||||
|
.distinct
|
||||||
|
.pluck("parent_posts.user_id")
|
||||||
|
|
||||||
|
# Count direct mentions to new users
|
||||||
|
mentioned_user_ids =
|
||||||
|
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 })
|
||||||
|
.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
|
||||||
|
|
||||||
|
return if unique_new_users == 0
|
||||||
|
|
||||||
|
{ data: { unique_new_users: unique_new_users }, identifier: "new-user-interactions" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# For a most user / received reactions cards
|
||||||
|
module DiscourseRewind
|
||||||
|
module Action
|
||||||
|
class Reactions < BaseReport
|
||||||
|
FakeData = {
|
||||||
|
data: {
|
||||||
|
post_received_reactions: {
|
||||||
|
"open_mouth" => 2,
|
||||||
|
"cat" => 32,
|
||||||
|
"dog" => 34,
|
||||||
|
"heart" => 45,
|
||||||
|
"grinning" => 82,
|
||||||
|
},
|
||||||
|
post_used_reactions: {
|
||||||
|
"open_mouth" => 2,
|
||||||
|
"cat" => 32,
|
||||||
|
"dog" => 34,
|
||||||
|
"heart" => 45,
|
||||||
|
"grinning" => 82,
|
||||||
|
},
|
||||||
|
chat_used_reactions: {
|
||||||
|
"open_mouth" => 2,
|
||||||
|
"cat" => 32,
|
||||||
|
"dog" => 34,
|
||||||
|
"heart" => 45,
|
||||||
|
"grinning" => 82,
|
||||||
|
},
|
||||||
|
chat_received_reactions: {
|
||||||
|
"open_mouth" => 2,
|
||||||
|
"cat" => 32,
|
||||||
|
"dog" => 34,
|
||||||
|
"heart" => 45,
|
||||||
|
"grinning" => 82,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
identifier: "reactions",
|
||||||
|
}
|
||||||
|
|
||||||
|
def call
|
||||||
|
return FakeData if Rails.env.development?
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
if defined?(DiscourseReactions::Reaction)
|
||||||
|
# This is missing heart reactions (default like)
|
||||||
|
data[:post_used_reactions] = sort_and_limit(
|
||||||
|
DiscourseReactions::Reaction
|
||||||
|
.by_user(user)
|
||||||
|
.where(created_at: date)
|
||||||
|
.group(:reaction_value)
|
||||||
|
.count,
|
||||||
|
)
|
||||||
|
|
||||||
|
data[:post_received_reactions] = sort_and_limit(
|
||||||
|
DiscourseReactions::Reaction
|
||||||
|
.includes(:post)
|
||||||
|
.where(posts: { user_id: user.id })
|
||||||
|
.where(created_at: date)
|
||||||
|
.group(:reaction_value)
|
||||||
|
.count,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if SiteSetting.chat_enabled
|
||||||
|
data[:chat_used_reactions] = sort_and_limit(
|
||||||
|
Chat::MessageReaction.where(user: user).where(created_at: date).group(:emoji).count,
|
||||||
|
)
|
||||||
|
|
||||||
|
data[:chat_received_reactions] = sort_and_limit(
|
||||||
|
Chat::MessageReaction
|
||||||
|
.includes(:chat_message)
|
||||||
|
.where(chat_message: { user_id: user.id })
|
||||||
|
.where(created_at: date)
|
||||||
|
.group(:emoji)
|
||||||
|
.count,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
{ data:, identifier: "reactions" }
|
||||||
|
end
|
||||||
|
|
||||||
|
def enabled?
|
||||||
|
SiteSetting.discourse_reactions_enabled || SiteSetting.chat_enabled
|
||||||
|
end
|
||||||
|
|
||||||
|
def sort_and_limit(reactions)
|
||||||
|
reactions.sort_by { |_, value| -value }.take(5).reverse.to_h
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+97
-110
@@ -3,78 +3,14 @@
|
|||||||
# For showcasing the reading time of a user
|
# For showcasing the reading time of a user
|
||||||
# Should we show book covers or just the names?
|
# Should we show book covers or just the names?
|
||||||
module DiscourseRewind
|
module DiscourseRewind
|
||||||
class Rewind::Action::ReadingTime < Rewind::Action::BaseReport
|
module Action
|
||||||
FakeData = {
|
class ReadingTime < BaseReport
|
||||||
data: {
|
POPULAR_BOOKS = {
|
||||||
reading_time: 2_880_000,
|
|
||||||
book: "The Combined Cosmere works + Wheel of Time",
|
|
||||||
isbn: "978-0812511819",
|
|
||||||
series: true,
|
|
||||||
},
|
|
||||||
identifier: "reading-time",
|
|
||||||
}
|
|
||||||
|
|
||||||
def call
|
|
||||||
return FakeData if Rails.env.development?
|
|
||||||
|
|
||||||
reading_time = UserVisit.where(user_id: user.id).where(visited_at: date).sum(:time_read)
|
|
||||||
book = best_book_fit(reading_time)
|
|
||||||
|
|
||||||
return if book.nil?
|
|
||||||
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
reading_time: reading_time,
|
|
||||||
book: book[:title],
|
|
||||||
isbn: book[:isbn],
|
|
||||||
series: book[:series],
|
|
||||||
},
|
|
||||||
identifier: "reading-time",
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def popular_books
|
|
||||||
{
|
|
||||||
"The Hunger Games" => {
|
|
||||||
reading_time: 19_740,
|
|
||||||
isbn: "978-0439023481",
|
|
||||||
series: false,
|
|
||||||
},
|
|
||||||
"The Metamorphosis" => {
|
"The Metamorphosis" => {
|
||||||
reading_time: 3120,
|
reading_time: 3120,
|
||||||
isbn: "978-0553213690",
|
isbn: "978-0553213690",
|
||||||
series: false,
|
series: false,
|
||||||
},
|
},
|
||||||
"To Kill a Mockingbird" => {
|
|
||||||
reading_time: 22_800,
|
|
||||||
isbn: "978-0061120084",
|
|
||||||
series: false,
|
|
||||||
},
|
|
||||||
"Pride and Prejudice" => {
|
|
||||||
reading_time: 25_200,
|
|
||||||
isbn: "978-1503290563",
|
|
||||||
series: false,
|
|
||||||
},
|
|
||||||
"1984" => {
|
|
||||||
reading_time: 16_800,
|
|
||||||
isbn: "978-0451524935",
|
|
||||||
series: false,
|
|
||||||
},
|
|
||||||
"The Lord of the Rings" => {
|
|
||||||
reading_time: 108_000,
|
|
||||||
isbn: "978-0544003415",
|
|
||||||
series: true,
|
|
||||||
},
|
|
||||||
"Harry Potter and the Sorcerer's Stone" => {
|
|
||||||
reading_time: 24_600,
|
|
||||||
isbn: "978-0590353427",
|
|
||||||
series: true,
|
|
||||||
},
|
|
||||||
"The Great Gatsby" => {
|
|
||||||
reading_time: 12_600,
|
|
||||||
isbn: "978-0743273565",
|
|
||||||
series: false,
|
|
||||||
},
|
|
||||||
"The Little Prince" => {
|
"The Little Prince" => {
|
||||||
reading_time: 5400,
|
reading_time: 5400,
|
||||||
isbn: "978-0156012195",
|
isbn: "978-0156012195",
|
||||||
@@ -85,14 +21,19 @@ module DiscourseRewind
|
|||||||
isbn: "978-0451526342",
|
isbn: "978-0451526342",
|
||||||
series: false,
|
series: false,
|
||||||
},
|
},
|
||||||
"The Catcher in the Rye" => {
|
"The Alchemist" => {
|
||||||
reading_time: 18_000,
|
reading_time: 10_800,
|
||||||
isbn: "978-0316769488",
|
isbn: "978-0061122415",
|
||||||
series: false,
|
series: false,
|
||||||
},
|
},
|
||||||
"Jane Eyre" => {
|
"The Great Gatsby" => {
|
||||||
reading_time: 34_200,
|
reading_time: 12_600,
|
||||||
isbn: "978-0141441146",
|
isbn: "978-0743273565",
|
||||||
|
series: false,
|
||||||
|
},
|
||||||
|
"The Hitchhiker's Guide to the Galaxy" => {
|
||||||
|
reading_time: 12_600,
|
||||||
|
isbn: "978-0345391803",
|
||||||
series: false,
|
series: false,
|
||||||
},
|
},
|
||||||
"Fahrenheit 451" => {
|
"Fahrenheit 451" => {
|
||||||
@@ -100,40 +41,70 @@ module DiscourseRewind
|
|||||||
isbn: "978-1451673319",
|
isbn: "978-1451673319",
|
||||||
series: false,
|
series: false,
|
||||||
},
|
},
|
||||||
|
"And Then There Were None" => {
|
||||||
|
reading_time: 16_200,
|
||||||
|
isbn: "978-0062073488",
|
||||||
|
series: false,
|
||||||
|
},
|
||||||
|
"1984" => {
|
||||||
|
reading_time: 16_800,
|
||||||
|
isbn: "978-0451524935",
|
||||||
|
series: false,
|
||||||
|
},
|
||||||
|
"The Catcher in the Rye" => {
|
||||||
|
reading_time: 18_000,
|
||||||
|
isbn: "978-0316769488",
|
||||||
|
series: false,
|
||||||
|
},
|
||||||
|
"The Hunger Games" => {
|
||||||
|
reading_time: 19_740,
|
||||||
|
isbn: "978-0439023481",
|
||||||
|
series: false,
|
||||||
|
},
|
||||||
|
"To Kill a Mockingbird" => {
|
||||||
|
reading_time: 22_800,
|
||||||
|
isbn: "978-0061120084",
|
||||||
|
series: false,
|
||||||
|
},
|
||||||
|
"Harry Potter and the Sorcerer's Stone" => {
|
||||||
|
reading_time: 24_600,
|
||||||
|
isbn: "978-0590353427",
|
||||||
|
series: true,
|
||||||
|
},
|
||||||
|
"Pride and Prejudice" => {
|
||||||
|
reading_time: 25_200,
|
||||||
|
isbn: "978-1503290563",
|
||||||
|
series: false,
|
||||||
|
},
|
||||||
"The Hobbit" => {
|
"The Hobbit" => {
|
||||||
reading_time: 27_000,
|
reading_time: 27_000,
|
||||||
isbn: "978-0547928227",
|
isbn: "978-0547928227",
|
||||||
series: false,
|
series: false,
|
||||||
},
|
},
|
||||||
"The Da Vinci Code" => {
|
|
||||||
reading_time: 37_800,
|
|
||||||
isbn: "978-0307474278",
|
|
||||||
series: false,
|
|
||||||
},
|
|
||||||
"Little Women" => {
|
"Little Women" => {
|
||||||
reading_time: 30_000,
|
reading_time: 30_000,
|
||||||
isbn: "978-0147514011",
|
isbn: "978-0147514011",
|
||||||
series: false,
|
series: false,
|
||||||
},
|
},
|
||||||
|
"Jane Eyre" => {
|
||||||
|
reading_time: 34_200,
|
||||||
|
isbn: "978-0141441146",
|
||||||
|
series: false,
|
||||||
|
},
|
||||||
|
"The Da Vinci Code" => {
|
||||||
|
reading_time: 37_800,
|
||||||
|
isbn: "978-0307474278",
|
||||||
|
series: false,
|
||||||
|
},
|
||||||
"One Hundred Years of Solitude" => {
|
"One Hundred Years of Solitude" => {
|
||||||
reading_time: 46_800,
|
reading_time: 46_800,
|
||||||
isbn: "978-0060883287",
|
isbn: "978-0060883287",
|
||||||
series: false,
|
series: false,
|
||||||
},
|
},
|
||||||
"And Then There Were None" => {
|
"The Lord of the Rings" => {
|
||||||
reading_time: 16_200,
|
reading_time: 108_000,
|
||||||
isbn: "978-0062073488",
|
isbn: "978-0544003415",
|
||||||
series: false,
|
series: true,
|
||||||
},
|
|
||||||
"The Alchemist" => {
|
|
||||||
reading_time: 10_800,
|
|
||||||
isbn: "978-0061122415",
|
|
||||||
series: false,
|
|
||||||
},
|
|
||||||
"The Hitchhiker's Guide to the Galaxy" => {
|
|
||||||
reading_time: 12_600,
|
|
||||||
isbn: "978-0345391803",
|
|
||||||
series: false,
|
|
||||||
},
|
},
|
||||||
"The Complete works of Shakespeare" => {
|
"The Complete works of Shakespeare" => {
|
||||||
reading_time: 180_000,
|
reading_time: 180_000,
|
||||||
@@ -150,7 +121,7 @@ module DiscourseRewind
|
|||||||
isbn: "978-0765348821",
|
isbn: "978-0765348821",
|
||||||
series: true,
|
series: true,
|
||||||
},
|
},
|
||||||
"Terry Pratchett’s Discworld series" => {
|
"Terry Pratchett's Discworld series" => {
|
||||||
reading_time: 1_440_000,
|
reading_time: 1_440_000,
|
||||||
isbn: "978-9123684458",
|
isbn: "978-9123684458",
|
||||||
series: true,
|
series: true,
|
||||||
@@ -171,30 +142,46 @@ module DiscourseRewind
|
|||||||
series: true,
|
series: true,
|
||||||
},
|
},
|
||||||
}.symbolize_keys
|
}.symbolize_keys
|
||||||
end
|
|
||||||
|
|
||||||
def best_book_fit(reading_time)
|
FakeData = {
|
||||||
reading_time_rest = reading_time
|
data: {
|
||||||
books = []
|
reading_time: 2_880_000,
|
||||||
|
book: "The Combined Cosmere works + Wheel of Time",
|
||||||
|
isbn: "978-0812511819",
|
||||||
|
series: true,
|
||||||
|
},
|
||||||
|
identifier: "reading-time",
|
||||||
|
}
|
||||||
|
|
||||||
while reading_time_rest > 0
|
def call
|
||||||
best_fit = popular_books.min_by { |_, v| (v[:reading_time] - reading_time_rest).abs }
|
return FakeData if Rails.env.development?
|
||||||
break if best_fit.nil?
|
|
||||||
|
|
||||||
books << best_fit.first
|
reading_time = UserVisit.where(user_id: user.id).where(visited_at: date).sum(:time_read)
|
||||||
reading_time_rest -= best_fit.last[:reading_time]
|
book = best_book_fit(reading_time)
|
||||||
|
|
||||||
|
return if book.nil?
|
||||||
|
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
reading_time: reading_time,
|
||||||
|
book: book[:title].to_s,
|
||||||
|
isbn: book[:isbn],
|
||||||
|
series: book[:series],
|
||||||
|
},
|
||||||
|
identifier: "reading-time",
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
return if books.empty?
|
def best_book_fit(reading_time)
|
||||||
|
best_fit =
|
||||||
|
POPULAR_BOOKS
|
||||||
|
.select { |_, v| v[:reading_time] > reading_time }
|
||||||
|
.min_by { |_, v| v[:reading_time] }
|
||||||
|
|
||||||
book_title =
|
return if best_fit.nil?
|
||||||
books.group_by { |book| book }.transform_values(&:count).max_by { |_, count| count }.first
|
|
||||||
|
|
||||||
{
|
{ title: best_fit.first, isbn: best_fit.last[:isbn], series: best_fit.last[:series] }
|
||||||
title: book_title,
|
end
|
||||||
isbn: popular_books[book_title][:isbn],
|
|
||||||
series: popular_books[book_title][:series],
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
FakeData = {
|
||||||
|
data: {
|
||||||
|
activity_by_hour: {
|
||||||
|
0 => 12,
|
||||||
|
1 => 8,
|
||||||
|
2 => 5,
|
||||||
|
3 => 2,
|
||||||
|
4 => 1,
|
||||||
|
5 => 3,
|
||||||
|
6 => 8,
|
||||||
|
7 => 15,
|
||||||
|
8 => 25,
|
||||||
|
9 => 32,
|
||||||
|
10 => 28,
|
||||||
|
11 => 24,
|
||||||
|
12 => 22,
|
||||||
|
13 => 20,
|
||||||
|
14 => 26,
|
||||||
|
15 => 30,
|
||||||
|
16 => 28,
|
||||||
|
17 => 22,
|
||||||
|
18 => 18,
|
||||||
|
19 => 16,
|
||||||
|
20 => 14,
|
||||||
|
21 => 18,
|
||||||
|
22 => 22,
|
||||||
|
23 => 15,
|
||||||
|
},
|
||||||
|
most_active_hour: 9,
|
||||||
|
personality: {
|
||||||
|
type: "early_bird",
|
||||||
|
percentage: 28.5,
|
||||||
|
},
|
||||||
|
total_activities: 414,
|
||||||
|
},
|
||||||
|
identifier: "time-of-day-activity",
|
||||||
|
}
|
||||||
|
|
||||||
|
def call
|
||||||
|
return FakeData if Rails.env.development?
|
||||||
|
# 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
|
||||||
+24
-17
@@ -1,23 +1,23 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module DiscourseRewind
|
module DiscourseRewind
|
||||||
class Rewind::Action::TopWords < Rewind::Action::BaseReport
|
module Action
|
||||||
FakeData = {
|
class TopWords < BaseReport
|
||||||
data: [
|
FakeData = {
|
||||||
{ word: "what", score: 100 },
|
data: [
|
||||||
{ word: "have", score: 90 },
|
{ word: "seven", score: 100 },
|
||||||
{ word: "you", score: 80 },
|
{ word: "longest", score: 90 },
|
||||||
{ word: "overachieved", score: 70 },
|
{ word: "you", score: 80 },
|
||||||
{ word: "this", score: 60 },
|
{ word: "overachieved", score: 70 },
|
||||||
{ word: "week", score: 50 },
|
{ word: "assume", score: 60 },
|
||||||
],
|
],
|
||||||
identifier: "top-words",
|
identifier: "top-words",
|
||||||
}
|
}
|
||||||
|
|
||||||
def call
|
def call
|
||||||
return FakeData if Rails.env.development?
|
return FakeData if Rails.env.development?
|
||||||
|
|
||||||
words = DB.query(<<~SQL, user_id: user.id, date_start: date.first, date_end: date.last)
|
words = DB.query(<<~SQL, user_id: user.id, date_start: date.first, date_end: date.last)
|
||||||
WITH popular_words AS (
|
WITH popular_words AS (
|
||||||
SELECT
|
SELECT
|
||||||
*
|
*
|
||||||
@@ -78,9 +78,16 @@ module DiscourseRewind
|
|||||||
LIMIT 100
|
LIMIT 100
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
word_score = words.map { { word: _1.original_word, score: _1.ndoc + _1.nentry } }
|
word_score =
|
||||||
|
words
|
||||||
|
.map do |word_data|
|
||||||
|
{ word: word_data.original_word, score: word_data.ndoc + word_data.nentry }
|
||||||
|
end
|
||||||
|
.sort_by! { |w| -w[:score] }
|
||||||
|
.take(5)
|
||||||
|
|
||||||
{ data: word_score, identifier: "top-words" }
|
{ data: word_score, identifier: "top-words" }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscourseRewind
|
||||||
|
module Action
|
||||||
|
class WritingAnalysis < BaseReport
|
||||||
|
FakeData = {
|
||||||
|
data: {
|
||||||
|
total_words: 45_230,
|
||||||
|
total_posts: 197,
|
||||||
|
average_post_length: 230,
|
||||||
|
readability_score: 65.4,
|
||||||
|
},
|
||||||
|
identifier: "writing-analysis",
|
||||||
|
}
|
||||||
|
|
||||||
|
def call
|
||||||
|
return FakeData if Rails.env.development?
|
||||||
|
|
||||||
|
total_words =
|
||||||
|
DB.query_single(<<~SQL, user_id: user.id, date_start: date.first, date_end: date.last)
|
||||||
|
SELECT SUM(word_count) FROM posts
|
||||||
|
WHERE user_id = :user_id
|
||||||
|
AND created_at BETWEEN :date_start AND :date_end
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
SQL
|
||||||
|
|
||||||
|
post_count =
|
||||||
|
DB.query_single(<<~SQL, user_id: user.id, date_start: date.first, date_end: date.last)
|
||||||
|
SELECT COUNT(*) FROM posts
|
||||||
|
WHERE user_id = :user_id
|
||||||
|
AND created_at BETWEEN :date_start AND :date_end
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
SQL
|
||||||
|
|
||||||
|
average_post_length =
|
||||||
|
post_count.first > 0 ? (total_words.first.to_f / post_count.first).round(2) : 0
|
||||||
|
|
||||||
|
# Calculated using the Flesch Reading Ease formula,
|
||||||
|
# with an approximation for syllables since this can
|
||||||
|
# be tricky to get right in SQL.
|
||||||
|
#
|
||||||
|
# Tries to handle short sentences or ones without delmiters
|
||||||
|
# and ending with emojis by treating them as a single sentence.
|
||||||
|
readability_score =
|
||||||
|
DB.query_single(<<~SQL, user_id: user.id, start: date.first, end: date.last)
|
||||||
|
WITH cleaned AS (
|
||||||
|
SELECT
|
||||||
|
p.id AS post_id,
|
||||||
|
p.user_id,
|
||||||
|
p.created_at,
|
||||||
|
p.word_count,
|
||||||
|
regexp_replace(p.cooked, '<[^>]+>', ' ', 'g') AS plain
|
||||||
|
FROM posts p
|
||||||
|
WHERE p.user_id = :user_id
|
||||||
|
AND p.created_at BETWEEN :start AND :end
|
||||||
|
),
|
||||||
|
metrics AS (
|
||||||
|
SELECT
|
||||||
|
post_id,
|
||||||
|
user_id,
|
||||||
|
created_at,
|
||||||
|
plain,
|
||||||
|
word_count AS words,
|
||||||
|
regexp_count(plain, '[.!?;:](\s|$)') AS sentences_raw,
|
||||||
|
regexp_count(lower(plain), '[aeiouy]+') AS syllables
|
||||||
|
FROM cleaned
|
||||||
|
),
|
||||||
|
scores AS (
|
||||||
|
SELECT
|
||||||
|
post_id,
|
||||||
|
user_id,
|
||||||
|
created_at,
|
||||||
|
words,
|
||||||
|
syllables,
|
||||||
|
plain,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN sentences_raw = 0 AND words > 5 THEN 1
|
||||||
|
ELSE sentences_raw
|
||||||
|
END AS sentences_fixed,
|
||||||
|
|
||||||
|
-- Flesch Reading Ease formula
|
||||||
|
CASE
|
||||||
|
WHEN words = 0 THEN NULL
|
||||||
|
WHEN (CASE WHEN sentences_raw = 0 AND words > 5 THEN 1 ELSE sentences_raw END) = 0 THEN NULL
|
||||||
|
ELSE (
|
||||||
|
206.835
|
||||||
|
- 1.015 * (
|
||||||
|
words::float /
|
||||||
|
(CASE WHEN sentences_raw = 0 AND words > 5 THEN 1 ELSE sentences_raw END)
|
||||||
|
)
|
||||||
|
- 84.6 * (syllables::float / words)
|
||||||
|
)
|
||||||
|
END AS readability_score
|
||||||
|
|
||||||
|
FROM metrics
|
||||||
|
)
|
||||||
|
SELECT AVG(readability_score) AS avg_readability_score
|
||||||
|
FROM scores
|
||||||
|
GROUP BY user_id;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
total_words: total_words.first,
|
||||||
|
total_posts: post_count.first,
|
||||||
|
average_post_length: average_post_length,
|
||||||
|
readability_score: readability_score.first,
|
||||||
|
},
|
||||||
|
identifier: "writing-analysis",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscourseRewind
|
||||||
|
# Service responsible to fetch a rewind for a username/year.
|
||||||
|
#
|
||||||
|
# @example
|
||||||
|
# ::DiscourseRewind::Rewind::Fetch.call(
|
||||||
|
# guardian: guardian,
|
||||||
|
# params: { year: 2023, username: 'codinghorror' }
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
class FetchReports
|
||||||
|
include Service::Base
|
||||||
|
|
||||||
|
# @!method self.call(guardian:, params:)
|
||||||
|
# @param [Guardian] guardian
|
||||||
|
# @param [Hash] params
|
||||||
|
# @option params [Integer] :year of the rewind
|
||||||
|
# @option params [Integer] :username of the rewind
|
||||||
|
# @return [Service::Base::Context]
|
||||||
|
|
||||||
|
CACHE_DURATION = Rails.env.development? ? 10.seconds : 5.minutes
|
||||||
|
|
||||||
|
# order matters
|
||||||
|
REPORTS = [
|
||||||
|
Action::TopWords,
|
||||||
|
Action::ReadingTime,
|
||||||
|
Action::WritingAnalysis,
|
||||||
|
Action::Reactions,
|
||||||
|
Action::Fbff,
|
||||||
|
Action::MostViewedTags,
|
||||||
|
Action::MostViewedCategories,
|
||||||
|
Action::BestTopics,
|
||||||
|
Action::BestPosts,
|
||||||
|
Action::ActivityCalendar,
|
||||||
|
Action::TimeOfDayActivity,
|
||||||
|
Action::NewUserInteractions,
|
||||||
|
Action::ChatUsage,
|
||||||
|
Action::AiUsage,
|
||||||
|
Action::FavoriteGifs,
|
||||||
|
Action::Assignments,
|
||||||
|
Action::Invites,
|
||||||
|
]
|
||||||
|
|
||||||
|
model :year
|
||||||
|
model :date
|
||||||
|
model :reports
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def fetch_year
|
||||||
|
current_date = Time.zone.now
|
||||||
|
current_month = current_date.month
|
||||||
|
current_year = current_date.year
|
||||||
|
|
||||||
|
case current_month
|
||||||
|
when 1
|
||||||
|
current_year - 1
|
||||||
|
when 12
|
||||||
|
current_year
|
||||||
|
else
|
||||||
|
# Otherwise it's impossible to test in browser unless you're
|
||||||
|
# in December or January
|
||||||
|
if Rails.env.development?
|
||||||
|
current_year
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_date(params:, year:)
|
||||||
|
Date.new(year).all_year
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_reports(date:, guardian:, year:)
|
||||||
|
key = "rewind:#{guardian.user.username}:#{year}"
|
||||||
|
reports = Discourse.redis.get(key)
|
||||||
|
|
||||||
|
if !reports
|
||||||
|
reports =
|
||||||
|
REPORTS.map { |report| report.call(date:, user: guardian.user, guardian:) }.compact
|
||||||
|
Discourse.redis.setex(key, CACHE_DURATION, MultiJson.dump(reports))
|
||||||
|
else
|
||||||
|
reports = MultiJson.load(reports, symbolize_keys: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
reports
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
# For a GitHub like calendar
|
|
||||||
# https://docs.github.com/assets/cb-35216/mw-1440/images/help/profile/contributions-graph.webp
|
|
||||||
module DiscourseRewind
|
|
||||||
class Rewind::Action::ActivityCalendar < Rewind::Action::BaseReport
|
|
||||||
FakeData = {
|
|
||||||
data:
|
|
||||||
(Date.new(2024, 1, 1)..Date.new(2024, 12, 31)).map do |date|
|
|
||||||
{ date: date.strftime("%Y-%m-%d"), post_count: rand(0..20), visited: false }
|
|
||||||
end,
|
|
||||||
identifier: "activity-calendar",
|
|
||||||
}
|
|
||||||
|
|
||||||
def call
|
|
||||||
return FakeData if Rails.env.development?
|
|
||||||
|
|
||||||
calendar =
|
|
||||||
Post
|
|
||||||
.unscoped
|
|
||||||
.joins(<<~SQL)
|
|
||||||
RIGHT JOIN
|
|
||||||
generate_series('#{date.first}', '#{date.last}', '1 day'::interval) ON
|
|
||||||
posts.created_at::date = generate_series::date AND
|
|
||||||
posts.user_id = #{user.id} AND
|
|
||||||
posts.deleted_at IS NULL
|
|
||||||
SQL
|
|
||||||
.joins(
|
|
||||||
"LEFT JOIN user_visits ON generate_series::date = visited_at AND user_visits.user_id = #{user.id}",
|
|
||||||
)
|
|
||||||
.select(
|
|
||||||
"generate_series::date as date, count(posts.id) as post_count, COUNT(visited_at) > 0 as visited",
|
|
||||||
)
|
|
||||||
.group("generate_series, user_visits.id")
|
|
||||||
.order("generate_series")
|
|
||||||
.map do |row|
|
|
||||||
{
|
|
||||||
date: row.date.strftime("%Y-%m-%d"),
|
|
||||||
post_count: row.post_count,
|
|
||||||
visited: row.visited,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
{ data: calendar, identifier: "activity-calendar" }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module DiscourseRewind
|
|
||||||
class Rewind::Action::BaseReport < Service::ActionBase
|
|
||||||
option :user
|
|
||||||
option :date
|
|
||||||
|
|
||||||
def call
|
|
||||||
raise NotImplementedError
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.enabled?
|
|
||||||
true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module DiscourseRewind
|
|
||||||
class Rewind::Action::BestPosts < Rewind::Action::BaseReport
|
|
||||||
def call
|
|
||||||
best_posts =
|
|
||||||
Post
|
|
||||||
.where(user_id: user.id)
|
|
||||||
.where(created_at: date)
|
|
||||||
.where(deleted_at: nil)
|
|
||||||
.where("post_number > 1")
|
|
||||||
.order("like_count DESC NULLS LAST")
|
|
||||||
.limit(3)
|
|
||||||
.pluck(:post_number, :topic_id, :like_count, :reply_count, :raw, :cooked)
|
|
||||||
|
|
||||||
{ data: best_posts, identifier: "best-posts" }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module DiscourseRewind
|
|
||||||
class Rewind::Action::BestTopics < Rewind::Action::BaseReport
|
|
||||||
def call
|
|
||||||
best_topics =
|
|
||||||
TopTopic
|
|
||||||
.includes(:topic)
|
|
||||||
.references(:topic)
|
|
||||||
.where(topic: { deleted_at: nil, created_at: date, user_id: user.id })
|
|
||||||
.order("yearly_score DESC NULLS LAST")
|
|
||||||
.limit(3)
|
|
||||||
.pluck(:topic_id, :title, :excerpt, :yearly_score)
|
|
||||||
.map do |topic_id, title, excerpt, yearly_score|
|
|
||||||
{ topic_id: topic_id, title: title, excerpt: excerpt, yearly_score: yearly_score }
|
|
||||||
end
|
|
||||||
|
|
||||||
{ data: best_topics, identifier: "best-topics" }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
# Topics visited grouped by category
|
|
||||||
module DiscourseRewind
|
|
||||||
class Rewind::Action::FavoriteCategories < Rewind::Action::BaseReport
|
|
||||||
FakeData = {
|
|
||||||
data: [
|
|
||||||
{ category_id: 1, name: "cats" },
|
|
||||||
{ category_id: 2, name: "dogs" },
|
|
||||||
{ category_id: 3, name: "countries" },
|
|
||||||
{ category_id: 4, name: "management" },
|
|
||||||
{ category_id: 5, name: "things" },
|
|
||||||
],
|
|
||||||
identifier: "favorite-categories",
|
|
||||||
}
|
|
||||||
def call
|
|
||||||
return FakeData if Rails.env.development?
|
|
||||||
|
|
||||||
favorite_categories =
|
|
||||||
TopicViewItem
|
|
||||||
.joins(:topic)
|
|
||||||
.joins("INNER JOIN categories ON categories.id = topics.category_id")
|
|
||||||
.where(user: user)
|
|
||||||
.where(viewed_at: date)
|
|
||||||
.group("categories.id, categories.name")
|
|
||||||
.order("COUNT(*) DESC")
|
|
||||||
.limit(5)
|
|
||||||
.pluck("categories.id, categories.name")
|
|
||||||
.map { |category_id, name| { category_id: category_id, name: name } }
|
|
||||||
|
|
||||||
{ data: favorite_categories, identifier: "favorite-categories" }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
# Topics visited grouped by tag
|
|
||||||
module DiscourseRewind
|
|
||||||
class Rewind::Action::FavoriteTags < Rewind::Action::BaseReport
|
|
||||||
FakeData = {
|
|
||||||
data: [
|
|
||||||
{ tag_id: 1, name: "cats" },
|
|
||||||
{ tag_id: 2, name: "dogs" },
|
|
||||||
{ tag_id: 3, name: "countries" },
|
|
||||||
{ tag_id: 4, name: "management" },
|
|
||||||
{ tag_id: 5, name: "things" },
|
|
||||||
],
|
|
||||||
identifier: "favorite-tags",
|
|
||||||
}
|
|
||||||
|
|
||||||
def call
|
|
||||||
return FakeData if Rails.env.development?
|
|
||||||
|
|
||||||
favorite_tags =
|
|
||||||
TopicViewItem
|
|
||||||
.joins(:topic)
|
|
||||||
.joins("INNER JOIN topic_tags ON topic_tags.topic_id = topics.id")
|
|
||||||
.joins("INNER JOIN tags ON tags.id = topic_tags.tag_id")
|
|
||||||
.where(user: user)
|
|
||||||
.where(viewed_at: date)
|
|
||||||
.group("tags.id, tags.name")
|
|
||||||
.order("COUNT(DISTINCT topic_views.topic_id) DESC")
|
|
||||||
.limit(5)
|
|
||||||
.pluck("tags.id, tags.name")
|
|
||||||
.map { |tag_id, name| { tag_id: tag_id, name: name } }
|
|
||||||
|
|
||||||
{ data: favorite_tags, identifier: "favorite-tags" }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
# Forum Best Friend Forever ranking
|
|
||||||
# Score is informative only, do not show in UI
|
|
||||||
module DiscourseRewind
|
|
||||||
class Rewind::Action::Fbff < Rewind::Action::BaseReport
|
|
||||||
MAX_SUMMARY_RESULTS ||= 50
|
|
||||||
LIKE_SCORE ||= 1
|
|
||||||
REPLY_SCORE ||= 10
|
|
||||||
|
|
||||||
def call
|
|
||||||
most_liked_users =
|
|
||||||
like_query(date)
|
|
||||||
.where(acting_user_id: user.id)
|
|
||||||
.group(:user_id)
|
|
||||||
.order("COUNT(*) DESC")
|
|
||||||
.limit(MAX_SUMMARY_RESULTS)
|
|
||||||
.pluck("user_actions.user_id, COUNT(*)")
|
|
||||||
.map { |user_id, count| { user_id => count } }
|
|
||||||
.reduce({}, :merge)
|
|
||||||
|
|
||||||
most_liked_by_users =
|
|
||||||
like_query(date)
|
|
||||||
.where(user: user)
|
|
||||||
.group(:acting_user_id)
|
|
||||||
.order("COUNT(*) DESC")
|
|
||||||
.limit(MAX_SUMMARY_RESULTS)
|
|
||||||
.pluck("acting_user_id, COUNT(*)")
|
|
||||||
.map { |acting_user_id, count| { acting_user_id => count } }
|
|
||||||
.reduce({}, :merge)
|
|
||||||
|
|
||||||
users_who_most_replied_me =
|
|
||||||
post_query(user, date)
|
|
||||||
.where(posts: { user_id: user.id })
|
|
||||||
.group("replies.user_id")
|
|
||||||
.order("COUNT(*) DESC")
|
|
||||||
.limit(MAX_SUMMARY_RESULTS)
|
|
||||||
.pluck("replies.user_id, COUNT(*)")
|
|
||||||
.map { |user_id, count| { user_id => count } }
|
|
||||||
.reduce({}, :merge)
|
|
||||||
|
|
||||||
users_i_most_replied =
|
|
||||||
post_query(user, date)
|
|
||||||
.where("replies.user_id = ?", user.id)
|
|
||||||
.group("posts.user_id")
|
|
||||||
.order("COUNT(*) DESC")
|
|
||||||
.limit(MAX_SUMMARY_RESULTS)
|
|
||||||
.pluck("posts.user_id, COUNT(*)")
|
|
||||||
.map { |user_id, count| { user_id => count } }
|
|
||||||
.reduce({}, :merge)
|
|
||||||
|
|
||||||
#TODO include chat?
|
|
||||||
|
|
||||||
fbffs = [
|
|
||||||
apply_score(most_liked_users, LIKE_SCORE),
|
|
||||||
apply_score(most_liked_by_users, LIKE_SCORE),
|
|
||||||
apply_score(users_who_most_replied_me, REPLY_SCORE),
|
|
||||||
apply_score(users_i_most_replied, REPLY_SCORE),
|
|
||||||
]
|
|
||||||
|
|
||||||
fbff_id =
|
|
||||||
fbffs
|
|
||||||
.flatten
|
|
||||||
.inject { |h1, h2| h1.merge(h2) { |_, v1, v2| v1 + v2 } }
|
|
||||||
&.sort_by { |_, v| -v }
|
|
||||||
&.first
|
|
||||||
&.first
|
|
||||||
|
|
||||||
return if !fbff_id
|
|
||||||
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
fbff: BasicUserSerializer.new(User.find(fbff_id), root: false).as_json,
|
|
||||||
yourself: BasicUserSerializer.new(user, root: false).as_json,
|
|
||||||
},
|
|
||||||
identifier: "fbff",
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def post_query(user, date)
|
|
||||||
Post
|
|
||||||
.joins(:topic)
|
|
||||||
.includes(:topic)
|
|
||||||
.where(
|
|
||||||
"posts.post_type IN (?)",
|
|
||||||
Topic.visible_post_types(user, include_moderator_actions: false),
|
|
||||||
)
|
|
||||||
.joins(
|
|
||||||
"INNER JOIN posts replies ON posts.topic_id = replies.topic_id AND posts.reply_to_post_number = replies.post_number",
|
|
||||||
)
|
|
||||||
.joins(
|
|
||||||
"INNER JOIN topics ON replies.topic_id = topics.id AND topics.archetype <> 'private_message'",
|
|
||||||
)
|
|
||||||
.joins(
|
|
||||||
"AND replies.post_type IN (#{Topic.visible_post_types(user, include_moderator_actions: false).join(",")})",
|
|
||||||
)
|
|
||||||
.where("replies.created_at BETWEEN ? AND ?", date.first, date.last)
|
|
||||||
.where("replies.user_id <> posts.user_id")
|
|
||||||
end
|
|
||||||
|
|
||||||
def like_query(date)
|
|
||||||
UserAction
|
|
||||||
.joins(:target_topic, :target_post)
|
|
||||||
.where(created_at: date)
|
|
||||||
.where(action_type: UserAction::WAS_LIKED)
|
|
||||||
end
|
|
||||||
|
|
||||||
def apply_score(users, score)
|
|
||||||
users.map { |user_id, count| { user_id => count * score } }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user