Author SHA1 Message Date
Rafael Silva 34973acc55 PERF: Load reports async 2025-11-26 15:12:59 -03:00
Rafael dos Santos Silva f4560c2292 PERF: Migrate AI usage report to use AiApiRequestStat for better perf (#31)
Changes the AiUsage report to query AiApiRequestStat instead of
AiApiAuditLog for significantly improved query performance.
2025-11-26 12:24:07 -03:00
Kris bdbac80063 UX: revert to emoji card backs (#30)
didn't get permission to use the classic solitaire images, so I switched
to some retro emoji!

<img width="717" height="219" alt="image"
src="https://github.com/user-attachments/assets/4c95da4f-dbbb-4bcb-8f4b-2e13ed287320"
/>
2025-11-24 09:38:52 -05:00
Martin Brennan 1cdd3fd708 FIX: Various bugs from 2024 rewind (#29)
* Use post excerpt for best posts to avoid giant posts
  ruining it
* Make sure short emojis (e.g. `:joy:`) render in best posts
  and best topics
* Make images show in best post excerpts (requires core change)
* Fix invite action error, using column that doesn't exist
* Add missing # for top category slugs
* Refactor report service + controller a little to make it easier
  to figure out what went wrong if it fails, and to use the guardian
  user

Copies a couple of fixes from
https://github.com/discourse/discourse-rewind/pull/28 to make
things work locally, the conflicts will be easy to resolve.

**Best topics before**

<img width="977" height="712" alt="image"
src="https://github.com/user-attachments/assets/e120507f-d0d8-44f0-b242-e1056084ef03"
/>


**Best topics after**

<img width="969" height="648" alt="image"
src="https://github.com/user-attachments/assets/dd13f6ac-35df-4e9a-907e-9ed5102843b6"
/>

**Categories before**

<img width="904" height="465" alt="image"
src="https://github.com/user-attachments/assets/0d15e73d-3534-4836-b776-2c97381e58ef"
/>


**Categories after**

<img width="925" height="466" alt="image"
src="https://github.com/user-attachments/assets/3d6e4eae-32ed-4f11-a4f4-090e4473ebe9"
/>
2025-11-19 09:36:08 +10:00
Kris 972cc6e5d9 UX: restyling for 2025 (#28)
This generally updates the styles for the 2025 version. I also made more
features work in development environments (so it's easier to test) and
fixed an issue with invites that was causing the report to not show.


<img width="400" alt="image"
src="https://github.com/user-attachments/assets/69a62b79-12e3-4539-a82c-9bbf9673abe2"
/>

<img width="1554" height="896" alt="image"
src="https://github.com/user-attachments/assets/60fabac5-6f20-4e01-9679-fd228c6850c2"
/>

<img width="754" height="459" alt="image"
src="https://github.com/user-attachments/assets/6725c6e2-1bac-417e-b4ee-190841ab656b"
/>

<img width="1472" height="490" alt="image"
src="https://github.com/user-attachments/assets/87c95e6a-5d78-4fb6-9f2d-dad13bcc9788"
/>

<img width="1480" height="702" alt="image"
src="https://github.com/user-attachments/assets/67bec8f9-dce5-4077-a07d-fe9e13696958"
/>
2025-11-18 09:03:41 -05:00
Martin Brennan a600db4d06 FIX: Rename Favorite Tags/Categories to Most Viewed Tags/Categories and isRewindActive fixes (#27)
This was confusing people last year, most people assumed it meant
the tags they used the most / categories they posted in the most.
This should clear things up.

Also removes the `isRewindActive` function in favour of
`is_rewind_active`
on the current user, since then we only have one place to check this,
and
it makes testing easier since we don't have to mock browser time.

Finally it moves the route for rewind in the UI to the new route path
format.
2025-11-07 10:20:25 +10:00
Rafael dos Santos SilvaandMartin Brennan 93b37e4069 FEATURE: Add seven new metrics to Discourse Rewind reports (#26)
## Summary
Adds seven new metrics to the Discourse Rewind plugin, significantly expanding the types of insights users can get about their annual
activity. These new reports cover temporal patterns, community engagement, plugin integrations, and content analysis.

## New Reports

### 1. Time of Day Activity (`time-of-day-activity`)
- Analyzes user activity by hour in their timezone
- Determines personality type: "early bird" (6-9am), "night owl" (10pm-2am), or "balanced"
- Aggregates posts, chat messages, and page views

### 2. New User Interactions (`new-user-interactions`)
- Tracks veteran mentorship and community building behavior
- Measures likes, replies, and mentions to users who joined this year
- Shows unique new users interacted with and total engagement

### 3. Chat Usage (`chat-usage`)
- Total messages and average message length
- Top 5 favorite channels with message counts
- DM statistics (message count, unique conversations)
- Reactions received on messages

### 4. AI Usage (`ai-usage`)
- Integrates with `discourse-ai` plugin
- Total requests, tokens consumed (request/response breakdown)
- Top 5 most used features and AI models
- Success rate calculation

### 5. Favorite GIFs (`favorite-gifs`)
- Extracts GIFs from posts and chat messages
- Ranks by engagement score (usage × 10 + likes + reactions)
- Supports Giphy, Tenor, and direct GIF URLs
- Shows top 5 GIFs with usage statistics

### 6. Assignments (`assignments`)
- Integrates with `discourse-assign` plugin
- Tracks assignments received and given
- Shows completion rate and pending assignments

### 7. Invites (`invites`)
- Total invites sent and redemption rate
- Impact metrics: invitee posts, topics, and likes created
- Most active invitee identification
- Average trust level of invitees

## Technical Details
- All reports extend `BaseReport` class
- Include proper enablement checks for plugin dependencies
- Use efficient database queries with proper joins and aggregations
- Return `nil` when no relevant data exists

Co-authored-by: Martin Brennan <[email protected]>
2025-10-30 11:18:35 -03:00
59 changed files with 1828 additions and 421 deletions
@@ -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
@@ -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
@@ -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
+49 -17
View File
@@ -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;
}
+3 -2
View File
@@ -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);
} }
} }
} }
+64 -45
View File
@@ -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