From ee76f1926defa8309b3a7ea64a25707519529a13 Mon Sep 17 00:00:00 2001
From: Robin Ward <robin.ward@gmail.com>
Date: Tue, 2 Apr 2013 16:52:51 -0400
Subject: [PATCH] Debugging Tool for Hot Topics

---
 .../controllers/list_topics_controller.js     | 11 +++-
 .../discourse/helpers/application_helpers.js  | 24 ++++++++
 .../list/topic_list_item.js.handlebars        |  5 ++
 .../modal/topic_rank_details.js.handlebars    | 46 ++++++++++++++
 .../views/modal/topic_rank_details_view.js    | 13 ++++
 .../stylesheets/application/modal.css.scss    |  2 +
 .../application/topic-list.css.scss           | 13 ++++
 app/controllers/list_controller.rb            |  1 -
 app/models/hot_topic.rb                       | 61 +++++++++++++++----
 app/models/topic.rb                           |  1 +
 app/models/topic_list.rb                      | 10 ++-
 app/serializers/topic_list_item_serializer.rb | 33 +++++++++-
 app/serializers/topic_list_serializer.rb      |  7 ++-
 config/locales/client.en.yml                  |  5 ++
 ...20130402210723_add_values_to_hot_topics.rb |  9 +++
 lib/topic_query.rb                            | 45 ++++++--------
 16 files changed, 244 insertions(+), 42 deletions(-)
 create mode 100644 app/assets/javascripts/discourse/templates/modal/topic_rank_details.js.handlebars
 create mode 100644 app/assets/javascripts/discourse/views/modal/topic_rank_details_view.js
 create mode 100644 db/migrate/20130402210723_add_values_to_hot_topics.rb

