1 Commits
Author SHA1 Message Date
Rafael Silva 34973acc55 PERF: Load reports async 2025-11-26 15:12:59 -03:00
77 changed files with 663 additions and 3927 deletions
+4 -2
View File
@@ -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`
@@ -6,7 +6,7 @@ module ::DiscourseRewind
requires_login requires_login
def show def index
DiscourseRewind::FetchReports.call(service_params) do DiscourseRewind::FetchReports.call(service_params) do
on_model_not_found(:year) do on_model_not_found(:year) do
raise Discourse::NotFound.new(nil, custom_message: "discourse_rewind.invalid_year") raise Discourse::NotFound.new(nil, custom_message: "discourse_rewind.invalid_year")
@@ -14,7 +14,27 @@ module ::DiscourseRewind
on_model_not_found(:reports) do on_model_not_found(:reports) do
raise Discourse::NotFound.new(nil, custom_message: "discourse_rewind.report_failed") raise Discourse::NotFound.new(nil, custom_message: "discourse_rewind.report_failed")
end end
on_success { |reports:| render json: MultiJson.dump(reports), status: :ok } 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")
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 end
end end
@@ -6,31 +6,7 @@
module DiscourseRewind module DiscourseRewind
module Action module Action
class AiUsage < BaseReport 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 def call
return FakeData if Rails.env.development?
return if !enabled? return if !enabled?
base_query = AiApiRequestStat.where(user_id: user.id).where(bucket_date: date) base_query = AiApiRequestStat.where(user_id: user.id).where(bucket_date: date)
@@ -5,19 +5,7 @@
module DiscourseRewind module DiscourseRewind
module Action module Action
class Assignments < BaseReport class Assignments < BaseReport
FakeData = {
data: {
total_assigned: 24,
completed: 18,
pending: 6,
assigned_by_user: 15,
completion_rate: 75.0,
},
identifier: "assignments",
}
def call def call
return FakeData if Rails.env.development?
return if !enabled? return if !enabled?
# Assignments made to the user # Assignments made to the user
@@ -3,37 +3,7 @@
module DiscourseRewind module DiscourseRewind
module Action module Action
class BestPosts < BaseReport 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 def call
return FakeData if Rails.env.development?
best_posts = best_posts =
Post Post
.where(user_id: user.id) .where(user_id: user.id)
@@ -3,32 +3,7 @@
module DiscourseRewind module DiscourseRewind
module Action module Action
class BestTopics < BaseReport 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 def call
return FakeData if Rails.env.development?
best_topics = best_topics =
TopTopic TopTopic
.includes(:topic) .includes(:topic)
@@ -5,27 +5,7 @@
module DiscourseRewind module DiscourseRewind
module Action module Action
class ChatUsage < BaseReport 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 def call
return FakeData if Rails.env.development?
return if !enabled? return if !enabled?
messages = messages =
@@ -15,47 +15,7 @@ module DiscourseRewind
}ix }ix
MAX_RESULTS = 5 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 def call
return FakeData if Rails.env.development?
gif_data = {} gif_data = {}
# Get GIFs from posts # Get GIFs from posts
@@ -9,26 +9,7 @@ module DiscourseRewind
LIKE_SCORE = 1 LIKE_SCORE = 1
REPLY_SCORE = 10 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 def call
return FakeData if Rails.env.development?
most_liked_users = most_liked_users =
like_query(date) like_query(date)
.where(acting_user_id: user.id) .where(acting_user_id: user.id)
@@ -5,27 +5,7 @@
module DiscourseRewind module DiscourseRewind
module Action module Action
class Invites < BaseReport 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 def call
return FakeData if Rails.env.development?
# Get all invites created by this user in the date range # Get all invites created by this user in the date range
invites = Invite.where(invited_by_id: user.id).where(created_at: date) invites = Invite.where(invited_by_id: user.id).where(created_at: date)
@@ -20,13 +20,9 @@ module DiscourseRewind
TopicViewItem TopicViewItem
.joins(:topic) .joins(:topic)
.joins("INNER JOIN categories ON categories.id = topics.category_id") .joins("INNER JOIN categories ON categories.id = topics.category_id")
.where( .where(user: user)
user: user, .where(viewed_at: date)
viewed_at: date, .where(categories: { id: user.guardian.allowed_category_ids })
categories: {
id: user.guardian.allowed_category_ids,
},
)
.group("categories.id, categories.name") .group("categories.id, categories.name")
.order("COUNT(*) DESC") .order("COUNT(*) DESC")
.limit(4) .limit(4)
@@ -22,7 +22,9 @@ module DiscourseRewind
.joins(:topic) .joins(:topic)
.joins("INNER JOIN topic_tags ON topic_tags.topic_id = topics.id") .joins("INNER JOIN topic_tags ON topic_tags.topic_id = topics.id")
.joins("INNER JOIN tags ON tags.id = topic_tags.tag_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) }) .where(user: user)
.where(viewed_at: date)
.where(tags: { id: Tag.visible(user.guardian).pluck(:id) })
.group("tags.id, tags.name") .group("tags.id, tags.name")
.order("COUNT(DISTINCT topic_views.topic_id) DESC") .order("COUNT(DISTINCT topic_views.topic_id) DESC")
.limit(4) .limit(4)
@@ -5,21 +5,7 @@
module DiscourseRewind module DiscourseRewind
module Action module Action
class NewUserInteractions < BaseReport 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 def call
return FakeData if Rails.env.development?
year_start = Date.new(date.first.year, 1, 1) year_start = Date.new(date.first.year, 1, 1)
# Find users who created accounts this year # Find users who created accounts this year
@@ -33,45 +19,66 @@ module DiscourseRewind
return if new_user_ids.empty? return if new_user_ids.empty?
# Count likes given to new users # Count likes given to new users
liked_user_ids = likes_scope =
UserAction UserAction.where(
.where( acting_user_id: user.id,
acting_user_id: user.id, user_id: new_user_ids,
user_id: new_user_ids, action_type: UserAction::WAS_LIKED,
action_type: UserAction::WAS_LIKED, ).where(created_at: date)
) likes_given = likes_scope.count
.where(created_at: date) liked_user_ids = likes_scope.distinct.pluck(:user_id)
.distinct
.pluck(:user_id)
# Count replies to new users' posts # Count replies to new users' posts
replied_user_ids = replies_scope =
Post Post
.joins( .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", "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(posts: { user_id: user.id, deleted_at: nil, created_at: date })
.where("parent_posts.user_id": new_user_ids) .where("parent_posts.user_id": new_user_ids)
.distinct replies_to_new_users = replies_scope.count
.pluck("parent_posts.user_id") replied_user_ids = replies_scope.distinct.pluck("parent_posts.user_id")
# Count direct mentions to new users # Count topics created by user that new users participated in
mentioned_user_ids = 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 Post
.joins( .joins(
"INNER JOIN user_actions ON user_actions.target_post_id = posts.id AND user_actions.action_type = #{UserAction::MENTION}", "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(posts: { user_id: user.id, deleted_at: nil, created_at: date })
.where(user_actions: { user_id: new_user_ids }) .where(user_actions: { user_id: new_user_ids })
.distinct mentions_to_new_users = mentions_scope.distinct.count
.pluck("user_actions.user_id") mentioned_user_ids = mentions_scope.distinct.pluck("user_actions.user_id")
# Unique new users interacted with # Unique new users interacted with
unique_new_users = (liked_user_ids + replied_user_ids + mentioned_user_ids).uniq.count unique_new_users = (liked_user_ids + replied_user_ids + mentioned_user_ids).uniq.count
return if unique_new_users == 0 total_interactions = likes_given + replies_to_new_users + mentions_to_new_users
{ data: { unique_new_users: unique_new_users }, identifier: "new-user-interactions" } 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
end end
@@ -85,7 +85,7 @@ module DiscourseRewind
end end
def sort_and_limit(reactions) def sort_and_limit(reactions)
reactions.sort_by { |_, value| -value }.take(5).reverse.to_h reactions.sort_by { |_, v| -v }.first(5).reverse.to_h
end end
end end
end end
File diff suppressed because it is too large Load Diff
@@ -10,46 +10,7 @@ module DiscourseRewind
NIGHT_OWL_THRESHOLD_PM = 22..23 NIGHT_OWL_THRESHOLD_PM = 22..23
NIGHT_OWL_THRESHOLD_AM = 0..2 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 def call
return FakeData if Rails.env.development?
# Get activity by hour of day (in user's timezone) # Get activity by hour of day (in user's timezone)
activity_by_hour = get_activity_by_hour activity_by_hour = get_activity_by_hour
@@ -10,6 +10,7 @@ module DiscourseRewind
{ word: "you", score: 80 }, { word: "you", score: 80 },
{ word: "overachieved", score: 70 }, { word: "overachieved", score: 70 },
{ word: "assume", score: 60 }, { word: "assume", score: 60 },
{ word: "week", score: 50 },
], ],
identifier: "top-words", identifier: "top-words",
} }
@@ -78,13 +79,7 @@ module DiscourseRewind
LIMIT 100 LIMIT 100
SQL SQL
word_score = word_score = words.map { { word: _1.original_word, score: _1.ndoc + _1.nentry } }
words
.map do |word_data|
{ word: word_data.original_word, score: word_data.ndoc + word_data.nentry }
end
.sort_by! { |w| -w[:score] }
.take(5)
{ data: word_score, identifier: "top-words" } { data: word_score, identifier: "top-words" }
end end
@@ -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
@@ -0,0 +1,92 @@
# 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
+34 -13
View File
@@ -5,8 +5,7 @@ module DiscourseRewind
# #
# @example # @example
# ::DiscourseRewind::Rewind::Fetch.call( # ::DiscourseRewind::Rewind::Fetch.call(
# guardian: guardian, # guardian: guardian
# params: { year: 2023, username: 'codinghorror' }
# ) # )
# #
class FetchReports class FetchReports
@@ -17,15 +16,16 @@ module DiscourseRewind
# @param [Hash] params # @param [Hash] params
# @option params [Integer] :year of the rewind # @option params [Integer] :year of the rewind
# @option params [Integer] :username 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] # @return [Service::Base::Context]
CACHE_DURATION = Rails.env.development? ? 10.seconds : 5.minutes CACHE_DURATION = Rails.env.development? ? 10.seconds : 5.minutes
INITIAL_REPORT_COUNT = 3
# order matters # order matters
REPORTS = [ REPORTS = [
Action::TopWords, Action::TopWords,
Action::ReadingTime, Action::ReadingTime,
Action::WritingAnalysis,
Action::Reactions, Action::Reactions,
Action::Fbff, Action::Fbff,
Action::MostViewedTags, Action::MostViewedTags,
@@ -44,7 +44,9 @@ module DiscourseRewind
model :year model :year
model :date model :date
model :enabled_reports
model :reports model :reports
model :total_available
private private
@@ -73,19 +75,38 @@ module DiscourseRewind
Date.new(year).all_year Date.new(year).all_year
end end
def fetch_reports(date:, guardian:, year:) def fetch_enabled_reports(date:, guardian:, year:)
key = "rewind:#{guardian.user.username}:#{year}" # Generate all reports and filter out nils (disabled/empty reports)
reports = Discourse.redis.get(key) # Cache the full list to maintain consistent indices across requests
key = "rewind:#{guardian.user.username}:#{year}:all_reports"
cached_list = Discourse.redis.get(key)
if !reports return MultiJson.load(cached_list, symbolize_keys: true) if cached_list
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 =
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 reports
end 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
end end
@@ -28,47 +28,21 @@ export default class ActivityCalendar extends Component {
return moment().month(monthIndex).format("MMM"); return moment().month(monthIndex).format("MMM");
} }
@action
computeCellTitle(cell) {
if (!cell || !cell.date) {
return "";
}
const date = moment(cell.date).format("LL");
if (cell.visited && cell.post_count === 0) {
return i18n(
"discourse_rewind.reports.activity_calendar.cell_title.visited_no_posts",
{ date }
);
} else if (cell.post_count > 0) {
return i18n(
"discourse_rewind.reports.activity_calendar.cell_title.visited_with_posts",
{ date, count: cell.post_count }
);
}
return i18n(
"discourse_rewind.reports.activity_calendar.cell_title.no_activity",
{ date }
);
}
@action @action
computeClass(count) { computeClass(count) {
if (!count) { if (!count) {
return "--empty"; return "-empty";
} else if (count < 10) { } else if (count < 10) {
return "--low"; return "-low";
} else if (count < 20) { } else if (count < 20) {
return "--medium"; return "-medium";
} else { } else {
return "--high"; return "-high";
} }
} }
<template> <template>
<div class="rewind-report-page --activity-calendar"> <div class="rewind-report-page -activity-calendar">
<h2 class="rewind-report-title">{{i18n <h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.activity_calendar.title" "discourse_rewind.reports.activity_calendar.title"
}}</h2> }}</h2>
@@ -133,7 +107,7 @@ export default class ActivityCalendar extends Component {
{{#each row as |cell|}} {{#each row as |cell|}}
<td <td
data-date={{cell.date}} data-date={{cell.date}}
title={{this.computeCellTitle cell}} title={{cell.date}}
class={{concatClass class={{concatClass
"rewind-calendar-cell" "rewind-calendar-cell"
(this.computeClass cell.post_count) (this.computeClass cell.post_count)
@@ -1,176 +0,0 @@
import Component from "@glimmer/component";
import { get } from "@ember/helper";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { service } from "@ember/service";
import number from "discourse/helpers/number";
import { i18n } from "discourse-i18n";
export default class AiUsage extends Component {
@service currentUser;
matrixInterval = null;
get totalRequests() {
return this.args.report.data.total_requests ?? 0;
}
get totalTokens() {
return this.args.report.data.total_tokens ?? 0;
}
get successRate() {
return this.args.report.data.success_rate ?? 0;
}
get featureUsage() {
return Object.entries(this.args.report.data.feature_usage ?? {})
.filter(([name]) => name && name.trim().length > 0)
.slice(0, 3);
}
get modelUsage() {
return Object.entries(this.args.report.data.model_usage ?? {})
.filter(([name]) => name && name.trim().length > 0)
.slice(0, 3);
}
@action
formatFeatureName(featureName) {
return featureName.replace(/_/g, " ");
}
@action
setupMatrix(element) {
const canvas = element;
const ctx = canvas.getContext("2d");
canvas.width = element.offsetWidth;
canvas.height = element.offsetHeight;
const characters =
"01アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン";
const fontSize = 14;
const columns = Math.floor(canvas.width / fontSize);
const drops = Array(columns).fill(1);
const draw = () => {
ctx.fillStyle = "rgba(0, 0, 0, 0.05)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#0f0";
ctx.font = `${fontSize}px monospace`;
for (let i = 0; i < drops.length; i++) {
const char = characters[Math.floor(Math.random() * characters.length)];
const x = i * fontSize;
const y = drops[i] * fontSize;
ctx.fillText(char, x, y);
if (y > canvas.height && Math.random() > 0.975) {
drops[i] = 0;
}
drops[i]++;
}
};
this.matrixInterval = setInterval(draw, 33);
}
@action
cleanupMatrix() {
if (this.matrixInterval) {
clearInterval(this.matrixInterval);
}
}
<template>
<div class="rewind-report-page --ai-usage">
<div class="matrix-container">
<canvas
class="matrix-rain"
{{didInsert this.setupMatrix}}
{{willDestroy this.cleanupMatrix}}
></canvas>
<div class="matrix-content">
<h2 class="matrix-title">
<div class="matrix-subhead">
{{i18n
"discourse_rewind.reports.ai_usage.wake_up"
username=this.currentUser.username
}}
</div>
{{i18n "discourse_rewind.reports.ai_usage.system_title"}}
</h2>
<div class="matrix-stats">
<div class="matrix-stat">
<div class="matrix-stat__label">
{{i18n "discourse_rewind.reports.ai_usage.total_requests"}}
</div>
<div class="matrix-stat__value">
{{number this.totalRequests}}
</div>
</div>
<div class="matrix-stat">
<div class="matrix-stat__label">
{{i18n "discourse_rewind.reports.ai_usage.total_tokens"}}
</div>
<div class="matrix-stat__value">{{number this.totalTokens}}</div>
</div>
<div class="matrix-stat">
<div class="matrix-stat__label">
{{i18n "discourse_rewind.reports.ai_usage.success_rate"}}
</div>
<div class="matrix-stat__value">
<span class="number">
{{this.successRate}}%
</span>
</div>
</div>
</div>
{{#if this.featureUsage.length}}
<div class="matrix-section">
<div class="matrix-section__title">&gt;
{{i18n "discourse_rewind.reports.ai_usage.section_features"}}
</div>
<div class="matrix-list">
{{#each this.featureUsage as |entry|}}
<div class="matrix-list__item">
<span class="matrix-list__name">
{{this.formatFeatureName (get entry "0")}}
</span>
<span class="matrix-list__count">{{get entry "1"}}</span>
</div>
{{/each}}
</div>
</div>
{{/if}}
{{#if this.modelUsage.length}}
<div class="matrix-section">
<div class="matrix-section__title">&gt;
{{i18n "discourse_rewind.reports.ai_usage.section_models"}}
</div>
<div class="matrix-list">
{{#each this.modelUsage as |entry|}}
<div class="matrix-list__item">
<span class="matrix-list__name">{{get entry "0"}}</span>
<span class="matrix-list__count">{{get entry "1"}}</span>
</div>
{{/each}}
</div>
</div>
{{/if}}
</div>
</div>
</div>
</template>
}
@@ -1,63 +0,0 @@
import number from "discourse/helpers/number";
import { i18n } from "discourse-i18n";
const Assignments = <template>
<div class="rewind-report-page --assignments">
<div class="sticky-board">
<div class="sticky-note --yellow --rotate-left">
<div class="sticky-note__content">
<div class="sticky-note__title">
{{i18n "discourse_rewind.reports.assignments.completed"}}
</div>
<div class="sticky-note__value">
{{number @report.data.completed}}
</div>
</div>
</div>
<div class="sticky-note --pink --rotate-right">
<div class="sticky-note__content">
<div class="sticky-note__title">
{{i18n "discourse_rewind.reports.assignments.pending"}}
</div>
<div class="sticky-note__value">{{number @report.data.pending}}</div>
</div>
</div>
<div class="sticky-note --blue --rotate-left-small">
<div class="sticky-note__content">
<div class="sticky-note__title">
{{i18n "discourse_rewind.reports.assignments.total_assigned"}}
</div>
<div class="sticky-note__value">
{{number @report.data.total_assigned}}
</div>
</div>
</div>
<div class="sticky-note --green --rotate-right-small">
<div class="sticky-note__content">
<div class="sticky-note__title">
{{i18n "discourse_rewind.reports.assignments.assigned_by_user"}}
</div>
<div class="sticky-note__value">
{{number @report.data.assigned_by_user}}
</div>
</div>
</div>
<div class="sticky-note --orange">
<div class="sticky-note__content">
<div class="sticky-note__title">
{{i18n "discourse_rewind.reports.assignments.completion_rate"}}
</div>
<div
class="sticky-note__value"
>{{@report.data.completion_rate}}%</div>
</div>
</div>
</div>
</div>
</template>;
export default Assignments;
@@ -1,41 +1,37 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { concat } from "@ember/helper";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon"; import icon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
export default class BestPosts extends Component { export default class BestPosts extends Component {
rankClass(idx) { rank(idx) {
return `rank-${idx + 1}`; return idx + 1;
} }
<template> <template>
{{#if @report.data.length}} {{#if @report.data.length}}
<div class="rewind-report-page --best-posts"> <div class="rewind-report-page -best-posts">
<h2 class="rewind-report-title"> <h2 class="rewind-report-title">{{i18n
{{i18n
"discourse_rewind.reports.best_posts.title" "discourse_rewind.reports.best_posts.title"
[email protected] [email protected]
}} }}</h2>
</h2>
<div class="rewind-report-container"> <div class="rewind-report-container">
{{#each @report.data as |post idx|}} {{#each @report.data as |post idx|}}
<div class={{concatClass "rewind-card" (this.rankClass idx)}}> <div class={{concat "rewind-card" " rank-" (this.rank idx)}}>
<span class="best-posts --rank"></span> <span class="best-posts -rank"></span>
<span class="best-posts --rank"></span> <span class="best-posts -rank"></span>
<div class="best-posts__post"> <div class="best-posts__post"><p>{{htmlSafe
<p>{{htmlSafe post.excerpt}}</p> post.excerpt
</div> }}</p></div>
<div class="best-posts__metadata"> <div class="best-posts__metadata">
<span class="best-posts__likes"> <span class="best-posts__likes">
{{icon "heart"}}{{post.like_count}} {{icon "heart"}}{{post.like_count}}</span>
</span>
<span class="best-posts__replies"> <span class="best-posts__replies">
{{icon "comment"}}{{post.reply_count}} {{icon "comment"}}{{post.reply_count}}</span>
</span> <a href="/t/{{post.topic_id}}/{{post.post_number}}">{{i18n
<a href="/t/{{post.topic_id}}/{{post.post_number}}"> "discourse_rewind.reports.best_posts.view_post"
{{i18n "discourse_rewind.reports.best_posts.view_post"}} }}</a>
</a>
</div> </div>
</div> </div>
{{/each}} {{/each}}
@@ -1,38 +1,34 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { concat } from "@ember/helper"; import { concat } from "@ember/helper";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import concatClass from "discourse/helpers/concat-class";
import replaceEmoji from "discourse/helpers/replace-emoji"; import replaceEmoji from "discourse/helpers/replace-emoji";
import getURL from "discourse/lib/get-url";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
export default class BestTopics extends Component { export default class BestTopics extends Component {
rankClass(idx) { rank(idx) {
return `rank-${idx + 1}`; return idx + 1;
} }
<template> <template>
{{#if @report.data.length}} {{#if @report.data.length}}
<div class="rewind-report-page --best-topics"> <div class="rewind-report-page -best-topics">
<h2 class="rewind-report-title"> <h2 class="rewind-report-title">{{i18n
{{i18n
"discourse_rewind.reports.best_topics.title" "discourse_rewind.reports.best_topics.title"
[email protected] [email protected]
}} }}</h2>
</h2>
<div class="rewind-report-container"> <div class="rewind-report-container">
<div class="rewind-card"> <div class="rewind-card">
{{#each @report.data as |topic idx|}} {{#each @report.data as |topic idx|}}
<a <a
href={{getURL (concat "/t/-/" topic.topic_id)}} href={{concat "/t/-/" topic.topic_id}}
class={{concatClass "best-topics__topic" (this.rankClass idx)}} class={{concat "best-topics__topic" " rank-" (this.rank idx)}}
> >
<span class="best-topics --rank"></span> <span class="best-topics -rank"></span>
<span class="best-topics --rank"></span> <span class="best-topics -rank"></span>
<h2 class="best-topics__header">{{topic.title}}</h2> <h2 class="best-topics__header">{{topic.title}}</h2>
<span class="best-topics__excerpt"> <span class="best-topics__excerpt">{{replaceEmoji
{{replaceEmoji (htmlSafe topic.excerpt)}} (htmlSafe topic.excerpt)
</span> }}</span>
</a> </a>
{{/each}} {{/each}}
</div> </div>
@@ -1,161 +0,0 @@
import Component from "@glimmer/component";
import { concat } from "@ember/helper";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import avatar from "discourse/helpers/avatar";
import number from "discourse/helpers/number";
import { i18n } from "discourse-i18n";
const BotMessage = <template>
<div class="chat-message__avatar">🤖</div>
<div class="chat-message__bubble">
<div class="chat-message__author">
{{i18n "discourse_rewind.reports.chat_usage.bot_name"}}
</div>
{{#if @message}}
<div class="chat-message__text">
{{htmlSafe @message}}
</div>
{{/if}}
{{yield}}
</div>
</template>;
const UserMessage = <template>
<div class="chat-message__bubble">
<div class="chat-message__author">
{{i18n "discourse_rewind.reports.chat_usage.you"}}
</div>
{{#if @replyKey}}
<div class="chat-message__text">
{{i18n (concat "discourse_rewind.reports.chat_usage." @replyKey)}}
</div>
{{/if}}
{{yield}}
</div>
<div class="chat-message__avatar">
{{avatar @user imageSize="small"}}
</div>
</template>;
export default class ChatUsage extends Component {
@service currentUser;
get favoriteChannels() {
return this.args.report.data.favorite_channels ?? [];
}
<template>
<div class="rewind-report-page --chat-usage">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.chat_usage.title"
}}</h2>
<div class="chat-window">
<div class="chat-window__header">
<span class="chat-window__title">
{{i18n "discourse_rewind.reports.chat_usage.channel_title"}}
</span>
<span class="chat-window__status">
{{i18n "discourse_rewind.reports.chat_usage.status_online"}}
</span>
</div>
<div class="chat-window__messages">
<div class="chat-message --left">
<BotMessage
@message={{i18n
"discourse_rewind.reports.chat_usage.message_1"
count=(number @report.data.total_messages)
}}
/>
</div>
<div class="chat-message --right">
<UserMessage @user={{this.currentUser}} @replyKey="reply_1" />
</div>
<div class="chat-message --left">
<BotMessage
@message={{htmlSafe
(i18n
"discourse_rewind.reports.chat_usage.message_2"
dm_count=(number @report.data.dm_message_count)
channel_count=(number @report.data.unique_dm_channels)
)
}}
/>
</div>
<div class="chat-message --right">
<UserMessage @user={{this.currentUser}} @replyKey="reply_2" />
</div>
<div class="chat-message --left">
<BotMessage
@message={{htmlSafe
(i18n
"discourse_rewind.reports.chat_usage.message_3"
count=(number @report.data.total_reactions_received)
)
}}
/>
</div>
<div class="chat-message --right">
<UserMessage @user={{this.currentUser}} @replyKey="reply_3" />
</div>
<div class="chat-message --left">
<BotMessage
@message={{htmlSafe
(i18n
"discourse_rewind.reports.chat_usage.message_4"
[email protected]_message_length
)
}}
/>
</div>
{{#if this.favoriteChannels.length}}
<div class="chat-message --left">
<BotMessage
@message={{i18n
"discourse_rewind.reports.chat_usage.message_5"
}}
>
<div class="chat-message__channels">
{{#each this.favoriteChannels as |channel|}}
<a
class="chat-channel-link"
href={{concat "/chat/c/-/" channel.channel_id}}
>
<span
class="chat-channel-link__name"
>#{{channel.channel_name}}</span>
<span class="chat-channel-link__count">
{{number channel.message_count}}
</span>
</a>
{{/each}}
</div>
</BotMessage>
</div>
{{/if}}
<div class="chat-message --right">
<UserMessage @user={{this.currentUser}}>
<img
src="/plugins/discourse-rewind/images/dancing_baby.gif"
alt={{i18n
"discourse_rewind.reports.chat_usage.dancing_baby_alt"
}}
class="chat-message__gif"
/>
</UserMessage>
</div>
</div>
</div>
</div>
</template>
}
@@ -1,53 +0,0 @@
import Component from "@glimmer/component";
import icon from "discourse/helpers/d-icon";
import number from "discourse/helpers/number";
import { i18n } from "discourse-i18n";
export default class FavoriteGifs extends Component {
get favoriteGifs() {
return this.args.report.data.favorite_gifs ?? [];
}
<template>
{{#if this.favoriteGifs.length}}
<div class="rewind-report-page --favorite-gifs">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.favorite_gifs.title"
count=this.favoriteGifs.length
}}</h2>
<div class="rewind-report-subtitle">{{i18n
"discourse_rewind.reports.favorite_gifs.total_usage"
[email protected]_gif_usage
}}</div>
<div class="rewind-report-container">
{{#each this.favoriteGifs as |gif idx|}}
<div class="rewind-card scale">
<div class="favorite-gifs__gif">
<img
src={{gif.url}}
alt="GIF #{{idx}}"
class="favorite-gifs__image"
loading="lazy"
/>
<div class="favorite-gifs__stats">
<span class="favorite-gifs__stat">
{{icon "repeat"}}
{{number gif.usage_count}}
</span>
<span class="favorite-gifs__stat">
{{icon "heart"}}
{{number gif.likes}}
</span>
<span class="favorite-gifs__stat">
{{icon "smile"}}
{{number gif.reactions}}
</span>
</div>
</div>
</div>
{{/each}}
</div>
</div>
{{/if}}
</template>
}
@@ -3,10 +3,10 @@ import avatar from "discourse/helpers/bound-avatar-template";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
const FBFF = <template> const FBFF = <template>
<div class="rewind-report-page --fbff"> <div class="rewind-report-page -fbff">
<h2 class="rewind-report-title"> <h2 class="rewind-report-title">{{i18n
{{i18n "discourse_rewind.reports.fbff.title"}} "discourse_rewind.reports.fbff.title"
</h2> }}</h2>
<div class="rewind-report-container"> <div class="rewind-report-container">
<div class="rewind-card"> <div class="rewind-card">
<div class="fbff-avatar-container"> <div class="fbff-avatar-container">
@@ -1,118 +0,0 @@
import Component from "@glimmer/component";
import { concat } from "@ember/helper";
import avatar from "discourse/helpers/bound-avatar-template";
import number from "discourse/helpers/number";
import { i18n } from "discourse-i18n";
export default class Invites extends Component {
get mostActiveInvitee() {
return this.args.report.data.most_active_invitee;
}
<template>
<div class="rewind-report-page --invites">
<div class="guest-book">
<div class="guest-book__cover">
<div class="guest-book__title">
{{i18n "discourse_rewind.reports.invites.guest_book_title"}}
</div>
<div class="guest-book__subtitle">
{{i18n "discourse_rewind.reports.invites.guest_book_subtitle"}}
</div>
</div>
<div class="guest-book__page">
<div class="guest-book__entry">
<div class="guest-book__entry-label">
{{i18n "discourse_rewind.reports.invites.label_invitations_sent"}}
</div>
<div class="guest-book__entry-value">
{{number @report.data.total_invites}}
</div>
</div>
<div class="guest-book__entry">
<div class="guest-book__entry-label">
{{i18n "discourse_rewind.reports.invites.label_guests_joined"}}
</div>
<div class="guest-book__entry-value">
{{number @report.data.redeemed_count}}
</div>
</div>
<div class="guest-book__entry">
<div class="guest-book__entry-label">
{{i18n "discourse_rewind.reports.invites.label_acceptance_rate"}}
</div>
<div class="guest-book__entry-value">
{{@report.data.redemption_rate}}%
</div>
</div>
<div class="guest-book__divider"></div>
<div class="guest-book__section-title">
{{i18n "discourse_rewind.reports.invites.section_contributions"}}
</div>
<div class="guest-book__entry --small">
<div class="guest-book__entry-label">
{{i18n "discourse_rewind.reports.invites.label_posts_written"}}
</div>
<div class="guest-book__entry-value">
{{number @report.data.invitee_post_count}}
</div>
</div>
<div class="guest-book__entry --small">
<div class="guest-book__entry-label">
{{i18n "discourse_rewind.reports.invites.label_topics_started"}}
</div>
<div class="guest-book__entry-value">
{{number @report.data.invitee_topic_count}}
</div>
</div>
<div class="guest-book__entry --small">
<div class="guest-book__entry-label">
{{i18n "discourse_rewind.reports.invites.label_likes_given"}}
</div>
<div class="guest-book__entry-value">
{{number @report.data.invitee_like_count}}
</div>
</div>
{{#if this.mostActiveInvitee}}
<div class="guest-book__divider"></div>
<div class="guest-book__section-title">
{{i18n "discourse_rewind.reports.invites.section_most_active"}}
</div>
<a
href={{concat "/u/" this.mostActiveInvitee.username}}
class="guest-book__signature"
>
{{avatar
this.mostActiveInvitee.avatar_template
"large"
username=this.mostActiveInvitee.username
name=this.mostActiveInvitee.name
}}
<div class="guest-book__signature-info">
<span class="guest-book__signature-name">
{{this.mostActiveInvitee.username}}
</span>
{{#if this.mostActiveInvitee.name}}
<span class="guest-book__signature-realname">
{{this.mostActiveInvitee.name}}
</span>
{{/if}}
</div>
</a>
{{/if}}
</div>
</div>
</div>
</template>
}
@@ -3,13 +3,11 @@ import { i18n } from "discourse-i18n";
const MostViewedCategories = <template> const MostViewedCategories = <template>
{{#if @report.data.length}} {{#if @report.data.length}}
<div class="rewind-report-page --most-viewed-categories"> <div class="rewind-report-page -most-viewed-categories">
<h2 class="rewind-report-title"> <h2 class="rewind-report-title">{{i18n
{{i18n
"discourse_rewind.reports.most_viewed_categories.title" "discourse_rewind.reports.most_viewed_categories.title"
[email protected] [email protected]
}} }}</h2>
</h2>
<div class="rewind-report-container"> <div class="rewind-report-container">
{{#each @report.data as |data|}} {{#each @report.data as |data|}}
<a class="folder-wrapper" href={{concat "/c/-/" data.category_id}}> <a class="folder-wrapper" href={{concat "/c/-/" data.category_id}}>

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