Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34973acc55 | ||
|
|
f4560c2292 | ||
|
|
bdbac80063 | ||
|
|
1cdd3fd708 | ||
|
|
972cc6e5d9 | ||
|
|
a600db4d06 | ||
|
|
93b37e4069 |
@@ -6,14 +6,35 @@ 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) { raise Discourse::NotFound }
|
on_model_not_found(:year) do
|
||||||
on_model_not_found(:user) { raise Discourse::NotFound }
|
raise Discourse::NotFound.new(nil, custom_message: "discourse_rewind.invalid_year")
|
||||||
on_success do |reports:|
|
|
||||||
@reports = reports
|
|
||||||
render json: MultiJson.dump(reports), status: :ok
|
|
||||||
end
|
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")
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Assignment statistics using discourse-assign plugin data
|
||||||
|
# Shows how many assignments, completed, pending, etc.
|
||||||
|
module DiscourseRewind
|
||||||
|
module Action
|
||||||
|
class Assignments < BaseReport
|
||||||
|
def call
|
||||||
|
return if !enabled?
|
||||||
|
|
||||||
|
# Assignments made to the user
|
||||||
|
assignments_scope =
|
||||||
|
Assignment
|
||||||
|
.where(assigned_to_id: user.id, assigned_to_type: "User")
|
||||||
|
.where(created_at: date)
|
||||||
|
|
||||||
|
total_assigned = assignments_scope.count
|
||||||
|
|
||||||
|
# Completed assignments (topics that were assigned and then closed or unassigned)
|
||||||
|
completed_count =
|
||||||
|
assignments_scope
|
||||||
|
.joins(:topic)
|
||||||
|
.where(
|
||||||
|
"topics.closed = true OR assignments.active = false OR assignments.updated_at > assignments.created_at",
|
||||||
|
)
|
||||||
|
.distinct
|
||||||
|
.count
|
||||||
|
|
||||||
|
# Currently pending (still open and assigned)
|
||||||
|
pending_count =
|
||||||
|
Assignment
|
||||||
|
.where(assigned_to_id: user.id, assigned_to_type: "User", active: true)
|
||||||
|
.joins(:topic)
|
||||||
|
.where(topics: { closed: false })
|
||||||
|
.count
|
||||||
|
|
||||||
|
# Assignments made by the user to others
|
||||||
|
assigned_by_user =
|
||||||
|
Assignment
|
||||||
|
.where(assigned_by_user_id: user.id)
|
||||||
|
.where(created_at: date)
|
||||||
|
.count
|
||||||
|
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
total_assigned: total_assigned,
|
||||||
|
completed: completed_count,
|
||||||
|
pending: pending_count,
|
||||||
|
assigned_by_user: assigned_by_user,
|
||||||
|
completion_rate:
|
||||||
|
total_assigned > 0 ? (completed_count.to_f / total_assigned * 100).round(1) : 0,
|
||||||
|
},
|
||||||
|
identifier: "assignments",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def enabled?
|
||||||
|
defined?(Assignment) && SiteSetting.assign_enabled
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,9 +10,19 @@ module DiscourseRewind
|
|||||||
.where(created_at: date)
|
.where(created_at: date)
|
||||||
.where(deleted_at: nil)
|
.where(deleted_at: nil)
|
||||||
.where("post_number > 1")
|
.where("post_number > 1")
|
||||||
.order("like_count DESC NULLS LAST")
|
.order("like_count DESC NULLS LAST, created_at ASC")
|
||||||
.limit(3)
|
.limit(3)
|
||||||
.pluck(:post_number, :topic_id, :like_count, :reply_count, :raw, :cooked)
|
.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" }
|
{ data: best_posts, identifier: "best-posts" }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Chat usage statistics
|
||||||
|
# Shows message counts, favorite channels, DM activity, etc.
|
||||||
|
module DiscourseRewind
|
||||||
|
module Action
|
||||||
|
class ChatUsage < BaseReport
|
||||||
|
def call
|
||||||
|
return if !enabled?
|
||||||
|
|
||||||
|
messages =
|
||||||
|
Chat::Message.where(user_id: user.id).where(created_at: date).where(deleted_at: nil)
|
||||||
|
|
||||||
|
total_messages = messages.count
|
||||||
|
return if total_messages == 0
|
||||||
|
|
||||||
|
# Get favorite channels (public channels)
|
||||||
|
channel_usage =
|
||||||
|
messages
|
||||||
|
.joins(:chat_channel)
|
||||||
|
.where(chat_channels: { type: "CategoryChannel" })
|
||||||
|
.group("chat_channels.id", "chat_channels.name")
|
||||||
|
.count
|
||||||
|
.sort_by { |_, count| -count }
|
||||||
|
.first(5)
|
||||||
|
.map do |(id, name), count|
|
||||||
|
{ channel_id: id, channel_name: name, message_count: count }
|
||||||
|
end
|
||||||
|
|
||||||
|
# DM statistics
|
||||||
|
dm_message_count =
|
||||||
|
messages.joins(:chat_channel).where(chat_channels: { type: "DirectMessageChannel" }).count
|
||||||
|
|
||||||
|
# Unique DM conversations
|
||||||
|
unique_dm_channels =
|
||||||
|
messages
|
||||||
|
.joins(:chat_channel)
|
||||||
|
.where(chat_channels: { type: "DirectMessageChannel" })
|
||||||
|
.distinct
|
||||||
|
.count(:chat_channel_id)
|
||||||
|
|
||||||
|
# Messages with reactions received
|
||||||
|
messages_with_reactions =
|
||||||
|
Chat::MessageReaction
|
||||||
|
.joins(:chat_message)
|
||||||
|
.where(chat_messages: { user_id: user.id })
|
||||||
|
.where(chat_messages: { created_at: date })
|
||||||
|
.distinct
|
||||||
|
.count(:chat_message_id)
|
||||||
|
|
||||||
|
# Total reactions received
|
||||||
|
total_reactions_received =
|
||||||
|
Chat::MessageReaction
|
||||||
|
.joins(:chat_message)
|
||||||
|
.where(chat_messages: { user_id: user.id })
|
||||||
|
.where(chat_messages: { created_at: date })
|
||||||
|
.count
|
||||||
|
|
||||||
|
# Average message length
|
||||||
|
avg_message_length =
|
||||||
|
messages.where("LENGTH(message) > 0").average("LENGTH(message)")&.to_f&.round(1) || 0
|
||||||
|
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
total_messages: total_messages,
|
||||||
|
favorite_channels: channel_usage,
|
||||||
|
dm_message_count: dm_message_count,
|
||||||
|
unique_dm_channels: unique_dm_channels,
|
||||||
|
messages_with_reactions: messages_with_reactions,
|
||||||
|
total_reactions_received: total_reactions_received,
|
||||||
|
avg_message_length: avg_message_length,
|
||||||
|
},
|
||||||
|
identifier: "chat-usage",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def enabled?
|
||||||
|
SiteSetting.chat_enabled
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Find the user's most used GIFs in posts and chat
|
||||||
|
# Ranks by usage count and engagement (likes/reactions)
|
||||||
|
module DiscourseRewind
|
||||||
|
module Action
|
||||||
|
class FavoriteGifs < BaseReport
|
||||||
|
GIF_URL_PATTERN =
|
||||||
|
%r{
|
||||||
|
https?://[^\s]+\.(?:gif|gifv)
|
||||||
|
|
|
||||||
|
https?://(?!(?:developers|support|blog)\.) (?:[^/\s]+\.)?giphy\.com/(?!dashboard\b)[^\s]+
|
||||||
|
|
|
||||||
|
https?://(?!(?:support)\.) (?:[^/\s]+\.)?tenor\.com/(?!gifapi\b)[^\s]+
|
||||||
|
}ix
|
||||||
|
MAX_RESULTS = 5
|
||||||
|
|
||||||
|
def call
|
||||||
|
gif_data = {}
|
||||||
|
|
||||||
|
# Get GIFs from posts
|
||||||
|
post_gifs = extract_gifs_from_posts
|
||||||
|
post_gifs.each do |url, data|
|
||||||
|
gif_data[url] ||= { url: url, usage_count: 0, likes: 0, reactions: 0 }
|
||||||
|
gif_data[url][:usage_count] += data[:count]
|
||||||
|
gif_data[url][:likes] += data[:likes]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get GIFs from chat messages if chat is enabled
|
||||||
|
if SiteSetting.chat_enabled
|
||||||
|
chat_gifs = extract_gifs_from_chat
|
||||||
|
chat_gifs.each do |url, data|
|
||||||
|
gif_data[url] ||= { url: url, usage_count: 0, likes: 0, reactions: 0 }
|
||||||
|
gif_data[url][:usage_count] += data[:count]
|
||||||
|
gif_data[url][:reactions] += data[:reactions]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return if gif_data.empty?
|
||||||
|
|
||||||
|
# Sort by engagement score (usage * 10 + likes + reactions)
|
||||||
|
sorted_gifs =
|
||||||
|
gif_data
|
||||||
|
.values
|
||||||
|
.sort_by { |gif| -(gif[:usage_count] * 10 + gif[:likes] + gif[:reactions]) }
|
||||||
|
.first(MAX_RESULTS)
|
||||||
|
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
favorite_gifs: sorted_gifs,
|
||||||
|
total_gif_usage: gif_data.values.sum { |g| g[:usage_count] },
|
||||||
|
},
|
||||||
|
identifier: "favorite-gifs",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def extract_gifs_from_posts
|
||||||
|
gif_usage = {}
|
||||||
|
|
||||||
|
posts =
|
||||||
|
Post
|
||||||
|
.where(user_id: user.id)
|
||||||
|
.where(created_at: date)
|
||||||
|
.where(deleted_at: nil)
|
||||||
|
.where("raw ~* ?", gif_sql_pattern)
|
||||||
|
.select(:id, :raw, :like_count)
|
||||||
|
|
||||||
|
posts.each do |post|
|
||||||
|
gif_urls = post.raw.scan(GIF_URL_PATTERN).uniq.select { |url| content_gif_url?(url) }
|
||||||
|
gif_urls.each do |url|
|
||||||
|
gif_usage[url] ||= { count: 0, likes: 0 }
|
||||||
|
gif_usage[url][:count] += 1
|
||||||
|
gif_usage[url][:likes] += post.like_count || 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
gif_usage
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_gifs_from_chat
|
||||||
|
gif_usage = {}
|
||||||
|
|
||||||
|
messages =
|
||||||
|
Chat::Message
|
||||||
|
.where(user_id: user.id)
|
||||||
|
.where(created_at: date)
|
||||||
|
.where(deleted_at: nil)
|
||||||
|
.where("message ~* ?", gif_sql_pattern)
|
||||||
|
.select(:id, :message)
|
||||||
|
|
||||||
|
messages.each do |message|
|
||||||
|
gif_urls =
|
||||||
|
message.message.scan(GIF_URL_PATTERN).uniq.select { |url| content_gif_url?(url) }
|
||||||
|
gif_urls.each do |url|
|
||||||
|
gif_usage[url] ||= { count: 0, reactions: 0 }
|
||||||
|
gif_usage[url][:count] += 1
|
||||||
|
|
||||||
|
# Count reactions on this message
|
||||||
|
reaction_count = Chat::MessageReaction.where(chat_message_id: message.id).count
|
||||||
|
gif_usage[url][:reactions] += reaction_count
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
gif_usage
|
||||||
|
end
|
||||||
|
|
||||||
|
def gif_sql_pattern
|
||||||
|
@gif_sql_pattern ||= GIF_URL_PATTERN.source.gsub(/\s+/, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
def content_gif_url?(url)
|
||||||
|
return true if url.match?(/\.(gif|gifv)(?:\?|$)/i)
|
||||||
|
return true if url.match?(%r{giphy\.com/(?:gifs?|media|embed|stickers|clips)}i)
|
||||||
|
return true if url.match?(%r{tenor\.com/(?:view|watch|embed|gif)}i)
|
||||||
|
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 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
|
||||||
+5
-6
@@ -3,21 +3,20 @@
|
|||||||
# Topics visited grouped by category
|
# Topics visited grouped by category
|
||||||
module DiscourseRewind
|
module DiscourseRewind
|
||||||
module Action
|
module Action
|
||||||
class FavoriteCategories < BaseReport
|
class MostViewedCategories < BaseReport
|
||||||
FakeData = {
|
FakeData = {
|
||||||
data: [
|
data: [
|
||||||
{ category_id: 1, name: "cats" },
|
{ category_id: 1, name: "cats" },
|
||||||
{ category_id: 2, name: "dogs" },
|
{ category_id: 2, name: "dogs" },
|
||||||
{ category_id: 3, name: "countries" },
|
{ category_id: 3, name: "countries" },
|
||||||
{ category_id: 4, name: "management" },
|
{ category_id: 4, name: "management" },
|
||||||
{ category_id: 5, name: "things" },
|
|
||||||
],
|
],
|
||||||
identifier: "favorite-categories",
|
identifier: "most-viewed-categories",
|
||||||
}
|
}
|
||||||
def call
|
def call
|
||||||
return FakeData if Rails.env.development?
|
return FakeData if Rails.env.development?
|
||||||
|
|
||||||
favorite_categories =
|
most_viewed_categories =
|
||||||
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")
|
||||||
@@ -26,11 +25,11 @@ module DiscourseRewind
|
|||||||
.where(categories: { id: user.guardian.allowed_category_ids })
|
.where(categories: { id: user.guardian.allowed_category_ids })
|
||||||
.group("categories.id, categories.name")
|
.group("categories.id, categories.name")
|
||||||
.order("COUNT(*) DESC")
|
.order("COUNT(*) DESC")
|
||||||
.limit(5)
|
.limit(4)
|
||||||
.pluck("categories.id, categories.name")
|
.pluck("categories.id, categories.name")
|
||||||
.map { |category_id, name| { category_id: category_id, name: name } }
|
.map { |category_id, name| { category_id: category_id, name: name } }
|
||||||
|
|
||||||
{ data: favorite_categories, identifier: "favorite-categories" }
|
{ data: most_viewed_categories, identifier: "most-viewed-categories" }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
+5
-6
@@ -3,22 +3,21 @@
|
|||||||
# Topics visited grouped by tag
|
# Topics visited grouped by tag
|
||||||
module DiscourseRewind
|
module DiscourseRewind
|
||||||
module Action
|
module Action
|
||||||
class FavoriteTags < BaseReport
|
class MostViewedTags < BaseReport
|
||||||
FakeData = {
|
FakeData = {
|
||||||
data: [
|
data: [
|
||||||
{ tag_id: 1, name: "cats" },
|
{ tag_id: 1, name: "cats" },
|
||||||
{ tag_id: 2, name: "dogs" },
|
{ tag_id: 2, name: "dogs" },
|
||||||
{ tag_id: 3, name: "countries" },
|
{ tag_id: 3, name: "countries" },
|
||||||
{ tag_id: 4, name: "management" },
|
{ tag_id: 4, name: "management" },
|
||||||
{ tag_id: 5, name: "things" },
|
|
||||||
],
|
],
|
||||||
identifier: "favorite-tags",
|
identifier: "most-viewed-tags",
|
||||||
}
|
}
|
||||||
|
|
||||||
def call
|
def call
|
||||||
return FakeData if Rails.env.development?
|
return FakeData if Rails.env.development?
|
||||||
|
|
||||||
favorite_tags =
|
most_viewed_tags =
|
||||||
TopicViewItem
|
TopicViewItem
|
||||||
.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")
|
||||||
@@ -28,11 +27,11 @@ module DiscourseRewind
|
|||||||
.where(tags: { id: Tag.visible(user.guardian).pluck(:id) })
|
.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(5)
|
.limit(4)
|
||||||
.pluck("tags.id, tags.name")
|
.pluck("tags.id, tags.name")
|
||||||
.map { |tag_id, name| { tag_id: tag_id, name: name } }
|
.map { |tag_id, name| { tag_id: tag_id, name: name } }
|
||||||
|
|
||||||
{ data: favorite_tags, identifier: "favorite-tags" }
|
{ data: most_viewed_tags, identifier: "most-viewed-tags" }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Tracks how much this user interacted with new users (created this year)
|
||||||
|
# Shows veteran mentorship and community building behavior
|
||||||
|
module DiscourseRewind
|
||||||
|
module Action
|
||||||
|
class NewUserInteractions < BaseReport
|
||||||
|
def call
|
||||||
|
year_start = Date.new(date.first.year, 1, 1)
|
||||||
|
|
||||||
|
# Find users who created accounts this year
|
||||||
|
new_user_ids =
|
||||||
|
User
|
||||||
|
.real
|
||||||
|
.where("created_at >= ? AND created_at <= ?", year_start, date.last)
|
||||||
|
.where("id != ?", user.id)
|
||||||
|
.pluck(:id)
|
||||||
|
|
||||||
|
return if new_user_ids.empty?
|
||||||
|
|
||||||
|
# Count likes given to new users
|
||||||
|
likes_scope =
|
||||||
|
UserAction.where(
|
||||||
|
acting_user_id: user.id,
|
||||||
|
user_id: new_user_ids,
|
||||||
|
action_type: UserAction::WAS_LIKED,
|
||||||
|
).where(created_at: date)
|
||||||
|
likes_given = likes_scope.count
|
||||||
|
liked_user_ids = likes_scope.distinct.pluck(:user_id)
|
||||||
|
|
||||||
|
# Count replies to new users' posts
|
||||||
|
replies_scope =
|
||||||
|
Post
|
||||||
|
.joins(
|
||||||
|
"INNER JOIN posts AS parent_posts ON posts.reply_to_post_number = parent_posts.post_number AND posts.topic_id = parent_posts.topic_id",
|
||||||
|
)
|
||||||
|
.where(posts: { user_id: user.id, deleted_at: nil, created_at: date })
|
||||||
|
.where("parent_posts.user_id": new_user_ids)
|
||||||
|
replies_to_new_users = replies_scope.count
|
||||||
|
replied_user_ids = replies_scope.distinct.pluck("parent_posts.user_id")
|
||||||
|
|
||||||
|
# Count topics created by user that new users participated in
|
||||||
|
topics_with_new_users =
|
||||||
|
Topic
|
||||||
|
.joins(:posts)
|
||||||
|
.where(topics: { user_id: user.id, deleted_at: nil })
|
||||||
|
.where(posts: { user_id: new_user_ids, deleted_at: nil })
|
||||||
|
.where(topics: { created_at: date })
|
||||||
|
.distinct
|
||||||
|
.count
|
||||||
|
|
||||||
|
# Count direct messages/mentions to new users
|
||||||
|
mentions_scope =
|
||||||
|
Post
|
||||||
|
.joins(
|
||||||
|
"INNER JOIN user_actions ON user_actions.target_post_id = posts.id AND user_actions.action_type = #{UserAction::MENTION}",
|
||||||
|
)
|
||||||
|
.where(posts: { user_id: user.id, deleted_at: nil, created_at: date })
|
||||||
|
.where(user_actions: { user_id: new_user_ids })
|
||||||
|
mentions_to_new_users = mentions_scope.distinct.count
|
||||||
|
mentioned_user_ids = mentions_scope.distinct.pluck("user_actions.user_id")
|
||||||
|
|
||||||
|
# Unique new users interacted with
|
||||||
|
unique_new_users = (liked_user_ids + replied_user_ids + mentioned_user_ids).uniq.count
|
||||||
|
|
||||||
|
total_interactions = likes_given + replies_to_new_users + mentions_to_new_users
|
||||||
|
|
||||||
|
return if total_interactions == 0
|
||||||
|
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
total_interactions: total_interactions,
|
||||||
|
likes_given: likes_given,
|
||||||
|
replies_to_new_users: replies_to_new_users,
|
||||||
|
mentions_to_new_users: mentions_to_new_users,
|
||||||
|
topics_with_new_users: topics_with_new_users,
|
||||||
|
unique_new_users: unique_new_users,
|
||||||
|
new_users_count: new_user_ids.count,
|
||||||
|
},
|
||||||
|
identifier: "new-user-interactions",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Time of day activity analysis
|
||||||
|
# Shows when a user is most active (considering their timezone)
|
||||||
|
# Determines if they are a night owl or early bird
|
||||||
|
module DiscourseRewind
|
||||||
|
module Action
|
||||||
|
class TimeOfDayActivity < BaseReport
|
||||||
|
EARLY_BIRD_THRESHOLD = 6..9
|
||||||
|
NIGHT_OWL_THRESHOLD_PM = 22..23
|
||||||
|
NIGHT_OWL_THRESHOLD_AM = 0..2
|
||||||
|
|
||||||
|
def call
|
||||||
|
# Get activity by hour of day (in user's timezone)
|
||||||
|
activity_by_hour = get_activity_by_hour
|
||||||
|
|
||||||
|
return if activity_by_hour.empty?
|
||||||
|
|
||||||
|
total_activities = activity_by_hour.values.sum
|
||||||
|
most_active_hour = activity_by_hour.max_by { |_, count| count }&.first
|
||||||
|
personality = determine_personality(activity_by_hour, total_activities)
|
||||||
|
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
activity_by_hour: activity_by_hour,
|
||||||
|
most_active_hour: most_active_hour,
|
||||||
|
personality: personality,
|
||||||
|
total_activities: total_activities,
|
||||||
|
},
|
||||||
|
identifier: "time-of-day-activity",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def get_activity_by_hour
|
||||||
|
# Get user timezone offset
|
||||||
|
user_timezone = user.user_option&.timezone || "UTC"
|
||||||
|
quoted_timezone = ActiveRecord::Base.connection.quote(user_timezone)
|
||||||
|
hour_extract_sql =
|
||||||
|
Arel.sql(
|
||||||
|
"EXTRACT(HOUR FROM created_at AT TIME ZONE 'UTC' AT TIME ZONE #{quoted_timezone})::integer",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize hash with all hours
|
||||||
|
activity = (0..23).to_h { |hour| [hour, 0] }
|
||||||
|
|
||||||
|
# Posts created
|
||||||
|
post_hours =
|
||||||
|
Post
|
||||||
|
.where(user_id: user.id)
|
||||||
|
.where(created_at: date)
|
||||||
|
.where(deleted_at: nil)
|
||||||
|
.pluck(hour_extract_sql)
|
||||||
|
.tally
|
||||||
|
|
||||||
|
# User visits (page views)
|
||||||
|
visit_hours =
|
||||||
|
UserHistory
|
||||||
|
.where(acting_user_id: user.id)
|
||||||
|
.where(created_at: date)
|
||||||
|
.where(action: UserHistory.actions[:page_view])
|
||||||
|
.pluck(hour_extract_sql)
|
||||||
|
.tally
|
||||||
|
|
||||||
|
# Chat messages if chat is enabled
|
||||||
|
if SiteSetting.chat_enabled
|
||||||
|
chat_hours =
|
||||||
|
Chat::Message
|
||||||
|
.where(user_id: user.id)
|
||||||
|
.where(created_at: date)
|
||||||
|
.where(deleted_at: nil)
|
||||||
|
.pluck(hour_extract_sql)
|
||||||
|
.tally
|
||||||
|
|
||||||
|
chat_hours.each { |hour, count| activity[hour] += count }
|
||||||
|
end
|
||||||
|
|
||||||
|
post_hours.each { |hour, count| activity[hour] += count }
|
||||||
|
visit_hours.each { |hour, count| activity[hour] += count }
|
||||||
|
|
||||||
|
activity
|
||||||
|
end
|
||||||
|
|
||||||
|
def determine_personality(activity_by_hour, total_activities)
|
||||||
|
return nil if total_activities == 0
|
||||||
|
|
||||||
|
early_bird_activity = EARLY_BIRD_THRESHOLD.sum { |hour| activity_by_hour[hour] || 0 }
|
||||||
|
night_owl_activity =
|
||||||
|
NIGHT_OWL_THRESHOLD_PM.sum { |hour| activity_by_hour[hour] || 0 } +
|
||||||
|
NIGHT_OWL_THRESHOLD_AM.sum { |hour| activity_by_hour[hour] || 0 }
|
||||||
|
|
||||||
|
early_bird_percentage = (early_bird_activity.to_f / total_activities * 100).round(1)
|
||||||
|
night_owl_percentage = (night_owl_activity.to_f / total_activities * 100).round(1)
|
||||||
|
|
||||||
|
if early_bird_percentage > 20
|
||||||
|
{ type: "early_bird", percentage: early_bird_percentage }
|
||||||
|
elsif night_owl_percentage > 20
|
||||||
|
{ type: "night_owl", percentage: night_owl_percentage }
|
||||||
|
else
|
||||||
|
{ type: "balanced", percentage: 0 }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
@@ -16,9 +16,11 @@ 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 = [
|
||||||
@@ -26,24 +28,28 @@ module DiscourseRewind
|
|||||||
Action::ReadingTime,
|
Action::ReadingTime,
|
||||||
Action::Reactions,
|
Action::Reactions,
|
||||||
Action::Fbff,
|
Action::Fbff,
|
||||||
Action::FavoriteTags,
|
Action::MostViewedTags,
|
||||||
Action::FavoriteCategories,
|
Action::MostViewedCategories,
|
||||||
Action::BestTopics,
|
Action::BestTopics,
|
||||||
Action::BestPosts,
|
Action::BestPosts,
|
||||||
Action::ActivityCalendar,
|
Action::ActivityCalendar,
|
||||||
|
Action::TimeOfDayActivity,
|
||||||
|
Action::NewUserInteractions,
|
||||||
|
Action::ChatUsage,
|
||||||
|
Action::AiUsage,
|
||||||
|
Action::FavoriteGifs,
|
||||||
|
Action::Assignments,
|
||||||
|
Action::Invites,
|
||||||
]
|
]
|
||||||
|
|
||||||
model :year
|
model :year
|
||||||
model :user
|
|
||||||
model :date
|
model :date
|
||||||
|
model :enabled_reports
|
||||||
model :reports
|
model :reports
|
||||||
|
model :total_available
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def fetch_user(guardian:)
|
|
||||||
User.find_by_username(guardian.user.username)
|
|
||||||
end
|
|
||||||
|
|
||||||
def fetch_year
|
def fetch_year
|
||||||
current_date = Time.zone.now
|
current_date = Time.zone.now
|
||||||
current_month = current_date.month
|
current_month = current_date.month
|
||||||
@@ -55,7 +61,13 @@ module DiscourseRewind
|
|||||||
when 12
|
when 12
|
||||||
current_year
|
current_year
|
||||||
else
|
else
|
||||||
false
|
# 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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -63,18 +75,38 @@ module DiscourseRewind
|
|||||||
Date.new(year).all_year
|
Date.new(year).all_year
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_reports(date:, user:, 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:) }
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Component from "@glimmer/component";
|
import Component from "@glimmer/component";
|
||||||
import { concat } from "@ember/helper";
|
import { concat } from "@ember/helper";
|
||||||
import { get } from "@ember/object";
|
|
||||||
import { htmlSafe } from "@ember/template";
|
import { htmlSafe } from "@ember/template";
|
||||||
import icon from "discourse/helpers/d-icon";
|
import icon from "discourse/helpers/d-icon";
|
||||||
import { i18n } from "discourse-i18n";
|
import { i18n } from "discourse-i18n";
|
||||||
@@ -22,13 +21,15 @@ export default class BestPosts extends Component {
|
|||||||
<div class={{concat "rewind-card" " rank-" (this.rank 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">{{htmlSafe (get post "5")}}</div>
|
<div class="best-posts__post"><p>{{htmlSafe
|
||||||
|
post.excerpt
|
||||||
|
}}</p></div>
|
||||||
<div class="best-posts__metadata">
|
<div class="best-posts__metadata">
|
||||||
<span class="best-posts__likes">
|
<span class="best-posts__likes">
|
||||||
{{icon "heart"}}{{htmlSafe (get post "2")}}</span>
|
{{icon "heart"}}{{post.like_count}}</span>
|
||||||
<span class="best-posts__replies">
|
<span class="best-posts__replies">
|
||||||
{{icon "comment"}}{{htmlSafe (get post "3")}}</span>
|
{{icon "comment"}}{{post.reply_count}}</span>
|
||||||
<a href="/t/{{get post '1'}}/{{get post '0'}}">{{i18n
|
<a href="/t/{{post.topic_id}}/{{post.post_number}}">{{i18n
|
||||||
"discourse_rewind.reports.best_posts.view_post"
|
"discourse_rewind.reports.best_posts.view_post"
|
||||||
}}</a>
|
}}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 replaceEmoji from "discourse/helpers/replace-emoji";
|
||||||
import { i18n } from "discourse-i18n";
|
import { i18n } from "discourse-i18n";
|
||||||
|
|
||||||
export default class BestTopics extends Component {
|
export default class BestTopics extends Component {
|
||||||
@@ -25,8 +26,8 @@ export default class BestTopics extends Component {
|
|||||||
<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">{{htmlSafe
|
<span class="best-topics__excerpt">{{replaceEmoji
|
||||||
topic.excerpt
|
(htmlSafe topic.excerpt)
|
||||||
}}</span>
|
}}</span>
|
||||||
</a>
|
</a>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { concat } from "@ember/helper";
|
|
||||||
import { i18n } from "discourse-i18n";
|
|
||||||
|
|
||||||
const FavoriteCategories = <template>
|
|
||||||
{{#if @report.data.length}}
|
|
||||||
<div class="rewind-report-page -favorite-categories">
|
|
||||||
<h2 class="rewind-report-title">{{i18n
|
|
||||||
"discourse_rewind.reports.favorite_categories.title"
|
|
||||||
[email protected]
|
|
||||||
}}</h2>
|
|
||||||
<div class="rewind-report-container">
|
|
||||||
{{#each @report.data as |data|}}
|
|
||||||
<a href={{concat "/c/-/" data.category_id}} class="rewind-card">
|
|
||||||
<p
|
|
||||||
class="favorite-categories__category"
|
|
||||||
href={{concat "/c/-/" data.category_id}}
|
|
||||||
>{{data.name}}</p>
|
|
||||||
</a>
|
|
||||||
{{/each}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
</template>;
|
|
||||||
|
|
||||||
export default FavoriteCategories;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { concat } from "@ember/helper";
|
|
||||||
import { i18n } from "discourse-i18n";
|
|
||||||
|
|
||||||
const FavoriteTags = <template>
|
|
||||||
{{#if @report.data.length}}
|
|
||||||
<div class="rewind-report-page -favorite-tags">
|
|
||||||
<h2 class="rewind-report-title">{{i18n
|
|
||||||
"discourse_rewind.reports.favorite_tags.title"
|
|
||||||
[email protected]
|
|
||||||
}}</h2>
|
|
||||||
<div class="rewind-report-container">
|
|
||||||
{{#each @report.data as |data|}}
|
|
||||||
<a class="rewind-card" href={{concat "/tag/" data.name}}>
|
|
||||||
<p
|
|
||||||
class="favorite-tags__tag"
|
|
||||||
href={{concat "/tag/" data.name}}
|
|
||||||
>#{{data.name}}</p>
|
|
||||||
</a>
|
|
||||||
{{/each}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
</template>;
|
|
||||||
|
|
||||||
export default FavoriteTags;
|
|
||||||
@@ -1,13 +1,41 @@
|
|||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
|
||||||
const RewindHeader = <template>
|
const RewindHeader = <template>
|
||||||
<div class="rewind__header">
|
<div class="rewind__header">
|
||||||
<img
|
<div class="rewind__header-logo">
|
||||||
class="rewind-logo -light"
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -1 104 106">
|
||||||
src="/plugins/discourse-rewind/images/discourse-rewind-logo.png"
|
<path
|
||||||
/>
|
fill="#231f20"
|
||||||
<img
|
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"
|
||||||
class="rewind-logo -dark"
|
/>
|
||||||
src="/plugins/discourse-rewind/images/discourse-rewind-logo-dark.png"
|
<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>
|
</div>
|
||||||
</template>;
|
</template>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { concat } from "@ember/helper";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
|
||||||
|
const MostViewedCategories = <template>
|
||||||
|
{{#if @report.data.length}}
|
||||||
|
<div class="rewind-report-page -most-viewed-categories">
|
||||||
|
<h2 class="rewind-report-title">{{i18n
|
||||||
|
"discourse_rewind.reports.most_viewed_categories.title"
|
||||||
|
[email protected]
|
||||||
|
}}</h2>
|
||||||
|
<div class="rewind-report-container">
|
||||||
|
{{#each @report.data as |data|}}
|
||||||
|
<a class="folder-wrapper" href={{concat "/c/-/" data.category_id}}>
|
||||||
|
<span class="folder-tab"></span>
|
||||||
|
<div class="rewind-card">
|
||||||
|
<p class="most-viewed-categories__category">#{{data.name}}</p>
|
||||||
|
</div>
|
||||||
|
<span class="folder-bg"></span>
|
||||||
|
</a>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</template>;
|
||||||
|
|
||||||
|
export default MostViewedCategories;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { concat } from "@ember/helper";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
|
||||||
|
const MostViewedTags = <template>
|
||||||
|
{{#if @report.data.length}}
|
||||||
|
<div class="rewind-report-page -most-viewed-tags">
|
||||||
|
<h2 class="rewind-report-title">{{i18n
|
||||||
|
"discourse_rewind.reports.most_viewed_tags.title"
|
||||||
|
[email protected]
|
||||||
|
}}</h2>
|
||||||
|
<div class="rewind-report-container">
|
||||||
|
{{#each @report.data as |data|}}
|
||||||
|
<a class="folder-wrapper" href={{concat "/tag/" data.name}}>
|
||||||
|
<span class="folder-tab"></span>
|
||||||
|
<div class="rewind-card">
|
||||||
|
<p
|
||||||
|
class="most-viewed-tags__tag"
|
||||||
|
href={{concat "/tag/" data.name}}
|
||||||
|
>
|
||||||
|
#{{data.name}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="folder-bg"></span>
|
||||||
|
</a>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</template>;
|
||||||
|
|
||||||
|
export default MostViewedTags;
|
||||||
@@ -8,11 +8,11 @@ import emoji from "discourse/helpers/emoji";
|
|||||||
import discourseLater from "discourse/lib/later";
|
import discourseLater from "discourse/lib/later";
|
||||||
|
|
||||||
const MYSTERY_EMOJIS = [
|
const MYSTERY_EMOJIS = [
|
||||||
"mag", // 🔍
|
"floppy_disk",
|
||||||
"question", // ❓
|
"videocassette",
|
||||||
"8ball", // 🎱
|
"computer_disk",
|
||||||
"crystal_ball", // 🔮
|
"pager",
|
||||||
"crescent_moon", // 🌙
|
"fax",
|
||||||
];
|
];
|
||||||
|
|
||||||
const BACKGROUND_COLORS = [
|
const BACKGROUND_COLORS = [
|
||||||
|
|||||||
@@ -3,18 +3,20 @@ import { tracked } from "@glimmer/tracking";
|
|||||||
import { on } from "@ember/modifier";
|
import { on } from "@ember/modifier";
|
||||||
import { action } from "@ember/object";
|
import { action } from "@ember/object";
|
||||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||||
import { eq } from "truth-helpers";
|
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
|
||||||
import DButton from "discourse/components/d-button";
|
import DButton from "discourse/components/d-button";
|
||||||
import concatClass from "discourse/helpers/concat-class";
|
import concatClass from "discourse/helpers/concat-class";
|
||||||
import { ajax } from "discourse/lib/ajax";
|
import { ajax } from "discourse/lib/ajax";
|
||||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
|
import { eq } from "discourse/truth-helpers";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
import ActivityCalendar from "discourse/plugins/discourse-rewind/discourse/components/reports/activity-calendar";
|
import ActivityCalendar from "discourse/plugins/discourse-rewind/discourse/components/reports/activity-calendar";
|
||||||
import BestPosts from "discourse/plugins/discourse-rewind/discourse/components/reports/best-posts";
|
import BestPosts from "discourse/plugins/discourse-rewind/discourse/components/reports/best-posts";
|
||||||
import BestTopics from "discourse/plugins/discourse-rewind/discourse/components/reports/best-topics";
|
import BestTopics from "discourse/plugins/discourse-rewind/discourse/components/reports/best-topics";
|
||||||
import FavoriteCategories from "discourse/plugins/discourse-rewind/discourse/components/reports/favorite-categories";
|
|
||||||
import FavoriteTags from "discourse/plugins/discourse-rewind/discourse/components/reports/favorite-tags";
|
|
||||||
import FBFF from "discourse/plugins/discourse-rewind/discourse/components/reports/fbff";
|
import FBFF from "discourse/plugins/discourse-rewind/discourse/components/reports/fbff";
|
||||||
import RewindHeader from "discourse/plugins/discourse-rewind/discourse/components/reports/header";
|
import RewindHeader from "discourse/plugins/discourse-rewind/discourse/components/reports/header";
|
||||||
|
import MostViewedCategories from "discourse/plugins/discourse-rewind/discourse/components/reports/most-viewed-categories";
|
||||||
|
import MostViewedTags from "discourse/plugins/discourse-rewind/discourse/components/reports/most-viewed-tags";
|
||||||
import Reactions from "discourse/plugins/discourse-rewind/discourse/components/reports/reactions";
|
import Reactions from "discourse/plugins/discourse-rewind/discourse/components/reports/reactions";
|
||||||
import ReadingTime from "discourse/plugins/discourse-rewind/discourse/components/reports/reading-time";
|
import ReadingTime from "discourse/plugins/discourse-rewind/discourse/components/reports/reading-time";
|
||||||
import TopWords from "discourse/plugins/discourse-rewind/discourse/components/reports/top-words";
|
import TopWords from "discourse/plugins/discourse-rewind/discourse/components/reports/top-words";
|
||||||
@@ -23,17 +25,44 @@ export default class Rewind extends Component {
|
|||||||
@tracked rewind = [];
|
@tracked rewind = [];
|
||||||
@tracked fullScreen = true;
|
@tracked fullScreen = true;
|
||||||
@tracked loadingRewind = false;
|
@tracked loadingRewind = false;
|
||||||
|
@tracked totalAvailable = 0;
|
||||||
|
@tracked loadingNextReport = false;
|
||||||
|
|
||||||
|
BUFFER_SIZE = 3;
|
||||||
|
|
||||||
|
// Arrow function for event listener - maintains 'this' binding
|
||||||
|
handleScroll = () => {
|
||||||
|
if (!this.scrollWrapper || this.loadingRewind) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollTop = this.scrollWrapper.scrollTop;
|
||||||
|
const scrollHeight = this.scrollWrapper.scrollHeight;
|
||||||
|
const clientHeight = this.scrollWrapper.clientHeight;
|
||||||
|
|
||||||
|
// Trigger preload when scrolled 70% through content
|
||||||
|
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
||||||
|
if (scrollPercentage > 0.7) {
|
||||||
|
this.preloadNextReports();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
registerScrollWrapper(element) {
|
registerScrollWrapper(element) {
|
||||||
this.scrollWrapper = element;
|
this.scrollWrapper = element;
|
||||||
|
this.scrollWrapper.addEventListener("scroll", this.handleScroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
async loadRewind() {
|
async loadRewind() {
|
||||||
try {
|
try {
|
||||||
this.loadingRewind = true;
|
this.loadingRewind = true;
|
||||||
this.rewind = await ajax("/rewinds");
|
const response = await ajax("/rewinds");
|
||||||
|
this.rewind = response.reports;
|
||||||
|
this.totalAvailable = response.total_available;
|
||||||
|
|
||||||
|
// Preload next report to maintain buffer
|
||||||
|
this.preloadNextReports();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
popupAjaxError(e);
|
popupAjaxError(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -41,6 +70,38 @@ export default class Rewind extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async preloadNextReports() {
|
||||||
|
// Load reports to maintain BUFFER_SIZE ahead of current position
|
||||||
|
const currentIndex = this.rewind.length;
|
||||||
|
const targetIndex = currentIndex + this.BUFFER_SIZE;
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.loadingNextReport ||
|
||||||
|
currentIndex >= this.totalAvailable ||
|
||||||
|
targetIndex > this.totalAvailable
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.loadingNextReport = true;
|
||||||
|
const response = await ajax(`/rewinds/${currentIndex}`);
|
||||||
|
this.rewind = [...this.rewind, response.report];
|
||||||
|
|
||||||
|
// Continue preloading if we haven't reached buffer size yet
|
||||||
|
if (this.rewind.length < targetIndex) {
|
||||||
|
this.preloadNextReports();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silently fail for preloading - user already has content to view
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("Failed to preload report:", e);
|
||||||
|
} finally {
|
||||||
|
this.loadingNextReport = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
toggleFullScreen() {
|
toggleFullScreen() {
|
||||||
this.fullScreen = !this.fullScreen;
|
this.fullScreen = !this.fullScreen;
|
||||||
@@ -54,13 +115,9 @@ export default class Rewind extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
handleScroll({ target }) {
|
handleBackdropClick(event) {
|
||||||
let children = this.rewindContainer.getElementsByClassName("parallax-bg");
|
if (this.fullScreen && event.target === event.currentTarget) {
|
||||||
|
this.fullScreen = false;
|
||||||
for (let i = 0; i < children.length; i++) {
|
|
||||||
children[i].style.transform = `translateY(-${
|
|
||||||
(target.scrollTop * (i + 1)) / 5
|
|
||||||
}px)`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +126,13 @@ export default class Rewind extends Component {
|
|||||||
this.rewindContainer = element;
|
this.rewindContainer = element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
cleanup() {
|
||||||
|
if (this.scrollWrapper) {
|
||||||
|
this.scrollWrapper.removeEventListener("scroll", this.handleScroll);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class={{concatClass
|
class={{concatClass
|
||||||
@@ -76,28 +140,30 @@ export default class Rewind extends Component {
|
|||||||
(if this.fullScreen "-fullscreen")
|
(if this.fullScreen "-fullscreen")
|
||||||
}}
|
}}
|
||||||
{{didInsert this.loadRewind}}
|
{{didInsert this.loadRewind}}
|
||||||
|
{{willDestroy this.cleanup}}
|
||||||
{{on "keydown" this.handleEscape}}
|
{{on "keydown" this.handleEscape}}
|
||||||
|
{{on "click" this.handleBackdropClick}}
|
||||||
{{didInsert this.registerRewindContainer}}
|
{{didInsert this.registerRewindContainer}}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div class="rewind">
|
<div class="rewind">
|
||||||
<RewindHeader />
|
<RewindHeader />
|
||||||
<div class="background-1 parallax-bg"></div>
|
|
||||||
{{#if this.loadingRewind}}
|
{{#if this.loadingRewind}}
|
||||||
<div class="rewind-loader">
|
<div class="rewind-loader">
|
||||||
<div class="spinner small"></div>
|
<div class="spinner small"></div>
|
||||||
<div class="rewind-loader__text">Crunching your data...</div>
|
<div class="rewind-loader__text">
|
||||||
|
{{i18n "discourse_rewind.loading"}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<DButton
|
<DButton
|
||||||
class="rewind__exit-fullscreen-btn"
|
class="btn-default rewind__exit-fullscreen-btn --special-kbd"
|
||||||
@icon={{if this.fullScreen "discourse-compress" "discourse-expand"}}
|
@icon={{if this.fullScreen "discourse-compress" "discourse-expand"}}
|
||||||
@action={{this.toggleFullScreen}}
|
@action={{this.toggleFullScreen}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="rewind__scroll-wrapper"
|
class="rewind__scroll-wrapper"
|
||||||
{{didInsert this.registerScrollWrapper}}
|
{{didInsert this.registerScrollWrapper}}
|
||||||
{{on "scroll" this.handleScroll}}
|
|
||||||
>
|
>
|
||||||
|
|
||||||
{{#each this.rewind as |report|}}
|
{{#each this.rewind as |report|}}
|
||||||
@@ -114,12 +180,12 @@ export default class Rewind extends Component {
|
|||||||
<BestTopics @report={{report}} />
|
<BestTopics @report={{report}} />
|
||||||
{{else if (eq report.identifier "activity-calendar")}}
|
{{else if (eq report.identifier "activity-calendar")}}
|
||||||
<ActivityCalendar @report={{report}} />
|
<ActivityCalendar @report={{report}} />
|
||||||
{{else if (eq report.identifier "favorite-tags")}}
|
{{else if (eq report.identifier "most-viewed-tags")}}
|
||||||
<FavoriteTags @report={{report}} />
|
<MostViewedTags @report={{report}} />
|
||||||
{{else if (eq report.identifier "reading-time")}}
|
{{else if (eq report.identifier "reading-time")}}
|
||||||
<ReadingTime @report={{report}} />
|
<ReadingTime @report={{report}} />
|
||||||
{{else if (eq report.identifier "favorite-categories")}}
|
{{else if (eq report.identifier "most-viewed-categories")}}
|
||||||
<FavoriteCategories @report={{report}} />
|
<MostViewedCategories @report={{report}} />
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import Component from "@glimmer/component";
|
import Component from "@glimmer/component";
|
||||||
|
import { service } from "@ember/service";
|
||||||
import { TrackedObject } from "@ember-compat/tracked-built-ins";
|
import { TrackedObject } from "@ember-compat/tracked-built-ins";
|
||||||
import bodyClass from "discourse/helpers/body-class";
|
import bodyClass from "discourse/helpers/body-class";
|
||||||
import KeyValueStore from "discourse/lib/key-value-store";
|
import KeyValueStore from "discourse/lib/key-value-store";
|
||||||
import isRewindActive from "discourse/plugins/discourse-rewind/discourse/lib/is-rewind-active";
|
|
||||||
|
|
||||||
export default class AvatarDecorator extends Component {
|
export default class AvatarDecorator extends Component {
|
||||||
|
@service currentUser;
|
||||||
|
|
||||||
store = new TrackedObject(
|
store = new TrackedObject(
|
||||||
new KeyValueStore("discourse_rewind_" + this.fetchYear)
|
new KeyValueStore("discourse_rewind_" + this.fetchYear)
|
||||||
);
|
);
|
||||||
@@ -26,7 +28,10 @@ export default class AvatarDecorator extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get showDecorator() {
|
get showDecorator() {
|
||||||
return isRewindActive() && !this.dismissed;
|
return (
|
||||||
|
this.currentUser?.is_development_env ||
|
||||||
|
(this.currentUser?.is_rewind_active && !this.dismissed)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -4,17 +4,24 @@ import { service } from "@ember/service";
|
|||||||
import DButton from "discourse/components/d-button";
|
import DButton from "discourse/components/d-button";
|
||||||
import icon from "discourse/helpers/d-icon";
|
import icon from "discourse/helpers/d-icon";
|
||||||
import KeyValueStore from "discourse/lib/key-value-store";
|
import KeyValueStore from "discourse/lib/key-value-store";
|
||||||
import isRewindActive from "discourse/plugins/discourse-rewind/discourse/lib/is-rewind-active";
|
import { i18n } from "discourse-i18n";
|
||||||
|
|
||||||
export default class RewindTab extends Component {
|
export default class RewindCallout extends Component {
|
||||||
@service router;
|
@service router;
|
||||||
|
@service currentUser;
|
||||||
|
|
||||||
store = new KeyValueStore("discourse_rewind_" + this.fetchYear);
|
store = new KeyValueStore("discourse_rewind_" + this.fetchYear);
|
||||||
|
|
||||||
get showCallout() {
|
get showCallout() {
|
||||||
return isRewindActive() && !this.dismissed;
|
return (
|
||||||
|
this.currentUser?.is_rewind_active &&
|
||||||
|
(this.currentUser?.is_development_env || !this.dismissed)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We want to show the previous year's rewind in January
|
||||||
|
// but the current year's rewind in any other month (in
|
||||||
|
// reality, only December).
|
||||||
get fetchYear() {
|
get fetchYear() {
|
||||||
const currentDate = new Date();
|
const currentDate = new Date();
|
||||||
const currentMonth = currentDate.getMonth();
|
const currentMonth = currentDate.getMonth();
|
||||||
@@ -44,16 +51,71 @@ export default class RewindTab extends Component {
|
|||||||
@action={{this.openRewind}}
|
@action={{this.openRewind}}
|
||||||
class="rewind-callout btn-transparent"
|
class="rewind-callout btn-transparent"
|
||||||
>
|
>
|
||||||
<img
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 515.48 76.72">
|
||||||
class="rewind-logo -light"
|
<path d="M0 0h515.48v76.72H0z" style="fill:#faf7e4" />
|
||||||
src="/plugins/discourse-rewind/images/discourse-rewind-logo.png"
|
<path
|
||||||
/>
|
d="M42.27 12.67c-12.8 0-23.58 10.38-23.58 23.18v24l23.57-.02c12.8 0 23.18-10.78 23.18-23.58S55.05 12.67 42.26 12.67z"
|
||||||
<img
|
style="fill:#010101"
|
||||||
class="rewind-logo -dark"
|
/>
|
||||||
src="/plugins/discourse-rewind/images/discourse-rewind-logo-dark.png"
|
<path
|
||||||
/>
|
d="M42.51 21.64c-7.94 0-14.37 6.44-14.36 14.38 0 2.39.6 4.73 1.73 6.83l-2.6 8.36 9.34-2.11a14.36 14.36 0 0 0 15.85-2.73 14.371 14.371 0 0 0-9.94-24.74h-.02Z"
|
||||||
|
style="fill:#fcf6b2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M53.75 44.9a14.356 14.356 0 0 1-17.13 4.18l-9.34 2.14 9.5-1.12c6.3 3.69 14.37 2.07 18.75-3.77s3.68-14.04-1.62-19.06c3.98 5.22 3.92 12.49-.16 17.63"
|
||||||
|
style="fill:#29abe2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M52.94 42.17c-3.52 5.55-10.35 7.99-16.6 5.95l-9.06 3.1 9.34-2.11c6.65 3 14.49.54 18.24-5.72s2.2-14.34-3.59-18.77c4.51 4.78 5.2 12.01 1.68 17.55Z"
|
||||||
|
style="fill:#10a94d"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M30.74 43.17c-2.6-6.27-.46-13.51 5.14-17.35S49 22.58 53.92 27.26c-4.55-5.98-12.94-7.44-19.24-3.35s-8.39 12.34-4.8 18.93l-2.6 8.36 3.46-8.04Z"
|
||||||
|
style="fill:#f15f25"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M29.88 42.85a14.36 14.36 0 0 1 3.31-17.77 14.35 14.35 0 0 1 18.07-.46c-5.16-5.43-13.63-5.99-19.45-1.28-5.83 4.71-7.05 13.11-2.82 19.29l-1.71 8.59zM181.65 0l71.41 76.72h34.22L215.88 0z"
|
||||||
|
style="fill:#d0222b"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m215.88 0 71.4 76.72h34.23L250.11 0z"
|
||||||
|
style="fill:#f15f25"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m250.11 0 71.4 76.72h34.23L284.33 0z"
|
||||||
|
style="fill:#fcf6b2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m284.33 0 71.41 76.72h34.22L318.56 0z"
|
||||||
|
style="fill:#10a94d"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m318.56 0 71.4 76.72h34.23L352.79 0z"
|
||||||
|
style="fill:#29abe2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<text
|
||||||
|
x="108"
|
||||||
|
y="22"
|
||||||
|
text-anchor="middle"
|
||||||
|
dominant-baseline="middle"
|
||||||
|
style="font-size: 20px; transform: skewX(15deg); fill: #010101; letter-spacing: .025em;"
|
||||||
|
>{{i18n "discourse_rewind.title"}}</text>
|
||||||
|
|
||||||
|
<text
|
||||||
|
x="115"
|
||||||
|
y="50"
|
||||||
|
text-anchor="middle"
|
||||||
|
dominant-baseline="middle"
|
||||||
|
style="font-size: 32px; font-weight: bold; fill: #010101;"
|
||||||
|
>{{i18n "discourse_rewind.year"}}</text>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<span class="btn no-text --special-kbd">
|
||||||
|
{{icon "play"}}
|
||||||
|
</span>
|
||||||
|
|
||||||
{{icon "arrow-right" class="rewind-callaout__arrow"}}
|
|
||||||
</DButton>
|
</DButton>
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { service } from "@ember/service";
|
||||||
import DNavigationItem from "discourse/components/d-navigation-item";
|
import DNavigationItem from "discourse/components/d-navigation-item";
|
||||||
import icon from "discourse/helpers/d-icon";
|
import icon from "discourse/helpers/d-icon";
|
||||||
import { i18n } from "discourse-i18n";
|
import { i18n } from "discourse-i18n";
|
||||||
import isRewindActive from "discourse/plugins/discourse-rewind/discourse/lib/is-rewind-active";
|
|
||||||
|
|
||||||
const RewindTab = <template>
|
export default class RewindTab extends Component {
|
||||||
{{#if isRewindActive}}
|
@service currentUser;
|
||||||
<DNavigationItem
|
|
||||||
@route="userActivity.rewind"
|
|
||||||
@ariaCurrentContext="subNav"
|
|
||||||
class="user-nav__activity-rewind"
|
|
||||||
>
|
|
||||||
{{icon "repeat"}}
|
|
||||||
<span>{{i18n "discourse_rewind.title"}}</span>
|
|
||||||
</DNavigationItem>
|
|
||||||
{{/if}}
|
|
||||||
</template>;
|
|
||||||
|
|
||||||
export default RewindTab;
|
<template>
|
||||||
|
{{#if this.currentUser.is_rewind_active}}
|
||||||
|
<DNavigationItem
|
||||||
|
@route="userActivity.rewind"
|
||||||
|
@ariaCurrentContext="subNav"
|
||||||
|
class="user-nav__activity-rewind"
|
||||||
|
>
|
||||||
|
{{icon "repeat"}}
|
||||||
|
<span>{{i18n "discourse_rewind.title"}}</span>
|
||||||
|
</DNavigationItem>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export default function isRewindActive() {
|
|
||||||
const currentDate = new Date();
|
|
||||||
const currentMonth = currentDate.getMonth();
|
|
||||||
return currentMonth === 0 || currentMonth === 11;
|
|
||||||
}
|
|
||||||
@@ -8,10 +8,11 @@
|
|||||||
@import "best-posts";
|
@import "best-posts";
|
||||||
@import "best-topics";
|
@import "best-topics";
|
||||||
@import "top-words";
|
@import "top-words";
|
||||||
@import "favorite-tags";
|
@import "most-viewed-tags";
|
||||||
@import "favorite-categories";
|
@import "most-viewed-categories";
|
||||||
@import "fonts";
|
@import "fonts";
|
||||||
@import "reading-time";
|
@import "reading-time";
|
||||||
@import "fbff";
|
@import "fbff";
|
||||||
@import "rewind-header";
|
@import "rewind-header";
|
||||||
@import "rewind-callout";
|
@import "rewind-callout";
|
||||||
|
@import "folder-styles";
|
||||||
|
|||||||
@@ -26,13 +26,12 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rewind-calendar-cell {
|
.rewind-calendar-cell {
|
||||||
height: 10px;
|
height: 10px;
|
||||||
width: 10px;
|
width: 10px;
|
||||||
border-radius: calc(var(--rewind-border-radius) / 2);
|
|
||||||
border: 1px solid rgb(var(--primary-rgb), 0.2);
|
|
||||||
|
|
||||||
@media screen and (width <= 475px) {
|
@media screen and (width <= 475px) {
|
||||||
height: 5px;
|
height: 5px;
|
||||||
@@ -44,15 +43,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.-low {
|
&.-low {
|
||||||
background: var(--tertiary-low);
|
background: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.-medium {
|
&.-medium {
|
||||||
background: var(--tertiary-medium);
|
background: var(--success-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.-high {
|
&.-high {
|
||||||
background: var(--tertiary-high);
|
background: var(--success-low);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +1,80 @@
|
|||||||
.-best-posts {
|
.-best-posts {
|
||||||
.rewind-report-container {
|
.rewind-report-container {
|
||||||
flex-direction: column;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, calc(32% - (1em / 3)));
|
||||||
gap: 1em;
|
gap: 1em;
|
||||||
|
|
||||||
|
@media screen and (width <= 580px) {
|
||||||
|
padding: 1em;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewind-report-title {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: none;
|
||||||
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rewind-card {
|
.rewind-card {
|
||||||
max-width: 700px;
|
box-sizing: border-box;
|
||||||
text-align: left;
|
padding: 0.5em;
|
||||||
align-items: unset;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
color: var(--rewind-black);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: left;
|
||||||
|
max-width: 700px;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
225deg,
|
||||||
|
transparent 13px,
|
||||||
|
var(--rewind-white) 13px
|
||||||
|
);
|
||||||
|
|
||||||
@include rewind-border;
|
&::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: -1px;
|
||||||
|
right: -1px;
|
||||||
|
border: 10px solid transparent;
|
||||||
|
border-left: 10px solid var(--rewind-white);
|
||||||
|
border-bottom: 10px solid var(--rewind-white);
|
||||||
|
box-shadow:
|
||||||
|
0 2px 4px rgb(0 0 0 / 0.4),
|
||||||
|
-1px 1px 4px rgb(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
&.rank-1 {
|
&.rank-1 {
|
||||||
--background: #ffe46a;
|
&::before {
|
||||||
--border-color: #ffd82a;
|
border-left: 10px solid #ffd82a;
|
||||||
|
border-bottom: 10px solid #ffd82a;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.rank-2 {
|
&.rank-2 {
|
||||||
--background: #d6d6d6;
|
&::before {
|
||||||
--border-color: #c4c4c4;
|
border-left: 10px solid #d6d6d6;
|
||||||
|
border-bottom: 10px solid #d6d6d6;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.rank-3 {
|
&.rank-3 {
|
||||||
--background: #dca570;
|
&::before {
|
||||||
--border-color: #cd7f32;
|
border-left: 10px solid #cd7f32;
|
||||||
}
|
border-bottom: 10px solid #cd7f32;
|
||||||
}
|
}
|
||||||
|
|
||||||
.best-posts.-rank {
|
|
||||||
z-index: -1;
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
left: -20px;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: none;
|
|
||||||
border-right: 28px solid var(--border-color);
|
|
||||||
border-top: 28px solid transparent;
|
|
||||||
|
|
||||||
&:nth-child(2) {
|
|
||||||
z-index: -1;
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
left: -20px;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-left: 28px solid var(--border-color);
|
|
||||||
border-right: 0;
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.best-posts__post {
|
.best-posts__post {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
@media screen and (width <= 475px) {
|
@media screen and (width <= 475px) {
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -76,10 +92,10 @@
|
|||||||
.best-posts__post p {
|
.best-posts__post p {
|
||||||
font-family: var(--pixel-text) !important;
|
font-family: var(--pixel-text) !important;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-size: var(--font-down-);
|
font-size: var(--font-down-2);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: var(--primary-800);
|
color: var(--rewind-grey);
|
||||||
|
|
||||||
@media screen and (width <= 475px) {
|
@media screen and (width <= 475px) {
|
||||||
font-size: var(--font-down-2);
|
font-size: var(--font-down-2);
|
||||||
@@ -118,7 +134,8 @@
|
|||||||
.best-posts__metadata a {
|
.best-posts__metadata a {
|
||||||
font-family: var(--pixel-text);
|
font-family: var(--pixel-text);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--primary);
|
color: var(--rewind-blue);
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.best-posts__metadata {
|
.best-posts__metadata {
|
||||||
@@ -128,12 +145,14 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
color: var(--primary-700);
|
color: var(--primary-700);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
margin-top: auto;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.best-posts__likes,
|
.best-posts__likes,
|
||||||
.best-posts__replies {
|
.best-posts__replies {
|
||||||
font-family: var(--pixel-text);
|
font-family: var(--pixel-text);
|
||||||
border: 1px solid var(--primary-200);
|
border: 1px solid var(--rewind-light-grey);
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
border-radius: var(--rewind-border-radius);
|
border-radius: var(--rewind-border-radius);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user