diff --git a/app/assets/javascripts/discourse/controllers/list_topics_controller.js b/app/assets/javascripts/discourse/controllers/list_topics_controller.js
index 8d666f67f..f7ad30459 100644
--- a/app/assets/javascripts/discourse/controllers/list_topics_controller.js
+++ b/app/assets/javascripts/discourse/controllers/list_topics_controller.js
@@ -7,7 +7,8 @@
   @module Discourse
 **/
 Discourse.ListTopicsController = Discourse.ObjectController.extend({
-  needs: ['list', 'composer'],
+  needs: ['list', 'composer', 'modal'],
+
   // If we're changing our channel
   previousChannel: null,
 
@@ -50,6 +51,14 @@ Discourse.ListTopicsController = Discourse.ObjectController.extend({
     topic.toggleStar();
   },
 
+  // Show rank details
+  showRankDetails: function(topic) {
+    var modalController = this.get('controllers.modal');
+    if (modalController) {
+      modalController.show(Discourse.TopicRankDetailsView.create({ topic: topic }));
+    }
+  },
+
   createTopic: function() {
     this.get('controllers.list').createTopic();
   },
diff --git a/app/assets/javascripts/discourse/helpers/application_helpers.js b/app/assets/javascripts/discourse/helpers/application_helpers.js
index fcc9857d4..866cdf5a1 100644
--- a/app/assets/javascripts/discourse/helpers/application_helpers.js
+++ b/app/assets/javascripts/discourse/helpers/application_helpers.js
@@ -180,6 +180,30 @@ Handlebars.registerHelper('editDate', function(property, options) {
   }
 });
 
+/**
+  Displays a percentile based on a `percent_rank` field
+
+  @method percentile
+  @for Ember.Handlebars
+**/
+Ember.Handlebars.registerHelper('percentile', function(property, options) {
+  var percentile = Ember.Handlebars.get(this, property, options);
+  return Math.round((1.0 - percentile) * 100)
+});
+
+/**
+  Displays a float nicely
+
+  @method float
+  @for Ember.Handlebars
+**/
+Ember.Handlebars.registerHelper('float', function(property, options) {
+  var x = Ember.Handlebars.get(this, property, options);
+  if (!x) return "0";
+  if (Math.round(x) === x) return x;
+  return x.toFixed(3)
+});
+
 /**
   Display logic for numbers.
 
diff --git a/app/assets/javascripts/discourse/templates/list/topic_list_item.js.handlebars b/app/assets/javascripts/discourse/templates/list/topic_list_item.js.handlebars
index b63c19e70..93bd48db8 100644
--- a/app/assets/javascripts/discourse/templates/list/topic_list_item.js.handlebars
+++ b/app/assets/javascripts/discourse/templates/list/topic_list_item.js.handlebars
@@ -17,7 +17,12 @@
       {{#if unseen}}
         <a href="{{lastReadUrl}}" class='badge new-posts badge-notification' title='{{i18n topic.new}}'><i class='icon icon-asterisk'></i></a>
       {{/if}}
+
+      {{#if rank_details}}
+        <i class='icon icon-beaker score' {{action showRankDetails this}} title='{{i18n rank_details.show}}'></i>
+      {{/if}}
   </td>
+
   <td class='category'>
     {{categoryLink category}}
   </td>
diff --git a/app/assets/javascripts/discourse/templates/modal/topic_rank_details.js.handlebars b/app/assets/javascripts/discourse/templates/modal/topic_rank_details.js.handlebars
new file mode 100644
index 000000000..411d3fb62
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/modal/topic_rank_details.js.handlebars
@@ -0,0 +1,46 @@
+{{#with view.topic.rank_details}}
+  <div class="modal-body">
+
+    <!-- Note this isn't translated because it's a debugging tool for a feature
+         that is not complete yet. We will probably rip this out altogether -->
+
+    <table class='table'>
+      <tr>
+        <td>hot topic type</td>
+        <td>
+          {{hot_topic_type}}
+        </td>
+      </tr>
+      <tr>
+        <td>random bias</td>
+        <td>{{float random_bias}}</td>
+      </tr>
+      <tr>
+        <td>random multiplier</td>
+        <td>{{float random_multiplier}}</td>
+      </tr>
+      <tr>
+        <td>days ago bias</td>
+        <td>{{float days_ago_bias}}</td>
+      </tr>
+      <tr>
+        <td>days ago multiplier</td>
+        <td>{{float days_ago_multiplier}}</td>
+      </tr>
+      <tr>
+        <td>ranking formula</td>
+        <td>
+          <p>= (random_bias * random_multiplier) +<br/>
+             (days_ago_bias * days_ago_multiplier)</p>
+          <p>= ({{float random_bias}} * {{float random_multiplier}}) + ({{float days_ago_bias}} * {{float days_ago_multiplier}})</p>
+        </td>
+      </tr>
+      <tr>
+        <td>ranking score</td>
+        <td><b>{{float ranking_score}}</b></td>
+      </tr>
+
+    </table>
+
+  </div>
+{{/with}}
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/views/modal/topic_rank_details_view.js b/app/assets/javascripts/discourse/views/modal/topic_rank_details_view.js
new file mode 100644
index 000000000..390953309
--- /dev/null
+++ b/app/assets/javascripts/discourse/views/modal/topic_rank_details_view.js
@@ -0,0 +1,13 @@
+/**
+  A modal view for displaying the ranking details of a topic
+
+  @class TopicRankDetailsView
+  @extends Discourse.ModalBodyView
+  @namespace Discourse
+  @module Discourse
+**/
+Discourse.TopicRankDetailsView = Discourse.ModalBodyView.extend({
+  templateName: 'modal/topic_rank_details',
+  title: Em.String.i18n('rank_details.title')
+
+});
diff --git a/app/assets/stylesheets/application/modal.css.scss b/app/assets/stylesheets/application/modal.css.scss
index 0ec27a482..3ce7872b0 100644
--- a/app/assets/stylesheets/application/modal.css.scss
+++ b/app/assets/stylesheets/application/modal.css.scss
@@ -151,6 +151,8 @@
     .archetype-option {
       margin-bottom: 20px;
     }
+
+
   }
   .password-confirmation {
     display: none;
diff --git a/app/assets/stylesheets/application/topic-list.css.scss b/app/assets/stylesheets/application/topic-list.css.scss
index 7c99dfc2a..2bbb6fa99 100755
--- a/app/assets/stylesheets/application/topic-list.css.scss
+++ b/app/assets/stylesheets/application/topic-list.css.scss
@@ -97,7 +97,20 @@
   .main-link {
     width: 515px;
     font-size: 16px;
+
+    &:hover i.score {
+      display: inline-block;
+    }
+
+    i.score {
+      color: green;
+      cursor: pointer;
+      display: none;
+    }
   }
+
+
+
   @include medium-width {
     .main-link {
       width: 400px;
diff --git a/app/controllers/list_controller.rb b/app/controllers/list_controller.rb
index 773e05c89..951c4e871 100644
--- a/app/controllers/list_controller.rb
+++ b/app/controllers/list_controller.rb
@@ -28,7 +28,6 @@ class ListController < ApplicationController
   end
 
   def category
-
     query = TopicQuery.new(current_user, page: params[:page])
     list = nil
 
diff --git a/app/models/hot_topic.rb b/app/models/hot_topic.rb
index 8a231ea62..c4ef81774 100644
--- a/app/models/hot_topic.rb
+++ b/app/models/hot_topic.rb
@@ -18,9 +18,23 @@ class HotTopic < ActiveRecord::Base
       no_old_in_first_x_rows = 8    # don't show old results in the first x rows
 
       # Include all sticky uncategorized on Hot
-      exec_sql("INSERT INTO hot_topics (topic_id, score)
-                SELECT t.id, RANDOM()
+      exec_sql("INSERT INTO hot_topics (topic_id,
+                                        random_bias,
+                                        random_multiplier,
+                                        days_ago_bias,
+                                        days_ago_multiplier,
+                                        score,
+                                        hot_topic_type)
+                SELECT t.id,
+                       calc.random_bias,
+                       1.0,
+                       0,
+                       1.0,
+                       calc.random_bias,
+                       1
                 FROM topics AS t
+                INNER JOIN (SELECT id, RANDOM() as random_bias
+                            FROM topics) AS calc ON calc.id = t.id
                 WHERE t.deleted_at IS NULL
                   AND t.visible
                   AND (NOT t.archived)
@@ -28,12 +42,27 @@ class HotTopic < ActiveRecord::Base
                   AND t.category_id IS NULL")
 
       # Include high percentile recent topics
-      inserted_count = exec_sql("INSERT INTO hot_topics (topic_id, category_id, score)
+      inserted_count = exec_sql("INSERT INTO hot_topics (topic_id,
+                                                         category_id,
+                                                         random_bias,
+                                                         random_multiplier,
+                                                         days_ago_bias,
+                                                         days_ago_multiplier,
+                                                         score,
+                                                         hot_topic_type)
                                   SELECT t.id,
                                          t.category_id,
-                                         ((1.0 - (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP-t.created_at)/86400) / :days_ago) * 0.95) +
-                                            (RANDOM() * 0.05)
+                                         calc.random_bias,
+                                         0.05,
+                                         calc.days_ago_bias,
+                                         0.95,
+                                         (calc.random_bias * 0.05) + (days_ago_bias * 0.95),
+                                         2
                                   FROM topics AS t
+                                  INNER JOIN (SELECT id,
+                                                     RANDOM() as random_bias,
+                                                     ((1.0 - (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP-created_at)/86400) / :days_ago) * 0.95) AS days_ago_bias
+                                              FROM topics) AS calc ON calc.id = t.id
                                   WHERE t.deleted_at IS NULL
                                     AND t.visible
                                     AND (NOT t.closed)
@@ -56,16 +85,26 @@ class HotTopic < ActiveRecord::Base
         max_old_score = HotTopic.order('score desc').limit(no_old_in_first_x_rows).last.score
       end
 
-
-
-
-
       # Add a sprinkling of random older topics
-      exec_sql("INSERT INTO hot_topics (topic_id, category_id, score)
+      exec_sql("INSERT INTO hot_topics (topic_id,
+                                       category_id,
+                                       random_bias,
+                                       random_multiplier,
+                                       days_ago_bias,
+                                       days_ago_multiplier,
+                                       score,
+                                       hot_topic_type)
                 SELECT t.id,
                        t.category_id,
-                       RANDOM() * :max_old_score
+                       calc.random_bias,
+                       :max_old_score,
+                       0,
+                       1.0,
+                       calc.random_bias * :max_old_score,
+                       3
                 FROM topics AS t
+                INNER JOIN (SELECT id, RANDOM() as random_bias
+                            FROM topics) AS calc ON calc.id = t.id
                 WHERE t.deleted_at IS NULL
                   AND t.visible
                   AND (NOT t.closed)
diff --git a/app/models/topic.rb b/app/models/topic.rb
index adbc2c84c..af082c771 100644
--- a/app/models/topic.rb
+++ b/app/models/topic.rb
@@ -55,6 +55,7 @@ class Topic < ActiveRecord::Base
   # When we want to temporarily attach some data to a forum topic (usually before serialization)
   attr_accessor :user_data
   attr_accessor :posters  # TODO: can replace with posters_summary once we remove old list code
+  attr_accessor :topic_list
 
 
   # The regular order
diff --git a/app/models/topic_list.rb b/app/models/topic_list.rb
index c5f9d448c..4fc096d0f 100644
--- a/app/models/topic_list.rb
+++ b/app/models/topic_list.rb
@@ -3,9 +3,14 @@ require_dependency 'avatar_lookup'
 class TopicList
   include ActiveModel::Serialization
 
-  attr_accessor :more_topics_url, :draft, :draft_key, :draft_sequence
+  attr_accessor :more_topics_url,
+                :draft,
+                :draft_key,
+                :draft_sequence,
+                :filter
 
-  def initialize(current_user, topics)
+  def initialize(filter, current_user, topics)
+    @filter = filter
     @current_user = current_user
     @topics_input = topics
   end
@@ -30,6 +35,7 @@ class TopicList
     @topics.each do |ft|
       ft.user_data = @topic_lookup[ft.id] if @topic_lookup.present?
       ft.posters = ft.posters_summary(ft.user_data, @current_user, avatar_lookup: avatar_lookup)
+      ft.topic_list = self
     end
 
     return @topics
diff --git a/app/serializers/topic_list_item_serializer.rb b/app/serializers/topic_list_item_serializer.rb
index eeed91251..e30f8e4e9 100644
--- a/app/serializers/topic_list_item_serializer.rb
+++ b/app/serializers/topic_list_item_serializer.rb
@@ -10,7 +10,8 @@ class TopicListItemSerializer < ListableTopicSerializer
              :archived,
              :starred,
              :has_best_of,
-             :archetype
+             :archetype,
+             :rank_details
 
   has_one :category
   has_many :posters, serializer: TopicPosterSerializer, embed: :objects
@@ -20,6 +21,35 @@ class TopicListItemSerializer < ListableTopicSerializer
   end
   alias :include_starred? :seen
 
+
+  # This is for debugging / tweaking the hot topic rankings.
+  # We will likely remove it after we are happier with things.
+  def rank_details
+
+    hot_topic_type = case object.hot_topic.hot_topic_type
+      when 1 then 'sticky'
+      when 2 then 'recent high scoring'
+      when 3 then 'old high scoring'
+    end
+
+    {topic_score: object.score,
+     percent_rank: object.percent_rank,
+     random_bias: object.hot_topic.random_bias,
+     random_multiplier: object.hot_topic.random_multiplier,
+     days_ago_bias: object.hot_topic.days_ago_bias,
+     days_ago_multiplier: object.hot_topic.days_ago_multiplier,
+     ranking_score: object.hot_topic.score,
+     hot_topic_type: hot_topic_type}
+  end
+
+  def include_rank_details?
+    return false unless object.topic_list.present?
+    return false unless scope.user.present?
+    return false unless scope.user.admin?
+
+    object.topic_list.filter == :hot
+  end
+
   def posters
     object.posters || []
   end
@@ -28,4 +58,5 @@ class TopicListItemSerializer < ListableTopicSerializer
     PinnedCheck.new(object, object.user_data).pinned?
   end
 
+
 end
diff --git a/app/serializers/topic_list_serializer.rb b/app/serializers/topic_list_serializer.rb
index a4c5cb39e..f08b4d373 100644
--- a/app/serializers/topic_list_serializer.rb
+++ b/app/serializers/topic_list_serializer.rb
@@ -1,6 +1,11 @@
 class TopicListSerializer < ApplicationSerializer
 
-  attributes :can_create_topic, :more_topics_url, :filter_summary, :draft, :draft_key, :draft_sequence
+  attributes :can_create_topic,
+             :more_topics_url,
+             :filter_summary,
+             :draft,
+             :draft_key,
+             :draft_sequence
 
   has_many :topics, serializer: TopicListItemSerializer, embed: :objects
 
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index bde01d8ef..4213783ef 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -386,6 +386,10 @@ en:
         favorited: "There are no more favorited topics to read."
         category: "There are no more {{category}} topics."
 
+    rank_details:
+      show: show topic rank details
+      title: Topic Rank Details
+
     topic:
       create_in: 'Create {{categoryName}} Topic'
       create: 'Create Topic'
@@ -407,6 +411,7 @@ en:
         description: "Sorry, we couldn't find that topic. Perhaps it was removed by a moderator?"
       unread_posts: "you have {{unread}} unread old posts in this topic"
       new_posts: "there are {{new_posts}} new posts in this topic since you last read it"
+
       likes:
         one: "there is 1 like in this topic"
         other: "there are {{count}} likes in this topic"
diff --git a/db/migrate/20130402210723_add_values_to_hot_topics.rb b/db/migrate/20130402210723_add_values_to_hot_topics.rb
new file mode 100644
index 000000000..dab4598a6
--- /dev/null
+++ b/db/migrate/20130402210723_add_values_to_hot_topics.rb
@@ -0,0 +1,9 @@
+class AddValuesToHotTopics < ActiveRecord::Migration
+  def change
+    add_column :hot_topics, :random_bias, :float
+    add_column :hot_topics, :random_multiplier, :float
+    add_column :hot_topics, :days_ago_bias, :float
+    add_column :hot_topics, :days_ago_multiplier, :float
+    add_column :hot_topics, :hot_topic_type, :integer
+  end
+end
diff --git a/lib/topic_query.rb b/lib/topic_query.rb
index 9f69ff112..e091b99db 100644
--- a/lib/topic_query.rb
+++ b/lib/topic_query.rb
@@ -81,7 +81,7 @@ class TopicQuery
 
     # If not logged in, return some random results, preferably in this category
     if @user.blank?
-      return TopicList.new(@user, random_suggested_results_for(topic, SiteSetting.suggested_topics, exclude_topic_ids))
+      return TopicList.new(:suggested, @user, random_suggested_results_for(topic, SiteSetting.suggested_topics, exclude_topic_ids))
     end
 
     results = unread_results(per_page: SiteSetting.suggested_topics)
@@ -118,49 +118,45 @@ class TopicQuery
       end
     end
 
-    TopicList.new(@user, results)
+    TopicList.new(:suggested, @user, results)
   end
 
   # The latest view of topics
   def list_latest
-    TopicList.new(@user, default_list)
+    create_list(:latest)
   end
 
   # The favorited topics
   def list_favorited
-    return_list do |list|
-      list.where('tu.starred')
-    end
+    create_list(:favorited) {|topics| topics.where('tu.starred') }
   end
 
   def list_read
-    return_list(unordered: true) do |list|
-      list.order('COALESCE(tu.last_visited_at, topics.bumped_at) DESC')
+    create_list(:read, unordered: true) do |topics|
+      topics.order('COALESCE(tu.last_visited_at, topics.bumped_at) DESC')
     end
   end
 
   def list_hot
-    return_list(unordered: true) do |list|
-      # Find hot topics
-      list = list.joins(:hot_topic)
-                 .order(TopicQuery.order_hotness)
+    create_list(:hot, unordered: true) do |topics|
+      topics.joins(:hot_topic).order(TopicQuery.order_hotness)
     end
   end
 
   def list_new
-    TopicList.new(@user, new_results)
+    TopicList.new(:new, @user, new_results)
   end
 
   def list_unread
-    TopicList.new(@user, unread_results)
+    TopicList.new(:unread, @user, unread_results)
   end
 
   def list_posted
-    return_list {|l| l.where('tu.user_id IS NOT NULL') }
+    create_list(:posted) {|l| l.where('tu.user_id IS NOT NULL') }
   end
 
   def list_uncategorized
-    return_list(unordered: true) do |list|
+    create_list(:uncategorized, unordered: true) do |list|
       list = list.where(category_id: nil)
 
       if @user_id.present?
@@ -172,7 +168,7 @@ class TopicQuery
   end
 
   def list_category(category)
-    return_list(unordered: true) do |list|
+    create_list(:category, unordered: true) do |list|
       list = list.where(category_id: category.id)
       if @user_id.present?
         list.order(TopicQuery.order_with_pinned_sql)
@@ -191,13 +187,15 @@ class TopicQuery
   end
 
   def list_new_in_category(category)
-    return_list {|l| l.where(category_id: category.id).by_newest.first(25)}
+    create_list(:new_in_category) {|l| l.where(category_id: category.id).by_newest.first(25)}
   end
 
   protected
 
-    def return_list(list_opts={})
-      TopicList.new(@user, yield(default_list(list_opts)))
+    def create_list(filter, list_opts={})
+      topics = default_list(list_opts)
+      topics = yield(topics) if block_given?
+      TopicList.new(filter, @user, topics)
     end
 
     # Create a list based on a bunch of detault options
@@ -233,7 +231,6 @@ class TopicQuery
     end
 
     def new_results(list_opts={})
-
       default_list(list_opts)
         .where("topics.created_at >= :created_at", created_at: @user.treat_as_new_topic_start_date)
         .where("tu.last_read_post_number IS NULL")
@@ -252,12 +249,10 @@ class TopicQuery
                  .where(closed: false, archived: false, visible: true)
 
       if topic.category_id.present?
-        results = results.order("CASE WHEN topics.category_id = #{topic.category_id.to_i} THEN 0 ELSE 1 END, RANDOM()")
-      else
-        results = results.order("RANDOM()")
+        return results.order("CASE WHEN topics.category_id = #{topic.category_id.to_i} THEN 0 ELSE 1 END, RANDOM()")
       end
 
-      results
+      results.order("RANDOM()")
     end
 
 end