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