vote_fu 0.0.11 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +84 -0
- data/README.md +265 -0
- data/app/assets/stylesheets/vote_fu/votes.css +391 -0
- data/app/channels/vote_fu/application_cable/channel.rb +8 -0
- data/app/channels/vote_fu/application_cable/connection.rb +39 -0
- data/app/channels/vote_fu/votes_channel.rb +99 -0
- data/app/components/vote_fu/like_button_component.rb +136 -0
- data/app/components/vote_fu/reaction_bar_component.rb +208 -0
- data/app/components/vote_fu/star_rating_component.rb +199 -0
- data/app/components/vote_fu/vote_widget_component.rb +181 -0
- data/app/controllers/vote_fu/application_controller.rb +49 -0
- data/app/controllers/vote_fu/votes_controller.rb +223 -0
- data/app/helpers/vote_fu/votes_helper.rb +176 -0
- data/app/javascript/vote_fu/channels/consumer.js +3 -0
- data/app/javascript/vote_fu/channels/index.js +2 -0
- data/app/javascript/vote_fu/channels/votes_channel.js +93 -0
- data/app/javascript/vote_fu/controllers/application.js +9 -0
- data/app/javascript/vote_fu/controllers/index.js +9 -0
- data/app/javascript/vote_fu/controllers/vote_fu_controller.js +148 -0
- data/app/javascript/vote_fu/controllers/vote_fu_reactions_controller.js +92 -0
- data/app/javascript/vote_fu/controllers/vote_fu_stars_controller.js +77 -0
- data/app/models/vote_fu/application_record.rb +7 -0
- data/app/models/vote_fu/vote.rb +90 -0
- data/app/views/vote_fu/votes/_count.html.erb +29 -0
- data/app/views/vote_fu/votes/_downvote_button.html.erb +40 -0
- data/app/views/vote_fu/votes/_error.html.erb +9 -0
- data/app/views/vote_fu/votes/_like_button.html.erb +67 -0
- data/app/views/vote_fu/votes/_upvote_button.html.erb +40 -0
- data/app/views/vote_fu/votes/_widget.html.erb +85 -0
- data/config/importmap.rb +6 -0
- data/config/routes.rb +9 -0
- data/lib/generators/vote_fu/install/install_generator.rb +56 -0
- data/lib/generators/vote_fu/install/templates/initializer.rb +42 -0
- data/lib/generators/vote_fu/install/templates/migration.rb.erb +41 -0
- data/lib/generators/vote_fu/migration/migration_generator.rb +29 -0
- data/lib/generators/vote_fu/migration/templates/create_vote_fu_votes.rb.erb +40 -0
- data/lib/vote_fu/algorithms/hacker_news.rb +54 -0
- data/lib/vote_fu/algorithms/reddit_hot.rb +55 -0
- data/lib/vote_fu/algorithms/wilson_score.rb +69 -0
- data/lib/vote_fu/concerns/karmatic.rb +320 -0
- data/lib/vote_fu/concerns/voteable.rb +291 -0
- data/lib/vote_fu/concerns/voter.rb +275 -0
- data/lib/vote_fu/configuration.rb +53 -0
- data/lib/vote_fu/engine.rb +54 -0
- data/lib/vote_fu/errors.rb +34 -0
- data/lib/vote_fu/version.rb +5 -0
- data/lib/vote_fu.rb +22 -9
- metadata +217 -60
- data/CHANGELOG.markdown +0 -31
- data/README.markdown +0 -220
- data/examples/routes.rb +0 -7
- data/examples/users_controller.rb +0 -76
- data/examples/voteable.html.erb +0 -8
- data/examples/voteable.rb +0 -10
- data/examples/voteables_controller.rb +0 -117
- data/examples/votes/_voteable_vote.html.erb +0 -23
- data/examples/votes/create.rjs +0 -1
- data/examples/votes_controller.rb +0 -110
- data/generators/vote_fu/templates/migration.rb +0 -21
- data/generators/vote_fu/vote_fu_generator.rb +0 -8
- data/init.rb +0 -1
- data/lib/acts_as_voteable.rb +0 -114
- data/lib/acts_as_voter.rb +0 -75
- data/lib/controllers/votes_controller.rb +0 -96
- data/lib/has_karma.rb +0 -68
- data/lib/models/vote.rb +0 -17
- data/rails/init.rb +0 -10
- data/test/vote_fu_test.rb +0 -8
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateVoteFuVotes < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :vote_fu_votes do |t|
|
|
6
|
+
# Polymorphic voter (User, Admin, etc.)
|
|
7
|
+
t.references :voter, polymorphic: true, null: false
|
|
8
|
+
|
|
9
|
+
# Polymorphic voteable (Post, Comment, etc.)
|
|
10
|
+
t.references :voteable, polymorphic: true, null: false
|
|
11
|
+
|
|
12
|
+
# Integer value: 1 for up, -1 for down, 1-5 for stars, etc.
|
|
13
|
+
t.integer :value, null: false, default: 1
|
|
14
|
+
|
|
15
|
+
# Optional scope for multiple voting contexts on same voteable
|
|
16
|
+
# Example: "quality", "helpfulness", etc.
|
|
17
|
+
t.string :scope
|
|
18
|
+
|
|
19
|
+
t.timestamps
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Unique constraint: one vote per voter per voteable per scope
|
|
23
|
+
add_index :vote_fu_votes,
|
|
24
|
+
%i[voter_type voter_id voteable_type voteable_id scope],
|
|
25
|
+
unique: true,
|
|
26
|
+
name: "idx_vote_fu_unique_vote"
|
|
27
|
+
|
|
28
|
+
# Index for querying all votes on a voteable
|
|
29
|
+
add_index :vote_fu_votes,
|
|
30
|
+
%i[voteable_type voteable_id],
|
|
31
|
+
name: "idx_vote_fu_voteable"
|
|
32
|
+
|
|
33
|
+
# Index for querying a voter's history
|
|
34
|
+
add_index :vote_fu_votes,
|
|
35
|
+
%i[voter_type voter_id created_at],
|
|
36
|
+
name: "idx_vote_fu_voter_history"
|
|
37
|
+
|
|
38
|
+
# Index for recent votes queries
|
|
39
|
+
add_index :vote_fu_votes, :created_at
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module VoteFu
|
|
7
|
+
module Generators
|
|
8
|
+
class MigrationGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Creates the VoteFu votes table migration"
|
|
14
|
+
|
|
15
|
+
def create_migration_file
|
|
16
|
+
migration_template(
|
|
17
|
+
"create_vote_fu_votes.rb.erb",
|
|
18
|
+
"db/migrate/create_vote_fu_votes.rb"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def migration_version
|
|
25
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateVoteFuVotes < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :vote_fu_votes do |t|
|
|
6
|
+
# Polymorphic voter (User, Admin, etc.)
|
|
7
|
+
t.references :voter, polymorphic: true, null: false
|
|
8
|
+
|
|
9
|
+
# Polymorphic voteable (Post, Comment, etc.)
|
|
10
|
+
t.references :voteable, polymorphic: true, null: false
|
|
11
|
+
|
|
12
|
+
# Integer value: 1 for up, -1 for down, 1-5 for stars, etc.
|
|
13
|
+
t.integer :value, null: false, default: 1
|
|
14
|
+
|
|
15
|
+
# Optional scope for multiple voting contexts on same voteable
|
|
16
|
+
t.string :scope
|
|
17
|
+
|
|
18
|
+
t.timestamps
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Unique constraint: one vote per voter per voteable per scope
|
|
22
|
+
add_index :vote_fu_votes,
|
|
23
|
+
%i[voter_type voter_id voteable_type voteable_id scope],
|
|
24
|
+
unique: true,
|
|
25
|
+
name: "idx_vote_fu_unique_vote"
|
|
26
|
+
|
|
27
|
+
# Index for querying all votes on a voteable
|
|
28
|
+
add_index :vote_fu_votes,
|
|
29
|
+
%i[voteable_type voteable_id],
|
|
30
|
+
name: "idx_vote_fu_voteable"
|
|
31
|
+
|
|
32
|
+
# Index for querying a voter's history
|
|
33
|
+
add_index :vote_fu_votes,
|
|
34
|
+
%i[voter_type voter_id created_at],
|
|
35
|
+
name: "idx_vote_fu_voter_history"
|
|
36
|
+
|
|
37
|
+
# Index for recent votes
|
|
38
|
+
add_index :vote_fu_votes, :created_at
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VoteFu
|
|
4
|
+
module Algorithms
|
|
5
|
+
# Hacker News ranking algorithm
|
|
6
|
+
#
|
|
7
|
+
# This algorithm heavily penalizes older content. Items decay
|
|
8
|
+
# rapidly based on age, making it suitable for fast-moving
|
|
9
|
+
# content feeds where freshness is paramount.
|
|
10
|
+
#
|
|
11
|
+
# Formula: Score = (P - 1) / (T + 2)^G
|
|
12
|
+
# Where:
|
|
13
|
+
# P = points (plusminus score)
|
|
14
|
+
# T = age in hours
|
|
15
|
+
# G = gravity (default 1.8)
|
|
16
|
+
#
|
|
17
|
+
# @see https://medium.com/hacking-and-gonzo/how-hacker-news-ranking-algorithm-works-1d9b0cf2c08d
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# Post.all.sort_by { |p| p.hacker_news_score }.reverse
|
|
21
|
+
#
|
|
22
|
+
class HackerNews
|
|
23
|
+
# Calculate the Hacker News score
|
|
24
|
+
#
|
|
25
|
+
# @param voteable [ActiveRecord::Base] The voteable object
|
|
26
|
+
# @param gravity [Float] Decay rate (higher = faster decay, default 1.8)
|
|
27
|
+
# @return [Float] The score (higher = better)
|
|
28
|
+
def self.call(voteable, gravity: 1.8)
|
|
29
|
+
new(voteable, gravity: gravity).calculate
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(voteable, gravity:)
|
|
33
|
+
@voteable = voteable
|
|
34
|
+
@gravity = gravity
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def calculate
|
|
38
|
+
points = [@voteable.plusminus - 1, 0].max
|
|
39
|
+
age_hours = hours_since_creation
|
|
40
|
+
|
|
41
|
+
return 0.0 if age_hours.negative?
|
|
42
|
+
|
|
43
|
+
points / ((age_hours + 2)**@gravity)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def hours_since_creation
|
|
49
|
+
created_at = @voteable.try(:created_at) || Time.current
|
|
50
|
+
(Time.current - created_at) / 1.hour
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VoteFu
|
|
4
|
+
module Algorithms
|
|
5
|
+
# Reddit's "Hot" ranking algorithm
|
|
6
|
+
#
|
|
7
|
+
# This algorithm balances popularity (vote score) with recency.
|
|
8
|
+
# Items with high scores rise quickly, but decay over time,
|
|
9
|
+
# allowing fresh content to surface.
|
|
10
|
+
#
|
|
11
|
+
# The formula uses logarithmic scaling for votes, so the first
|
|
12
|
+
# 10 votes have the same impact as the next 100, then 1000, etc.
|
|
13
|
+
# This prevents runaway popular items from dominating forever.
|
|
14
|
+
#
|
|
15
|
+
# @see https://medium.com/hacking-and-gonzo/how-reddit-ranking-algorithms-work-ef111e33d0d9
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# Post.all.sort_by(&:hot_score).reverse
|
|
19
|
+
#
|
|
20
|
+
class RedditHot
|
|
21
|
+
# Reddit's epoch (December 8, 2005)
|
|
22
|
+
EPOCH = Time.utc(2005, 12, 8, 7, 46, 43).to_i
|
|
23
|
+
|
|
24
|
+
# Calculate the hot score
|
|
25
|
+
#
|
|
26
|
+
# @param voteable [ActiveRecord::Base] The voteable object
|
|
27
|
+
# @param gravity [Float] Not used in Reddit's algorithm but kept for API consistency
|
|
28
|
+
# @return [Float] The hot score (higher = hotter)
|
|
29
|
+
def self.call(voteable, gravity: 1.8)
|
|
30
|
+
new(voteable).calculate
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(voteable)
|
|
34
|
+
@voteable = voteable
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def calculate
|
|
38
|
+
score = @voteable.plusminus
|
|
39
|
+
order = Math.log10([score.abs, 1].max)
|
|
40
|
+
sign = score <=> 0
|
|
41
|
+
seconds = epoch_seconds
|
|
42
|
+
|
|
43
|
+
# The score decays over time (45000 seconds ≈ 12.5 hours)
|
|
44
|
+
(sign * order + seconds / 45_000.0).round(7)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def epoch_seconds
|
|
50
|
+
created_at = @voteable.try(:created_at) || Time.current
|
|
51
|
+
created_at.to_i - EPOCH
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VoteFu
|
|
4
|
+
module Algorithms
|
|
5
|
+
# Wilson Score Confidence Interval for Bernoulli Parameter
|
|
6
|
+
#
|
|
7
|
+
# This algorithm provides the lower bound of a Wilson score confidence interval.
|
|
8
|
+
# It's excellent for ranking items by quality when you have binary ratings
|
|
9
|
+
# (up/down votes). Unlike simple averages, it accounts for statistical uncertainty
|
|
10
|
+
# when there are few votes.
|
|
11
|
+
#
|
|
12
|
+
# @see https://www.evanmiller.org/how-not-to-sort-by-average-rating.html
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# post.wilson_score # => 0.85 (high confidence it's good)
|
|
16
|
+
#
|
|
17
|
+
class WilsonScore
|
|
18
|
+
# Z-scores for common confidence levels
|
|
19
|
+
Z_SCORES = {
|
|
20
|
+
0.80 => 1.28,
|
|
21
|
+
0.85 => 1.44,
|
|
22
|
+
0.90 => 1.64,
|
|
23
|
+
0.95 => 1.96,
|
|
24
|
+
0.99 => 2.58
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# Calculate the Wilson Score Lower Bound
|
|
28
|
+
#
|
|
29
|
+
# @param voteable [ActiveRecord::Base] The voteable object
|
|
30
|
+
# @param confidence [Float] Confidence level (0.80 to 0.99)
|
|
31
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
32
|
+
# @return [Float] Score from 0.0 to 1.0
|
|
33
|
+
def self.call(voteable, confidence: 0.95, scope: nil)
|
|
34
|
+
new(voteable, confidence: confidence, scope: scope).calculate
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(voteable, confidence:, scope:)
|
|
38
|
+
@voteable = voteable
|
|
39
|
+
@z = Z_SCORES.fetch(confidence) { Z_SCORES[0.95] }
|
|
40
|
+
@scope = scope
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def calculate
|
|
44
|
+
n = total_votes
|
|
45
|
+
return 0.0 if n.zero?
|
|
46
|
+
|
|
47
|
+
pos = positive_votes
|
|
48
|
+
phat = pos / n
|
|
49
|
+
|
|
50
|
+
# Wilson Score Interval lower bound formula
|
|
51
|
+
numerator = phat + (@z**2 / (2 * n)) -
|
|
52
|
+
@z * Math.sqrt((phat * (1 - phat) + @z**2 / (4 * n)) / n)
|
|
53
|
+
denominator = 1 + @z**2 / n
|
|
54
|
+
|
|
55
|
+
(numerator / denominator).clamp(0.0, 1.0).round(6)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def total_votes
|
|
61
|
+
@voteable.votes_count(scope: @scope).to_f
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def positive_votes
|
|
65
|
+
@voteable.votes_for(scope: @scope).to_f
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module VoteFu
|
|
6
|
+
module Concerns
|
|
7
|
+
module Karmatic
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
# Default karma level thresholds
|
|
11
|
+
DEFAULT_LEVELS = {
|
|
12
|
+
0 => "Newcomer",
|
|
13
|
+
10 => "Contributor",
|
|
14
|
+
50 => "Active",
|
|
15
|
+
100 => "Trusted",
|
|
16
|
+
250 => "Veteran",
|
|
17
|
+
500 => "Expert",
|
|
18
|
+
1000 => "Legend"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
class_methods do
|
|
22
|
+
# Configure karma calculation based on votes on owned content
|
|
23
|
+
#
|
|
24
|
+
# @example Simple karma from posts
|
|
25
|
+
# class User < ApplicationRecord
|
|
26
|
+
# has_many :posts
|
|
27
|
+
# has_karma :posts
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# @example With custom foreign key
|
|
31
|
+
# class User < ApplicationRecord
|
|
32
|
+
# has_many :articles, foreign_key: :author_id
|
|
33
|
+
# has_karma :articles, as: :author
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# @example With weighted karma (upvotes worth 1, downvotes worth -0.5)
|
|
37
|
+
# class User < ApplicationRecord
|
|
38
|
+
# has_karma :posts, weight: [1.0, 0.5]
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
# @example With time decay (votes older than 90 days worth less)
|
|
42
|
+
# class User < ApplicationRecord
|
|
43
|
+
# has_karma :posts, decay: { half_life: 90.days }
|
|
44
|
+
# end
|
|
45
|
+
#
|
|
46
|
+
# @example With scoped karma
|
|
47
|
+
# class User < ApplicationRecord
|
|
48
|
+
# has_karma :posts, scope: :quality
|
|
49
|
+
# end
|
|
50
|
+
#
|
|
51
|
+
# @param association [Symbol] The association name
|
|
52
|
+
# @param as [Symbol, nil] Custom foreign key name (without _id)
|
|
53
|
+
# @param weight [Float, Array<Float>] Weight(s) for karma calculation
|
|
54
|
+
# @param decay [Hash, nil] Time decay options (:half_life, :floor)
|
|
55
|
+
# @param scope [Symbol, nil] Only count votes with this scope
|
|
56
|
+
def has_karma(association, as: nil, weight: 1.0, decay: nil, scope: nil)
|
|
57
|
+
class_attribute :karma_sources, default: [] unless respond_to?(:karma_sources)
|
|
58
|
+
class_attribute :karma_levels, default: DEFAULT_LEVELS.dup unless respond_to?(:karma_levels)
|
|
59
|
+
|
|
60
|
+
foreign_key = as ? "#{as}_id" : "#{name.underscore}_id"
|
|
61
|
+
weights = Array(weight)
|
|
62
|
+
|
|
63
|
+
self.karma_sources = karma_sources + [{
|
|
64
|
+
association: association,
|
|
65
|
+
foreign_key: foreign_key,
|
|
66
|
+
positive_weight: weights[0].to_f,
|
|
67
|
+
negative_weight: weights[1]&.to_f || weights[0].to_f,
|
|
68
|
+
decay: decay,
|
|
69
|
+
scope: scope
|
|
70
|
+
}]
|
|
71
|
+
|
|
72
|
+
include VoteFu::Concerns::Karmatic::InstanceMethods
|
|
73
|
+
extend VoteFu::Concerns::Karmatic::KarmaClassMethods
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Configure karma levels with custom thresholds
|
|
77
|
+
#
|
|
78
|
+
# @example Custom levels
|
|
79
|
+
# class User < ApplicationRecord
|
|
80
|
+
# set_karma_levels 0 => "Noob", 100 => "Pro", 1000 => "Elite"
|
|
81
|
+
# end
|
|
82
|
+
def set_karma_levels(levels)
|
|
83
|
+
self.karma_levels = levels
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
module KarmaClassMethods
|
|
88
|
+
# Order users by karma (requires subquery, can be slow)
|
|
89
|
+
# For performance, consider adding a karma_cache column
|
|
90
|
+
def by_karma(direction = :desc)
|
|
91
|
+
if column_names.include?("karma_cache")
|
|
92
|
+
order(karma_cache: direction)
|
|
93
|
+
else
|
|
94
|
+
all.sort_by(&:karma).tap { |r| r.reverse! if direction == :desc }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Users above a karma threshold
|
|
99
|
+
def with_karma_above(threshold)
|
|
100
|
+
if column_names.include?("karma_cache")
|
|
101
|
+
where("karma_cache > ?", threshold)
|
|
102
|
+
else
|
|
103
|
+
select { |u| u.karma > threshold }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Users at or above a certain level
|
|
108
|
+
def with_karma_level(level_name)
|
|
109
|
+
threshold = karma_levels.key(level_name) || 0
|
|
110
|
+
with_karma_above(threshold - 1)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
module InstanceMethods
|
|
115
|
+
# Calculate total karma from all sources
|
|
116
|
+
#
|
|
117
|
+
# @param force [Boolean] Bypass cache
|
|
118
|
+
# @return [Integer] Total karma points
|
|
119
|
+
def karma(force: false)
|
|
120
|
+
return 0 unless self.class.respond_to?(:karma_sources)
|
|
121
|
+
|
|
122
|
+
# Use cached value if available and not forcing recalculation
|
|
123
|
+
if !force && has_attribute?(:karma_cache) && karma_cache.present?
|
|
124
|
+
return karma_cache
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
calculated = self.class.karma_sources.sum do |source|
|
|
128
|
+
calculate_karma_for(source)
|
|
129
|
+
end.round
|
|
130
|
+
|
|
131
|
+
# Update cache if column exists
|
|
132
|
+
update_karma_cache(calculated) if has_attribute?(:karma_cache) && force
|
|
133
|
+
|
|
134
|
+
calculated
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Get karma for last N days only
|
|
138
|
+
#
|
|
139
|
+
# @param days [Integer] Number of days to look back
|
|
140
|
+
# @return [Integer]
|
|
141
|
+
def recent_karma(days: 30)
|
|
142
|
+
return 0 unless self.class.respond_to?(:karma_sources)
|
|
143
|
+
|
|
144
|
+
self.class.karma_sources.sum do |source|
|
|
145
|
+
calculate_karma_for(source, since: days.days.ago)
|
|
146
|
+
end.round
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Get karma breakdown by source
|
|
150
|
+
#
|
|
151
|
+
# @return [Array<Hash>] Array of {source:, value:, recent:} hashes
|
|
152
|
+
def karma_breakdown
|
|
153
|
+
return [] unless self.class.respond_to?(:karma_sources)
|
|
154
|
+
|
|
155
|
+
self.class.karma_sources.map do |source|
|
|
156
|
+
{
|
|
157
|
+
source: source[:association],
|
|
158
|
+
value: calculate_karma_for(source).round,
|
|
159
|
+
recent: calculate_karma_for(source, since: 30.days.ago).round
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Get karma for a specific source
|
|
165
|
+
#
|
|
166
|
+
# @param association [Symbol] The association name
|
|
167
|
+
# @return [Integer]
|
|
168
|
+
def karma_for(association)
|
|
169
|
+
source = self.class.karma_sources.find { |s| s[:association] == association }
|
|
170
|
+
return 0 unless source
|
|
171
|
+
|
|
172
|
+
calculate_karma_for(source).round
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Get the user's karma level
|
|
176
|
+
#
|
|
177
|
+
# @return [String] Level name
|
|
178
|
+
def karma_level
|
|
179
|
+
return "Unknown" unless self.class.respond_to?(:karma_levels)
|
|
180
|
+
|
|
181
|
+
current_karma = karma
|
|
182
|
+
level = "Unknown"
|
|
183
|
+
|
|
184
|
+
self.class.karma_levels.sort_by { |k, _| k }.each do |threshold, name|
|
|
185
|
+
level = name if current_karma >= threshold
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
level
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Get progress to next karma level
|
|
192
|
+
#
|
|
193
|
+
# @return [Hash] { current_level:, next_level:, progress:, karma_needed: }
|
|
194
|
+
def karma_progress
|
|
195
|
+
return nil unless self.class.respond_to?(:karma_levels)
|
|
196
|
+
|
|
197
|
+
current_karma = karma
|
|
198
|
+
sorted_levels = self.class.karma_levels.sort_by { |k, _| k }
|
|
199
|
+
|
|
200
|
+
current_threshold = 0
|
|
201
|
+
current_level = sorted_levels.first&.last || "Unknown"
|
|
202
|
+
next_threshold = nil
|
|
203
|
+
next_level = nil
|
|
204
|
+
|
|
205
|
+
sorted_levels.each_with_index do |(threshold, name), i|
|
|
206
|
+
if current_karma >= threshold
|
|
207
|
+
current_threshold = threshold
|
|
208
|
+
current_level = name
|
|
209
|
+
# Only set next level if there IS a next level
|
|
210
|
+
if i < sorted_levels.length - 1
|
|
211
|
+
next_data = sorted_levels[i + 1]
|
|
212
|
+
next_threshold = next_data[0]
|
|
213
|
+
next_level = next_data[1]
|
|
214
|
+
else
|
|
215
|
+
next_threshold = nil
|
|
216
|
+
next_level = nil
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
if next_threshold.nil?
|
|
222
|
+
# Already at max level
|
|
223
|
+
{
|
|
224
|
+
current_level: current_level,
|
|
225
|
+
next_level: nil,
|
|
226
|
+
progress: 100.0,
|
|
227
|
+
karma_needed: 0
|
|
228
|
+
}
|
|
229
|
+
else
|
|
230
|
+
range = next_threshold - current_threshold
|
|
231
|
+
progress_in_range = current_karma - current_threshold
|
|
232
|
+
{
|
|
233
|
+
current_level: current_level,
|
|
234
|
+
next_level: next_level,
|
|
235
|
+
progress: ((progress_in_range.to_f / range) * 100).round(1),
|
|
236
|
+
karma_needed: next_threshold - current_karma
|
|
237
|
+
}
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Check if user has at least a certain karma level
|
|
242
|
+
#
|
|
243
|
+
# @param level_name [String] The level name to check
|
|
244
|
+
# @return [Boolean]
|
|
245
|
+
def karma_level?(level_name)
|
|
246
|
+
return false unless self.class.respond_to?(:karma_levels)
|
|
247
|
+
|
|
248
|
+
threshold = self.class.karma_levels.key(level_name)
|
|
249
|
+
return false unless threshold
|
|
250
|
+
|
|
251
|
+
karma >= threshold
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Update the karma cache (call periodically or after votes)
|
|
255
|
+
def update_karma_cache(value = nil)
|
|
256
|
+
return unless has_attribute?(:karma_cache)
|
|
257
|
+
|
|
258
|
+
value ||= self.class.karma_sources.sum { |s| calculate_karma_for(s) }.round
|
|
259
|
+
update_column(:karma_cache, value)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Alias for update_karma_cache
|
|
263
|
+
def recalculate_karma!
|
|
264
|
+
update_karma_cache
|
|
265
|
+
karma(force: true)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
private
|
|
269
|
+
|
|
270
|
+
def calculate_karma_for(source, since: nil)
|
|
271
|
+
klass = source[:association].to_s.classify.constantize
|
|
272
|
+
|
|
273
|
+
# Get all voteables owned by this user
|
|
274
|
+
voteable_ids = klass.where(source[:foreign_key] => id).pluck(:id)
|
|
275
|
+
return 0.0 if voteable_ids.empty?
|
|
276
|
+
|
|
277
|
+
# Build votes query
|
|
278
|
+
votes = VoteFu::Vote.where(
|
|
279
|
+
voteable_type: klass.name,
|
|
280
|
+
voteable_id: voteable_ids
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Apply scope filter if specified
|
|
284
|
+
votes = votes.with_scope(source[:scope]) if source[:scope]
|
|
285
|
+
|
|
286
|
+
# Apply time filter if specified
|
|
287
|
+
votes = votes.where(created_at: since..) if since
|
|
288
|
+
|
|
289
|
+
# If decay is enabled, calculate weighted sum
|
|
290
|
+
if source[:decay]
|
|
291
|
+
calculate_decayed_karma(votes, source)
|
|
292
|
+
else
|
|
293
|
+
upvotes = votes.up.count
|
|
294
|
+
downvotes = votes.down.count
|
|
295
|
+
(upvotes * source[:positive_weight]) - (downvotes * source[:negative_weight])
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def calculate_decayed_karma(votes, source)
|
|
300
|
+
half_life = source[:decay][:half_life] || 90.days
|
|
301
|
+
floor = source[:decay][:floor] || 0.1
|
|
302
|
+
|
|
303
|
+
now = Time.current
|
|
304
|
+
total = 0.0
|
|
305
|
+
|
|
306
|
+
# This is slow for many votes - consider caching
|
|
307
|
+
votes.find_each do |vote|
|
|
308
|
+
age = now - vote.created_at
|
|
309
|
+
decay_factor = [2**(-age / half_life), floor].max
|
|
310
|
+
|
|
311
|
+
weight = vote.up? ? source[:positive_weight] : -source[:negative_weight]
|
|
312
|
+
total += weight * decay_factor
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
total
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|