Add time frames to the Top Contributors list

Note that these queries are a bit slow. I don't think these new subpages will be accessed anywhere near often enough for their ~2sec query time to be a big deal. But if we start getting into trouble with it (e.g. someone starts slamming us for fun), we can look into how how cache these values over time.
This commit is contained in:
Emi Matchu 2026-01-20 19:54:14 -08:00
parent 04ed182cef
commit 366158b698
17 changed files with 703 additions and 121 deletions

View file

@ -87,4 +87,5 @@ gem "shell", "~> 0.8.1"
# For automated tests. # For automated tests.
gem 'rspec-rails', '~> 8.0', '>= 8.0.2', group: [:development, :test] gem 'rspec-rails', '~> 8.0', '>= 8.0.2', group: [:development, :test]
gem 'rails-controller-testing', group: [:test]
gem "webmock", "~> 3.24", group: [:test] gem "webmock", "~> 3.24", group: [:test]

View file

@ -341,6 +341,10 @@ GEM
activesupport (= 8.1.2) activesupport (= 8.1.2)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.1.2) railties (= 8.1.2)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.3.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
@ -505,6 +509,7 @@ DEPENDENCIES
rack-attack (~> 6.7) rack-attack (~> 6.7)
rack-mini-profiler (~> 4.0, >= 4.0.1) rack-mini-profiler (~> 4.0, >= 4.0.1)
rails (~> 8.0, >= 8.0.1) rails (~> 8.0, >= 8.0.1)
rails-controller-testing
rails-i18n (~> 8.0, >= 8.0.1) rails-i18n (~> 8.0, >= 8.0.1)
rdiscount (~> 2.2, >= 2.2.7.1) rdiscount (~> 2.2, >= 2.2.7.1)
rspec-rails (~> 8.0, >= 8.0.2) rspec-rails (~> 8.0, >= 8.0.2)

View file

@ -3,6 +3,14 @@
body.users-top_contributors body.users-top_contributors
text-align: center text-align: center
.timeframe-nav
margin: 1em 0
display: flex
justify-content: center
gap: 1em
list-style: none
padding: 0
#top-contributors #top-contributors
border: border:
spacing: 0 spacing: 0

View file

@ -14,7 +14,10 @@ class UsersController < ApplicationController
end end
def top_contributors def top_contributors
@users = User.top_contributors.paginate :page => params[:page], :per_page => 20 valid_timeframes = User::VALID_TIMEFRAMES.map(&:to_s)
@timeframe = params[:timeframe].presence_in(valid_timeframes) || 'all_time'
@users = User.top_contributors_for(@timeframe.to_sym)
.paginate(page: params[:page], per_page: 20)
end end
def edit def edit

View file

