Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b1af6f717 |
@@ -1,3 +0,0 @@
|
|||||||
< 3.6.0.beta1-dev: ca316389b51afeb17a697efaff041e425a0deac6
|
|
||||||
< 3.5.0.beta5-dev: cbcbbd2e3316b06c91998c7a0fd493d89ab6d2a5
|
|
||||||
< 3.5.0.beta1-dev: b3f205375a2f12287aa3338d8adc1afa98c4bfbb
|
|
||||||
|
|||||||
+52
-67
@@ -1,95 +1,80 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
activesupport (8.0.3)
|
activesupport (7.1.3.3)
|
||||||
base64
|
base64
|
||||||
benchmark (>= 0.3)
|
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
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)
|
||||||
securerandom (>= 0.3)
|
mutex_m
|
||||||
tzinfo (~> 2.0, >= 2.0.5)
|
tzinfo (~> 2.0)
|
||||||
uri (>= 0.13.1)
|
ast (2.4.2)
|
||||||
ast (2.4.3)
|
base64 (0.2.0)
|
||||||
base64 (0.3.0)
|
bigdecimal (3.1.8)
|
||||||
benchmark (0.4.1)
|
concurrent-ruby (1.2.3)
|
||||||
bigdecimal (3.3.0)
|
connection_pool (2.4.1)
|
||||||
concurrent-ruby (1.3.5)
|
drb (2.2.1)
|
||||||
connection_pool (2.5.4)
|
i18n (1.14.5)
|
||||||
drb (2.2.3)
|
|
||||||
i18n (1.14.7)
|
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
json (2.15.1)
|
json (2.7.2)
|
||||||
language_server-protocol (3.17.0.5)
|
language_server-protocol (3.17.0.3)
|
||||||
lint_roller (1.1.0)
|
minitest (5.23.1)
|
||||||
logger (1.7.0)
|
mutex_m (0.2.0)
|
||||||
minitest (5.26.0)
|
parallel (1.24.0)
|
||||||
parallel (1.27.0)
|
parser (3.3.1.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)
|
||||||
prism (1.5.1)
|
racc (1.8.0)
|
||||||
racc (1.8.1)
|
rack (3.0.11)
|
||||||
rack (3.2.3)
|
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
regexp_parser (2.11.3)
|
regexp_parser (2.9.2)
|
||||||
rubocop (1.81.1)
|
rexml (3.3.9)
|
||||||
|
rubocop (1.64.0)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (>= 3.17.0)
|
||||||
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 (>= 2.9.3, < 3.0)
|
regexp_parser (>= 1.8, < 3.0)
|
||||||
rubocop-ast (>= 1.47.1, < 2.0)
|
rexml (>= 3.2.5, < 4.0)
|
||||||
|
rubocop-ast (>= 1.31.1, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 3.0)
|
||||||
rubocop-ast (1.47.1)
|
rubocop-ast (1.31.3)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.1.0)
|
||||||
prism (~> 1.4)
|
rubocop-capybara (2.20.0)
|
||||||
rubocop-capybara (2.22.1)
|
rubocop (~> 1.41)
|
||||||
lint_roller (~> 1.1)
|
rubocop-discourse (3.8.0)
|
||||||
rubocop (~> 1.72, >= 1.72.1)
|
|
||||||
rubocop-discourse (3.13.3)
|
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
lint_roller (>= 1.1.0)
|
rubocop (>= 1.59.0)
|
||||||
rubocop (>= 1.73.2)
|
rubocop-capybara (>= 2.0.0)
|
||||||
rubocop-capybara (>= 2.22.0)
|
rubocop-factory_bot (>= 2.0.0)
|
||||||
rubocop-factory_bot (>= 2.27.0)
|
rubocop-rails (>= 2.25.0)
|
||||||
rubocop-rails (>= 2.30.3)
|
rubocop-rspec (>= 2.25.0)
|
||||||
rubocop-rspec (>= 3.0.1)
|
rubocop-factory_bot (2.25.1)
|
||||||
rubocop-rspec_rails (>= 2.31.0)
|
rubocop (~> 1.41)
|
||||||
rubocop-factory_bot (2.27.1)
|
rubocop-rails (2.25.0)
|
||||||
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.75.0, < 2.0)
|
rubocop (>= 1.33.0, < 2.0)
|
||||||
rubocop-ast (>= 1.44.0, < 2.0)
|
rubocop-ast (>= 1.31.1, < 2.0)
|
||||||
rubocop-rspec (3.7.0)
|
rubocop-rspec (2.29.2)
|
||||||
lint_roller (~> 1.1)
|
rubocop (~> 1.40)
|
||||||
rubocop (~> 1.72, >= 1.72.1)
|
rubocop-capybara (~> 2.17)
|
||||||
rubocop-rspec_rails (2.31.0)
|
rubocop-factory_bot (~> 2.22)
|
||||||
lint_roller (~> 1.1)
|
rubocop-rspec_rails (~> 2.28)
|
||||||
rubocop (~> 1.72, >= 1.72.1)
|
rubocop-rspec_rails (2.28.3)
|
||||||
rubocop-rspec (~> 3.5)
|
rubocop (~> 1.40)
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
securerandom (0.4.1)
|
syntax_tree (6.2.0)
|
||||||
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 (3.2.0)
|
unicode-display_width (2.5.0)
|
||||||
unicode-emoji (~> 4.1)
|
|
||||||
unicode-emoji (4.1.0)
|
|
||||||
uri (1.0.4)
|
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
ruby
|
ruby
|
||||||
@@ -99,4 +84,4 @@ DEPENDENCIES
|
|||||||
syntax_tree
|
syntax_tree
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.7.2
|
2.5.10
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# DiscourseRewind
|
# DiscourseRewind
|
||||||
|
|
||||||
## DEPRECATED
|
Display stats on your last year of Discourse usage.
|
||||||
|
|
||||||
This plugin has been moved into Discourse core as at https://github.com/discourse/discourse/pull/36409/
|
## Usage
|
||||||
|
|
||||||
|
Navigate to `/my/activity/rewind`
|
||||||
|
|||||||
@@ -7,14 +7,13 @@ module ::DiscourseRewind
|
|||||||
requires_login
|
requires_login
|
||||||
|
|
||||||
def show
|
def show
|
||||||
DiscourseRewind::FetchReports.call(service_params) do
|
DiscourseRewind::Rewind::Fetch.call(service_params) do
|
||||||
on_model_not_found(:year) do
|
on_model_not_found(:year) { raise Discourse::NotFound }
|
||||||
raise Discourse::NotFound.new(nil, custom_message: "discourse_rewind.invalid_year")
|
on_model_not_found(:user) { raise Discourse::NotFound }
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,43 +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
|
|
||||||
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
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# For a most user / received reactions cards
|
||||||
|
module DiscourseRewind
|
||||||
|
class Rewind::Action::Reactions < Rewind::Action::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 { |_, v| -v }.first(5).reverse.to_h
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+163
-150
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user