Merge branch 'main' into feature/wardrobe-v2

This commit is contained in:
Emi Matchu 2026-02-06 19:24:30 -08:00
commit f13481783d
25 changed files with 2099 additions and 986 deletions

View file

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

View file

@ -341,6 +341,10 @@ GEM
activesupport (= 8.1.2)
bundler (>= 1.15.0)
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)
activesupport (>= 5.0.0)
minitest
@ -505,6 +509,7 @@ DEPENDENCIES
rack-attack (~> 6.7)
rack-mini-profiler (~> 4.0, >= 4.0.1)
rails (~> 8.0, >= 8.0.1)
rails-controller-testing
rails-i18n (~> 8.0, >= 8.0.1)
rdiscount (~> 2.2, >= 2.2.7.1)
rspec-rails (~> 8.0, >= 8.0.2)

View file

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

View file

@ -34,9 +34,10 @@ class PetsController < ApplicationController
end
def destination
case (params[:destination] || params[:origin])
when 'wardrobe' then wardrobe_path
else root_path
if request.get?
wardrobe_path
else
root_path
end
end

View file

@ -14,7 +14,10 @@ class UsersController < ApplicationController
end
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
def edit

View file

@ -1,8 +1,4 @@
module OutfitsHelper
def destination_tag(value)
hidden_field_tag 'destination', value, :id => nil
end
def latest_contribution_description(contribution)
user = contribution.user
contributed = contribution.contributed

View file