@ -25,6 +25,51 @@ class User < ApplicationRecord
scope :top_contributors, -> { order('points DESC').where('points > 0') } scope :top_contributors, -> { order('points DESC').where('points > 0') }
VALID_TIMEFRAMES = [:all_time, :this_year, :this_month, :this_week]
scope :top_contributors_for, ->(timeframe = :all_time) {
case timeframe.to_sym
when :all_time
top_contributors # Use existing efficient scope
else
top_contributors_by_period(timeframe)
end
}
def self.top_contributors_by_period(timeframe)
start_date = case timeframe.to_sym
when :this_week then 1.week.ago
when :this_month then 1.month.ago
when :this_year then 1.year.ago
else raise ArgumentError, "Invalid timeframe: #{timeframe}"
end
# Build the CASE statement dynamically from Contribution::POINT_VALUES
point_case = Contribution::POINT_VALUES.map { |type, points|
"WHEN #{connection.quote(type)} THEN #{points}"
}.join("\n ")
select(
'users.*',
"COALESCE(SUM(
CASE contributions.contributed_type
#{point_case}
END
), 0) AS period_points"
)
.joins('INNER JOIN contributions ON contributions.user_id = users.id')
.where('contributions.created_at >= ?', start_date)
.group('users.id')
.having('period_points > 0')
.order('period_points DESC, users.id ASC')
end
# Virtual attribute reader for dynamically calculated points (from time-period queries).
# Falls back to the denormalized `points` column when not calculated.
def period_points
attributes['period_points'] || points
end
after_update :sync_name_with_auth_user!, if: :saved_change_to_name? after_update :sync_name_with_auth_user!, if: :saved_change_to_name?
after_update :log_trade_activity, if: -> user { after_update :log_trade_activity, if: -> user {
(user.saved_change_to_owned_closet_hangers_visibility? && (user.saved_change_to_owned_closet_hangers_visibility? &&

View file

@ -1,4 +1,13 @@
- title t('.title') - title t('.title')
%ul.timeframe-nav
- ['all_time', 'this_year', 'this_month', 'this_week'].each do |tf|
%li
- if @timeframe == tf
%strong= t(".timeframes.#{tf}")
- else
= link_to t(".timeframes.#{tf}"), top_contributors_path(timeframe: tf)
= will_paginate @users = will_paginate @users
%table#top-contributors %table#top-contributors
%thead %thead
@ -11,5 +20,5 @@
%tr %tr
%th{:scope => 'row'}= @users.offset + rank + 1 %th{:scope => 'row'}= @users.offset + rank + 1
%td= link_to user.name, user_contributions_path(user) %td= link_to user.name, user_contributions_path(user)
%td= user.points %td= user.period_points
= will_paginate @users = will_paginate @users

View file

@ -640,6 +640,11 @@ en-MEEP:
rank: Reep rank: Reep
user: Meepit user: Meepit
points: Peeps points: Peeps
timeframes:
all_time: All Meep
this_year: Meeps Year
this_month: Meeps Month
this_week: Meeps Week
update: update:
success: Settings successfully meeped. success: Settings successfully meeped.

View file

@ -783,6 +783,11 @@ en:
rank: Rank rank: Rank
user: User user: User
points: Points points: Points
timeframes:
all_time: All Time
this_year: This Year
this_month: This Month
this_week: This Week
update: update:
success: Settings successfully saved. success: Settings successfully saved.

View file

@ -505,6 +505,11 @@ es:
rank: Puesto rank: Puesto
user: Usuario user: Usuario
points: Puntos points: Puntos
timeframes:
all_time: Todo el Tiempo
this_year: Este Año
this_month: Este Mes
this_week: Esta Semana
update: update:
success: Ajustes guardados correctamente. success: Ajustes guardados correctamente.
invalid: "No hemos podido guardar los ajustes: %{errors}" invalid: "No hemos podido guardar los ajustes: %{errors}"

View file

@ -499,6 +499,11 @@ pt:
rank: Rank rank: Rank
user: Usuário user: Usuário
points: Pontos points: Pontos
timeframes:
all_time: Todo o Tempo
this_year: Este Ano
this_month: Este Mês
this_week: Esta Semana
update: update:
success: Configurações salvas com sucesso success: Configurações salvas com sucesso
invalid: "Não foi possível salvar as configurações: %{errors}" invalid: "Não foi possível salvar as configurações: %{errors}"

View file

@ -0,0 +1,6 @@
class AddIndexToContributionsUserIdAndCreatedAt < ActiveRecord::Migration[8.1]
def change
add_index :contributions, [:user_id, :created_at],
name: 'index_contributions_on_user_id_and_created_at'
end
end

View file

@ -10,28 +10,28 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2024_04_08_120359) do ActiveRecord::Schema[8.1].define(version: 2024_04_08_120359) do
create_table "users", id: { type: :integer, unsigned: true }, charset: "utf8mb3", collation: "utf8mb3_general_ci", force: :cascade do |t| create_table "users", id: { type: :integer, unsigned: true }, charset: "utf8mb3", collation: "utf8mb3_general_ci", force: :cascade do |t|
t.string "name", limit: 30, null: false t.datetime "created_at", precision: nil
t.string "encrypted_password", limit: 64 t.datetime "current_sign_in_at", precision: nil
t.string "current_sign_in_ip"
t.string "email", limit: 50 t.string "email", limit: 50
t.string "encrypted_password", limit: 64
t.integer "failed_attempts", default: 0
t.datetime "last_sign_in_at", precision: nil
t.string "last_sign_in_ip"
t.datetime "locked_at", precision: nil
t.string "name", limit: 30, null: false
t.string "neopass_email"
t.string "password_salt", limit: 32 t.string "password_salt", limit: 32
t.string "provider"
t.datetime "remember_created_at"
t.datetime "reset_password_sent_at", precision: nil
t.string "reset_password_token" t.string "reset_password_token"
t.integer "sign_in_count", default: 0 t.integer "sign_in_count", default: 0
t.datetime "current_sign_in_at", precision: nil
t.datetime "last_sign_in_at", precision: nil
t.string "current_sign_in_ip"
t.string "last_sign_in_ip"
t.integer "failed_attempts", default: 0
t.string "unlock_token"
t.datetime "locked_at", precision: nil
t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil
t.datetime "reset_password_sent_at", precision: nil
t.datetime "remember_created_at"
t.string "provider"
t.string "uid" t.string "uid"
t.string "neopass_email" t.string "unlock_token"
t.datetime "updated_at", precision: nil
t.index ["email"], name: "index_users_on_email", unique: true t.index ["email"], name: "index_users_on_email", unique: true
t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true

View file

@ -10,50 +10,50 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do ActiveRecord::Schema[8.1].define(version: 2026_01_21_031001) do
create_table "alt_styles", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "alt_styles", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "species_id", null: false
t.integer "color_id", null: false
t.integer "body_id", null: false t.integer "body_id", null: false
t.integer "color_id", null: false
t.datetime "created_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.string "series_name"
t.string "thumbnail_url", null: false
t.string "full_name" t.string "full_name"
t.string "series_name"
t.integer "species_id", null: false
t.string "thumbnail_url", null: false
t.datetime "updated_at", precision: nil, null: false
t.index ["color_id"], name: "index_alt_styles_on_color_id" t.index ["color_id"], name: "index_alt_styles_on_color_id"
t.index ["species_id"], name: "index_alt_styles_on_species_id" t.index ["species_id"], name: "index_alt_styles_on_species_id"
end end
create_table "auth_servers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "auth_servers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.string "short_name", limit: 10, null: false
t.string "name", limit: 40, null: false
t.text "icon", size: :long, null: false
t.text "gateway", size: :long, null: false t.text "gateway", size: :long, null: false
t.text "icon", size: :long, null: false
t.string "name", limit: 40, null: false
t.string "secret", limit: 64, null: false t.string "secret", limit: 64, null: false
t.string "short_name", limit: 10, null: false
end end
create_table "campaigns", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "campaigns", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "progress", default: 0, null: false
t.integer "goal", null: false
t.boolean "active", null: false t.boolean "active", null: false
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.boolean "advertised", default: true, null: false t.boolean "advertised", default: true, null: false
t.datetime "created_at", precision: nil, null: false
t.text "description", size: :long, null: false t.text "description", size: :long, null: false
t.string "purpose", default: "our hosting costs this year", null: false t.integer "goal", null: false
t.string "theme_id", default: "hug", null: false
t.text "thanks", size: :long
t.string "name" t.string "name"
t.integer "progress", default: 0, null: false
t.string "purpose", default: "our hosting costs this year", null: false
t.text "thanks", size: :long
t.string "theme_id", default: "hug", null: false
t.datetime "updated_at", precision: nil, null: false
end end
create_table "closet_hangers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "closet_hangers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "item_id"
t.integer "user_id"
t.integer "quantity"
t.datetime "created_at", precision: nil t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil t.integer "item_id"
t.boolean "owned", default: true, null: false
t.integer "list_id" t.integer "list_id"
t.boolean "owned", default: true, null: false
t.integer "quantity"
t.datetime "updated_at", precision: nil
t.integer "user_id"
t.index ["item_id", "owned"], name: "index_closet_hangers_on_item_id_and_owned" t.index ["item_id", "owned"], name: "index_closet_hangers_on_item_id_and_owned"
t.index ["list_id"], name: "index_closet_hangers_on_list_id" t.index ["list_id"], name: "index_closet_hangers_on_list_id"
t.index ["user_id", "list_id", "item_id", "owned", "created_at"], name: "index_closet_hangers_test_20131226" t.index ["user_id", "list_id", "item_id", "owned", "created_at"], name: "index_closet_hangers_test_20131226"
@ -63,84 +63,85 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
end end
create_table "closet_lists", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "closet_lists", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.string "name"
t.text "description", size: :long
t.integer "user_id"
t.boolean "hangers_owned", null: false
t.datetime "created_at", precision: nil t.datetime "created_at", precision: nil
t.text "description", size: :long
t.boolean "hangers_owned", null: false
t.string "name"
t.datetime "updated_at", precision: nil t.datetime "updated_at", precision: nil
t.integer "user_id"
t.integer "visibility", default: 1, null: false t.integer "visibility", default: 1, null: false
t.index ["user_id"], name: "index_closet_lists_on_user_id" t.index ["user_id"], name: "index_closet_lists_on_user_id"
end end
create_table "colors", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "colors", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.boolean "basic" t.boolean "basic"
t.boolean "standard"
t.string "name", null: false t.string "name", null: false
t.string "pb_item_name" t.string "pb_item_name"
t.string "pb_item_thumbnail_url" t.string "pb_item_thumbnail_url"
t.boolean "standard"
end end
create_table "contributions", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "contributions", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.string "contributed_type", limit: 8, null: false
t.integer "contributed_id", null: false t.integer "contributed_id", null: false
t.integer "user_id", null: false t.string "contributed_type", limit: 8, null: false
t.datetime "created_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false
t.integer "user_id", null: false
t.index ["contributed_id", "contributed_type"], name: "index_contributions_on_contributed_id_and_contributed_type" t.index ["contributed_id", "contributed_type"], name: "index_contributions_on_contributed_id_and_contributed_type"
t.index ["user_id", "created_at"], name: "index_contributions_on_user_id_and_created_at"
t.index ["user_id"], name: "index_contributions_on_user_id" t.index ["user_id"], name: "index_contributions_on_user_id"
end end
create_table "donation_features", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "donation_features", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false
t.integer "donation_id", null: false t.integer "donation_id", null: false
t.integer "outfit_id" t.integer "outfit_id"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false
end end
create_table "donations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "donations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "amount", null: false t.integer "amount", null: false
t.integer "campaign_id", null: false
t.string "charge_id", null: false t.string "charge_id", null: false
t.integer "user_id" t.datetime "created_at", precision: nil, null: false
t.string "donor_email"
t.string "donor_name" t.string "donor_name"
t.string "secret" t.string "secret"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false
t.string "donor_email" t.integer "user_id"
t.integer "campaign_id", null: false
end end
create_table "item_outfit_relationships", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "item_outfit_relationships", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.datetime "created_at", precision: nil
t.boolean "is_worn"
t.integer "item_id" t.integer "item_id"
t.integer "outfit_id" t.integer "outfit_id"
t.boolean "is_worn"
t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil t.datetime "updated_at", precision: nil
t.index ["item_id"], name: "index_item_outfit_relationships_on_item_id" t.index ["item_id"], name: "index_item_outfit_relationships_on_item_id"
t.index ["outfit_id", "is_worn"], name: "index_item_outfit_relationships_on_outfit_id_and_is_worn" t.index ["outfit_id", "is_worn"], name: "index_item_outfit_relationships_on_outfit_id_and_is_worn"
end end
create_table "items", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "items", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.text "zones_restrict", size: :medium, null: false t.text "cached_compatible_body_ids", default: ""
t.text "thumbnail_url", size: :long, null: false t.string "cached_occupied_zone_ids", default: ""
t.boolean "cached_predicted_fully_modeled", default: false, null: false
t.string "category", limit: 50 t.string "category", limit: 50
t.string "type", limit: 50
t.integer "rarity_index", limit: 2
t.integer "price", limit: 3, null: false
t.integer "weight_lbs", limit: 2
t.text "species_support_ids", size: :long
t.datetime "created_at", precision: nil t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil t.text "description", size: :medium, null: false
t.integer "dyeworks_base_item_id"
t.boolean "explicitly_body_specific", default: false, null: false t.boolean "explicitly_body_specific", default: false, null: false
t.boolean "is_manually_nc", default: false, null: false
t.integer "manual_special_color_id" t.integer "manual_special_color_id"
t.column "modeling_status_hint", "enum('done','glitchy')" t.column "modeling_status_hint", "enum('done','glitchy')"
t.boolean "is_manually_nc", default: false, null: false
t.string "name", null: false t.string "name", null: false
t.text "description", size: :medium, null: false t.integer "price", limit: 3, null: false
t.string "rarity", default: "", null: false t.string "rarity", default: "", null: false
t.integer "dyeworks_base_item_id" t.integer "rarity_index", limit: 2
t.string "cached_occupied_zone_ids", default: "" t.text "species_support_ids", size: :long
t.text "cached_compatible_body_ids", default: "" t.text "thumbnail_url", size: :long, null: false
t.boolean "cached_predicted_fully_modeled", default: false, null: false t.string "type", limit: 50
t.datetime "updated_at", precision: nil
t.integer "weight_lbs", limit: 2
t.text "zones_restrict", size: :medium, null: false
t.index ["dyeworks_base_item_id"], name: "index_items_on_dyeworks_base_item_id" t.index ["dyeworks_base_item_id"], name: "index_items_on_dyeworks_base_item_id"
t.index ["modeling_status_hint", "created_at", "id"], name: "items_modeling_status_hint_and_created_at_and_id" t.index ["modeling_status_hint", "created_at", "id"], name: "items_modeling_status_hint_and_created_at_and_id"
t.index ["modeling_status_hint", "created_at"], name: "items_modeling_status_hint_and_created_at" t.index ["modeling_status_hint", "created_at"], name: "items_modeling_status_hint_and_created_at"
@ -150,9 +151,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
end end
create_table "login_cookies", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "login_cookies", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "user_id", null: false
t.integer "series", null: false t.integer "series", null: false
t.integer "token", null: false t.integer "token", null: false
t.integer "user_id", null: false
t.index ["user_id", "series"], name: "login_cookies_user_id_and_series" t.index ["user_id", "series"], name: "login_cookies_user_id_and_series"
t.index ["user_id"], name: "login_cookies_user_id" t.index ["user_id"], name: "login_cookies_user_id"
end end
@ -164,34 +165,34 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
end end
create_table "nc_mall_records", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| create_table "nc_mall_records", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "item_id", null: false t.datetime "created_at", null: false
t.integer "price", null: false
t.integer "discount_price"
t.datetime "discount_begins_at" t.datetime "discount_begins_at"
t.datetime "discount_ends_at" t.datetime "discount_ends_at"
t.datetime "created_at", null: false t.integer "discount_price"
t.integer "item_id", null: false
t.integer "price", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["item_id"], name: "index_nc_mall_records_on_item_id", unique: true t.index ["item_id"], name: "index_nc_mall_records_on_item_id", unique: true
end end
create_table "neopets_connections", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "neopets_connections", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "user_id"
t.string "neopets_username"
t.datetime "created_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false
t.string "neopets_username"
t.datetime "updated_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false
t.integer "user_id"
end end
create_table "outfits", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "outfits", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "pet_state_id"
t.integer "user_id"
t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil
t.string "name"
t.boolean "starred", default: false, null: false
t.string "image"
t.string "image_layers_hash"
t.boolean "image_enqueued", default: false, null: false
t.bigint "alt_style_id" t.bigint "alt_style_id"
t.datetime "created_at", precision: nil
t.string "image"
t.boolean "image_enqueued", default: false, null: false
t.string "image_layers_hash"
t.string "name"
t.integer "pet_state_id"
t.boolean "starred", default: false, null: false
t.datetime "updated_at", precision: nil
t.integer "user_id"
t.index ["alt_style_id"], name: "index_outfits_on_alt_style_id" t.index ["alt_style_id"], name: "index_outfits_on_alt_style_id"
t.index ["pet_state_id"], name: "index_outfits_on_pet_state_id" t.index ["pet_state_id"], name: "index_outfits_on_pet_state_id"
t.index ["user_id"], name: "index_outfits_on_user_id" t.index ["user_id"], name: "index_outfits_on_user_id"
@ -199,40 +200,40 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
create_table "parents_swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "parents_swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "parent_id", null: false t.integer "parent_id", null: false
t.integer "swf_asset_id", null: false
t.string "parent_type", limit: 8, null: false t.string "parent_type", limit: 8, null: false
t.integer "swf_asset_id", null: false
t.index ["parent_id", "parent_type"], name: "index_parents_swf_assets_on_parent_id_and_parent_type" t.index ["parent_id", "parent_type"], name: "index_parents_swf_assets_on_parent_id_and_parent_type"
t.index ["parent_id", "swf_asset_id"], name: "unique_parents_swf_assets", unique: true t.index ["parent_id", "swf_asset_id"], name: "unique_parents_swf_assets", unique: true
t.index ["swf_asset_id"], name: "parents_swf_assets_swf_asset_id" t.index ["swf_asset_id"], name: "parents_swf_assets_swf_asset_id"
end end
create_table "pet_loads", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "pet_loads", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.string "pet_name", limit: 20, null: false
t.text "amf", size: :long, null: false t.text "amf", size: :long, null: false
t.datetime "created_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false
t.string "pet_name", limit: 20, null: false
end end
create_table "pet_states", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "pet_states", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "pet_type_id", null: false
t.text "swf_asset_ids", size: :medium, null: false
t.boolean "female"
t.integer "mood_id"
t.boolean "unconverted"
t.boolean "labeled", default: false, null: false
t.boolean "glitched", default: false, null: false
t.string "artist_neopets_username" t.string "artist_neopets_username"
t.datetime "created_at" t.datetime "created_at"
t.boolean "female"
t.boolean "glitched", default: false, null: false
t.boolean "labeled", default: false, null: false
t.integer "mood_id"
t.integer "pet_type_id", null: false
t.text "swf_asset_ids", size: :medium, null: false
t.boolean "unconverted"
t.datetime "updated_at" t.datetime "updated_at"
t.index ["pet_type_id"], name: "pet_states_pet_type_id" t.index ["pet_type_id"], name: "pet_states_pet_type_id"
end end
create_table "pet_types", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "pet_types", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "color_id", null: false
t.integer "species_id", null: false
t.datetime "created_at", precision: nil, null: false
t.integer "body_id", limit: 2, null: false
t.string "image_hash", limit: 8
t.string "basic_image_hash" t.string "basic_image_hash"
t.integer "body_id", limit: 2, null: false
t.integer "color_id", null: false
t.datetime "created_at", precision: nil, null: false
t.string "image_hash", limit: 8
t.integer "species_id", null: false
t.index ["body_id", "color_id", "species_id"], name: "pet_types_body_id_and_color_id_and_species_id" t.index ["body_id", "color_id", "species_id"], name: "pet_types_body_id_and_color_id_and_species_id"
t.index ["body_id"], name: "pet_types_body_id" t.index ["body_id"], name: "pet_types_body_id"
t.index ["color_id", "species_id"], name: "pet_types_color_id_and_species_id" t.index ["color_id", "species_id"], name: "pet_types_color_id_and_species_id"
@ -252,50 +253,50 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
end end
create_table "swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.string "type", limit: 7, null: false t.integer "body_id", limit: 2, null: false
t.datetime "converted_at", precision: nil
t.datetime "created_at", precision: nil, null: false
t.boolean "has_image", default: false, null: false
t.boolean "image_manual", default: false, null: false
t.boolean "image_requested", default: false, null: false
t.string "known_glitches", limit: 128, default: ""
t.text "manifest", size: :long
t.timestamp "manifest_cached_at"
t.datetime "manifest_loaded_at"
t.integer "manifest_status_code"
t.string "manifest_url"
t.integer "remote_id", limit: 3, null: false t.integer "remote_id", limit: 3, null: false
t.datetime "reported_broken_at", precision: nil
t.string "type", limit: 7, null: false
t.text "url", size: :long, null: false t.text "url", size: :long, null: false
t.integer "zone_id", null: false t.integer "zone_id", null: false
t.text "zones_restrict", size: :medium, null: false t.text "zones_restrict", size: :medium, null: false
t.datetime "created_at", precision: nil, null: false
t.integer "body_id", limit: 2, null: false
t.boolean "has_image", default: false, null: false
t.boolean "image_requested", default: false, null: false
t.datetime "reported_broken_at", precision: nil
t.datetime "converted_at", precision: nil
t.boolean "image_manual", default: false, null: false
t.text "manifest", size: :long
t.timestamp "manifest_cached_at"
t.string "known_glitches", limit: 128, default: ""
t.string "manifest_url"
t.datetime "manifest_loaded_at"
t.integer "manifest_status_code"
t.index ["body_id"], name: "swf_assets_body_id_and_object_id" t.index ["body_id"], name: "swf_assets_body_id_and_object_id"
t.index ["type", "remote_id"], name: "swf_assets_type_and_id" t.index ["type", "remote_id"], name: "swf_assets_type_and_id"
t.index ["zone_id"], name: "idx_swf_assets_zone_id" t.index ["zone_id"], name: "idx_swf_assets_zone_id"
end end
create_table "users", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "users", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.string "name", limit: 30, null: false
t.integer "auth_server_id", limit: 1, null: false t.integer "auth_server_id", limit: 1, null: false
t.integer "remote_id", null: false
t.integer "points", default: 0, null: false
t.boolean "beta", default: false, null: false t.boolean "beta", default: false, null: false
t.string "remember_token"
t.datetime "remember_created_at", precision: nil
t.integer "owned_closet_hangers_visibility", default: 1, null: false
t.integer "wanted_closet_hangers_visibility", default: 1, null: false
t.integer "contact_neopets_connection_id" t.integer "contact_neopets_connection_id"
t.timestamp "last_trade_activity_at" t.timestamp "last_trade_activity_at"
t.boolean "support_staff", default: false, null: false t.string "name", limit: 30, null: false
t.integer "owned_closet_hangers_visibility", default: 1, null: false
t.integer "points", default: 0, null: false
t.datetime "remember_created_at", precision: nil
t.string "remember_token"
t.integer "remote_id", null: false
t.boolean "shadowbanned", default: false, null: false t.boolean "shadowbanned", default: false, null: false
t.boolean "support_staff", default: false, null: false
t.integer "wanted_closet_hangers_visibility", default: 1, null: false
end end
create_table "zones", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "zones", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "depth" t.integer "depth"
t.integer "type_id"
t.string "label", null: false t.string "label", null: false
t.string "plain_label", null: false t.string "plain_label", null: false
t.integer "type_id"
end end
add_foreign_key "alt_styles", "colors" add_foreign_key "alt_styles", "colors"

View file

@ -0,0 +1,72 @@
# Sample contributions for testing Top Contributors feature
# Run with: rails runner db/seeds/top_contributors_sample_data.rb
puts "Creating sample contributions for Top Contributors testing..."
# Find or create test users
users = []
5.times do |i|
name = "TestContributor#{i + 1}"
user = User.find_or_create_by!(name: name) do |u|
# Create a corresponding auth_user record
auth_user = AuthUser.create!(
name: name,
email: "test#{i + 1}@example.com",
password: 'password123',
)
u.remote_id = auth_user.id
u.auth_server_id = 1
end
users << user
end
# Get some existing items/pet types to contribute
items = Item.limit(10).to_a
pet_types = PetType.limit(5).to_a
swf_assets = SwfAsset.limit(5).to_a
if items.empty? || pet_types.empty?
puts "WARNING: No items or pet types found. Create some first or contributions will be limited."
end
# Create contributions with different time periods
# User 1: Heavy contributor this week
if items.any?
3.times { Contribution.create!(user: users[0], contributed: items.sample, created_at: 2.days.ago) }
5.times { Contribution.create!(user: users[0], contributed: items.sample, created_at: 5.days.ago) }
end
# User 2: Heavy contributor this month, but not this week
if items.any? && pet_types.any?
2.times { Contribution.create!(user: users[1], contributed: items.sample, created_at: 15.days.ago) }
1.times { Contribution.create!(user: users[1], contributed: pet_types.sample, created_at: 20.days.ago) }
end
# User 3: Heavy contributor this year, but not this month
if pet_types.any?
3.times { Contribution.create!(user: users[2], contributed: pet_types.sample, created_at: 3.months.ago) }
end
# User 4: Old contributor (only in all-time)
if items.any?
users[3].update!(points: 500) # Set points directly for all-time view
2.times { Contribution.create!(user: users[3], contributed: items.sample, created_at: 2.years.ago) }
end
# User 5: Mixed contributions across all periods
if items.any? && pet_types.any?
Contribution.create!(user: users[4], contributed: items.sample, created_at: 1.day.ago)
Contribution.create!(user: users[4], contributed: pet_types.sample, created_at: 10.days.ago)
Contribution.create!(user: users[4], contributed: items.sample, created_at: 2.months.ago)
end
if swf_assets.any?
Contribution.create!(user: users[4], contributed: swf_assets.sample, created_at: 4.days.ago)
end
puts "Created sample contributions:"
puts "- #{users[0].name}: #{users[0].contributions.count} contributions (focus: this week)"
puts "- #{users[1].name}: #{users[1].contributions.count} contributions (focus: this month)"
puts "- #{users[2].name}: #{users[2].contributions.count} contributions (focus: this year)"
puts "- #{users[3].name}: #{users[3].contributions.count} contributions (focus: all-time, #{users[3].points} points)"
puts "- #{users[4].name}: #{users[4].contributions.count} contributions (mixed periods)"
puts "\nTest the feature at: http://localhost:3000/users/top-contributors"

View file

@ -0,0 +1,119 @@
require_relative '../rails_helper'
RSpec.describe UsersController, type: :controller do
include Devise::Test::ControllerHelpers
describe 'GET #top_contributors' do
let!(:user1) { create_user('Alice', 100) }
let!(:user2) { create_user('Bob', 50) }
let!(:user3) { create_user('Charlie', 0) }
context 'without timeframe parameter' do
it 'defaults to all_time timeframe' do
get :top_contributors
expect(assigns(:timeframe)).to eq('all_time')
end
it 'returns users ordered by points' do
get :top_contributors
users = assigns(:users)
expect(users.to_a.map(&:id)).to eq([user1.id, user2.id])
end
it 'paginates results' do
get :top_contributors, params: { page: 1 }
users = assigns(:users)
expect(users).to respond_to(:total_pages)
expect(users).to respond_to(:current_page)
end
end
context 'with valid timeframe parameter' do
it 'accepts all_time' do
get :top_contributors, params: { timeframe: 'all_time' }
expect(assigns(:timeframe)).to eq('all_time')
end
it 'accepts this_year' do
get :top_contributors, params: { timeframe: 'this_year' }
expect(assigns(:timeframe)).to eq('this_year')
end
it 'accepts this_month' do
get :top_contributors, params: { timeframe: 'this_month' }
expect(assigns(:timeframe)).to eq('this_month')
end
it 'accepts this_week' do
get :top_contributors, params: { timeframe: 'this_week' }
expect(assigns(:timeframe)).to eq('this_week')
end
it 'calls User.top_contributors_for with the timeframe' do
expect(User).to receive(:top_contributors_for).with(:this_week).and_call_original
get :top_contributors, params: { timeframe: 'this_week' }
end
end
context 'with invalid timeframe parameter' do
it 'defaults to all_time' do
get :top_contributors, params: { timeframe: 'invalid' }
expect(assigns(:timeframe)).to eq('all_time')
end
it 'does not raise an error' do
expect {
get :top_contributors, params: { timeframe: 'invalid' }
}.not_to raise_error
end
end
context 'with pagination' do
before do
# Create 25 users to test pagination (per_page is 20)
25.times do |i|
create_user("User#{i}", 100 - i)
end
end
it 'paginates with 20 users per page' do
get :top_contributors
expect(assigns(:users).size).to eq(20)
end
it 'supports page parameter' do
get :top_contributors, params: { page: 2 }
expect(assigns(:users).current_page).to eq(2)
end
it 'works with timeframe and pagination together' do
get :top_contributors, params: { timeframe: 'all_time', page: 2 }
expect(assigns(:timeframe)).to eq('all_time')
expect(assigns(:users).current_page).to eq(2)
end
end
context 'renders the correct template' do
it 'renders the top_contributors template' do
get :top_contributors
expect(response).to render_template('top_contributors')
end
it 'returns HTTP success' do
get :top_contributors
expect(response).to have_http_status(:success)
end
end
end
# Helper methods
def create_user(name, points = 0)
auth_user = AuthUser.create!(
name: name,
email: "#{name.downcase}@example.com",
password: 'password123',
password_confirmation: 'password123'
)
User.create!(name: name, remote_id: auth_user.id, auth_server_id: 1, points: points)
end
end

293
spec/models/user_spec.rb Normal file
View file

@ -0,0 +1,293 @@
require_relative '../rails_helper'
RSpec.describe User do
describe '.top_contributors_for' do
let!(:user1) { create_user('Alice') }
let!(:user2) { create_user('Bob') }
let!(:user3) { create_user('Charlie') }
context 'with all_time timeframe' do
it 'uses the denormalized points column' do
user1.update!(points: 100)
user2.update!(points: 50)
user3.update!(points: 0)
results = User.top_contributors_for(:all_time)
expect(results.map(&:id)).to eq([user1.id, user2.id])
expect(results.first.points).to eq(100)
expect(results.second.points).to eq(50)
end
it 'excludes users with zero points' do
user1.update!(points: 100)
user2.update!(points: 0)
results = User.top_contributors_for(:all_time)
expect(results).not_to include(user2)
end
it 'orders by points descending' do
user1.update!(points: 50)
user2.update!(points: 100)
user3.update!(points: 75)
results = User.top_contributors_for(:all_time)
expect(results.map(&:id)).to eq([user2.id, user3.id, user1.id])
end
end
context 'with this_week timeframe' do
let(:item) { create_item }
before do
# Create contributions from this week
create_contribution(user1, item, 3.days.ago) # 3 points
create_contribution(user1, item, 2.days.ago) # 3 points
# Create contributions from last month (should be excluded)
create_contribution(user2, item, 1.month.ago) # 3 points (excluded)
end
it 'calculates points from contributions in the last week' do
results = User.top_contributors_for(:this_week)
expect(results.first).to eq(user1)
expect(results.first.period_points).to eq(6)
end
it 'excludes users with no recent contributions' do
results = User.top_contributors_for(:this_week)
expect(results).not_to include(user2)
end
it 'excludes contributions older than one week' do
create_contribution(user3, item, 8.days.ago)
results = User.top_contributors_for(:this_week)
expect(results).not_to include(user3)
end
end
context 'with this_month timeframe' do
let(:item) { create_item }
let(:pet_type) { create_pet_type }
before do
# User 1: contributions from this month
create_contribution(user1, item, 15.days.ago) # 3 points
create_contribution(user1, pet_type, 20.days.ago) # 15 points
# User 2: contributions older than one month
create_contribution(user2, item, 35.days.ago) # 3 points (excluded)
end
it 'calculates points from contributions in the last month' do
results = User.top_contributors_for(:this_month)
expect(results.first).to eq(user1)
expect(results.first.period_points).to eq(18)
end
it 'excludes contributions older than one month' do
results = User.top_contributors_for(:this_month)
expect(results).not_to include(user2)
end
end
context 'with this_year timeframe' do
let(:item) { create_item }
before do
# User 1: contributions from this year
create_contribution(user1, item, 3.months.ago) # 3 points
create_contribution(user1, item, 6.months.ago) # 3 points
# User 2: contributions older than one year
create_contribution(user2, item, 13.months.ago) # 3 points (excluded)
end
it 'calculates points from contributions in the last year' do
results = User.top_contributors_for(:this_year)
expect(results.first).to eq(user1)
expect(results.first.period_points).to eq(6)
end
it 'excludes contributions older than one year' do
results = User.top_contributors_for(:this_year)
expect(results).not_to include(user2)
end
end
context 'point value calculations' do
let(:item) { create_item }
let(:pet_type) { create_pet_type }
let(:alt_style) { create_alt_style }
it 'assigns 3 points for Item contributions' do
create_contribution(user1, item, 1.day.ago)
results = User.top_contributors_for(:this_week)
expect(results.first.period_points).to eq(3)
end
it 'assigns 15 points for PetType contributions' do
create_contribution(user1, pet_type, 1.day.ago)
results = User.top_contributors_for(:this_week)
expect(results.first.period_points).to eq(15)
end
it 'assigns 30 points for AltStyle contributions' do
create_contribution(user1, alt_style, 1.day.ago)
results = User.top_contributors_for(:this_week)
expect(results.first.period_points).to eq(30)
end
it 'sums multiple contribution types correctly' do
create_contribution(user1, item, 1.day.ago) # 3 points
create_contribution(user1, pet_type, 2.days.ago) # 15 points
create_contribution(user1, alt_style, 3.days.ago) # 30 points
results = User.top_contributors_for(:this_week)
expect(results.first.period_points).to eq(48)
end
end
context 'ordering and filtering' do
let(:item) { create_item }
before do
# Create various contributions
3.times { create_contribution(user1, item, 1.day.ago) } # 9 points
5.times { create_contribution(user2, item, 2.days.ago) } # 15 points
2.times { create_contribution(user3, item, 3.days.ago) } # 6 points
end
it 'orders by period_points descending' do
results = User.top_contributors_for(:this_week)
expect(results.map(&:id)).to eq([user2.id, user1.id, user3.id])
end
it 'uses user.id as secondary sort for tied scores' do
# Create two users with same points
user4 = create_user('Dave')
user5 = create_user('Eve')
create_contribution(user4, item, 1.day.ago) # 3 points
create_contribution(user5, item, 1.day.ago) # 3 points
results = User.top_contributors_for(:this_week).where(id: [user4.id, user5.id])
# Should be ordered by user.id ASC when points are tied
expect(results.first.id).to be < results.second.id
end
it 'excludes users with zero contributions in period' do
# user3 has no contributions this week
user4 = create_user('Dave')
results = User.top_contributors_for(:this_week)
expect(results).not_to include(user4)
end
end
context 'with invalid timeframe' do
it 'raises ArgumentError' do
expect { User.top_contributors_by_period(:invalid) }.
to raise_error(ArgumentError, /Invalid timeframe/)
end
end
end
describe '#period_points' do
let(:user) { create_user('Alice') }
context 'when period_points attribute is set' do
it 'returns the calculated period_points' do
# Simulate a query that sets period_points
user_with_period = User.select('users.*, 42 AS period_points').find(user.id)
expect(user_with_period.period_points).to eq(42)
end
end
context 'when period_points attribute is not set' do
it 'falls back to denormalized points column' do
user.update!(points: 100)
expect(user.period_points).to eq(100)
end
end
end
# Helper methods
def create_user(name)
auth_user = AuthUser.create!(
name: name,
email: "#{name.downcase}@example.com",
password: 'password123',
password_confirmation: 'password123'
)
User.create!(name: name, remote_id: auth_user.id, auth_server_id: 1)
end
def create_contribution(user, contributed, created_at)
Contribution.create!(
user: user,
contributed: contributed,
created_at: created_at
)
end
def create_item
# Create a minimal item for testing
Item.create!(
name: "Test Item #{SecureRandom.hex(4)}",
description: "Test item",
thumbnail_url: "http://example.com/thumb.png",
rarity: "",
price: 0,
zones_restrict: ""
)
end
def create_swf_asset
# Create a minimal swf_asset for testing
zone = Zone.first || Zone.create!(id: 1, label: "Test Zone", plain_label: "Test Zone", type_id: 1)
SwfAsset.create!(
type: 'object',
remote_id: SecureRandom.random_number(100000),
url: "http://example.com/test.swf",
zone_id: zone.id,
body_id: 0
)
end
def create_pet_type
# Use find_or_create_by to avoid duplicate key errors
species = Species.find_or_create_by!(name: "Test Species #{SecureRandom.hex(4)}")
color = Color.find_or_create_by!(name: "Test Color #{SecureRandom.hex(4)}")
PetType.create!(
species_id: species.id,
color_id: color.id,
body_id: 0
)
end
def create_pet_state
pet_type = create_pet_type
PetState.create!(
pet_type: pet_type,
swf_asset_ids: []
)
end
def create_alt_style
# Use find_or_create_by to avoid duplicate key errors
species = Species.find_or_create_by!(name: "Test Species #{SecureRandom.hex(4)}")
color = Color.find_or_create_by!(name: "Test Color #{SecureRandom.hex(4)}")
AltStyle.create!(
species_id: species.id,
color_id: color.id,
body_id: 0,
series_name: "Test Series",
thumbnail_url: "http://example.com/thumb.png"
)
end
end

Binary file not shown.