From 16a80ff3a63a029569da5e7bc08de2d2691ca868 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Tue, 4 Mar 2025 13:22:03 +0000 Subject: [PATCH] More dynamic activity scores This introduces a more dynamic system of activity scoring, to improve the way bubbles "bubble up" due to their activity. There are a few different parts we can tune here, and it's likely we'll need to make adjustments once we get a feel for how this works in practice. The basic idea here is: - We assign points for certain types of event that happen on a bubble. A boost gets 1 point, a comment gets 10 points, and so on. - These points decay over time, at a rate of 50% per day. So old activity is worth much less than new activity. Bubbles should rise up quickly when acted upon, bit will float back down if left idle. - Some comments can score higher than others: the first comment from each person on a bubble is worth more (20) because it signals that more people are getting involved; and comments that follow a comment by a different author are also worth more (15) because that signals there's ongoing conversation between people, not just a series of notes being left by one individual. In terms of implementation, we persist the score on the bubble whenever it changes, but we handle the decay on the client side. That allows us to cache the bubble representation without having to continually change it while its activity decays. We also keep a separate `activity_score_order` attribute on the model. This can be used to sort the bubbles in order of "most active", without having to think about the decay. --- app/helpers/bubbles_helper.rb | 13 ----- .../controllers/bubble_size_controller.js | 43 ++++++++++++++ app/models/bubble.rb | 7 +-- app/models/bubble/scorable.rb | 58 +++++++++++++++++++ app/models/comment.rb | 16 +++++ app/models/event.rb | 9 +++ app/views/bubbles/_bubble.html.erb | 5 +- app/views/bubbles/index.html.erb | 2 +- app/views/buckets/_bucket.html.erb | 5 +- app/views/buckets/index.html.erb | 4 +- app/views/filters/_filter.html.erb | 5 +- ...140641_add_activity_score_at_to_bubbles.rb | 11 ++++ db/schema.rb | 7 ++- test/models/bubble/scorable_test.rb | 56 ++++++++++++++++++ test/models/bubble_test.rb | 11 ++-- test/models/comment_test.rb | 50 ++++++++++++++-- test/models/filter_test.rb | 2 +- test/test_helper.rb | 2 +- test/test_helpers/change_test_helper.rb | 8 +++ 19 files changed, 274 insertions(+), 40 deletions(-) create mode 100644 app/javascript/controllers/bubble_size_controller.js create mode 100644 app/models/bubble/scorable.rb create mode 100644 db/migrate/20250304140641_add_activity_score_at_to_bubbles.rb create mode 100644 test/models/bubble/scorable_test.rb create mode 100644 test/test_helpers/change_test_helper.rb diff --git a/app/helpers/bubbles_helper.rb b/app/helpers/bubbles_helper.rb index 0eaa94bf9..b530cab2b 100644 --- a/app/helpers/bubbles_helper.rb +++ b/app/helpers/bubbles_helper.rb @@ -10,17 +10,4 @@ module BubblesHelper "--bubble-rotate: #{value}deg;" end - - def bubble_size(bubble) - rank = - case bubble.activity_score - when 0..5 then "one" - when 6..10 then "two" - when 11..25 then "three" - when 26..50 then "four" - else "five" - end - - "--bubble-size: var(--bubble-size-#{rank});" - end end diff --git a/app/javascript/controllers/bubble_size_controller.js b/app/javascript/controllers/bubble_size_controller.js new file mode 100644 index 000000000..912e19d66 --- /dev/null +++ b/app/javascript/controllers/bubble_size_controller.js @@ -0,0 +1,43 @@ +import { Controller } from "@hotwired/stimulus" + +const SIZES = [ "one", "two", "three", "four", "five" ] + +export default class extends Controller { + static targets = [ "bubble" ] + + connect() { + this.resize() + } + + resize() { + const [ min, max ] = this.#getScoreRange() + + this.bubbleTargets.forEach(bubble => { + const score = this.#currentBubbleScore(bubble) + const idx = Math.round((score - min) / (max - min) * (SIZES.length - 1)) + + bubble.style.setProperty("--bubble-size", `var(--bubble-size-${SIZES[idx]})`) + }) + } + + #getScoreRange() { + var min = 0, max = 1; + + this.bubbleTargets.forEach(bubble => { + const score = this.#currentBubbleScore(bubble) + + min = Math.min(min, score) + max = Math.max(max, score) + }) + + return [ min, max ] + } + + #currentBubbleScore(el) { + const score = el.dataset.activityScore + const scoreAt = el.dataset.activityScoreAt + const daysAgo = (Date.now() / 1000 - scoreAt) / (60 * 60 * 24) + + return score / (2**daysAgo) + } +} diff --git a/app/models/bubble.rb b/app/models/bubble.rb index 275ff1881..3c1454d30 100644 --- a/app/models/bubble.rb +++ b/app/models/bubble.rb @@ -1,6 +1,6 @@ class Bubble < ApplicationRecord include Assignable, Boostable, Colored, Commentable, Eventable, - Messages, Notifiable, Poppable, Searchable, Staged, Statuses, Taggable, Watchable + Messages, Notifiable, Poppable, Scorable, Searchable, Staged, Statuses, Taggable, Watchable belongs_to :bucket, touch: true belongs_to :creator, class_name: "User", default: -> { Current.user } @@ -14,7 +14,6 @@ class Bubble < ApplicationRecord scope :reverse_chronologically, -> { order created_at: :desc, id: :desc } scope :chronologically, -> { order created_at: :asc, id: :asc } - scope :ordered_by_activity, -> { order activity_score: :desc } scope :in_bucket, ->(bucket) { where bucket: bucket } scope :indexed_by, ->(index) do @@ -28,10 +27,6 @@ class Bubble < ApplicationRecord end end - def rescore - update! activity_score: boosts_count + comments_count - end - private def track_due_date_change if due_on.present? diff --git a/app/models/bubble/scorable.rb b/app/models/bubble/scorable.rb new file mode 100644 index 000000000..579f8af8d --- /dev/null +++ b/app/models/bubble/scorable.rb @@ -0,0 +1,58 @@ +module Bubble::Scorable + extend ActiveSupport::Concern + + REFERENCE_DATE = Time.utc(2025, 1, 1) + + included do + scope :ordered_by_activity, -> { order activity_score_order: :desc } + end + + def rescore + score = calculate_activity_score + score_at = last_scorable_activity_at + + update! \ + activity_score: score, + activity_score_at: score_at, + activity_score_order: event_score_reference(score, score_at) + end + + private + def calculate_activity_score + scorable_events.sum { |event| event_score(event) } + end + + def event_score(event) + days_ago = (last_scorable_activity_at - event.created_at) / 1.day + event_weight(event) / (2**days_ago) + end + + def event_weight(event) + case + when event.boosted? then 1 + when event.comment&.first_by_author_on_bubble? then 20 + when event.comment&.follows_comment_by_another_author? then 15 + when event.commented? then 10 + else 0 + end + end + + def event_score_reference(score, activity_at) + # The reference score is used to make the activity score comparable + # across different bubbles, since it represents the bubble's activity + # level at a consistent point in time. + # + # We store this as log2 to tame the huge/tiny numbers we'd otherwise get + # when activity is far from the reference date. + days_diff = (activity_at - REFERENCE_DATE) / 1.day + Math.log2(score) + days_diff + end + + def last_scorable_activity_at + scorable_events.maximum(:created_at) || created_at + end + + def scorable_events + events.where(action: [ :commented, :boosted ]) + end +end diff --git a/app/models/comment.rb b/app/models/comment.rb index a56cc747c..03b45c6c3 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -10,6 +10,14 @@ class Comment < ApplicationRecord before_destroy :cleanup_events + def first_by_author_on_bubble? + bubble_comments.many? && bubble_comments_prior.where(creator_id: creator_id).none? + end + + def follows_comment_by_another_author? + bubble_comments.many? && bubble_comments_prior.last&.creator != creator + end + private def cleanup_events @@ -21,4 +29,12 @@ class Comment < ApplicationRecord # Delete events that reference directly in particulars Event.where(particulars: { comment_id: id }).destroy_all end + + def bubble_comments_prior + bubble_comments.where(created_at: ...created_at) + end + + def bubble_comments + Comment.joins(:message).where(messages: { bubble: bubble }) + end end diff --git a/app/models/event.rb b/app/models/event.rb index b8a5f5bbd..31b8a9c5c 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -12,9 +12,18 @@ class Event < ApplicationRecord scope :chronologically, -> { order created_at: :asc, id: :desc } scope :non_boosts, -> { where.not action: :boosted } scope :boosts, -> { where action: :boosted } + scope :comments, -> { where action: :commented } after_create -> { bubble.update_auto_pop_at(created_at) } + def boosted? + action == "boosted" + end + + def commented? + action == "commented" + end + def generate_notifications Notifier.for(self)&.generate end diff --git a/app/views/bubbles/_bubble.html.erb b/app/views/bubbles/_bubble.html.erb index 46e8a492f..841ac2dbf 100644 --- a/app/views/bubbles/_bubble.html.erb +++ b/app/views/bubbles/_bubble.html.erb @@ -1,6 +1,9 @@ <% cache bubble do %>
" - style="view-transition-name: bubble-<%= bubble.id -%>; --bubble-color: <%= bubble.color %>; <%= bubble_rotation(bubble) %> <%= bubble_size(bubble) %>" + style="view-transition-name: bubble-<%= bubble.id -%>; --bubble-color: <%= bubble.color %>; <%= bubble_rotation(bubble) %>" + data-bubble-size-target="bubble" + data-activity-score="<%= bubble.activity_score %>" + data-activity-score-at="<%= bubble.activity_score_at.to_i %>" data-controller="animation upload-preview" data-animation-play-class="bubble--wobble" data-animation-play-on-load-value="true" diff --git a/app/views/bubbles/index.html.erb b/app/views/bubbles/index.html.erb index 8089ad0de..e9ca8dbf6 100644 --- a/app/views/bubbles/index.html.erb +++ b/app/views/bubbles/index.html.erb @@ -29,7 +29,7 @@ <% end %> -
+
<% if @bubbles.any? %> <%= render partial: "bubbles/bubble", collection: @bubbles.limit(10), cached: true %> <% else %> diff --git a/app/views/buckets/_bucket.html.erb b/app/views/buckets/_bucket.html.erb index 597ddb528..16550c55f 100644 --- a/app/views/buckets/_bucket.html.erb +++ b/app/views/buckets/_bucket.html.erb @@ -2,7 +2,10 @@ <%= link_to bubbles_path(bucket_ids: [ bucket ]), class: "border border-radius margin-block-end-half bubbles__container flex justify-center align-center position-relative" do %>
<% bucket.bubbles.active.published_or_drafted_by(Current.user).ordered_by_activity.limit(10).each do |bubble| %> -
" style="--bubble-color: <%= bubble.color %>; <%= bubble_rotation(bubble) %> <%= bubble_size(bubble) %>"> +
" style="--bubble-color: <%= bubble.color %>; <%= bubble_rotation(bubble) %>" + data-bubble-size-target="bubble" + data-activity-score="<%= bubble.activity_score %>" + data-activity-score-at="<%= bubble.activity_score_at.to_i %>">
<% end %> diff --git a/app/views/buckets/index.html.erb b/app/views/buckets/index.html.erb index 3d1054734..9197893aa 100644 --- a/app/views/buckets/index.html.erb +++ b/app/views/buckets/index.html.erb @@ -22,10 +22,10 @@ <% end %> -
+
<%= render @buckets, cached: true %>
-
+
<%= render @filters %>
diff --git a/app/views/filters/_filter.html.erb b/app/views/filters/_filter.html.erb index 7d3e4fc82..bcf6cd7da 100644 --- a/app/views/filters/_filter.html.erb +++ b/app/views/filters/_filter.html.erb @@ -3,7 +3,10 @@ <%= link_to bubbles_path(**filter.as_params), class: "border border-radius margin-block-end-half bubbles__container flex justify-center align-center position-relative" do %>
<% filter.bubbles.ordered_by_activity.limit(10).each do |bubble| %> -
+
" style="--bubble-color: <%= bubble.color %>; <%= bubble_rotation(bubble) %>" + data-bubble-size-target="bubble" + data-activity-score="<%= bubble.activity_score %>" + data-activity-score-at="<%= bubble.activity_score_at.to_i %>">
<% end %> diff --git a/db/migrate/20250304140641_add_activity_score_at_to_bubbles.rb b/db/migrate/20250304140641_add_activity_score_at_to_bubbles.rb new file mode 100644 index 000000000..406124742 --- /dev/null +++ b/db/migrate/20250304140641_add_activity_score_at_to_bubbles.rb @@ -0,0 +1,11 @@ +class AddActivityScoreAtToBubbles < ActiveRecord::Migration[8.1] + def change + change_table :bubbles do |t| + t.datetime :activity_score_at + t.float :activity_score_order, null: false, default: 0 + t.change :activity_score, :float, null: false, default: 0 + + t.index :activity_score_order, order: :desc + end + end +end diff --git a/db/schema.rb b/db/schema.rb index aa3fbb365..106d050cb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_02_24_152047) do +ActiveRecord::Schema[8.1].define(version: 2025_03_04_140641) do create_table "accesses", force: :cascade do |t| t.integer "bucket_id", null: false t.integer "user_id", null: false @@ -105,9 +105,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_02_24_152047) do t.integer "boosts_count", default: 0, null: false t.integer "stage_id" t.integer "comments_count", default: 0, null: false - t.integer "activity_score", default: 0, null: false + t.float "activity_score", default: 0.0, null: false t.text "status", default: "creating", null: false t.datetime "auto_pop_at", null: false + t.datetime "activity_score_at" + t.float "activity_score_order", default: 0.0, null: false + t.index ["activity_score_order"], name: "index_bubbles_on_activity_score_order", order: :desc t.index ["auto_pop_at"], name: "index_bubbles_on_auto_pop_at" t.index ["bucket_id"], name: "index_bubbles_on_bucket_id" t.index ["stage_id"], name: "index_bubbles_on_stage_id" diff --git a/test/models/bubble/scorable_test.rb b/test/models/bubble/scorable_test.rb new file mode 100644 index 000000000..fc89c11c2 --- /dev/null +++ b/test/models/bubble/scorable_test.rb @@ -0,0 +1,56 @@ +require "test_helper" + +class Bubble::ScorableTest < ActiveSupport::TestCase + test "a bubble has a score that increases with activity" do + bubble = bubbles(:logo) + + score = bubble.activity_score + assert_operator score, :>, 0 + + with_current_user :kevin do + bubble.capture Comment.create(body: "This is exciting!") + end + + assert_operator bubble.activity_score, :>, score + end + + test "commenting on a bubble boosts its score more than boosting it" do + bubble = bubbles(:logo) + bubble.rescore + + comment_change = capture_change -> { bubble.activity_score } do + with_current_user :kevin do + bubble.capture Comment.create(body: "This is exciting!") + end + end + + boost_change = capture_change -> { bubble.activity_score } do + with_current_user :kevin do + bubble.boost! + end + end + + assert_operator comment_change, :>, boost_change + end + + test "recent activity counts more than older activity in the ordering" do + with_current_user :kevin do + travel_to 5.days.ago + bubble_old = buckets(:writebook).bubbles.create! status: :published, title: "old" + bubble_mid = buckets(:writebook).bubbles.create! status: :published, title: "mid" + bubble_new = buckets(:writebook).bubbles.create! status: :published, title: "new" + + bubble_old.boost! + bubble_old.boost! + + travel_back + travel_to 2.days.ago + bubble_mid.boost! + + travel_back + bubble_new.boost! + + assert_equal [ bubble_new, bubble_mid, bubble_old ], Bubble.where(id: [ bubble_old, bubble_mid, bubble_new ]).ordered_by_activity + end + end +end diff --git a/test/models/bubble_test.rb b/test/models/bubble_test.rb index 33466acbd..0706533fb 100644 --- a/test/models/bubble_test.rb +++ b/test/models/bubble_test.rb @@ -14,8 +14,10 @@ class BubbleTest < ActiveSupport::TestCase end test "boosting" do - assert_difference %w[ bubbles(:logo).boosts_count bubbles(:logo).activity_score Event.count ], +1 do - bubbles(:logo).boost!(bubbles(:logo).boosts_count+ 1) + assert_changes -> { bubbles(:logo).activity_score } do + assert_difference -> { bubbles(:logo).boosts_count }, +1 do + bubbles(:logo).boost!(bubbles(:logo).boosts_count+ 1) + end end end @@ -72,11 +74,6 @@ class BubbleTest < ActiveSupport::TestCase assert_includes Bubble.search("haggis"), bubble end - test "ordering by activity" do - bubbles(:layout).tap { |b| b.update!(boosts_count: 1_000) }.rescore - assert_equal bubbles(:layout, :logo, :shipping, :text), Bubble.ordered_by_activity - end - test "ordering by comments" do assert_equal bubbles(:logo, :layout, :shipping, :text), Bubble.ordered_by_comments end diff --git a/test/models/comment_test.rb b/test/models/comment_test.rb index 39e8f72b5..c7fcacff3 100644 --- a/test/models/comment_test.rb +++ b/test/models/comment_test.rb @@ -12,12 +12,54 @@ class CommentTest < ActiveSupport::TestCase end test "updating bubble counter" do - assert_difference %w[ bubbles(:logo).comments_count bubbles(:logo).activity_score ], +1 do - bubbles(:logo).capture Comment.new(body: "I'd prefer something more rustic") + assert_difference -> { bubbles(:logo).comments_count } do + assert_changes -> { bubbles(:logo).activity_score } do + bubbles(:logo).capture Comment.new(body: "I'd prefer something more rustic") + end end - assert_difference %w[ bubbles(:logo).comments_count bubbles(:logo).activity_score ], -1 do - bubbles(:logo).messages.comments.last.destroy + assert_difference -> { bubbles(:logo).comments_count }, -1 do + assert_changes -> { bubbles(:logo).activity_score } do + bubbles(:logo).messages.comments.last.destroy + end + end + end + + test "first_by_author_on_bubble?" do + assert_not Comment.new.first_by_author_on_bubble? + + with_current_user :david do + comment = Comment.new.tap { |c| bubbles(:logo).capture c } + assert comment.first_by_author_on_bubble? + + comment = Comment.new.tap { |c| bubbles(:logo).capture c } + assert_not comment.first_by_author_on_bubble? + end + + with_current_user :kevin do + comment = Comment.new.tap { |c| bubbles(:logo).capture c } + assert_not comment.first_by_author_on_bubble? + end + end + + test "follows_comment_by_another_author?" do + assert_not Comment.new.follows_comment_by_another_author? + + bubble = buckets(:writebook).bubbles.create! + + with_current_user :david do + comment = Comment.new.tap { |c| bubble.capture c } + assert_not comment.follows_comment_by_another_author? + end + + with_current_user :kevin do + comment = Comment.new.tap { |c| bubble.capture c } + assert comment.follows_comment_by_another_author? + end + + with_current_user :david do + comment = Comment.new.tap { |c| bubble.capture c } + assert comment.follows_comment_by_another_author? end end end diff --git a/test/models/filter_test.rb b/test/models/filter_test.rb index e31794b52..9b7c4ab66 100644 --- a/test/models/filter_test.rb +++ b/test/models/filter_test.rb @@ -26,7 +26,7 @@ class FilterTest < ActiveSupport::TestCase assert_equal [ @new_bubble ], filter.bubbles filter = users(:david).filters.new terms: [ "haggis" ] - assert_equal bubbles(:logo, :layout), filter.bubbles + assert_equal bubbles(:logo, :layout).sort, filter.bubbles.sort filter = users(:david).filters.new terms: [ "haggis", "love" ] assert_equal [ bubbles(:logo) ], filter.bubbles diff --git a/test/test_helper.rb b/test/test_helper.rb index 37e5e0a22..f7cc1e088 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -10,6 +10,6 @@ module ActiveSupport # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all - include SessionTestHelper + include ChangeTestHelper, SessionTestHelper end end diff --git a/test/test_helpers/change_test_helper.rb b/test/test_helpers/change_test_helper.rb new file mode 100644 index 000000000..da37e26d3 --- /dev/null +++ b/test/test_helpers/change_test_helper.rb @@ -0,0 +1,8 @@ +module ChangeTestHelper + def capture_change(target) + before = target.call + yield + after = target.call + after - before + end +end