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.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +84 -0
  3. data/README.md +265 -0
  4. data/app/assets/stylesheets/vote_fu/votes.css +391 -0
  5. data/app/channels/vote_fu/application_cable/channel.rb +8 -0
  6. data/app/channels/vote_fu/application_cable/connection.rb +39 -0
  7. data/app/channels/vote_fu/votes_channel.rb +99 -0
  8. data/app/components/vote_fu/like_button_component.rb +136 -0
  9. data/app/components/vote_fu/reaction_bar_component.rb +208 -0
  10. data/app/components/vote_fu/star_rating_component.rb +199 -0
  11. data/app/components/vote_fu/vote_widget_component.rb +181 -0
  12. data/app/controllers/vote_fu/application_controller.rb +49 -0
  13. data/app/controllers/vote_fu/votes_controller.rb +223 -0
  14. data/app/helpers/vote_fu/votes_helper.rb +176 -0
  15. data/app/javascript/vote_fu/channels/consumer.js +3 -0
  16. data/app/javascript/vote_fu/channels/index.js +2 -0
  17. data/app/javascript/vote_fu/channels/votes_channel.js +93 -0
  18. data/app/javascript/vote_fu/controllers/application.js +9 -0
  19. data/app/javascript/vote_fu/controllers/index.js +9 -0
  20. data/app/javascript/vote_fu/controllers/vote_fu_controller.js +148 -0
  21. data/app/javascript/vote_fu/controllers/vote_fu_reactions_controller.js +92 -0
  22. data/app/javascript/vote_fu/controllers/vote_fu_stars_controller.js +77 -0
  23. data/app/models/vote_fu/application_record.rb +7 -0
  24. data/app/models/vote_fu/vote.rb +90 -0
  25. data/app/views/vote_fu/votes/_count.html.erb +29 -0
  26. data/app/views/vote_fu/votes/_downvote_button.html.erb +40 -0
  27. data/app/views/vote_fu/votes/_error.html.erb +9 -0
  28. data/app/views/vote_fu/votes/_like_button.html.erb +67 -0
  29. data/app/views/vote_fu/votes/_upvote_button.html.erb +40 -0
  30. data/app/views/vote_fu/votes/_widget.html.erb +85 -0
  31. data/config/importmap.rb +6 -0
  32. data/config/routes.rb +9 -0
  33. data/lib/generators/vote_fu/install/install_generator.rb +56 -0
  34. data/lib/generators/vote_fu/install/templates/initializer.rb +42 -0
  35. data/lib/generators/vote_fu/install/templates/migration.rb.erb +41 -0
  36. data/lib/generators/vote_fu/migration/migration_generator.rb +29 -0
  37. data/lib/generators/vote_fu/migration/templates/create_vote_fu_votes.rb.erb +40 -0
  38. data/lib/vote_fu/algorithms/hacker_news.rb +54 -0
  39. data/lib/vote_fu/algorithms/reddit_hot.rb +55 -0
  40. data/lib/vote_fu/algorithms/wilson_score.rb +69 -0
  41. data/lib/vote_fu/concerns/karmatic.rb +320 -0
  42. data/lib/vote_fu/concerns/voteable.rb +291 -0
  43. data/lib/vote_fu/concerns/voter.rb +275 -0
  44. data/lib/vote_fu/configuration.rb +53 -0
  45. data/lib/vote_fu/engine.rb +54 -0
  46. data/lib/vote_fu/errors.rb +34 -0
  47. data/lib/vote_fu/version.rb +5 -0
  48. data/lib/vote_fu.rb +22 -9
  49. metadata +217 -60
  50. data/CHANGELOG.markdown +0 -31
  51. data/README.markdown +0 -220
  52. data/examples/routes.rb +0 -7
  53. data/examples/users_controller.rb +0 -76
  54. data/examples/voteable.html.erb +0 -8
  55. data/examples/voteable.rb +0 -10
  56. data/examples/voteables_controller.rb +0 -117
  57. data/examples/votes/_voteable_vote.html.erb +0 -23
  58. data/examples/votes/create.rjs +0 -1
  59. data/examples/votes_controller.rb +0 -110
  60. data/generators/vote_fu/templates/migration.rb +0 -21
  61. data/generators/vote_fu/vote_fu_generator.rb +0 -8
  62. data/init.rb +0 -1
  63. data/lib/acts_as_voteable.rb +0 -114
  64. data/lib/acts_as_voter.rb +0 -75
  65. data/lib/controllers/votes_controller.rb +0 -96
  66. data/lib/has_karma.rb +0 -68
  67. data/lib/models/vote.rb +0 -17
  68. data/rails/init.rb +0 -10
  69. 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