Merge pull request #414 from basecamp/drop-activity-score

Drop activity scoring
This commit is contained in:
David Heinemeier Hansson
2025-04-18 17:09:22 +02:00
committed by GitHub
9 changed files with 49 additions and 221 deletions
@@ -1,43 +0,0 @@
import { Controller } from "@hotwired/stimulus"
const SIZES = [ "one", "two", "three", "four", "five" ]
export default class extends Controller {
static targets = [ "card" ]
connect() {
this.resize()
}
resize() {
const [ min, max ] = this.#getScoreRange()
this.cardTargets.forEach(card => {
const score = this.#currentCardScore(card)
const idx = Math.round((score - min) / (max - min) * (SIZES.length - 1))
card.style.setProperty("--card-size", `var(--card-size-${SIZES[idx]})`)
})
}
#getScoreRange() {
var min = 0, max = 1;
this.cardTargets.forEach(card => {
const score = this.#currentCardScore(card)
min = Math.min(min, score)
max = Math.max(max, score)
})
return [ min, max ]
}
#currentCardScore(el) {
const score = el.dataset.activityScore
const scoreAt = el.dataset.activityScoreAt
const daysAgo = (Date.now() / 1000 - scoreAt) / (60 * 60 * 24)
return score / (2**daysAgo)
}
}
+6 -7
View File
@@ -1,6 +1,6 @@
class Card < ApplicationRecord
include Assignable, Colored, DraftCommenting, Engageable, Eventable, Golden,
Messages, Notifiable, Pinnable, Closeable, Scorable, Searchable, Staged,
Messages, Notifiable, Pinnable, Closeable, Searchable, Staged,
Statuses, Taggable, Watchable
belongs_to :collection, touch: true
@@ -21,12 +21,11 @@ class Card < ApplicationRecord
scope :indexed_by, ->(index) do
case index
when "most_active" then ordered_by_activity
when "newest" then reverse_chronologically
when "oldest" then chronologically
when "latest" then latest
when "stalled" then ordered_by_staleness
when "closed" then closed
when "newest" then reverse_chronologically
when "oldest" then chronologically
when "latest" then latest
when "stalled" then chronologically
when "closed" then closed
end
end
-64
View File
@@ -1,64 +0,0 @@
module Card::Scorable
extend ActiveSupport::Concern
REFERENCE_DATE = Time.utc(2025, 1, 1)
included do
scope :ordered_by_activity, -> { order activity_score_order: :desc }
# Staleness is measured by the amount of activity_score lost since it was last updated.
# The factor 0.9 is chosen to make the decay curve closer to linear; an exponential
# curve would make recent items appear stale very quickly, due to the sharp dropoff.
scope :ordered_by_staleness, -> { order Arel.sql(
"coalesce(activity_score * (1 - power(0.9, julianday('now') - julianday(activity_score_at))), 0) 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.comment&.first_by_author_on_card? 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 cards, since it represents the card'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(1.0 + score) + days_diff
end
def last_scorable_activity_at
scorable_events.maximum(:created_at) || created_at
end
def scorable_events
events.where(action: :commented)
end
end
@@ -1,4 +1,4 @@
<section id="doing-cards" class="cards cards--doing" style="view-transition-name: cards-container;" data-controller="card-size" data-action="turbo:morph@window->card-size#resize">
<section id="doing-cards" class="cards cards--doing" style="view-transition-name: cards-container;">
<% if workflow = filter.single_workflow %>
<%= render "cards/index/workflow_filter", workflow: workflow, filter: filter %>
<% else %>
@@ -0,0 +1,8 @@
class RemoveActivityFromCards < ActiveRecord::Migration[8.1]
def change
remove_index :cards, :activity_score_order
remove_column :cards, :activity_score_order
remove_column :cards, :activity_score_at
remove_column :cards, :activity_score
end
end
Generated
+1 -5
View File
@@ -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_04_15_131804) do
ActiveRecord::Schema[8.1].define(version: 2025_04_18_150259) do
create_table "accesses", force: :cascade do |t|
t.integer "collection_id", null: false
t.datetime "created_at", null: false
@@ -108,9 +108,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_15_131804) do
end
create_table "cards", force: :cascade do |t|
t.float "activity_score", default: 0.0, null: false
t.datetime "activity_score_at"
t.float "activity_score_order", default: 0.0, null: false
t.integer "collection_id", null: false
t.datetime "created_at", null: false
t.integer "creator_id", null: false
@@ -120,7 +117,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_15_131804) do
t.text "status", default: "creating", null: false
t.string "title"
t.datetime "updated_at", null: false
t.index ["activity_score_order"], name: "index_cards_on_activity_score_order"
t.index ["collection_id"], name: "index_cards_on_collection_id"
t.index ["last_active_at", "status"], name: "index_cards_on_last_active_at_and_status"
t.index ["stage_id"], name: "index_cards_on_stage_id"
+33 -87
View File
@@ -21,7 +21,7 @@ columns:
default_function:
collation:
comment:
- &24 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &22 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: collection_id
cast_type: *1
@@ -80,7 +80,7 @@ columns:
default_function:
collation:
comment:
- &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &28 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: user_id
cast_type: *1
@@ -374,47 +374,9 @@ columns:
- *9
cards:
- *5
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: activity_score
cast_type: &22 !ruby/object:ActiveModel::Type::Float
precision:
scale:
limit:
sql_type_metadata: &23 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: float
type: :float
limit:
precision:
scale:
'null': false
default: 0.0
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: activity_score_at
cast_type: *3
sql_type_metadata: *4
'null': true
default:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: activity_score_order
cast_type: *22
sql_type_metadata: *23
'null': false
default: 0.0
default_function:
collation:
comment:
- *24
- *22
- *6
- &25 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &23 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: creator_id
cast_type: *1
@@ -427,12 +389,12 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: due_on
cast_type: &26 !ruby/object:ActiveRecord::Type::Date
cast_type: &24 !ruby/object:ActiveRecord::Type::Date
precision:
scale:
limit:
timezone:
sql_type_metadata: &27 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type_metadata: &25 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: date
type: :date
limit:
@@ -473,7 +435,7 @@ columns:
default_function:
collation:
comment:
- &32 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: title
cast_type: *7
@@ -528,11 +490,11 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: all_access
cast_type: &33 !ruby/object:ActiveModel::Type::Boolean
cast_type: &31 !ruby/object:ActiveModel::Type::Boolean
precision:
scale:
limit:
sql_type_metadata: &34 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type_metadata: &32 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: boolean
type: :boolean
limit:
@@ -544,7 +506,7 @@ columns:
collation:
comment:
- *6
- *25
- *23
- *10
- *9
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
@@ -558,15 +520,15 @@ columns:
collation:
comment:
collections_filters:
- *24
- *22
- *18
comments:
- *5
- *6
- *25
- *23
- *9
creators_filters:
- *25
- *23
- *18
event_summaries:
- *5
@@ -586,12 +548,12 @@ columns:
comment:
- *21
- *6
- *25
- *23
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: due_date
cast_type: *26
sql_type_metadata: *27
cast_type: *24
sql_type_metadata: *25
'null': true
default:
default_function:
@@ -600,11 +562,11 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: particulars
cast_type: &28 !ruby/object:ActiveRecord::Type::Json
cast_type: &26 !ruby/object:ActiveRecord::Type::Json
precision:
scale:
limit:
sql_type_metadata: &29 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type_metadata: &27 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: json
type: :json
limit:
@@ -629,12 +591,12 @@ columns:
filters:
- *5
- *6
- *25
- *23
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: fields
cast_type: *28
sql_type_metadata: *29
cast_type: *26
sql_type_metadata: *27
'null': false
default: "{}"
default_function:
@@ -665,7 +627,7 @@ columns:
comment:
filters_tags:
- *18
- &31 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &29 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: tag_id
cast_type: *1
@@ -745,13 +707,13 @@ columns:
collation:
comment:
- *9
- *30
- *28
pins:
- *5
- *21
- *6
- *9
- *30
- *28
reactions:
- *5
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
@@ -831,25 +793,25 @@ columns:
default_function:
collation:
comment:
- *30
- *28
taggings:
- *5
- *21
- *6
- *31
- *29
- *9
tags:
- *5
- *6
- *32
- *30
- *9
users:
- *5
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: active
cast_type: *33
sql_type_metadata: *34
cast_type: *31
sql_type_metadata: *32
'null': false
default: true
default_function:
@@ -893,12 +855,12 @@ columns:
- *21
- *6
- *9
- *30
- *28
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: watching
cast_type: *33
sql_type_metadata: *34
cast_type: *31
sql_type_metadata: *32
'null': false
default: true
default_function:
@@ -1350,22 +1312,6 @@ indexes:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: cards
name: index_cards_on_activity_score_order
unique: false
columns:
- activity_score_order
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
closure_reasons: []
closures:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
@@ -1992,4 +1938,4 @@ indexes:
comment:
valid: true
workflows: []
version: 20250415131804
version: 20250418150259
-2
View File
@@ -4,7 +4,6 @@ logo:
title: The logo isn't big enough
due_on: <%= 3.days.from_now %>
created_at: <%= 1.week.ago %>
activity_score: 7
status: published
last_active_at: <%= Time.current %>
@@ -13,7 +12,6 @@ layout:
creator: david
title: Layout is broken
created_at: <%= 1.week.ago %>
activity_score: 1
status: published
last_active_at: <%= Time.current %>
-12
View File
@@ -1,12 +0,0 @@
require "test_helper"
class Card::ScorableTest < ActiveSupport::TestCase
test "cards with no activity have a valid activity_score_order" do
card = Card.create! collection: collections(:writebook), creator: users(:kevin)
card.rescore
assert card.activity_score.zero?
assert_not card.activity_score_order.infinite?
end
end