Author SHA1 Message Date
Joffrey JAFFEUX 5d165376a1 render rewind in ember 2024-12-12 17:38:25 +01:00
Joffrey JAFFEUX b56070313e qol 2024-12-12 15:28:49 +01:00
Joffrey JAFFEUX 89d78e5401 render different reports in partials 2024-12-12 15:28:44 +01:00
Joffrey JAFFEUX 0a8aa07078 integrate reports 2024-12-12 15:10:52 +01:00
Rafael Silva 8593c27e7c Add first reports 2024-12-12 15:10:45 +01:00
114 changed files with 2342 additions and 6411 deletions
-3
View File
@@ -1,3 +0,0 @@
< 3.6.0.beta1-dev: ca316389b51afeb17a697efaff041e425a0deac6
< 3.5.0.beta5-dev: cbcbbd2e3316b06c91998c7a0fd493d89ab6d2a5
< 3.5.0.beta1-dev: b3f205375a2f12287aa3338d8adc1afa98c4bfbb
+52 -67
View File
@@ -1,95 +1,80 @@
GEM
remote: https://rubygems.org/
specs:
activesupport (8.0.3)
activesupport (7.1.3.3)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
ast (2.4.3)
base64 (0.3.0)
benchmark (0.4.1)
bigdecimal (3.3.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.4)
drb (2.2.3)
i18n (1.14.7)
mutex_m
tzinfo (~> 2.0)
ast (2.4.2)
base64 (0.2.0)
bigdecimal (3.1.8)
concurrent-ruby (1.2.3)
connection_pool (2.4.1)
drb (2.2.1)
i18n (1.14.5)
concurrent-ruby (~> 1.0)
json (2.15.1)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
minitest (5.26.0)
parallel (1.27.0)
parser (3.3.9.0)
json (2.7.2)
language_server-protocol (3.17.0.3)
minitest (5.23.1)
mutex_m (0.2.0)
parallel (1.24.0)
parser (3.3.1.0)
ast (~> 2.4.1)
racc
prettier_print (1.2.1)
prism (1.5.1)
racc (1.8.1)
rack (3.2.3)
racc (1.8.0)
rack (3.0.11)
rainbow (3.1.1)
regexp_parser (2.11.3)
rubocop (1.81.1)
regexp_parser (2.9.2)
rexml (3.3.9)
rubocop (1.64.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.47.1, < 2.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.47.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-capybara (2.22.1)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-discourse (3.13.3)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.31.3)
parser (>= 3.3.1.0)
rubocop-capybara (2.20.0)
rubocop (~> 1.41)
rubocop-discourse (3.8.0)
activesupport (>= 6.1)
lint_roller (>= 1.1.0)
rubocop (>= 1.73.2)
rubocop-capybara (>= 2.22.0)
rubocop-factory_bot (>= 2.27.0)
rubocop-rails (>= 2.30.3)
rubocop-rspec (>= 3.0.1)
rubocop-rspec_rails (>= 2.31.0)
rubocop-factory_bot (2.27.1)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rails (2.33.4)
rubocop (>= 1.59.0)
rubocop-capybara (>= 2.0.0)
rubocop-factory_bot (>= 2.0.0)
rubocop-rails (>= 2.25.0)
rubocop-rspec (>= 2.25.0)
rubocop-factory_bot (2.25.1)
rubocop (~> 1.41)
rubocop-rails (2.25.0)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rspec (3.7.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec_rails (2.31.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec (~> 3.5)
rubocop (>= 1.33.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (2.29.2)
rubocop (~> 1.40)
rubocop-capybara (~> 2.17)
rubocop-factory_bot (~> 2.22)
rubocop-rspec_rails (~> 2.28)
rubocop-rspec_rails (2.28.3)
rubocop (~> 1.40)
ruby-progressbar (1.13.0)
securerandom (0.4.1)
syntax_tree (6.3.0)
syntax_tree (6.2.0)
prettier_print (>= 1.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uri (1.0.4)
unicode-display_width (2.5.0)
PLATFORMS
ruby
@@ -99,4 +84,4 @@ DEPENDENCIES
syntax_tree
BUNDLED WITH
2.7.2
2.5.10
@@ -4,37 +4,12 @@ module ::DiscourseRewind
class RewindsController < ::ApplicationController
requires_plugin PLUGIN_NAME
requires_login
def index
DiscourseRewind::FetchReports.call(service_params) do
on_model_not_found(:year) do
raise Discourse::NotFound.new(nil, custom_message: "discourse_rewind.invalid_year")
end
on_model_not_found(:reports) do
raise Discourse::NotFound.new(nil, custom_message: "discourse_rewind.report_failed")
end
on_success do |reports:, total_available:|
render json: { reports: reports, total_available: total_available }, status: :ok
end
end
end
def show
DiscourseRewind::FetchReport.call(service_params) do
on_model_not_found(:year) do
raise Discourse::NotFound.new(nil, custom_message: "discourse_rewind.invalid_year")
DiscourseRewind::Rewind::Fetch.call(service_params) do
on_success do |reports:|
@reports = reports
render json: MultiJson.dump(reports), status: 200
end
on_model_not_found(:report_class) do
raise Discourse::NotFound.new(
nil,
custom_message: "discourse_rewind.invalid_report_index",
)
end
on_model_not_found(:report) do
raise Discourse::NotFound.new(nil, custom_message: "discourse_rewind.report_failed")
end
on_success { |report:| render json: { report: report }, status: :ok }
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,73 +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
def call
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,62 +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
def call
return if !enabled?
# Assignments made to the user
assignments_scope =
Assignment
.where(assigned_to_id: user.id, assigned_to_type: "User")
.where(created_at: date)
total_assigned = assignments_scope.count
# Completed assignments (topics that were assigned and then closed or unassigned)
completed_count =
assignments_scope
.joins(:topic)
.where(
"topics.closed = true OR assignments.active = false OR assignments.updated_at > assignments.created_at",
)
.distinct
.count
# Currently pending (still open and assigned)
pending_count =
Assignment
.where(assigned_to_id: user.id, assigned_to_type: "User", active: true)
.joins(:topic)
.where(topics: { closed: false })
.count
# Assignments made by the user to others
assigned_by_user =
Assignment
.where(assigned_by_user_id: user.id)
.where(created_at: date)
.count
{
data: {
total_assigned: total_assigned,
completed: completed_count,
pending: pending_count,
assigned_by_user: assigned_by_user,
completion_rate:
total_assigned > 0 ? (completed_count.to_f / total_assigned * 100).round(1) : 0,
},
identifier: "assignments",
}
end
def enabled?
defined?(Assignment) && SiteSetting.assign_enabled
end
end
end
end
@@ -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,31 +0,0 @@
# frozen_string_literal: true
module DiscourseRewind
module Action
class BestPosts < 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, 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,23 +0,0 @@
# frozen_string_literal: true
module DiscourseRewind
module Action
class BestTopics < 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
end
@@ -1,82 +0,0 @@
# frozen_string_literal: true
# Chat usage statistics
# Shows message counts, favorite channels, DM activity, etc.
module DiscourseRewind
module Action
class ChatUsage < BaseReport
def call
return if !enabled?
messages =
Chat::Message.where(user_id: user.id).where(created_at: date).where(deleted_at: nil)
total_messages = messages.count
return if total_messages == 0
# Get favorite channels (public channels)
channel_usage =
messages
.joins(:chat_channel)
.where(chat_channels: { type: "CategoryChannel" })
.group("chat_channels.id", "chat_channels.name")
.count
.sort_by { |_, count| -count }
.first(5)
.map do |(id, name), count|
{ channel_id: id, channel_name: name, message_count: count }
end
# DM statistics
dm_message_count =
messages.joins(:chat_channel).where(chat_channels: { type: "DirectMessageChannel" }).count
# Unique DM conversations
unique_dm_channels =
messages
.joins(:chat_channel)
.where(chat_channels: { type: "DirectMessageChannel" })
.distinct
.count(:chat_channel_id)
# Messages with reactions received
messages_with_reactions =
Chat::MessageReaction
.joins(:chat_message)
.where(chat_messages: { user_id: user.id })
.where(chat_messages: { created_at: date })
.distinct
.count(:chat_message_id)
# Total reactions received
total_reactions_received =
Chat::MessageReaction
.joins(:chat_message)
.where(chat_messages: { user_id: user.id })
.where(chat_messages: { created_at: date })
.count
# Average message length
avg_message_length =
messages.where("LENGTH(message) > 0").average("LENGTH(message)")&.to_f&.round(1) || 0
{
data: {
total_messages: total_messages,
favorite_channels: channel_usage,
dm_message_count: dm_message_count,
unique_dm_channels: unique_dm_channels,
messages_with_reactions: messages_with_reactions,
total_reactions_received: total_reactions_received,
avg_message_length: avg_message_length,
},
identifier: "chat-usage",
}
end
def enabled?
SiteSetting.chat_enabled
end
end
end
end
@@ -1,122 +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
def call
gif_data = {}
# Get GIFs from posts
post_gifs = extract_gifs_from_posts
post_gifs.each do |url, data|
gif_data[url] ||= { url: url, usage_count: 0, likes: 0, reactions: 0 }
gif_data[url][:usage_count] += data[:count]
gif_data[url][:likes] += data[:likes]
end
# Get GIFs from chat messages if chat is enabled
if SiteSetting.chat_enabled
chat_gifs = extract_gifs_from_chat
chat_gifs.each do |url, data|
gif_data[url] ||= { url: url, usage_count: 0, likes: 0, reactions: 0 }
gif_data[url][:usage_count] += data[:count]
gif_data[url][:reactions] += data[:reactions]
end
end
return if gif_data.empty?
# Sort by engagement score (usage * 10 + likes + reactions)
sorted_gifs =
gif_data
.values
.sort_by { |gif| -(gif[:usage_count] * 10 + gif[:likes] + gif[:reactions]) }
.first(MAX_RESULTS)
{
data: {
favorite_gifs: sorted_gifs,
total_gif_usage: gif_data.values.sum { |g| g[:usage_count] },
},
identifier: "favorite-gifs",
}
end
private
def extract_gifs_from_posts
gif_usage = {}
posts =
Post
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.where("raw ~* ?", gif_sql_pattern)
.select(:id, :raw, :like_count)
posts.each do |post|
gif_urls = post.raw.scan(GIF_URL_PATTERN).uniq.select { |url| content_gif_url?(url) }
gif_urls.each do |url|
gif_usage[url] ||= { count: 0, likes: 0 }
gif_usage[url][:count] += 1
gif_usage[url][:likes] += post.like_count || 0
end
end
gif_usage
end
def extract_gifs_from_chat
gif_usage = {}
messages =
Chat::Message
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.where("message ~* ?", gif_sql_pattern)
.select(:id, :message)
messages.each do |message|
gif_urls =
message.message.scan(GIF_URL_PATTERN).uniq.select { |url| content_gif_url?(url) }
gif_urls.each do |url|
gif_usage[url] ||= { count: 0, reactions: 0 }
gif_usage[url][:count] += 1
# Count reactions on this message
reaction_count = Chat::MessageReaction.where(chat_message_id: message.id).count
gif_usage[url][:reactions] += reaction_count
end
end
gif_usage
end
def gif_sql_pattern
@gif_sql_pattern ||= GIF_URL_PATTERN.source.gsub(/\s+/, "")
end
def content_gif_url?(url)
return true if url.match?(/\.(gif|gifv)(?:\?|$)/i)
return true if url.match?(%r{giphy\.com/(?:gifs?|media|embed|stickers|clips)}i)
return true if url.match?(%r{tenor\.com/(?:view|watch|embed|gif)}i)
false
end
end
end
end
@@ -1,114 +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
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
end
@@ -1,81 +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
def call
# Get all invites created by this user in the date range
invites = Invite.where(invited_by_id: user.id).where(created_at: date)
total_invites = invites.count
return if total_invites == 0
# Redeemed invites (users who actually joined)
redeemed_count = invites.where("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,36 +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)
.where(viewed_at: date)
.where(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,38 +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)
.where(viewed_at: date)
.where(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,85 +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
def call
year_start = Date.new(date.first.year, 1, 1)
# Find users who created accounts this year
new_user_ids =
User
.real
.where("created_at >= ? AND created_at <= ?", year_start, date.last)
.where("id != ?", user.id)
.pluck(:id)
return if new_user_ids.empty?
# Count likes given to new users
likes_scope =
UserAction.where(
acting_user_id: user.id,
user_id: new_user_ids,
action_type: UserAction::WAS_LIKED,
).where(created_at: date)
likes_given = likes_scope.count
liked_user_ids = likes_scope.distinct.pluck(:user_id)
# Count replies to new users' posts
replies_scope =
Post
.joins(
"INNER JOIN posts AS parent_posts ON posts.reply_to_post_number = parent_posts.post_number AND posts.topic_id = parent_posts.topic_id",
)
.where(posts: { user_id: user.id, deleted_at: nil, created_at: date })
.where("parent_posts.user_id": new_user_ids)
replies_to_new_users = replies_scope.count
replied_user_ids = replies_scope.distinct.pluck("parent_posts.user_id")
# Count topics created by user that new users participated in
topics_with_new_users =
Topic
.joins(:posts)
.where(topics: { user_id: user.id, deleted_at: nil })
.where(posts: { user_id: new_user_ids, deleted_at: nil })
.where(topics: { created_at: date })
.distinct
.count
# Count direct messages/mentions to new users
mentions_scope =
Post
.joins(
"INNER JOIN user_actions ON user_actions.target_post_id = posts.id AND user_actions.action_type = #{UserAction::MENTION}",
)
.where(posts: { user_id: user.id, deleted_at: nil, created_at: date })
.where(user_actions: { user_id: new_user_ids })
mentions_to_new_users = mentions_scope.distinct.count
mentioned_user_ids = mentions_scope.distinct.pluck("user_actions.user_id")
# Unique new users interacted with
unique_new_users = (liked_user_ids + replied_user_ids + mentioned_user_ids).uniq.count
total_interactions = likes_given + replies_to_new_users + mentions_to_new_users
return if total_interactions == 0
{
data: {
total_interactions: total_interactions,
likes_given: likes_given,
replies_to_new_users: replies_to_new_users,
mentions_to_new_users: mentions_to_new_users,
topics_with_new_users: topics_with_new_users,
unique_new_users: unique_new_users,
new_users_count: new_user_ids.count,
},
identifier: "new-user-interactions",
}
end
end
end
end
@@ -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 { |_, v| -v }.first(5).reverse.to_h
end
end
end
end
@@ -1,202 +0,0 @@
# frozen_string_literal: true
# For showcasing the reading time of a user
# Should we show book covers or just the names?
module DiscourseRewind
module Action
class ReadingTime < BaseReport
FakeData = {
data: {
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" => {
reading_time: 3120,
isbn: "978-0553213690",
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" => {
reading_time: 5400,
isbn: "978-0156012195",
series: false,
},
"Animal Farm" => {
reading_time: 7200,
isbn: "978-0451526342",
series: false,
},
"The Catcher in the Rye" => {
reading_time: 18_000,
isbn: "978-0316769488",
series: false,
},
"Jane Eyre" => {
reading_time: 34_200,
isbn: "978-0141441146",
series: false,
},
"Fahrenheit 451" => {
reading_time: 15_000,
isbn: "978-1451673319",
series: false,
},
"The Hobbit" => {
reading_time: 27_000,
isbn: "978-0547928227",
series: false,
},
"The Da Vinci Code" => {
reading_time: 37_800,
isbn: "978-0307474278",
series: false,
},
"Little Women" => {
reading_time: 30_000,
isbn: "978-0147514011",
series: false,
},
"One Hundred Years of Solitude" => {
reading_time: 46_800,
isbn: "978-0060883287",
series: false,
},
"And Then There Were None" => {
reading_time: 16_200,
isbn: "978-0062073488",
series: false,
},
"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" => {
reading_time: 180_000,
isbn: "978-1853268953",
series: true,
},
"The Game of Thrones Series" => {
reading_time: 360_000,
isbn: "978-0007477159",
series: true,
},
"Malazan Book of the Fallen" => {
reading_time: 720_000,
isbn: "978-0765348821",
series: true,
},
"Terry Pratchett’s Discworld series" => {
reading_time: 1_440_000,
isbn: "978-9123684458",
series: true,
},
"The Wandering Inn web series" => {
reading_time: 2_160_000,
isbn: "the-wandering-inn",
series: true,
},
"The Combined Cosmere works + Wheel of Time" => {
reading_time: 2_880_000,
isbn: "978-0812511819",
series: true,
},
"The Star Trek novels" => {
reading_time: 3_600_000,
isbn: "978-1852860691",
series: true,
},
}.symbolize_keys
end
def best_book_fit(reading_time)
reading_time_rest = reading_time
books = []
while reading_time_rest > 0
best_fit = popular_books.min_by { |_, v| (v[:reading_time] - reading_time_rest).abs }
break if best_fit.nil?
books << best_fit.first
reading_time_rest -= best_fit.last[:reading_time]
end
return if books.empty?
book_title =
books.group_by { |book| book }.transform_values(&:count).max_by { |_, count| count }.first
{
title: book_title,
isbn: popular_books[book_title][:isbn],
series: popular_books[book_title][:series],
}
end
end
end
end
@@ -1,106 +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
def call
# Get activity by hour of day (in user's timezone)
activity_by_hour = get_activity_by_hour
return if activity_by_hour.empty?
total_activities = activity_by_hour.values.sum
most_active_hour = activity_by_hour.max_by { |_, count| count }&.first
personality = determine_personality(activity_by_hour, total_activities)
{
data: {
activity_by_hour: activity_by_hour,
most_active_hour: most_active_hour,
personality: personality,
total_activities: total_activities,
},
identifier: "time-of-day-activity",
}
end
private
def get_activity_by_hour
# Get user timezone offset
user_timezone = user.user_option&.timezone || "UTC"
quoted_timezone = ActiveRecord::Base.connection.quote(user_timezone)
hour_extract_sql =
Arel.sql(
"EXTRACT(HOUR FROM created_at AT TIME ZONE 'UTC' AT TIME ZONE #{quoted_timezone})::integer",
)
# Initialize hash with all hours
activity = (0..23).to_h { |hour| [hour, 0] }
# Posts created
post_hours =
Post
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.pluck(hour_extract_sql)
.tally
# User visits (page views)
visit_hours =
UserHistory
.where(acting_user_id: user.id)
.where(created_at: date)
.where(action: UserHistory.actions[:page_view])
.pluck(hour_extract_sql)
.tally
# Chat messages if chat is enabled
if SiteSetting.chat_enabled
chat_hours =
Chat::Message
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.pluck(hour_extract_sql)
.tally
chat_hours.each { |hour, count| activity[hour] += count }
end
post_hours.each { |hour, count| activity[hour] += count }
visit_hours.each { |hour, count| activity[hour] += count }
activity
end
def determine_personality(activity_by_hour, total_activities)
return nil if total_activities == 0
early_bird_activity = EARLY_BIRD_THRESHOLD.sum { |hour| activity_by_hour[hour] || 0 }
night_owl_activity =
NIGHT_OWL_THRESHOLD_PM.sum { |hour| activity_by_hour[hour] || 0 } +
NIGHT_OWL_THRESHOLD_AM.sum { |hour| activity_by_hour[hour] || 0 }
early_bird_percentage = (early_bird_activity.to_f / total_activities * 100).round(1)
night_owl_percentage = (night_owl_activity.to_f / total_activities * 100).round(1)
if early_bird_percentage > 20
{ type: "early_bird", percentage: early_bird_percentage }
elsif night_owl_percentage > 20
{ type: "night_owl", percentage: night_owl_percentage }
else
{ type: "balanced", percentage: 0 }
end
end
end
end
end
@@ -1,88 +0,0 @@
# frozen_string_literal: true
module DiscourseRewind
module Action
class TopWords < BaseReport
FakeData = {
data: [
{ word: "seven", score: 100 },
{ word: "longest", score: 90 },
{ word: "you", score: 80 },
{ word: "overachieved", score: 70 },
{ word: "assume", score: 60 },
{ word: "week", score: 50 },
],
identifier: "top-words",
}
def call
return FakeData if Rails.env.development?
words = DB.query(<<~SQL, user_id: user.id, date_start: date.first, date_end: date.last)
WITH popular_words AS (
SELECT
*
FROM
ts_stat(
$INNERSQL$
SELECT
search_data
FROM
post_search_data
INNER JOIN
posts ON posts.id = post_search_data.post_id
WHERE
posts.user_id = :user_id
AND posts.created_at BETWEEN :date_start AND :date_end
$INNERSQL$
) AS search_data
ORDER BY
nentry DESC,
ndoc DESC,
word
LIMIT
100
), lex AS (
SELECT
DISTINCT ON (lexeme) to_tsvector('english', word) as lexeme,
word as original_word
FROM
ts_stat ($INNERSQL$
SELECT
to_tsvector('simple', raw)
FROM
posts AS p
WHERE
p.created_at BETWEEN :date_start AND :date_end
AND p.user_id = :user_id
$INNERSQL$)
), ranked_words AS (
SELECT
popular_words.*, lex.original_word,
ROW_NUMBER() OVER (PARTITION BY word ORDER BY LENGTH(original_word)) AS rn
FROM
popular_words
INNER JOIN
lex ON lex.lexeme @@ to_tsquery('english', popular_words.word)
)
SELECT
word,
ndoc,
nentry,
original_word
FROM
ranked_words
WHERE
rn = 1
ORDER BY
ndoc + nentry DESC
LIMIT 100
SQL
word_score = words.map { { word: _1.original_word, score: _1.ndoc + _1.nentry } }
{ data: word_score, identifier: "top-words" }
end
end
end
end
@@ -1,92 +0,0 @@
# frozen_string_literal: true
module DiscourseRewind
# Service responsible to fetch a single report by index.
#
# @example
# ::DiscourseRewind::FetchReport.call(
# guardian: guardian,
# index: 3
# )
#
class FetchReport
include Service::Base
# @!method self.call(guardian:, params:)
# @param [Guardian] guardian
# @param [Hash] params
# @option params [Integer] :index the report index
# @return [Service::Base::Context]
CACHE_DURATION = Rails.env.development? ? 10.seconds : 5.minutes
model :year
model :date
model :report_class
model :report
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_report_class(date:, guardian:, year:, params:)
# Use the same cached all_reports list as FetchReports
# If not cached, generate it now
key = "rewind:#{guardian.user.username}:#{year}:all_reports"
cached_list = Discourse.redis.get(key)
all_reports =
if cached_list
MultiJson.load(cached_list, symbolize_keys: true)
else
# Generate all reports and cache them
reports =
FetchReports::REPORTS.filter_map do |report_class|
begin
report_class.call(date:, user: guardian.user, guardian:)
rescue => e
Rails.logger.error("Failed to generate report #{report_class.name}: #{e.message}")
nil
end
end
Discourse.redis.setex(key, CACHE_DURATION, MultiJson.dump(reports))
reports
end
# Access index from params data object (params.index) or hash (params[:index])
index = (params[:index] || params.index).to_i
return false if index < 0 || index >= all_reports.length
all_reports[index]
end
def fetch_report(report_class:)
# Report is already generated and cached in the all_reports list
report_class
end
end
end
@@ -1,112 +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
# )
#
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
# @option params [Integer] :count number of reports to fetch (optional, defaults to 3)
# @return [Service::Base::Context]
CACHE_DURATION = Rails.env.development? ? 10.seconds : 5.minutes
INITIAL_REPORT_COUNT = 3
# order matters
REPORTS = [
Action::TopWords,
Action::ReadingTime,
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 :enabled_reports
model :reports
model :total_available
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_enabled_reports(date:, guardian:, year:)
# Generate all reports and filter out nils (disabled/empty reports)
# Cache the full list to maintain consistent indices across requests
key = "rewind:#{guardian.user.username}:#{year}:all_reports"
cached_list = Discourse.redis.get(key)
return MultiJson.load(cached_list, symbolize_keys: true) if cached_list
reports =
REPORTS.filter_map do |report_class|
begin
report_class.call(date:, user: guardian.user, guardian:)
rescue => e
Rails.logger.error("Failed to generate report #{report_class.name}: #{e.message}")
nil
end
end
# Cache the complete enabled reports list
Discourse.redis.setex(key, CACHE_DURATION, MultiJson.dump(reports))
reports
end
def fetch_total_available(enabled_reports:)
enabled_reports.length
end
def fetch_reports(enabled_reports:, params:)
count = params[:count]&.to_i || INITIAL_REPORT_COUNT
count = [[count, 1].max, enabled_reports.length].min
enabled_reports.first(count)
end
end
end
@@ -0,0 +1,23 @@
# 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::PostingCalendar < Service::ActionBase
option :user
option :date
def call
calendar =
Post
.where(user: user)
.where(created_at: date)
.select("DATE(created_at) as date, count(*) as count")
.group("DATE(created_at)")
.order("DATE(created_at)")
.map { |post| { date: post.date, count: post.count } }
{ data: calendar, identifier: "posting-calendar" }
end
end
end
@@ -0,0 +1,57 @@
# frozen_string_literal: true
# For showcasing the reading time of a user
# Should we show book covers or just the names?
module DiscourseRewind
class Rewind::Action::ReadingTime < Service::ActionBase
option :user
option :date
def call
reading_time = UserVisit.where(user_id: user.id).where(visited_at: date).sum(:time_read)
{
data: {
reading_time: reading_time,
books: best_book_fit(reading_time),
},
identifier: "reading-time",
}
end
def popular_book_reading_time
{
"The Hunger Games" => 19_740,
"The Metamorphosis" => 3120,
"To Kill a Mockingbird" => 22_800,
"Pride and Prejudice" => 25_200,
"1984" => 16_800,
"The Lord of the Rings" => 108_000,
"Harry Potter and the Sorcerer's Stone" => 24_600,
"The Great Gatsby" => 12_600,
"The Little Prince" => 5400,
"Animal Farm" => 7200,
"The Catcher in the Rye" => 18_000,
"Jane Eyre" => 34_200,
"Fahrenheit 451" => 15_000,
"The Hobbit" => 27_000,
"The Da Vinci Code" => 37_800,
"Little Women" => 30_000,
"One Hundred Years of Solitude" => 46_800,
"And Then There Were None" => 16_200,
"The Alchemist" => 10_800,
"The Hitchhiker's Guide to the Galaxy" => 12_600,
}.symbolize_keys
end
def best_book_fit(reading_time)
reading_time_rest = reading_time
books = []
while reading_time_rest > 0
books << popular_book_reading_time.min_by { |_, v| (v - reading_time_rest).abs }.first
reading_time_rest -= popular_book_reading_time[books.last]
end
books.group_by(&:itself).transform_values(&:count)
end
end
end
@@ -0,0 +1,64 @@
# frozen_string_literal: true
module DiscourseRewind
# Service responsible to fetch a rewind for a username/year.
#
# @example
# ::DiscourseRewind::Rewind::Fetch.call(
# guardian: guardian
# )
#
class Rewind::Fetch
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]
# order matters, rewinds are displayed in the order they are defined
REPORTS = [
DiscourseRewind::Rewind::Action::ReadingTime,
DiscourseRewind::Rewind::Action::PostingCalendar,
].freeze
CACHE_DURATION = 5.minutes
model :user
model :date
model :reports
private
def fetch_user(guardian:)
User.find_by_username(guardian.user.username)
end
def fetch_date(params:)
Date.new(2024).all_year
end
def fetch_reports(date:, user:, guardian:)
key = "rewind:#{guardian.user.username}:#{2024}"
reports = Discourse.redis.get(key)
if Rails.env.development? || !reports
reports = REPORTS.map { |report| report.call(date:, user:, guardian:) }
Discourse.redis.setex(key, CACHE_DURATION, MultiJson.dump(reports))
else
reports = MultiJson.load(reports, symbolize_keys: true)
end
if Rails.env.development?
reports.each do |report|
puts "\n--- #{report[:identifier]} ---\n"
pp report
end
end
reports
end
end
end
@@ -1,124 +0,0 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import concatClass from "discourse/helpers/concat-class";
import { i18n } from "discourse-i18n";
const ROWS = 7;
const COLS = 53;
export default class ActivityCalendar extends Component {
get rowsArray() {
const data = this.args.report.data;
let rowsArray = [];
for (let r = 0; r < ROWS; r++) {
let rowData = [];
for (let c = 0; c < COLS; c++) {
const index = c * ROWS + r;
rowData.push(data[index] ? data[index] : "");
}
rowsArray.push(rowData);
}
return rowsArray;
}
@action
getAbbreviatedMonth(monthIndex) {
return moment().month(monthIndex).format("MMM");
}
@action
computeClass(count) {
if (!count) {
return "-empty";
} else if (count < 10) {
return "-low";
} else if (count < 20) {
return "-medium";
} else {
return "-high";
}
}
<template>
<div class="rewind-report-page -activity-calendar">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.activity_calendar.title"
}}</h2>
<div class="rewind-card">
<table class="rewind-calendar">
<thead>
<tr>
<td
colspan="5"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 0}}</td>
<td
colspan="4"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 1}}</td>
<td
colspan="4"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 2}}</td>
<td
colspan="5"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 3}}</td>
<td
colspan="4"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 4}}</td>
<td
colspan="4"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 5}}</td>
<td
colspan="5"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 6}}</td>
<td
colspan="4"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 7}}</td>
<td
colspan="5"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 8}}</td>
<td
colspan="4"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 9}}</td>
<td
colspan="4"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 10}}</td>
<td
colspan="4"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 11}}</td>
</tr>
</thead>
<tbody>
{{#each this.rowsArray as |row|}}
<tr>
{{#each row as |cell|}}
<td
data-date={{cell.date}}
title={{cell.date}}
class={{concatClass
"rewind-calendar-cell"
(this.computeClass cell.post_count)
}}
></td>
{{/each}}
</tr>
{{/each}}
</tbody>
</table>
</div>
</div>
</template>
}
@@ -1,42 +0,0 @@
import Component from "@glimmer/component";
import { concat } from "@ember/helper";
import { htmlSafe } from "@ember/template";
import icon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";
export default class BestPosts extends Component {
rank(idx) {
return idx + 1;
}
<template>
{{#if @report.data.length}}
<div class="rewind-report-page -best-posts">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.best_posts.title"
[email protected]
}}</h2>
<div class="rewind-report-container">
{{#each @report.data as |post idx|}}
<div class={{concat "rewind-card" " rank-" (this.rank idx)}}>
<span class="best-posts -rank"></span>
<span class="best-posts -rank"></span>
<div class="best-posts__post"><p>{{htmlSafe
post.excerpt
}}</p></div>
<div class="best-posts__metadata">
<span class="best-posts__likes">
{{icon "heart"}}{{post.like_count}}</span>
<span class="best-posts__replies">
{{icon "comment"}}{{post.reply_count}}</span>
<a href="/t/{{post.topic_id}}/{{post.post_number}}">{{i18n
"discourse_rewind.reports.best_posts.view_post"
}}</a>
</div>
</div>
{{/each}}
</div>
</div>
{{/if}}
</template>
}
@@ -1,39 +0,0 @@
import Component from "@glimmer/component";
import { concat } from "@ember/helper";
import { htmlSafe } from "@ember/template";
import replaceEmoji from "discourse/helpers/replace-emoji";
import { i18n } from "discourse-i18n";
export default class BestTopics extends Component {
rank(idx) {
return idx + 1;
}
<template>
{{#if @report.data.length}}
<div class="rewind-report-page -best-topics">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.best_topics.title"
[email protected]
}}</h2>
<div class="rewind-report-container">
<div class="rewind-card">
{{#each @report.data as |topic idx|}}
<a
href={{concat "/t/-/" topic.topic_id}}
class={{concat "best-topics__topic" " rank-" (this.rank idx)}}
>
<span class="best-topics -rank"></span>
<span class="best-topics -rank"></span>
<h2 class="best-topics__header">{{topic.title}}</h2>
<span class="best-topics__excerpt">{{replaceEmoji
(htmlSafe topic.excerpt)
}}</span>
</a>
{{/each}}
</div>
</div>
</div>
{{/if}}
</template>
}
@@ -1,39 +0,0 @@
import { hash } from "@ember/helper";
import avatar from "discourse/helpers/bound-avatar-template";
import { i18n } from "discourse-i18n";
const FBFF = <template>
<div class="rewind-report-page -fbff">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.fbff.title"
}}</h2>
<div class="rewind-report-container">
<div class="rewind-card">
<div class="fbff-avatar-container">
{{avatar
@report.data.fbff.avatar_template
"huge"
(hash [email protected])
}}
<p class="fbff-avatar-name">@{{@report.data.fbff.username}}</p>
</div>
<div class="fbff-gif-container">
<img
class="fbff-gif"
src="/plugins/discourse-rewind/images/fbff.gif"
/>
</div>
<div class="fbff-avatar-container">
{{avatar
@report.data.yourself.avatar_template
"huge"
(hash [email protected])
}}
<p class="fbff-avatar-name">@{{@report.data.yourself.username}}</p>
</div>
</div>
</div>
</div>
</template>;
export default FBFF;
@@ -1,42 +0,0 @@
import { i18n } from "discourse-i18n";
const RewindHeader = <template>
<div class="rewind__header">
<div class="rewind__header-logo">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -1 104 106">
<path
fill="#231f20"
d="M51.87 0C23.71 0 0 22.83 0 51v52.81l51.86-.05c28.16 0 51-23.71 51-51.87S80 0 51.87 0Z"
/>
<path
fill="#fff9ae"
d="M52.37 19.74a31.62 31.62 0 0 0-27.79 46.67l-5.72 18.4 20.54-4.64a31.61 31.61 0 1 0 13-60.43Z"
/>
<path
fill="#00aeef"
d="M77.45 32.12a31.6 31.6 0 0 1-38.05 48l-20.54 4.7 20.91-2.47a31.6 31.6 0 0 0 37.68-50.23Z"
/>
<path
fill="#00a94f"
d="M71.63 26.29A31.6 31.6 0 0 1 38.8 78l-19.94 6.82 20.54-4.65a31.6 31.6 0 0 0 32.23-53.88Z"
/>
<path
fill="#f15d22"
d="M26.47 67.11a31.61 31.61 0 0 1 51-35 31.61 31.61 0 0 0-52.89 34.3l-5.72 18.4Z"
/>
<path
fill="#e31b23"
d="M24.58 66.41a31.61 31.61 0 0 1 47.05-40.12 31.61 31.61 0 0 0-49 39.63l-3.76 18.9Z"
/>
</svg>
<div class="rewind__header-title">
{{i18n "discourse_rewind.title"}}
</div>
<div class="rewind__header-year">
{{i18n "discourse_rewind.year"}}
</div>
</div>
</div>
</template>;
export default RewindHeader;

Some files were not shown because too many files have changed in this diff Show More