@ -25,6 +25,51 @@ class User < ApplicationRecord
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 :log_trade_activity, if: -> user {
(user.saved_change_to_owned_closet_hangers_visibility? &&

View file

@ -1,10 +1,11 @@
- html_options = {} unless defined? html_options
- html_options[:id] ||= "outfit-viewer-#{SecureRandom.hex(8)}"
- viewer_id = html_options[:id] ||= "outfit-viewer-#{SecureRandom.hex(8)}"
= content_tag "outfit-viewer", **html_options do
.loading-indicator= render partial: "hanger_spinner"
- outfit.visible_layers.each do |swf_asset|
%outfit-layer{
id: "#{viewer_id}-layer-#{swf_asset.id}",
data: {
"asset-id": swf_asset.id,
"zone": swf_asset.zone.label,

View file

@ -25,8 +25,7 @@
%h1= t 'app_name'
%h2= t '.tagline'
= form_tag load_pet_path, method: 'POST', class: 'primary load-pet-to-wardrobe' do
= hidden_field_tag 'destination', 'wardrobe'
= form_tag load_pet_path, method: 'GET', class: 'primary load-pet-to-wardrobe' do
%fieldset
%legend= t '.load_pet'
= pet_name_tag class: 'main-pet-name'

View file

@ -1,4 +1,13 @@
- 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
%table#top-contributors
%thead
@ -11,5 +20,5 @@
%tr
%th{:scope => 'row'}= @users.offset + rank + 1
%td= link_to user.name, user_contributions_path(user)
%td= user.points
%td= user.period_points
= will_paginate @users

View file

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

View file

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

View file

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

View file

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

View file

@ -48,7 +48,7 @@ OpenneoImpressItems::Application.routes.draw do
get '/alt-styles', to: redirect('/rainbow-pool/styles')
# Loading and modeling pets!
post '/pets/load' => 'pets#load', :as => :load_pet
match '/pets/load' => 'pets#load', :as => :load_pet, via: [:get, :post]
get '/modeling' => 'pets#bulk', :as => :bulk_pets
# Contributions to our modeling database!

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.
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|
t.string "name", limit: 30, null: false
t.string "encrypted_password", limit: 64
t.datetime "created_at", precision: nil
t.datetime "current_sign_in_at", precision: nil
t.string "current_sign_in_ip"
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 "provider"
t.datetime "remember_created_at"
t.datetime "reset_password_sent_at", precision: nil
t.string "reset_password_token"
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 "neopass_email"
t.string "unlock_token"
t.datetime "updated_at", precision: nil
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 ["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.
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|
t.integer "species_id", null: false
t.integer "color_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 "updated_at", precision: nil, null: false
t.string "series_name"
t.string "thumbnail_url", null: false
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 ["species_id"], name: "index_alt_styles_on_species_id"
end
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 "icon", size: :long, null: false
t.string "name", limit: 40, null: false
t.string "secret", limit: 64, null: false
t.string "short_name", limit: 10, null: false
end
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.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, 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.string "purpose", default: "our hosting costs this year", null: false
t.string "theme_id", default: "hug", null: false
t.text "thanks", size: :long
t.integer "goal", null: false
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
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 "updated_at", precision: nil
t.boolean "owned", default: true, null: false
t.integer "item_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 ["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"
@ -63,84 +63,85 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
end
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.text "description", size: :long
t.boolean "hangers_owned", null: false
t.string "name"
t.datetime "updated_at", precision: nil
t.integer "user_id"
t.integer "visibility", default: 1, null: false
t.index ["user_id"], name: "index_closet_lists_on_user_id"
end
create_table "colors", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.boolean "basic"
t.boolean "standard"
t.string "name", null: false
t.string "pb_item_name"
t.string "pb_item_thumbnail_url"
t.boolean "standard"
end
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 "user_id", null: false
t.string "contributed_type", limit: 8, 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 ["user_id", "created_at"], name: "index_contributions_on_user_id_and_created_at"
t.index ["user_id"], name: "index_contributions_on_user_id"
end
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 "outfit_id"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
end
create_table "donations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "amount", null: false
t.integer "campaign_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 "secret"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.string "donor_email"
t.integer "campaign_id", null: false
t.integer "user_id"
end
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 "outfit_id"
t.boolean "is_worn"
t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil
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"
end
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 "thumbnail_url", size: :long, null: false
t.text "cached_compatible_body_ids", default: ""
t.string "cached_occupied_zone_ids", default: ""
t.boolean "cached_predicted_fully_modeled", default: false, null: false
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 "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 "is_manually_nc", default: false, null: false
t.integer "manual_special_color_id"
t.column "modeling_status_hint", "enum('done','glitchy')"
t.boolean "is_manually_nc", default: false, 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.integer "dyeworks_base_item_id"
t.string "cached_occupied_zone_ids", default: ""
t.text "cached_compatible_body_ids", default: ""
t.boolean "cached_predicted_fully_modeled", default: false, null: false
t.integer "rarity_index", limit: 2
t.text "species_support_ids", size: :long
t.text "thumbnail_url", size: :long, 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 ["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"
@ -150,9 +151,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
end
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 "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"], name: "login_cookies_user_id"
end
@ -164,34 +165,34 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
end
create_table "nc_mall_records", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "item_id", null: false
t.integer "price", null: false
t.integer "discount_price"
t.datetime "created_at", null: false
t.datetime "discount_begins_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.index ["item_id"], name: "index_nc_mall_records_on_item_id", unique: true
end
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.string "neopets_username"
t.datetime "updated_at", precision: nil, null: false
t.integer "user_id"
end
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.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 ["pet_state_id"], name: "index_outfits_on_pet_state_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|
t.integer "parent_id", null: false
t.integer "swf_asset_id", 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", "swf_asset_id"], name: "unique_parents_swf_assets", unique: true
t.index ["swf_asset_id"], name: "parents_swf_assets_swf_asset_id"
end
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.datetime "created_at", precision: nil, null: false
t.string "pet_name", limit: 20, null: false
end
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.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.index ["pet_type_id"], name: "pet_states_pet_type_id"
end
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.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"], name: "pet_types_body_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
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.datetime "reported_broken_at", precision: nil
t.string "type", limit: 7, null: false
t.text "url", size: :long, null: false
t.integer "zone_id", 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 ["type", "remote_id"], name: "swf_assets_type_and_id"
t.index ["zone_id"], name: "idx_swf_assets_zone_id"
end
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 "remote_id", null: false
t.integer "points", default: 0, 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.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 "support_staff", default: false, null: false
t.integer "wanted_closet_hangers_visibility", default: 1, null: false
end
create_table "zones", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "depth"
t.integer "type_id"
t.string "label", null: false
t.string "plain_label", null: false
t.integer "type_id"
end
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

@ -442,13 +442,12 @@
mode: "755"
state: directory
- name: Remove 10min cron job to run `rails nc_mall:sync`
- name: Create 2min cron job to run `rails items:auto_model`
become_user: impress
cron:
state: absent
name: "Impress: sync NC Mall data"
minute: "*/10"
job: "bash -c 'source /etc/profile && source ~/.bash_profile && cd /srv/impress/current && bin/rails nc_mall:sync'"
name: "Impress: auto-model items"
minute: "*/2"
job: "bash -c 'source /etc/profile && source ~/.bash_profile && cd /srv/impress/current && bin/rails items:auto_model'"
- name: Create 10min cron job to run `rails neopets:import`
become_user: impress

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.

File diff suppressed because it is too large Load diff