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 %>