28 Commits
Author SHA1 Message Date
Martin Brennan 5effd054ec DEV: Mark plugin as deprecated (#35)
We are moving it into core with
https://github.com/discourse/discourse/pull/36409/
2025-12-05 16:01:41 +10:00
d9f9e0c461 UX: New visualizations for new reports (#34)
This adds some new visualizations and styles and reports and cleans up
some existing styles and class names.

Daily activity rhythm 

<img width="1588" height="412" alt="image"
src="https://github.com/user-attachments/assets/0c7980e4-7bb6-4e08-9957-24c3c5718572"
/>


Helping new members

<img width="1456" height="310" alt="image"
src="https://github.com/user-attachments/assets/096a13da-ab63-45d4-9b68-53a01df62a78"
/>

Chat activity 

<img width="1442" height="926" alt="image"
src="https://github.com/user-attachments/assets/07ce2cab-1963-4918-a9ff-805c251a1377"
/>

Assigns 

<img width="1388" height="844" alt="image"
src="https://github.com/user-attachments/assets/ee49c6ee-2da6-4bfa-b500-715ed7ceb13a"
/>

AI usage 

<img width="1442" height="1254" alt="image"
src="https://github.com/user-attachments/assets/b382bffb-010d-4072-9144-cb7e6a8680e3"
/>

Invitations 

<img width="1486" height="1346" alt="image"
src="https://github.com/user-attachments/assets/020f68a9-69d2-46ed-9f38-b0d5e98d02de"
/>

Writing analysis

<img width="917" height="409" alt="image"
src="https://github.com/user-attachments/assets/1147427c-513f-4737-8f28-99bfc6f4aff9"
/>

---------

Co-authored-by: awesomerobot <[email protected]>
Co-authored-by: Martin Brennan <[email protected]>
2025-12-02 09:41:30 -05:00
Martin Brennan 26a77d4c69 DEV: Adding specs and refactors (#21)
Add specs for the following reports:

* Reading time
* Activity calendar
* Best posts
* Best topics
* Top words
* Most viewed tags
* Most viewed categories

Did some minor UI and ruby refactors for related components.

Also made a minor change to the Activity calendar, to show a title based
on the number of posts or if the user was active on hover.

Still missing specs for:

* Reactions
* FBFF

And the newly added reports that don't yet have UI components.
2025-12-01 10:08:30 +10:00
Rafael dos Santos Silva 09ee91ad67 DEV: FakeData for all reports (#32) 2025-11-26 15:50:18 -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
dependabot[bot] ea11f40e61 Bump rack from 3.2.2 to 3.2.3 (#24)
Bumps [rack](https://github.com/rack/rack) from 3.2.2 to 3.2.3.
- [Release notes](https://github.com/rack/rack/releases)
- [Changelog](https://github.com/rack/rack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rack/rack/compare/v3.2.2...v3.2.3)

---
updated-dependencies:
- dependency-name: rack
  dependency-version: 3.2.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-11 01:04:16 +02:00
Jarek Radosz cdf6f0d0e7 DEV: Update linting (#23) 2025-10-09 13:53:37 +01:00
Alan Guo Xiang Tan 09ac87205c DEV: Pin version for Discourse < 3.6.0.beta1-dev (#20) 2025-08-27 12:47:41 +08:00
dependabot[bot] ca316389b5 Bump rack from 3.1.12 to 3.1.16 (#19)
Bumps [rack](https://github.com/rack/rack) from 3.1.12 to 3.1.16.
- [Release notes](https://github.com/rack/rack/releases)
- [Changelog](https://github.com/rack/rack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rack/rack/compare/v3.1.12...v3.1.16)

---
updated-dependencies:
- dependency-name: rack
  dependency-version: 3.1.16
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-21 17:52:10 +02:00
Jarek Radosz 4b621412ba DEV: Update linting config and run gjs-codemod (#18) 2025-06-05 11:39:18 +01:00
Loïc Guitaut 139688358b DEV: Add system spec to check core features are working fine (#16) 2025-04-09 14:26:53 +02:00
Jarek Radosz 5e2acd9c2d DEV: Update linting (#15) 2025-03-17 11:03:35 +00:00
Joffrey JAFFEUX ffdd50cc65 DEV: fix spec failure (#13)
Reports are only returned in december and january, so since we move into february the spec was failing.
2025-02-26 16:25:10 +01:00
dependabot[bot] 0b7de95705 Bump rack from 3.0.11 to 3.0.12 (#12)
Bumps [rack](https://github.com/rack/rack) from 3.0.11 to 3.0.12.
- [Release notes](https://github.com/rack/rack/releases)
- [Changelog](https://github.com/rack/rack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rack/rack/commits/v3.0.12)

---
updated-dependencies:
- dependency-name: rack
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-13 23:32:18 +00:00
David Taylor e090efa809 DEV: Bump dependencies and fix linting (#11) 2025-02-06 19:43:34 +01:00
David Taylor 6da3c9388d DEV: Pin version for Discourse <3.5.0.beta1-dev (#10) 2025-02-06 17:35:01 +01:00
Jordan Vidrine b3f205375a UX: Text & width adjustments (#9) 2025-01-22 12:23:40 -03:00
Rafael Silva 824d4a426b Restrict tags and categories to user visibility level 2025-01-21 12:45:57 -03:00
Joffrey JAFFEUX 6a7efb7f0c disable favorite tags for now 2025-01-20 18:49:08 +01:00
Joffrey JAFFEUX 8b1943fb9a fbff font 2025-01-20 18:24:09 +01:00
Joffrey JAFFEUX c7a8c3d30b basic specs 2025-01-20 17:17:18 +01:00
Joffrey JAFFEUX a59d6c7931 caching and load order 2025-01-20 16:50:51 +01:00
Joffrey JAFFEUX 141140a410 make fonts local to the project 2025-01-20 15:59:12 +01:00
115 changed files with 7864 additions and 3340 deletions
+3
View File
@@ -0,0 +1,3 @@
< 3.6.0.beta1-dev: ca316389b51afeb17a697efaff041e425a0deac6
< 3.5.0.beta5-dev: cbcbbd2e3316b06c91998c7a0fd493d89ab6d2a5
< 3.5.0.beta1-dev: b3f205375a2f12287aa3338d8adc1afa98c4bfbb
+67 -52
View File
@@ -1,80 +1,95 @@
GEM
remote: https://rubygems.org/
specs:
activesupport (7.1.3.3)
activesupport (8.0.3)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
ast (2.4.2)
base64 (0.2.0)
bigdecimal (3.1.8)
concurrent-ruby (1.2.3)
connection_pool (2.4.1)
drb (2.2.1)
i18n (1.14.5)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
ast (2.4.3)
base64 (0.3.0)
benchmark (0.4.1)
bigdecimal (3.3.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.4)
drb (2.2.3)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
json (2.7.2)
language_server-protocol (3.17.0.3)
minitest (5.23.1)
mutex_m (0.2.0)
parallel (1.24.0)
parser (3.3.1.0)
json (2.15.1)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
minitest (5.26.0)
parallel (1.27.0)
parser (3.3.9.0)
ast (~> 2.4.1)
racc
prettier_print (1.2.1)
racc (1.8.0)
rack (3.0.11)
prism (1.5.1)
racc (1.8.1)
rack (3.2.3)
rainbow (3.1.1)
regexp_parser (2.9.2)
rexml (3.3.9)
rubocop (1.64.0)
regexp_parser (2.11.3)
rubocop (1.81.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.31.3)
parser (>= 3.3.1.0)
rubocop-capybara (2.20.0)
rubocop (~> 1.41)
rubocop-discourse (3.8.0)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.47.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-capybara (2.22.1)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-discourse (3.13.3)
activesupport (>= 6.1)
rubocop (>= 1.59.0)
rubocop-capybara (>= 2.0.0)
rubocop-factory_bot (>= 2.0.0)
rubocop-rails (>= 2.25.0)
rubocop-rspec (>= 2.25.0)
rubocop-factory_bot (2.25.1)
rubocop (~> 1.41)
rubocop-rails (2.25.0)
lint_roller (>= 1.1.0)
rubocop (>= 1.73.2)
rubocop-capybara (>= 2.22.0)
rubocop-factory_bot (>= 2.27.0)
rubocop-rails (>= 2.30.3)
rubocop-rspec (>= 3.0.1)
rubocop-rspec_rails (>= 2.31.0)
rubocop-factory_bot (2.27.1)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rails (2.33.4)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (2.29.2)
rubocop (~> 1.40)
rubocop-capybara (~> 2.17)
rubocop-factory_bot (~> 2.22)
rubocop-rspec_rails (~> 2.28)
rubocop-rspec_rails (2.28.3)
rubocop (~> 1.40)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rspec (3.7.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec_rails (2.31.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec (~> 3.5)
ruby-progressbar (1.13.0)
syntax_tree (6.2.0)
securerandom (0.4.1)
syntax_tree (6.3.0)
prettier_print (>= 1.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.5.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uri (1.0.4)
PLATFORMS
ruby
@@ -84,4 +99,4 @@ DEPENDENCIES
syntax_tree
BUNDLED WITH
2.5.10
2.7.2
+2 -4
View File
@@ -1,7 +1,5 @@
# DiscourseRewind
Display stats on your last year of Discourse usage.
## DEPRECATED
## Usage
Navigate to `/my/activity/rewind`
This plugin has been moved into Discourse core as at https://github.com/discourse/discourse/pull/36409/
@@ -7,13 +7,14 @@ module ::DiscourseRewind
requires_login
def show
DiscourseRewind::Rewind::Fetch.call(service_params) do
on_model_not_found(:year) { raise Discourse::NotFound }
on_model_not_found(:user) { raise Discourse::NotFound }
on_success do |reports:|
@reports = reports
render json: MultiJson.dump(reports), status: 200
DiscourseRewind::FetchReports.call(service_params) do
on_model_not_found(:year) do
raise Discourse::NotFound.new(nil, custom_message: "discourse_rewind.invalid_year")
end
on_model_not_found(:reports) do
raise Discourse::NotFound.new(nil, custom_message: "discourse_rewind.report_failed")
end
on_success { |reports:| render json: MultiJson.dump(reports), status: :ok }
end
end
end
@@ -0,0 +1,43 @@
# frozen_string_literal: true
# For a GitHub like calendar
# https://docs.github.com/assets/cb-35216/mw-1440/images/help/profile/contributions-graph.webp
module DiscourseRewind
module Action
class ActivityCalendar < BaseReport
FakeData = {
data:
(Date.new(2024, 1, 1)..Date.new(2024, 12, 31)).map do |date|
{ date:, post_count: rand(0..20), visited: false }
end,
identifier: "activity-calendar",
}
def call
return FakeData if Rails.env.development?
calendar =
Post
.unscoped
.joins(<<~SQL)
RIGHT JOIN
generate_series('#{date.first}', '#{date.last}', '1 day'::interval) ON
posts.created_at::date = generate_series::date AND
posts.user_id = #{user.id} AND
posts.deleted_at IS NULL
SQL
.joins(
"LEFT JOIN user_visits ON generate_series::date = visited_at AND user_visits.user_id = #{user.id}",
)
.select(
"generate_series::date as date, count(posts.id) as post_count, COUNT(visited_at) > 0 as visited",
)
.group("generate_series, user_visits.id")
.order("generate_series")
.map { |row| { date: row.date, post_count: row.post_count, visited: row.visited } }
{ data: calendar, identifier: "activity-calendar" }
end
end
end
end
@@ -0,0 +1,97 @@
# frozen_string_literal: true
# AI usage statistics from discourse-ai plugin
# Shows total usage, favorite features, token consumption, etc.
# Uses AiApiRequestStat for efficient aggregation queries
module DiscourseRewind
module Action
class AiUsage < BaseReport
FakeData = {
data: {
total_requests: 247,
total_tokens: 156_890,
request_tokens: 45_230,
response_tokens: 111_660,
feature_usage: {
"chat_composer_helper" => 89,
"post_summarizer" => 56,
"semantic_search" => 42,
"topic_gist" => 38,
"similar_topics" => 22,
},
model_usage: {
"gpt-4" => 123,
"claude-3-5-sonnet" => 89,
"gpt-3.5-turbo" => 35,
},
success_rate: 94.7,
},
identifier: "ai-usage",
}
def call
return FakeData if Rails.env.development?
return if !enabled?
base_query = AiApiRequestStat.where(user_id: user.id).where(bucket_date: date)
# Get aggregated stats in a single query
stats =
base_query.select(
"COALESCE(SUM(usage_count), 0) as total_requests",
"COALESCE(SUM(request_tokens), 0) as total_request_tokens",
"COALESCE(SUM(response_tokens), 0) as total_response_tokens",
"COALESCE(SUM(CASE WHEN response_tokens > 0 THEN usage_count ELSE 0 END), 0) as successful_requests",
).take
return if stats.total_requests == 0
total_tokens = stats.total_request_tokens + stats.total_response_tokens
success_rate =
(
if stats.total_requests > 0
(stats.successful_requests.to_f / stats.total_requests * 100).round(1)
else
0
end
)
# Most used features (top 5)
feature_usage =
base_query
.group(:feature_name)
.order("SUM(usage_count) DESC")
.limit(5)
.pluck(:feature_name, Arel.sql("SUM(usage_count)"))
.to_h
# Most used AI model (top 5)
model_usage =
base_query
.where.not(language_model: nil)
.group(:language_model)
.order("SUM(usage_count) DESC")
.limit(5)
.pluck(:language_model, Arel.sql("SUM(usage_count)"))
.to_h
{
data: {
total_requests: stats.total_requests,
total_tokens: total_tokens,
request_tokens: stats.total_request_tokens,
response_tokens: stats.total_response_tokens,
feature_usage: feature_usage,
model_usage: model_usage,
success_rate: success_rate,
},
identifier: "ai-usage",
}
end
def enabled?
defined?(AiApiRequestStat) && SiteSetting.discourse_ai_enabled
end
end
end
end
@@ -0,0 +1,74 @@
# frozen_string_literal: true
# Assignment statistics using discourse-assign plugin data
# Shows how many assignments, completed, pending, etc.
module DiscourseRewind
module Action
class Assignments < BaseReport
FakeData = {
data: {
total_assigned: 24,
completed: 18,
pending: 6,
assigned_by_user: 15,
completion_rate: 75.0,
},
identifier: "assignments",
}
def call
return FakeData if Rails.env.development?
return if !enabled?
# Assignments made to the user
assignments_scope =
Assignment
.where(assigned_to_id: user.id, assigned_to_type: "User")
.where(created_at: date)
total_assigned = assignments_scope.count
# Completed assignments (topics that were assigned and then closed or unassigned)
completed_count =
assignments_scope
.joins(:topic)
.where(
"topics.closed = true OR assignments.active = false OR assignments.updated_at > assignments.created_at",
)
.distinct
.count
# Currently pending (still open and assigned)
pending_count =
Assignment
.where(assigned_to_id: user.id, assigned_to_type: "User", active: true)
.joins(:topic)
.where(topics: { closed: false })
.count
# Assignments made by the user to others
assigned_by_user =
Assignment
.where(assigned_by_user_id: user.id)
.where(created_at: date)
.count
{
data: {
total_assigned: total_assigned,
completed: completed_count,
pending: pending_count,
assigned_by_user: assigned_by_user,
completion_rate:
total_assigned > 0 ? (completed_count.to_f / total_assigned * 100).round(1) : 0,
},
identifier: "assignments",
}
end
def enabled?
defined?(Assignment) && SiteSetting.assign_enabled
end
end
end
end
@@ -0,0 +1,18 @@
# frozen_string_literal: true
module DiscourseRewind
module Action
class BaseReport < Service::ActionBase
option :user
option :date
def call
raise NotImplementedError
end
def self.enabled?
true
end
end
end
end
@@ -0,0 +1,61 @@
# frozen_string_literal: true
module DiscourseRewind
module Action
class BestPosts < BaseReport
FakeData = {
data: [
{
post_number: 5,
topic_id: 42,
like_count: 23,
reply_count: 8,
excerpt: "This is a great explanation of how ActiveRecord works under the hood...",
},
{
post_number: 12,
topic_id: 89,
like_count: 19,
reply_count: 5,
excerpt:
"Here's a comprehensive guide to testing Rails applications with RSpec and system tests...",
},
{
post_number: 3,
topic_id: 156,
like_count: 15,
reply_count: 12,
excerpt:
"The key to understanding PostgreSQL performance is looking at your query plans...",
},
],
identifier: "best-posts",
}
def call
return FakeData if Rails.env.development?
best_posts =
Post
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.where("post_number > 1")
.order("like_count DESC NULLS LAST, created_at ASC")
.limit(3)
.select(:post_number, :topic_id, :like_count, :reply_count, :raw, :cooked)
.map do |post|
{
post_number: post.post_number,
topic_id: post.topic_id,
like_count: post.like_count,
reply_count: post.reply_count,
excerpt:
post.excerpt(200, { strip_links: true, remap_emoji: true, keep_images: true }),
}
end
{ data: best_posts, identifier: "best-posts" }
end
end
end
end
@@ -0,0 +1,48 @@
# frozen_string_literal: true
module DiscourseRewind
module Action
class BestTopics < BaseReport
FakeData = {
data: [
{
topic_id: 1,
title: "How to get started with Rails",
excerpt: "A comprehensive guide to getting started with Ruby on Rails...",
yearly_score: 42.5,
},
{
topic_id: 2,
title: "Best practices for database optimization",
excerpt: "Learn how to optimize your database queries for better performance...",
yearly_score: 38.2,
},
{
topic_id: 3,
title: "Understanding ActiveRecord associations",
excerpt: "Deep dive into has_many, belongs_to, and other associations...",
yearly_score: 35.7,
},
],
identifier: "best-topics",
}
def call
return FakeData if Rails.env.development?
best_topics =
TopTopic
.includes(:topic)
.references(:topic)
.where(topic: { deleted_at: nil, created_at: date, user_id: user.id })
.order("yearly_score DESC NULLS LAST")
.limit(3)
.pluck(:topic_id, :title, :excerpt, :yearly_score)
.map do |topic_id, title, excerpt, yearly_score|
{ topic_id: topic_id, title: title, excerpt: excerpt, yearly_score: yearly_score }
end
{ data: best_topics, identifier: "best-topics" }
end
end
end
end
@@ -0,0 +1,102 @@
# frozen_string_literal: true
# Chat usage statistics
# Shows message counts, favorite channels, DM activity, etc.
module DiscourseRewind
module Action
class ChatUsage < BaseReport
FakeData = {
data: {
total_messages: 342,
favorite_channels: [
{ channel_id: 1, channel_name: "general", message_count: 156 },
{ channel_id: 2, channel_name: "tech-talk", message_count: 89 },
{ channel_id: 3, channel_name: "random", message_count: 45 },
{ channel_id: 4, channel_name: "dev", message_count: 32 },
{ channel_id: 5, channel_name: "announcements", message_count: 12 },
],
dm_message_count: 87,
unique_dm_channels: 12,
messages_with_reactions: 42,
total_reactions_received: 156,
avg_message_length: 78.5,
},
identifier: "chat-usage",
}
def call
return FakeData if Rails.env.development?
return if !enabled?
messages =
Chat::Message.where(user_id: user.id).where(created_at: date).where(deleted_at: nil)
total_messages = messages.count
return if total_messages == 0
# Get favorite channels (public channels)
channel_usage =
messages
.joins(:chat_channel)
.where(chat_channels: { type: "CategoryChannel" })
.group("chat_channels.id", "chat_channels.name")
.count
.sort_by { |_, count| -count }
.first(5)
.map do |(id, name), count|
{ channel_id: id, channel_name: name, message_count: count }
end
# DM statistics
dm_message_count =
messages.joins(:chat_channel).where(chat_channels: { type: "DirectMessageChannel" }).count
# Unique DM conversations
unique_dm_channels =
messages
.joins(:chat_channel)
.where(chat_channels: { type: "DirectMessageChannel" })
.distinct
.count(:chat_channel_id)
# Messages with reactions received
messages_with_reactions =
Chat::MessageReaction
.joins(:chat_message)
.where(chat_messages: { user_id: user.id })
.where(chat_messages: { created_at: date })
.distinct
.count(:chat_message_id)
# Total reactions received
total_reactions_received =
Chat::MessageReaction
.joins(:chat_message)
.where(chat_messages: { user_id: user.id })
.where(chat_messages: { created_at: date })
.count
# Average message length
avg_message_length =
messages.where("LENGTH(message) > 0").average("LENGTH(message)")&.to_f&.round(1) || 0
{
data: {
total_messages: total_messages,
favorite_channels: channel_usage,
dm_message_count: dm_message_count,
unique_dm_channels: unique_dm_channels,
messages_with_reactions: messages_with_reactions,
total_reactions_received: total_reactions_received,
avg_message_length: avg_message_length,
},
identifier: "chat-usage",
}
end
def enabled?
SiteSetting.chat_enabled
end
end
end
end
@@ -0,0 +1,162 @@
# frozen_string_literal: true
# Find the user's most used GIFs in posts and chat
# Ranks by usage count and engagement (likes/reactions)
module DiscourseRewind
module Action
class FavoriteGifs < BaseReport
GIF_URL_PATTERN =
%r{
https?://[^\s]+\.(?:gif|gifv)
|
https?://(?!(?:developers|support|blog)\.) (?:[^/\s]+\.)?giphy\.com/(?!dashboard\b)[^\s]+
|
https?://(?!(?:support)\.) (?:[^/\s]+\.)?tenor\.com/(?!gifapi\b)[^\s]+
}ix
MAX_RESULTS = 5
FakeData = {
data: {
favorite_gifs: [
{
url: "https://media.giphy.com/media/111ebonMs90YLu/giphy.gif",
usage_count: 12,
likes: 45,
reactions: 23,
},
{
url: "https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif",
usage_count: 8,
likes: 32,
reactions: 18,
},
{
url: "https://media1.tenor.com/m/XnODae53zvYAAAAd/joke-stickfigure.gif",
usage_count: 7,
likes: 28,
reactions: 15,
},
{
url: "https://c.tenor.com/tX_T48A14BwAAAAd/khaby-really.gif",
usage_count: 5,
likes: 20,
reactions: 12,
},
{
url: "https://media.giphy.com/media/3oriO0OEd9QIDdllqo/giphy.gif",
usage_count: 4,
likes: 15,
reactions: 8,
},
],
total_gif_usage: 36,
},
identifier: "favorite-gifs",
}
def call
return FakeData if Rails.env.development?
gif_data = {}
# Get GIFs from posts
post_gifs = extract_gifs_from_posts
post_gifs.each do |url, data|
gif_data[url] ||= { url: url, usage_count: 0, likes: 0, reactions: 0 }
gif_data[url][:usage_count] += data[:count]
gif_data[url][:likes] += data[:likes]
end
# Get GIFs from chat messages if chat is enabled
if SiteSetting.chat_enabled
chat_gifs = extract_gifs_from_chat
chat_gifs.each do |url, data|
gif_data[url] ||= { url: url, usage_count: 0, likes: 0, reactions: 0 }
gif_data[url][:usage_count] += data[:count]
gif_data[url][:reactions] += data[:reactions]
end
end
return if gif_data.empty?
# Sort by engagement score (usage * 10 + likes + reactions)
sorted_gifs =
gif_data
.values
.sort_by { |gif| -(gif[:usage_count] * 10 + gif[:likes] + gif[:reactions]) }
.first(MAX_RESULTS)
{
data: {
favorite_gifs: sorted_gifs,
total_gif_usage: gif_data.values.sum { |g| g[:usage_count] },
},
identifier: "favorite-gifs",
}
end
private
def extract_gifs_from_posts
gif_usage = {}
posts =
Post
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.where("raw ~* ?", gif_sql_pattern)
.select(:id, :raw, :like_count)
posts.each do |post|
gif_urls = post.raw.scan(GIF_URL_PATTERN).uniq.select { |url| content_gif_url?(url) }
gif_urls.each do |url|
gif_usage[url] ||= { count: 0, likes: 0 }
gif_usage[url][:count] += 1
gif_usage[url][:likes] += post.like_count || 0
end
end
gif_usage
end
def extract_gifs_from_chat
gif_usage = {}
messages =
Chat::Message
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.where("message ~* ?", gif_sql_pattern)
.select(:id, :message)
messages.each do |message|
gif_urls =
message.message.scan(GIF_URL_PATTERN).uniq.select { |url| content_gif_url?(url) }
gif_urls.each do |url|
gif_usage[url] ||= { count: 0, reactions: 0 }
gif_usage[url][:count] += 1
# Count reactions on this message
reaction_count = Chat::MessageReaction.where(chat_message_id: message.id).count
gif_usage[url][:reactions] += reaction_count
end
end
gif_usage
end
def gif_sql_pattern
@gif_sql_pattern ||= GIF_URL_PATTERN.source.gsub(/\s+/, "")
end
def content_gif_url?(url)
return true if url.match?(/\.(gif|gifv)(?:\?|$)/i)
return true if url.match?(%r{giphy\.com/(?:gifs?|media|embed|stickers|clips)}i)
return true if url.match?(%r{tenor\.com/(?:view|watch|embed|gif)}i)
false
end
end
end
end
@@ -0,0 +1,133 @@
# frozen_string_literal: true
# Forum Best Friend Forever ranking
# Score is informative only, do not show in UI
module DiscourseRewind
module Action
class Fbff < BaseReport
MAX_SUMMARY_RESULTS = 50
LIKE_SCORE = 1
REPLY_SCORE = 10
FakeData = {
data: {
fbff: {
id: 2,
username: "codingpal",
name: "Coding Pal",
avatar_template: "/letter_avatar_proxy/v4/letter/c/3be4f8/{size}.png",
},
yourself: {
id: 1,
username: "you",
name: "You",
avatar_template: "/letter_avatar_proxy/v4/letter/y/f05b48/{size}.png",
},
},
identifier: "fbff",
}
def call
return FakeData if Rails.env.development?
most_liked_users =
like_query(date)
.where(acting_user_id: user.id)
.group(:user_id)
.order("COUNT(*) DESC")
.limit(MAX_SUMMARY_RESULTS)
.pluck("user_actions.user_id, COUNT(*)")
.map { |user_id, count| { user_id => count } }
.reduce({}, :merge)
most_liked_by_users =
like_query(date)
.where(user: user)
.group(:acting_user_id)
.order("COUNT(*) DESC")
.limit(MAX_SUMMARY_RESULTS)
.pluck("acting_user_id, COUNT(*)")
.map { |acting_user_id, count| { acting_user_id => count } }
.reduce({}, :merge)
users_who_most_replied_me =
post_query(user, date)
.where(posts: { user_id: user.id })
.group("replies.user_id")
.order("COUNT(*) DESC")
.limit(MAX_SUMMARY_RESULTS)
.pluck("replies.user_id, COUNT(*)")
.map { |user_id, count| { user_id => count } }
.reduce({}, :merge)
users_i_most_replied =
post_query(user, date)
.where("replies.user_id = ?", user.id)
.group("posts.user_id")
.order("COUNT(*) DESC")
.limit(MAX_SUMMARY_RESULTS)
.pluck("posts.user_id, COUNT(*)")
.map { |user_id, count| { user_id => count } }
.reduce({}, :merge)
#TODO include chat?
fbffs = [
apply_score(most_liked_users, LIKE_SCORE),
apply_score(most_liked_by_users, LIKE_SCORE),
apply_score(users_who_most_replied_me, REPLY_SCORE),
apply_score(users_i_most_replied, REPLY_SCORE),
]
fbff_id =
fbffs
.flatten
.inject { |h1, h2| h1.merge(h2) { |_, v1, v2| v1 + v2 } }
&.sort_by { |_, v| -v }
&.first
&.first
return if !fbff_id
{
data: {
fbff: BasicUserSerializer.new(User.find(fbff_id), root: false).as_json,
yourself: BasicUserSerializer.new(user, root: false).as_json,
},
identifier: "fbff",
}
end
def post_query(user, date)
Post
.joins(:topic)
.includes(:topic)
.where(
"posts.post_type IN (?)",
Topic.visible_post_types(user, include_moderator_actions: false),
)
.joins(
"INNER JOIN posts replies ON posts.topic_id = replies.topic_id AND posts.reply_to_post_number = replies.post_number",
)
.joins(
"INNER JOIN topics ON replies.topic_id = topics.id AND topics.archetype <> 'private_message'",
)
.joins(
"AND replies.post_type IN (#{Topic.visible_post_types(user, include_moderator_actions: false).join(",")})",
)
.where("replies.created_at BETWEEN ? AND ?", date.first, date.last)
.where("replies.user_id <> posts.user_id")
end
def like_query(date)
UserAction
.joins(:target_topic, :target_post)
.where(created_at: date)
.where(action_type: UserAction::WAS_LIKED)
end
def apply_score(users, score)
users.map { |user_id, count| { user_id => count * score } }
end
end
end
end
@@ -0,0 +1,101 @@
# frozen_string_literal: true
# Invite statistics
# Shows how many users this user invited and the impact of those invitees
module DiscourseRewind
module Action
class Invites < BaseReport
FakeData = {
data: {
total_invites: 18,
redeemed_count: 12,
redemption_rate: 66.7,
invitee_post_count: 145,
invitee_topic_count: 23,
invitee_like_count: 89,
avg_trust_level: 1.8,
most_active_invitee: {
id: 42,
username: "newbie_123",
name: "New User",
avatar_template: "/letter_avatar_proxy/v4/letter/n/8c91d9/{size}.png",
},
},
identifier: "invites",
}
def call
return FakeData if Rails.env.development?
# Get all invites created by this user in the date range
invites = Invite.where(invited_by_id: user.id).where(created_at: date)
total_invites = invites.count
return if total_invites == 0
# Redeemed invites (users who actually joined)
redeemed_count = invites.where("redemption_count > 0").count
# Get the users who were invited (via InvitedUser or redeemed invites)
invited_user_ids = InvitedUser.where(invite: invites).pluck(:user_id).compact
invited_users = User.where(id: invited_user_ids)
# Calculate impact of invitees
invitee_post_count =
Post.where(user_id: invited_user_ids).where(created_at: date).where(deleted_at: nil).count
invitee_topic_count =
Topic
.where(user_id: invited_user_ids)
.where(created_at: date)
.where(deleted_at: nil)
.count
invitee_like_count =
UserAction
.where(user_id: invited_user_ids)
.where(action_type: UserAction::LIKE)
.where(created_at: date)
.count
# Calculate average trust level of invitees
avg_trust_level = invited_users.average(:trust_level)&.to_f&.round(1) || 0
# Most active invitee
most_active_invitee = nil
if invited_user_ids.any?
most_active_id =
Post
.where(user_id: invited_user_ids)
.where(created_at: date)
.where(deleted_at: nil)
.group(:user_id)
.count
.max_by { |_, count| count }
&.first
if most_active_id
most_active_user = User.find_by(id: most_active_id)
most_active_invitee =
BasicUserSerializer.new(most_active_user, root: false).as_json if most_active_user
end
end
{
data: {
total_invites: total_invites,
redeemed_count: redeemed_count,
redemption_rate:
total_invites > 0 ? (redeemed_count.to_f / total_invites * 100).round(1) : 0,
invitee_post_count: invitee_post_count,
invitee_topic_count: invitee_topic_count,
invitee_like_count: invitee_like_count,
avg_trust_level: avg_trust_level,
most_active_invitee: most_active_invitee,
},
identifier: "invites",
}
end
end
end
end
@@ -0,0 +1,40 @@
# frozen_string_literal: true
# Topics visited grouped by category
module DiscourseRewind
module Action
class MostViewedCategories < BaseReport
FakeData = {
data: [
{ category_id: 1, name: "cats" },
{ category_id: 2, name: "dogs" },
{ category_id: 3, name: "countries" },
{ category_id: 4, name: "management" },
],
identifier: "most-viewed-categories",
}
def call
return FakeData if Rails.env.development?
most_viewed_categories =
TopicViewItem
.joins(:topic)
.joins("INNER JOIN categories ON categories.id = topics.category_id")
.where(
user: user,
viewed_at: date,
categories: {
id: user.guardian.allowed_category_ids,
},
)
.group("categories.id, categories.name")
.order("COUNT(*) DESC")
.limit(4)
.pluck("categories.id, categories.name")
.map { |category_id, name| { category_id: category_id, name: name } }
{ data: most_viewed_categories, identifier: "most-viewed-categories" }
end
end
end
end
@@ -0,0 +1,36 @@
# frozen_string_literal: true
# Topics visited grouped by tag
module DiscourseRewind
module Action
class MostViewedTags < BaseReport
FakeData = {
data: [
{ tag_id: 1, name: "cats" },
{ tag_id: 2, name: "dogs" },
{ tag_id: 3, name: "countries" },
{ tag_id: 4, name: "management" },
],
identifier: "most-viewed-tags",
}
def call
return FakeData if Rails.env.development?
most_viewed_tags =
TopicViewItem
.joins(:topic)
.joins("INNER JOIN topic_tags ON topic_tags.topic_id = topics.id")
.joins("INNER JOIN tags ON tags.id = topic_tags.tag_id")
.where(user: user, viewed_at: date, tags: { id: Tag.visible(user.guardian).pluck(:id) })
.group("tags.id, tags.name")
.order("COUNT(DISTINCT topic_views.topic_id) DESC")
.limit(4)
.pluck("tags.id, tags.name")
.map { |tag_id, name| { tag_id: tag_id, name: name } }
{ data: most_viewed_tags, identifier: "most-viewed-tags" }
end
end
end
end
@@ -0,0 +1,78 @@
# frozen_string_literal: true
# Tracks how much this user interacted with new users (created this year)
# Shows veteran mentorship and community building behavior
module DiscourseRewind
module Action
class NewUserInteractions < BaseReport
FakeData = {
data: {
total_interactions: 127,
likes_given: 45,
replies_to_new_users: 62,
mentions_to_new_users: 20,
topics_with_new_users: 8,
unique_new_users: 24,
new_users_count: 156,
},
identifier: "new-user-interactions",
}
def call
return FakeData if Rails.env.development?
year_start = Date.new(date.first.year, 1, 1)
# Find users who created accounts this year
new_user_ids =
User
.real
.where("created_at >= ? AND created_at <= ?", year_start, date.last)
.where("id != ?", user.id)
.pluck(:id)
return if new_user_ids.empty?
# Count likes given to new users
liked_user_ids =
UserAction
.where(
acting_user_id: user.id,
user_id: new_user_ids,
action_type: UserAction::WAS_LIKED,
)
.where(created_at: date)
.distinct
.pluck(:user_id)
# Count replies to new users' posts
replied_user_ids =
Post
.joins(
"INNER JOIN posts AS parent_posts ON posts.reply_to_post_number = parent_posts.post_number AND posts.topic_id = parent_posts.topic_id",
)
.where(posts: { user_id: user.id, deleted_at: nil, created_at: date })
.where("parent_posts.user_id": new_user_ids)
.distinct
.pluck("parent_posts.user_id")
# Count direct mentions to new users
mentioned_user_ids =
Post
.joins(
"INNER JOIN user_actions ON user_actions.target_post_id = posts.id AND user_actions.action_type = #{UserAction::MENTION}",
)
.where(posts: { user_id: user.id, deleted_at: nil, created_at: date })
.where(user_actions: { user_id: new_user_ids })
.distinct
.pluck("user_actions.user_id")
# Unique new users interacted with
unique_new_users = (liked_user_ids + replied_user_ids + mentioned_user_ids).uniq.count
return if unique_new_users == 0
{ data: { unique_new_users: unique_new_users }, identifier: "new-user-interactions" }
end
end
end
end
@@ -0,0 +1,92 @@
# frozen_string_literal: true
# For a most user / received reactions cards
module DiscourseRewind
module Action
class Reactions < BaseReport
FakeData = {
data: {
post_received_reactions: {
"open_mouth" => 2,
"cat" => 32,
"dog" => 34,
"heart" => 45,
"grinning" => 82,
},
post_used_reactions: {
"open_mouth" => 2,
"cat" => 32,
"dog" => 34,
"heart" => 45,
"grinning" => 82,
},
chat_used_reactions: {
"open_mouth" => 2,
"cat" => 32,
"dog" => 34,
"heart" => 45,
"grinning" => 82,
},
chat_received_reactions: {
"open_mouth" => 2,
"cat" => 32,
"dog" => 34,
"heart" => 45,
"grinning" => 82,
},
},
identifier: "reactions",
}
def call
return FakeData if Rails.env.development?
data = {}
if defined?(DiscourseReactions::Reaction)
# This is missing heart reactions (default like)
data[:post_used_reactions] = sort_and_limit(
DiscourseReactions::Reaction
.by_user(user)
.where(created_at: date)
.group(:reaction_value)
.count,
)
data[:post_received_reactions] = sort_and_limit(
DiscourseReactions::Reaction
.includes(:post)
.where(posts: { user_id: user.id })
.where(created_at: date)
.group(:reaction_value)
.count,
)
end
if SiteSetting.chat_enabled
data[:chat_used_reactions] = sort_and_limit(
Chat::MessageReaction.where(user: user).where(created_at: date).group(:emoji).count,
)
data[:chat_received_reactions] = sort_and_limit(
Chat::MessageReaction
.includes(:chat_message)
.where(chat_message: { user_id: user.id })
.where(created_at: date)
.group(:emoji)
.count,
)
end
{ data:, identifier: "reactions" }
end
def enabled?
SiteSetting.discourse_reactions_enabled || SiteSetting.chat_enabled
end
def sort_and_limit(reactions)
reactions.sort_by { |_, value| -value }.take(5).reverse.to_h
end
end
end
end
@@ -3,78 +3,14 @@
# For showcasing the reading time of a user
# Should we show book covers or just the names?
module DiscourseRewind
class Rewind::Action::ReadingTime < Rewind::Action::BaseReport
FakeData = {
data: {
reading_time: 2_880_000,
book: "The Combined Cosmere works + Wheel of Time",
isbn: "978-0812511819",
series: true,
},
identifier: "reading-time",
}
def call
return FakeData if Rails.env.development?
reading_time = UserVisit.where(user_id: user.id).where(visited_at: date).sum(:time_read)
book = best_book_fit(reading_time)
return if book.nil?
{
data: {
reading_time: reading_time,
book: book[:title],
isbn: book[:isbn],
series: book[:series],
},
identifier: "reading-time",
}
end
def popular_books
{
"The Hunger Games" => {
reading_time: 19_740,
isbn: "978-0439023481",
series: false,
},
module Action
class ReadingTime < BaseReport
POPULAR_BOOKS = {
"The Metamorphosis" => {
reading_time: 3120,
isbn: "978-0553213690",
series: false,
},
"To Kill a Mockingbird" => {
reading_time: 22_800,
isbn: "978-0061120084",
series: false,
},
"Pride and Prejudice" => {
reading_time: 25_200,
isbn: "978-1503290563",
series: false,
},
"1984" => {
reading_time: 16_800,
isbn: "978-0451524935",
series: false,
},
"The Lord of the Rings" => {
reading_time: 108_000,
isbn: "978-0544003415",
series: true,
},
"Harry Potter and the Sorcerer's Stone" => {
reading_time: 24_600,
isbn: "978-0590353427",
series: true,
},
"The Great Gatsby" => {
reading_time: 12_600,
isbn: "978-0743273565",
series: false,
},
"The Little Prince" => {
reading_time: 5400,
isbn: "978-0156012195",
@@ -85,14 +21,19 @@ module DiscourseRewind
isbn: "978-0451526342",
series: false,
},
"The Catcher in the Rye" => {
reading_time: 18_000,
isbn: "978-0316769488",
"The Alchemist" => {
reading_time: 10_800,
isbn: "978-0061122415",
series: false,
},
"Jane Eyre" => {
reading_time: 34_200,
isbn: "978-0141441146",
"The Great Gatsby" => {
reading_time: 12_600,
isbn: "978-0743273565",
series: false,
},
"The Hitchhiker's Guide to the Galaxy" => {
reading_time: 12_600,
isbn: "978-0345391803",
series: false,
},
"Fahrenheit 451" => {
@@ -100,40 +41,70 @@ module DiscourseRewind
isbn: "978-1451673319",
series: false,
},
"And Then There Were None" => {
reading_time: 16_200,
isbn: "978-0062073488",
series: false,
},
"1984" => {
reading_time: 16_800,
isbn: "978-0451524935",
series: false,
},
"The Catcher in the Rye" => {
reading_time: 18_000,
isbn: "978-0316769488",
series: false,
},
"The Hunger Games" => {
reading_time: 19_740,
isbn: "978-0439023481",
series: false,
},
"To Kill a Mockingbird" => {
reading_time: 22_800,
isbn: "978-0061120084",
series: false,
},
"Harry Potter and the Sorcerer's Stone" => {
reading_time: 24_600,
isbn: "978-0590353427",
series: true,
},
"Pride and Prejudice" => {
reading_time: 25_200,
isbn: "978-1503290563",
series: false,
},
"The Hobbit" => {
reading_time: 27_000,
isbn: "978-0547928227",
series: false,
},
"The Da Vinci Code" => {
reading_time: 37_800,
isbn: "978-0307474278",
series: false,
},
"Little Women" => {
reading_time: 30_000,
isbn: "978-0147514011",
series: false,
},
"Jane Eyre" => {
reading_time: 34_200,
isbn: "978-0141441146",
series: false,
},
"The Da Vinci Code" => {
reading_time: 37_800,
isbn: "978-0307474278",
series: false,
},
"One Hundred Years of Solitude" => {
reading_time: 46_800,
isbn: "978-0060883287",
series: false,
},
"And Then There Were None" => {
reading_time: 16_200,
isbn: "978-0062073488",
series: false,
},
"The Alchemist" => {
reading_time: 10_800,
isbn: "978-0061122415",
series: false,
},
"The Hitchhiker's Guide to the Galaxy" => {
reading_time: 12_600,
isbn: "978-0345391803",
series: false,
"The Lord of the Rings" => {
reading_time: 108_000,
isbn: "978-0544003415",
series: true,
},
"The Complete works of Shakespeare" => {
reading_time: 180_000,
@@ -150,7 +121,7 @@ module DiscourseRewind
isbn: "978-0765348821",
series: true,
},
"Terry Pratchett’s Discworld series" => {
"Terry Pratchett's Discworld series" => {
reading_time: 1_440_000,
isbn: "978-9123684458",
series: true,
@@ -171,30 +142,46 @@ module DiscourseRewind
series: true,
},
}.symbolize_keys
end
def best_book_fit(reading_time)
reading_time_rest = reading_time
books = []
FakeData = {
data: {
reading_time: 2_880_000,
book: "The Combined Cosmere works + Wheel of Time",
isbn: "978-0812511819",
series: true,
},
identifier: "reading-time",
}
while reading_time_rest > 0
best_fit = popular_books.min_by { |_, v| (v[:reading_time] - reading_time_rest).abs }
break if best_fit.nil?
def call
return FakeData if Rails.env.development?
books << best_fit.first
reading_time_rest -= best_fit.last[:reading_time]
reading_time = UserVisit.where(user_id: user.id).where(visited_at: date).sum(:time_read)
book = best_book_fit(reading_time)
return if book.nil?
{
data: {
reading_time: reading_time,
book: book[:title].to_s,
isbn: book[:isbn],
series: book[:series],
},
identifier: "reading-time",
}
end
return if books.empty?
def best_book_fit(reading_time)
best_fit =
POPULAR_BOOKS
.select { |_, v| v[:reading_time] > reading_time }
.min_by { |_, v| v[:reading_time] }
book_title =
books.group_by { |book| book }.transform_values(&:count).max_by { |_, count| count }.first
return if best_fit.nil?
{
title: book_title,
isbn: popular_books[book_title][:isbn],
series: popular_books[book_title][:series],
}
{ title: best_fit.first, isbn: best_fit.last[:isbn], series: best_fit.last[:series] }
end
end
end
end
@@ -0,0 +1,145 @@
# frozen_string_literal: true
# Time of day activity analysis
# Shows when a user is most active (considering their timezone)
# Determines if they are a night owl or early bird
module DiscourseRewind
module Action
class TimeOfDayActivity < BaseReport
EARLY_BIRD_THRESHOLD = 6..9
NIGHT_OWL_THRESHOLD_PM = 22..23
NIGHT_OWL_THRESHOLD_AM = 0..2
FakeData = {
data: {
activity_by_hour: {
0 => 12,
1 => 8,
2 => 5,
3 => 2,
4 => 1,
5 => 3,
6 => 8,
7 => 15,
8 => 25,
9 => 32,
10 => 28,
11 => 24,
12 => 22,
13 => 20,
14 => 26,
15 => 30,
16 => 28,
17 => 22,
18 => 18,
19 => 16,
20 => 14,
21 => 18,
22 => 22,
23 => 15,
},
most_active_hour: 9,
personality: {
type: "early_bird",
percentage: 28.5,
},
total_activities: 414,
},
identifier: "time-of-day-activity",
}
def call
return FakeData if Rails.env.development?
# Get activity by hour of day (in user's timezone)
activity_by_hour = get_activity_by_hour
return if activity_by_hour.empty?
total_activities = activity_by_hour.values.sum
most_active_hour = activity_by_hour.max_by { |_, count| count }&.first
personality = determine_personality(activity_by_hour, total_activities)
{
data: {
activity_by_hour: activity_by_hour,
most_active_hour: most_active_hour,
personality: personality,
total_activities: total_activities,
},
identifier: "time-of-day-activity",
}
end
private
def get_activity_by_hour
# Get user timezone offset
user_timezone = user.user_option&.timezone || "UTC"
quoted_timezone = ActiveRecord::Base.connection.quote(user_timezone)
hour_extract_sql =
Arel.sql(
"EXTRACT(HOUR FROM created_at AT TIME ZONE 'UTC' AT TIME ZONE #{quoted_timezone})::integer",
)
# Initialize hash with all hours
activity = (0..23).to_h { |hour| [hour, 0] }
# Posts created
post_hours =
Post
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.pluck(hour_extract_sql)
.tally
# User visits (page views)
visit_hours =
UserHistory
.where(acting_user_id: user.id)
.where(created_at: date)
.where(action: UserHistory.actions[:page_view])
.pluck(hour_extract_sql)
.tally
# Chat messages if chat is enabled
if SiteSetting.chat_enabled
chat_hours =
Chat::Message
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.pluck(hour_extract_sql)
.tally
chat_hours.each { |hour, count| activity[hour] += count }
end
post_hours.each { |hour, count| activity[hour] += count }
visit_hours.each { |hour, count| activity[hour] += count }
activity
end
def determine_personality(activity_by_hour, total_activities)
return nil if total_activities == 0
early_bird_activity = EARLY_BIRD_THRESHOLD.sum { |hour| activity_by_hour[hour] || 0 }
night_owl_activity =
NIGHT_OWL_THRESHOLD_PM.sum { |hour| activity_by_hour[hour] || 0 } +
NIGHT_OWL_THRESHOLD_AM.sum { |hour| activity_by_hour[hour] || 0 }
early_bird_percentage = (early_bird_activity.to_f / total_activities * 100).round(1)
night_owl_percentage = (night_owl_activity.to_f / total_activities * 100).round(1)
if early_bird_percentage > 20
{ type: "early_bird", percentage: early_bird_percentage }
elsif night_owl_percentage > 20
{ type: "night_owl", percentage: night_owl_percentage }
else
{ type: "balanced", percentage: 0 }
end
end
end
end
end
@@ -1,23 +1,23 @@
# frozen_string_literal: true
module DiscourseRewind
class Rewind::Action::TopWords < Rewind::Action::BaseReport
FakeData = {
data: [
{ word: "what", score: 100 },
{ word: "have", score: 90 },
{ word: "you", score: 80 },
{ word: "overachieved", score: 70 },
{ word: "this", score: 60 },
{ word: "week", score: 50 },
],
identifier: "top-words",
}
module Action
class TopWords < BaseReport
FakeData = {
data: [
{ word: "seven", score: 100 },
{ word: "longest", score: 90 },
{ word: "you", score: 80 },
{ word: "overachieved", score: 70 },
{ word: "assume", score: 60 },
],
identifier: "top-words",
}
def call
return FakeData if Rails.env.development?
def call
return FakeData if Rails.env.development?
words = DB.query(<<~SQL, user_id: user.id, date_start: date.first, date_end: date.last)
words = DB.query(<<~SQL, user_id: user.id, date_start: date.first, date_end: date.last)
WITH popular_words AS (
SELECT
*
@@ -78,9 +78,16 @@ module DiscourseRewind
LIMIT 100
SQL
word_score = words.map { { word: _1.original_word, score: _1.ndoc + _1.nentry } }
word_score =
words
.map do |word_data|
{ word: word_data.original_word, score: word_data.ndoc + word_data.nentry }
end
.sort_by! { |w| -w[:score] }
.take(5)
{ data: word_score, identifier: "top-words" }
{ data: word_score, identifier: "top-words" }
end
end
end
end
@@ -0,0 +1,115 @@
# frozen_string_literal: true
module DiscourseRewind
module Action
class WritingAnalysis < BaseReport
FakeData = {
data: {
total_words: 45_230,
total_posts: 197,
average_post_length: 230,
readability_score: 65.4,
},
identifier: "writing-analysis",
}
def call
return FakeData if Rails.env.development?
total_words =
DB.query_single(<<~SQL, user_id: user.id, date_start: date.first, date_end: date.last)
SELECT SUM(word_count) FROM posts
WHERE user_id = :user_id
AND created_at BETWEEN :date_start AND :date_end
AND deleted_at IS NULL
SQL
post_count =
DB.query_single(<<~SQL, user_id: user.id, date_start: date.first, date_end: date.last)
SELECT COUNT(*) FROM posts
WHERE user_id = :user_id
AND created_at BETWEEN :date_start AND :date_end
AND deleted_at IS NULL
SQL
average_post_length =
post_count.first > 0 ? (total_words.first.to_f / post_count.first).round(2) : 0
# Calculated using the Flesch Reading Ease formula,
# with an approximation for syllables since this can
# be tricky to get right in SQL.
#
# Tries to handle short sentences or ones without delmiters
# and ending with emojis by treating them as a single sentence.
readability_score =
DB.query_single(<<~SQL, user_id: user.id, start: date.first, end: date.last)
WITH cleaned AS (
SELECT
p.id AS post_id,
p.user_id,
p.created_at,
p.word_count,
regexp_replace(p.cooked, '<[^>]+>', ' ', 'g') AS plain
FROM posts p
WHERE p.user_id = :user_id
AND p.created_at BETWEEN :start AND :end
),
metrics AS (
SELECT
post_id,
user_id,
created_at,
plain,
word_count AS words,
regexp_count(plain, '[.!?;:](\s|$)') AS sentences_raw,
regexp_count(lower(plain), '[aeiouy]+') AS syllables
FROM cleaned
),
scores AS (
SELECT
post_id,
user_id,
created_at,
words,
syllables,
plain,
CASE
WHEN sentences_raw = 0 AND words > 5 THEN 1
ELSE sentences_raw
END AS sentences_fixed,
-- Flesch Reading Ease formula
CASE
WHEN words = 0 THEN NULL
WHEN (CASE WHEN sentences_raw = 0 AND words > 5 THEN 1 ELSE sentences_raw END) = 0 THEN NULL
ELSE (
206.835
- 1.015 * (
words::float /
(CASE WHEN sentences_raw = 0 AND words > 5 THEN 1 ELSE sentences_raw END)
)
- 84.6 * (syllables::float / words)
)
END AS readability_score
FROM metrics
)
SELECT AVG(readability_score) AS avg_readability_score
FROM scores
GROUP BY user_id;
SQL
{
data: {
total_words: total_words.first,
total_posts: post_count.first,
average_post_length: average_post_length,
readability_score: readability_score.first,
},
identifier: "writing-analysis",
}
end
end
end
end
@@ -0,0 +1,91 @@
# frozen_string_literal: true
module DiscourseRewind
# Service responsible to fetch a rewind for a username/year.
#
# @example
# ::DiscourseRewind::Rewind::Fetch.call(
# guardian: guardian,
# params: { year: 2023, username: 'codinghorror' }
# )
#
class FetchReports
include Service::Base
# @!method self.call(guardian:, params:)
# @param [Guardian] guardian
# @param [Hash] params
# @option params [Integer] :year of the rewind
# @option params [Integer] :username of the rewind
# @return [Service::Base::Context]
CACHE_DURATION = Rails.env.development? ? 10.seconds : 5.minutes
# order matters
REPORTS = [
Action::TopWords,
Action::ReadingTime,
Action::WritingAnalysis,
Action::Reactions,
Action::Fbff,
Action::MostViewedTags,
Action::MostViewedCategories,
Action::BestTopics,
Action::BestPosts,
Action::ActivityCalendar,
Action::TimeOfDayActivity,
Action::NewUserInteractions,
Action::ChatUsage,
Action::AiUsage,
Action::FavoriteGifs,
Action::Assignments,
Action::Invites,
]
model :year
model :date
model :reports
private
def fetch_year
current_date = Time.zone.now
current_month = current_date.month
current_year = current_date.year
case current_month
when 1
current_year - 1
when 12
current_year
else
# Otherwise it's impossible to test in browser unless you're
# in December or January
if Rails.env.development?
current_year
else
false
end
end
end
def fetch_date(params:, year:)
Date.new(year).all_year
end
def fetch_reports(date:, guardian:, year:)
key = "rewind:#{guardian.user.username}:#{year}"
reports = Discourse.redis.get(key)
if !reports
reports =
REPORTS.map { |report| report.call(date:, user: guardian.user, guardian:) }.compact
Discourse.redis.setex(key, CACHE_DURATION, MultiJson.dump(reports))
else
reports = MultiJson.load(reports, symbolize_keys: true)
end
reports
end
end
end
@@ -1,41 +0,0 @@
# frozen_string_literal: true
# For a GitHub like calendar
# https://docs.github.com/assets/cb-35216/mw-1440/images/help/profile/contributions-graph.webp
module DiscourseRewind
class Rewind::Action::ActivityCalendar < Rewind::Action::BaseReport
FakeData = {
data:
(Date.new(2024, 1, 1)..Date.new(2024, 12, 31)).map do |date|
{ date:, post_count: rand(0..20), visited: false }
end,
identifier: "activity-calendar",
}
def call
return FakeData if Rails.env.development?
calendar =
Post
.unscoped
.joins(<<~SQL)
RIGHT JOIN
generate_series('#{date.first}', '#{date.last}', '1 day'::interval) ON
posts.created_at::date = generate_series::date AND
posts.user_id = #{user.id} AND
posts.deleted_at IS NULL
SQL
.joins(
"LEFT JOIN user_visits ON generate_series::date = visited_at AND user_visits.user_id = #{user.id}",
)
.select(
"generate_series::date as date, count(posts.id) as post_count, COUNT(visited_at) > 0 as visited",
)
.group("generate_series, user_visits.id")
.order("generate_series")
.map { |row| { date: row.date, post_count: row.post_count, visited: row.visited } }
{ data: calendar, identifier: "activity-calendar" }
end
end
end
@@ -1,16 +0,0 @@
# frozen_string_literal: true
module DiscourseRewind
class Rewind::Action::BaseReport < Service::ActionBase
option :user
option :date
def call
raise NotImplementedError
end
def self.enabled?
true
end
end
end
@@ -1,19 +0,0 @@
# frozen_string_literal: true
module DiscourseRewind
class Rewind::Action::BestPosts < Rewind::Action::BaseReport
def call
best_posts =
Post
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.where("post_number > 1")
.order("like_count DESC NULLS LAST")
.limit(3)
.pluck(:post_number, :topic_id, :like_count, :reply_count, :raw, :cooked)
{ data: best_posts, identifier: "best-posts" }
end
end
end
@@ -1,21 +0,0 @@
# frozen_string_literal: true
module DiscourseRewind
class Rewind::Action::BestTopics < Rewind::Action::BaseReport
def call
best_topics =
TopTopic
.includes(:topic)
.references(:topic)
.where(topic: { deleted_at: nil, created_at: date, user_id: user.id })
.order("yearly_score DESC NULLS LAST")
.limit(3)
.pluck(:topic_id, :title, :excerpt, :yearly_score)
.map do |topic_id, title, excerpt, yearly_score|
{ topic_id: topic_id, title: title, excerpt: excerpt, yearly_score: yearly_score }
end
{ data: best_topics, identifier: "best-topics" }
end
end
end
@@ -1,34 +0,0 @@
# frozen_string_literal: true
# Topics visited grouped by category
module DiscourseRewind
class Rewind::Action::FavoriteCategories < Rewind::Action::BaseReport
FakeData = {
data: [
{ category_id: 1, name: "cats" },
{ category_id: 2, name: "dogs" },
{ category_id: 3, name: "countries" },
{ category_id: 4, name: "management" },
{ category_id: 5, name: "things" },
],
identifier: "favorite-categories",
}
def call
return FakeData if Rails.env.development?
favorite_categories =
TopicViewItem
.joins(:topic)
.joins("INNER JOIN categories ON categories.id = topics.category_id")
.where(user: user)
.where(viewed_at: date)
.group("categories.id, categories.name")
.order("COUNT(*) DESC")
.limit(5)
.pluck("categories.id, categories.name")
.map { |category_id, name| { category_id: category_id, name: name } }
{ data: favorite_categories, identifier: "favorite-categories" }
end
end
end
@@ -1,36 +0,0 @@
# frozen_string_literal: true
# Topics visited grouped by tag
module DiscourseRewind
class Rewind::Action::FavoriteTags < Rewind::Action::BaseReport
FakeData = {
data: [
{ tag_id: 1, name: "cats" },
{ tag_id: 2, name: "dogs" },
{ tag_id: 3, name: "countries" },
{ tag_id: 4, name: "management" },
{ tag_id: 5, name: "things" },
],
identifier: "favorite-tags",
}
def call
return FakeData if Rails.env.development?
favorite_tags =
TopicViewItem
.joins(:topic)
.joins("INNER JOIN topic_tags ON topic_tags.topic_id = topics.id")
.joins("INNER JOIN tags ON tags.id = topic_tags.tag_id")
.where(user: user)
.where(viewed_at: date)
.group("tags.id, tags.name")
.order("COUNT(DISTINCT topic_views.topic_id) DESC")
.limit(5)
.pluck("tags.id, tags.name")
.map { |tag_id, name| { tag_id: tag_id, name: name } }
{ data: favorite_tags, identifier: "favorite-tags" }
end
end
end
@@ -1,112 +0,0 @@
# frozen_string_literal: true
# Forum Best Friend Forever ranking
# Score is informative only, do not show in UI
module DiscourseRewind
class Rewind::Action::Fbff < Rewind::Action::BaseReport
MAX_SUMMARY_RESULTS ||= 50
LIKE_SCORE ||= 1
REPLY_SCORE ||= 10
def call
most_liked_users =
like_query(date)
.where(acting_user_id: user.id)
.group(:user_id)
.order("COUNT(*) DESC")
.limit(MAX_SUMMARY_RESULTS)
.pluck("user_actions.user_id, COUNT(*)")
.map { |user_id, count| { user_id => count } }
.reduce({}, :merge)
most_liked_by_users =
like_query(date)
.where(user: user)
.group(:acting_user_id)
.order("COUNT(*) DESC")
.limit(MAX_SUMMARY_RESULTS)
.pluck("acting_user_id, COUNT(*)")
.map { |acting_user_id, count| { acting_user_id => count } }
.reduce({}, :merge)
users_who_most_replied_me =
post_query(user, date)
.where(posts: { user_id: user.id })
.group("replies.user_id")
.order("COUNT(*) DESC")
.limit(MAX_SUMMARY_RESULTS)
.pluck("replies.user_id, COUNT(*)")
.map { |user_id, count| { user_id => count } }
.reduce({}, :merge)
users_i_most_replied =
post_query(user, date)
.where("replies.user_id = ?", user.id)
.group("posts.user_id")
.order("COUNT(*) DESC")
.limit(MAX_SUMMARY_RESULTS)
.pluck("posts.user_id, COUNT(*)")
.map { |user_id, count| { user_id => count } }
.reduce({}, :merge)
#TODO include chat?
fbffs = [
apply_score(most_liked_users, LIKE_SCORE),
apply_score(most_liked_by_users, LIKE_SCORE),
apply_score(users_who_most_replied_me, REPLY_SCORE),
apply_score(users_i_most_replied, REPLY_SCORE),
]
fbff_id =
fbffs
.flatten
.inject { |h1, h2| h1.merge(h2) { |_, v1, v2| v1 + v2 } }
&.sort_by { |_, v| -v }
&.first
&.first
return if !fbff_id
{
data: {
fbff: BasicUserSerializer.new(User.find(fbff_id), root: false).as_json,
yourself: BasicUserSerializer.new(user, root: false).as_json,
},
identifier: "fbff",
}
end
def post_query(user, date)
Post
.joins(:topic)
.includes(:topic)
.where(
"posts.post_type IN (?)",
Topic.visible_post_types(user, include_moderator_actions: false),
)
.joins(
"INNER JOIN posts replies ON posts.topic_id = replies.topic_id AND posts.reply_to_post_number = replies.post_number",
)
.joins(
"INNER JOIN topics ON replies.topic_id = topics.id AND topics.archetype <> 'private_message'",
)
.joins(
"AND replies.post_type IN (#{Topic.visible_post_types(user, include_moderator_actions: false).join(",")})",
)
.where("replies.created_at BETWEEN ? AND ?", date.first, date.last)
.where("replies.user_id <> posts.user_id")
end
def like_query(date)
UserAction
.joins(:target_topic, :target_post)
.where(created_at: date)
.where(action_type: UserAction::WAS_LIKED)
end
def apply_score(users, score)
users.map { |user_id, count| { user_id => count * score } }
end
end
end

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