diff --git a/app/controllers/discourse_rewind/rewinds_controller.rb b/app/controllers/discourse_rewind/rewinds_controller.rb index cccff9f..303677b 100644 --- a/app/controllers/discourse_rewind/rewinds_controller.rb +++ b/app/controllers/discourse_rewind/rewinds_controller.rb @@ -7,7 +7,7 @@ module ::DiscourseRewind requires_login def show - DiscourseRewind::Rewind::Fetch.call(service_params) do + DiscourseRewind::FetchReports.call(service_params) do on_model_not_found(:year) { raise Discourse::NotFound } on_model_not_found(:user) { raise Discourse::NotFound } on_success do |reports:| diff --git a/app/services/discourse_rewind/action/activity_calendar.rb b/app/services/discourse_rewind/action/activity_calendar.rb new file mode 100644 index 0000000..17fe5fe --- /dev/null +++ b/app/services/discourse_rewind/action/activity_calendar.rb @@ -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 diff --git a/app/services/discourse_rewind/action/base_report.rb b/app/services/discourse_rewind/action/base_report.rb new file mode 100644 index 0000000..a2a2c8e --- /dev/null +++ b/app/services/discourse_rewind/action/base_report.rb @@ -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 diff --git a/app/services/discourse_rewind/action/best_posts.rb b/app/services/discourse_rewind/action/best_posts.rb new file mode 100644 index 0000000..b3c3783 --- /dev/null +++ b/app/services/discourse_rewind/action/best_posts.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module DiscourseRewind + module Action + class BestPosts < BaseReport + def call + best_posts = + Post + .where(user_id: user.id) + .where(created_at: date) + .where(deleted_at: nil) + .where("post_number > 1") + .order("like_count DESC NULLS LAST") + .limit(3) + .pluck(:post_number, :topic_id, :like_count, :reply_count, :raw, :cooked) + + { data: best_posts, identifier: "best-posts" } + end + end + end +end diff --git a/app/services/discourse_rewind/action/best_topics.rb b/app/services/discourse_rewind/action/best_topics.rb new file mode 100644 index 0000000..6068083 --- /dev/null +++ b/app/services/discourse_rewind/action/best_topics.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module DiscourseRewind + module Action + class BestTopics < BaseReport + def call + best_topics = + TopTopic + .includes(:topic) + .references(:topic) + .where(topic: { deleted_at: nil, created_at: date, user_id: user.id }) + .order("yearly_score DESC NULLS LAST") + .limit(3) + .pluck(:topic_id, :title, :excerpt, :yearly_score) + .map do |topic_id, title, excerpt, yearly_score| + { topic_id: topic_id, title: title, excerpt: excerpt, yearly_score: yearly_score } + end + + { data: best_topics, identifier: "best-topics" } + end + end + end +end diff --git a/app/services/discourse_rewind/action/favorite_categories.rb b/app/services/discourse_rewind/action/favorite_categories.rb new file mode 100644 index 0000000..c0d4f76 --- /dev/null +++ b/app/services/discourse_rewind/action/favorite_categories.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Topics visited grouped by category +module DiscourseRewind + module Action + class FavoriteCategories < 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 +end diff --git a/app/services/discourse_rewind/action/favorite_tags.rb b/app/services/discourse_rewind/action/favorite_tags.rb new file mode 100644 index 0000000..0c75c72 --- /dev/null +++ b/app/services/discourse_rewind/action/favorite_tags.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Topics visited grouped by tag +module DiscourseRewind + module Action + class FavoriteTags < 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 +end diff --git a/app/services/discourse_rewind/action/fbff.rb b/app/services/discourse_rewind/action/fbff.rb new file mode 100644 index 0000000..7f0d90f --- /dev/null +++ b/app/services/discourse_rewind/action/fbff.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +# Forum Best Friend Forever ranking +# Score is informative only, do not show in UI +module DiscourseRewind + module Action + class Fbff < BaseReport + MAX_SUMMARY_RESULTS ||= 50 + LIKE_SCORE ||= 1 + REPLY_SCORE ||= 10 + + def call + most_liked_users = + like_query(date) + .where(acting_user_id: user.id) + .group(:user_id) + .order("COUNT(*) DESC") + .limit(MAX_SUMMARY_RESULTS) + .pluck("user_actions.user_id, COUNT(*)") + .map { |user_id, count| { user_id => count } } + .reduce({}, :merge) + + most_liked_by_users = + like_query(date) + .where(user: user) + .group(:acting_user_id) + .order("COUNT(*) DESC") + .limit(MAX_SUMMARY_RESULTS) + .pluck("acting_user_id, COUNT(*)") + .map { |acting_user_id, count| { acting_user_id => count } } + .reduce({}, :merge) + + users_who_most_replied_me = + post_query(user, date) + .where(posts: { user_id: user.id }) + .group("replies.user_id") + .order("COUNT(*) DESC") + .limit(MAX_SUMMARY_RESULTS) + .pluck("replies.user_id, COUNT(*)") + .map { |user_id, count| { user_id => count } } + .reduce({}, :merge) + + users_i_most_replied = + post_query(user, date) + .where("replies.user_id = ?", user.id) + .group("posts.user_id") + .order("COUNT(*) DESC") + .limit(MAX_SUMMARY_RESULTS) + .pluck("posts.user_id, COUNT(*)") + .map { |user_id, count| { user_id => count } } + .reduce({}, :merge) + + #TODO include chat? + + fbffs = [ + apply_score(most_liked_users, LIKE_SCORE), + apply_score(most_liked_by_users, LIKE_SCORE), + apply_score(users_who_most_replied_me, REPLY_SCORE), + apply_score(users_i_most_replied, REPLY_SCORE), + ] + + fbff_id = + fbffs + .flatten + .inject { |h1, h2| h1.merge(h2) { |_, v1, v2| v1 + v2 } } + &.sort_by { |_, v| -v } + &.first + &.first + + return if !fbff_id + + { + data: { + fbff: BasicUserSerializer.new(User.find(fbff_id), root: false).as_json, + yourself: BasicUserSerializer.new(user, root: false).as_json, + }, + identifier: "fbff", + } + end + + def post_query(user, date) + Post + .joins(:topic) + .includes(:topic) + .where( + "posts.post_type IN (?)", + Topic.visible_post_types(user, include_moderator_actions: false), + ) + .joins( + "INNER JOIN posts replies ON posts.topic_id = replies.topic_id AND posts.reply_to_post_number = replies.post_number", + ) + .joins( + "INNER JOIN topics ON replies.topic_id = topics.id AND topics.archetype <> 'private_message'", + ) + .joins( + "AND replies.post_type IN (#{Topic.visible_post_types(user, include_moderator_actions: false).join(",")})", + ) + .where("replies.created_at BETWEEN ? AND ?", date.first, date.last) + .where("replies.user_id <> posts.user_id") + end + + def like_query(date) + UserAction + .joins(:target_topic, :target_post) + .where(created_at: date) + .where(action_type: UserAction::WAS_LIKED) + end + + def apply_score(users, score) + users.map { |user_id, count| { user_id => count * score } } + end + end + end +end diff --git a/app/services/discourse_rewind/action/reactions.rb b/app/services/discourse_rewind/action/reactions.rb new file mode 100644 index 0000000..d7e52db --- /dev/null +++ b/app/services/discourse_rewind/action/reactions.rb @@ -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 { |_, v| -v }.first(5).reverse.to_h + end + end + end +end diff --git a/app/services/discourse_rewind/action/reading_time.rb b/app/services/discourse_rewind/action/reading_time.rb new file mode 100644 index 0000000..dfc6885 --- /dev/null +++ b/app/services/discourse_rewind/action/reading_time.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +# For showcasing the reading time of a user +# Should we show book covers or just the names? +module DiscourseRewind + module Action + class ReadingTime < BaseReport + FakeData = { + data: { + reading_time: 2_880_000, + book: "The Combined Cosmere works + Wheel of Time", + isbn: "978-0812511819", + series: true, + }, + identifier: "reading-time", + } + + def call + return FakeData if Rails.env.development? + + reading_time = UserVisit.where(user_id: user.id).where(visited_at: date).sum(:time_read) + book = best_book_fit(reading_time) + + return if book.nil? + + { + data: { + reading_time: reading_time, + book: book[:title], + isbn: book[:isbn], + series: book[:series], + }, + identifier: "reading-time", + } + end + + def popular_books + { + "The Hunger Games" => { + reading_time: 19_740, + isbn: "978-0439023481", + series: false, + }, + "The Metamorphosis" => { + reading_time: 3120, + isbn: "978-0553213690", + series: false, + }, + "To Kill a Mockingbird" => { + reading_time: 22_800, + isbn: "978-0061120084", + series: false, + }, + "Pride and Prejudice" => { + reading_time: 25_200, + isbn: "978-1503290563", + series: false, + }, + "1984" => { + reading_time: 16_800, + isbn: "978-0451524935", + series: false, + }, + "The Lord of the Rings" => { + reading_time: 108_000, + isbn: "978-0544003415", + series: true, + }, + "Harry Potter and the Sorcerer's Stone" => { + reading_time: 24_600, + isbn: "978-0590353427", + series: true, + }, + "The Great Gatsby" => { + reading_time: 12_600, + isbn: "978-0743273565", + series: false, + }, + "The Little Prince" => { + reading_time: 5400, + isbn: "978-0156012195", + series: false, + }, + "Animal Farm" => { + reading_time: 7200, + isbn: "978-0451526342", + series: false, + }, + "The Catcher in the Rye" => { + reading_time: 18_000, + isbn: "978-0316769488", + series: false, + }, + "Jane Eyre" => { + reading_time: 34_200, + isbn: "978-0141441146", + series: false, + }, + "Fahrenheit 451" => { + reading_time: 15_000, + isbn: "978-1451673319", + series: false, + }, + "The Hobbit" => { + reading_time: 27_000, + isbn: "978-0547928227", + series: false, + }, + "The Da Vinci Code" => { + reading_time: 37_800, + isbn: "978-0307474278", + series: false, + }, + "Little Women" => { + reading_time: 30_000, + isbn: "978-0147514011", + series: false, + }, + "One Hundred Years of Solitude" => { + reading_time: 46_800, + isbn: "978-0060883287", + series: false, + }, + "And Then There Were None" => { + reading_time: 16_200, + isbn: "978-0062073488", + series: false, + }, + "The Alchemist" => { + reading_time: 10_800, + isbn: "978-0061122415", + series: false, + }, + "The Hitchhiker's Guide to the Galaxy" => { + reading_time: 12_600, + isbn: "978-0345391803", + series: false, + }, + "The Complete works of Shakespeare" => { + reading_time: 180_000, + isbn: "978-1853268953", + series: true, + }, + "The Game of Thrones Series" => { + reading_time: 360_000, + isbn: "978-0007477159", + series: true, + }, + "Malazan Book of the Fallen" => { + reading_time: 720_000, + isbn: "978-0765348821", + series: true, + }, + "Terry Pratchett’s Discworld series" => { + reading_time: 1_440_000, + isbn: "978-9123684458", + series: true, + }, + "The Wandering Inn web series" => { + reading_time: 2_160_000, + isbn: "the-wandering-inn", + series: true, + }, + "The Combined Cosmere works + Wheel of Time" => { + reading_time: 2_880_000, + isbn: "978-0812511819", + series: true, + }, + "The Star Trek novels" => { + reading_time: 3_600_000, + isbn: "978-1852860691", + series: true, + }, + }.symbolize_keys + end + + def best_book_fit(reading_time) + reading_time_rest = reading_time + books = [] + + while reading_time_rest > 0 + best_fit = popular_books.min_by { |_, v| (v[:reading_time] - reading_time_rest).abs } + break if best_fit.nil? + + books << best_fit.first + reading_time_rest -= best_fit.last[:reading_time] + end + + return if books.empty? + + book_title = + books.group_by { |book| book }.transform_values(&:count).max_by { |_, count| count }.first + + { + title: book_title, + isbn: popular_books[book_title][:isbn], + series: popular_books[book_title][:series], + } + end + end + end +end diff --git a/app/services/discourse_rewind/rewind/action/top_words.rb b/app/services/discourse_rewind/action/top_words.rb similarity index 71% rename from app/services/discourse_rewind/rewind/action/top_words.rb rename to app/services/discourse_rewind/action/top_words.rb index 9700809..3c516c7 100644 --- a/app/services/discourse_rewind/rewind/action/top_words.rb +++ b/app/services/discourse_rewind/action/top_words.rb @@ -1,23 +1,24 @@ # 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: "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", + } - 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 +79,10 @@ module DiscourseRewind LIMIT 100 SQL - word_score = words.map { { word: _1.original_word, score: _1.ndoc + _1.nentry } } + word_score = words.map { { word: _1.original_word, score: _1.ndoc + _1.nentry } } - { data: word_score, identifier: "top-words" } + { data: word_score, identifier: "top-words" } + end end end end diff --git a/app/services/discourse_rewind/rewind/fetch.rb b/app/services/discourse_rewind/fetch_reports.rb similarity index 60% rename from app/services/discourse_rewind/rewind/fetch.rb rename to app/services/discourse_rewind/fetch_reports.rb index 082bfb5..361711f 100644 --- a/app/services/discourse_rewind/rewind/fetch.rb +++ b/app/services/discourse_rewind/fetch_reports.rb @@ -8,7 +8,7 @@ module DiscourseRewind # guardian: guardian # ) # - class Rewind::Fetch + class FetchReports include Service::Base # @!method self.call(guardian:, params:) @@ -18,7 +18,20 @@ module DiscourseRewind # @option params [Integer] :username of the rewind # @return [Service::Base::Context] - CACHE_DURATION = 5.minutes + CACHE_DURATION = Rails.env.development? ? 10.seconds : 5.minutes + + # order matters + REPORTS = [ + Action::TopWords, + Action::ReadingTime, + Action::Reactions, + Action::Fbff, + Action::FavoriteTags, + Action::FavoriteCategories, + Action::BestTopics, + Action::BestPosts, + Action::ActivityCalendar, + ] model :year model :user @@ -51,19 +64,15 @@ module DiscourseRewind end def fetch_reports(date:, user:, guardian:, year:) - # key = "rewind:#{guardian.user.username}:#{year}" - # reports = Discourse.redis.get(key) + key = "rewind:#{guardian.user.username}:#{year}" + reports = Discourse.redis.get(key) - # if Rails.env.development? || !reports - reports = - ::DiscourseRewind::Rewind::Action::BaseReport - .descendants - .filter { _1.enabled? } - .map { |report| report.call(date:, user:, guardian:) } - # Discourse.redis.setex(key, CACHE_DURATION, MultiJson.dump(reports)) - # else - # reports = MultiJson.load(reports.compact, symbolize_keys: true) - # end + if !reports + reports = REPORTS.map { |report| report.call(date:, user:, guardian:) } + Discourse.redis.setex(key, CACHE_DURATION, MultiJson.dump(reports)) + else + reports = MultiJson.load(reports, symbolize_keys: true) + end reports end diff --git a/app/services/discourse_rewind/rewind/action/activity_calendar.rb b/app/services/discourse_rewind/rewind/action/activity_calendar.rb deleted file mode 100644 index 6fd1fda..0000000 --- a/app/services/discourse_rewind/rewind/action/activity_calendar.rb +++ /dev/null @@ -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 diff --git a/app/services/discourse_rewind/rewind/action/base_report.rb b/app/services/discourse_rewind/rewind/action/base_report.rb deleted file mode 100644 index 2e66c66..0000000 --- a/app/services/discourse_rewind/rewind/action/base_report.rb +++ /dev/null @@ -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 diff --git a/app/services/discourse_rewind/rewind/action/best_posts.rb b/app/services/discourse_rewind/rewind/action/best_posts.rb deleted file mode 100644 index d3bf29c..0000000 --- a/app/services/discourse_rewind/rewind/action/best_posts.rb +++ /dev/null @@ -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 diff --git a/app/services/discourse_rewind/rewind/action/best_topics.rb b/app/services/discourse_rewind/rewind/action/best_topics.rb deleted file mode 100644 index 99a473f..0000000 --- a/app/services/discourse_rewind/rewind/action/best_topics.rb +++ /dev/null @@ -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 diff --git a/app/services/discourse_rewind/rewind/action/favorite_categories.rb b/app/services/discourse_rewind/rewind/action/favorite_categories.rb deleted file mode 100644 index f521b06..0000000 --- a/app/services/discourse_rewind/rewind/action/favorite_categories.rb +++ /dev/null @@ -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 diff --git a/app/services/discourse_rewind/rewind/action/favorite_tags.rb b/app/services/discourse_rewind/rewind/action/favorite_tags.rb deleted file mode 100644 index 088bc2c..0000000 --- a/app/services/discourse_rewind/rewind/action/favorite_tags.rb +++ /dev/null @@ -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 diff --git a/app/services/discourse_rewind/rewind/action/fbff.rb b/app/services/discourse_rewind/rewind/action/fbff.rb deleted file mode 100644 index 2d03a82..0000000 --- a/app/services/discourse_rewind/rewind/action/fbff.rb +++ /dev/null @@ -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 diff --git a/app/services/discourse_rewind/rewind/action/reactions.rb b/app/services/discourse_rewind/rewind/action/reactions.rb deleted file mode 100644 index 987cd51..0000000 --- a/app/services/discourse_rewind/rewind/action/reactions.rb +++ /dev/null @@ -1,90 +0,0 @@ -# frozen_string_literal: true - -# For a most user / received reactions cards -module DiscourseRewind - class Rewind::Action::Reactions < Rewind::Action::BaseReport - FakeData = { - data: { - post_received_reactions: { - "open_mouth" => 2, - "cat" => 32, - "dog" => 34, - "heart" => 45, - "grinning" => 82, - }, - post_used_reactions: { - "open_mouth" => 2, - "cat" => 32, - "dog" => 34, - "heart" => 45, - "grinning" => 82, - }, - chat_used_reactions: { - "open_mouth" => 2, - "cat" => 32, - "dog" => 34, - "heart" => 45, - "grinning" => 82, - }, - chat_received_reactions: { - "open_mouth" => 2, - "cat" => 32, - "dog" => 34, - "heart" => 45, - "grinning" => 82, - }, - }, - identifier: "reactions", - } - - def call - return FakeData if Rails.env.development? - - data = {} - if defined?(DiscourseReactions::Reaction) - # This is missing heart reactions (default like) - data[:post_used_reactions] = sort_and_limit( - DiscourseReactions::Reaction - .by_user(user) - .where(created_at: date) - .group(:reaction_value) - .count, - ) - - data[:post_received_reactions] = sort_and_limit( - DiscourseReactions::Reaction - .includes(:post) - .where(posts: { user_id: user.id }) - .where(created_at: date) - .group(:reaction_value) - .count, - ) - end - - if SiteSetting.chat_enabled - data[:chat_used_reactions] = sort_and_limit( - Chat::MessageReaction.where(user: user).where(created_at: date).group(:emoji).count, - ) - - data[:chat_received_reactions] = sort_and_limit( - Chat::MessageReaction - .includes(:chat_message) - .where(chat_message: { user_id: user.id }) - .where(created_at: date) - .group(:emoji) - .count, - ) - end - - { data:, identifier: "reactions" } - end - - def enabled? - SiteSetting.discourse_reactions_enabled || SiteSetting.chat_enabled - end - - def sort_and_limit(reactions) - reactions.sort_by { |_, v| -v }.first(5).reverse.to_h - end - end -end diff --git a/app/services/discourse_rewind/rewind/action/reading_time.rb b/app/services/discourse_rewind/rewind/action/reading_time.rb deleted file mode 100644 index 32130fc..0000000 --- a/app/services/discourse_rewind/rewind/action/reading_time.rb +++ /dev/null @@ -1,200 +0,0 @@ -# frozen_string_literal: true - -# For showcasing the reading time of a user -# Should we show book covers or just the names? -module DiscourseRewind - 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, - }, - "The Metamorphosis" => { - reading_time: 3120, - isbn: "978-0553213690", - series: false, - }, - "To Kill a Mockingbird" => { - reading_time: 22_800, - isbn: "978-0061120084", - series: false, - }, - "Pride and Prejudice" => { - reading_time: 25_200, - isbn: "978-1503290563", - series: false, - }, - "1984" => { - reading_time: 16_800, - isbn: "978-0451524935", - series: false, - }, - "The Lord of the Rings" => { - reading_time: 108_000, - isbn: "978-0544003415", - series: true, - }, - "Harry Potter and the Sorcerer's Stone" => { - reading_time: 24_600, - isbn: "978-0590353427", - series: true, - }, - "The Great Gatsby" => { - reading_time: 12_600, - isbn: "978-0743273565", - series: false, - }, - "The Little Prince" => { - reading_time: 5400, - isbn: "978-0156012195", - series: false, - }, - "Animal Farm" => { - reading_time: 7200, - isbn: "978-0451526342", - series: false, - }, - "The Catcher in the Rye" => { - reading_time: 18_000, - isbn: "978-0316769488", - series: false, - }, - "Jane Eyre" => { - reading_time: 34_200, - isbn: "978-0141441146", - series: false, - }, - "Fahrenheit 451" => { - reading_time: 15_000, - isbn: "978-1451673319", - series: false, - }, - "The Hobbit" => { - reading_time: 27_000, - isbn: "978-0547928227", - series: false, - }, - "The Da Vinci Code" => { - reading_time: 37_800, - isbn: "978-0307474278", - series: false, - }, - "Little Women" => { - reading_time: 30_000, - isbn: "978-0147514011", - series: false, - }, - "One Hundred Years of Solitude" => { - reading_time: 46_800, - isbn: "978-0060883287", - series: false, - }, - "And Then There Were None" => { - reading_time: 16_200, - isbn: "978-0062073488", - series: false, - }, - "The Alchemist" => { - reading_time: 10_800, - isbn: "978-0061122415", - series: false, - }, - "The Hitchhiker's Guide to the Galaxy" => { - reading_time: 12_600, - isbn: "978-0345391803", - series: false, - }, - "The Complete works of Shakespeare" => { - reading_time: 180_000, - isbn: "978-1853268953", - series: true, - }, - "The Game of Thrones Series" => { - reading_time: 360_000, - isbn: "978-0007477159", - series: true, - }, - "Malazan Book of the Fallen" => { - reading_time: 720_000, - isbn: "978-0765348821", - series: true, - }, - "Terry Pratchett’s Discworld series" => { - reading_time: 1_440_000, - isbn: "978-9123684458", - series: true, - }, - "The Wandering Inn web series" => { - reading_time: 2_160_000, - isbn: "the-wandering-inn", - series: true, - }, - "The Combined Cosmere works + Wheel of Time" => { - reading_time: 2_880_000, - isbn: "978-0812511819", - series: true, - }, - "The Star Trek novels" => { - reading_time: 3_600_000, - isbn: "978-1852860691", - series: true, - }, - }.symbolize_keys - end - - def best_book_fit(reading_time) - reading_time_rest = reading_time - books = [] - - while reading_time_rest > 0 - best_fit = popular_books.min_by { |_, v| (v[:reading_time] - reading_time_rest).abs } - break if best_fit.nil? - - books << best_fit.first - reading_time_rest -= best_fit.last[:reading_time] - end - - return if books.empty? - - book_title = - books.group_by { |book| book }.transform_values(&:count).max_by { |_, count| count }.first - - { - title: book_title, - isbn: popular_books[book_title][:isbn], - series: popular_books[book_title][:series], - } - end - end -end diff --git a/spec/services/fetch_spec.rb b/spec/services/fetch_spec.rb new file mode 100644 index 0000000..e69de29