From c110d28c6508f66c6cb40121d19b88a2bed3441c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:13:27 +0000 Subject: [PATCH 001/266] Bump actions/checkout from 5 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4adf8d4..6824190 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -29,7 +29,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -45,7 +45,7 @@ jobs: RUBOCOP_CACHE_ROOT: tmp/rubocop steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -76,7 +76,7 @@ jobs: # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -101,7 +101,7 @@ jobs: # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 From 6e460c699ef8ac55b9b5f91a3f123198f74a1bad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:13:31 +0000 Subject: [PATCH 002/266] Bump actions/upload-artifact from 4 to 6 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4adf8d4..b689bbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,7 +116,7 @@ jobs: run: bin/rails db:test:prepare test:system - name: Keep screenshots from failed system tests - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 if: failure() with: name: screenshots From 1cd86a4f34ef2e72d090f54d9d4af2f390319ad0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:13:34 +0000 Subject: [PATCH 003/266] Bump actions/cache from 4 to 5 Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4adf8d4..b400c32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,7 @@ jobs: bundler-cache: true - name: Prepare RuboCop cache - uses: actions/cache@v4 + uses: actions/cache@v5 env: DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }} with: From 9f88aecef9cd05d5944ebd7208fe93b4b0fddb35 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:48:23 +0000 Subject: [PATCH 004/266] Bump puma from 7.1.0 to 7.2.0 Bumps [puma](https://github.com/puma/puma) from 7.1.0 to 7.2.0. - [Release notes](https://github.com/puma/puma/releases) - [Changelog](https://github.com/puma/puma/blob/main/History.md) - [Commits](https://github.com/puma/puma/compare/v7.1.0...v7.2.0) --- updated-dependencies: - dependency-name: puma dependency-version: 7.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index f44c6a2..a54c05a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -238,7 +238,7 @@ GEM date stringio public_suffix (7.0.2) - puma (7.1.0) + puma (7.2.0) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) From 368f75352741ad75a7f24bd56c15cf7aa5330968 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:48:39 +0000 Subject: [PATCH 005/266] Bump selenium-webdriver from 4.39.0 to 4.40.0 Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.39.0 to 4.40.0. - [Release notes](https://github.com/SeleniumHQ/selenium/releases) - [Changelog](https://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES) - [Commits](https://github.com/SeleniumHQ/selenium/compare/selenium-4.39.0...selenium-4.40.0) --- updated-dependencies: - dependency-name: selenium-webdriver dependency-version: 4.40.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index f44c6a2..366ca48 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -325,7 +325,7 @@ GEM ruby2_keywords (0.0.5) rubyzip (3.2.2) securerandom (0.4.1) - selenium-webdriver (4.39.0) + selenium-webdriver (4.40.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) From 9c8f7dcb14b7f08abc9d29245ee91ef25ba041b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:48:55 +0000 Subject: [PATCH 006/266] Bump turbo-rails from 2.0.20 to 2.0.21 Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.20 to 2.0.21. - [Release notes](https://github.com/hotwired/turbo-rails/releases) - [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.20...v2.0.21) --- updated-dependencies: - dependency-name: turbo-rails dependency-version: 2.0.21 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index f44c6a2..68ecb74 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -373,7 +373,7 @@ GEM thruster (0.1.17-x86_64-linux) timeout (0.6.0) tsort (0.2.0) - turbo-rails (2.0.20) + turbo-rails (2.0.21) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) From 1e7505f4dc7426241ff3ec51098323cf7e63e149 Mon Sep 17 00:00:00 2001 From: Bram Date: Mon, 26 Jan 2026 20:20:49 +0100 Subject: [PATCH 007/266] hall of famers route --- app/assets/stylesheets/application.css | 30 ++++++++----- app/controllers/hall_of_famers_controller.rb | 46 ++++++++++++++++++++ app/jobs/daily/personal_stats_job.rb | 8 ++-- app/views/hall_of_famers/index.html.erb | 40 +++++++++++++++++ app/views/layouts/_navbar.html.erb | 1 + config/routes.rb | 1 + 6 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 app/controllers/hall_of_famers_controller.rb create mode 100644 app/views/hall_of_famers/index.html.erb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index c3ccdee..e1beec6 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -100,8 +100,8 @@ table { border-collapse: collapse; margin: 20px 0; font-size: 16px; - color: #fff; /* Text color for dark background */ background-color: #333; /* Background for table body */ + color: #fff; /* Text color for dark background */ } th, td { @@ -121,16 +121,6 @@ tr:nth-child(even) { background-color: #3a3a3a; /* Alternating row color for better readability */ } -tbody tr:hover { - background-color: #555; - cursor: pointer; -} - -.highlight-red { - color: red; - margin: 0px 5px 0px 0px; -} - .navbar-link { color: inherit; text-decoration: none; @@ -238,3 +228,21 @@ tbody tr:hover { background: #e0fbec; color: #2c8e47; } + +.player-link { + color: #fff; + text-decoration: none; + transition: color 0.2s; +} + +.player-link:hover { + color: #e63946; +} + +.sort-arrow { + opacity: 0.3; +} + +.sort-arrow.active { + opacity: 1; +} diff --git a/app/controllers/hall_of_famers_controller.rb b/app/controllers/hall_of_famers_controller.rb new file mode 100644 index 0000000..18897bc --- /dev/null +++ b/app/controllers/hall_of_famers_controller.rb @@ -0,0 +1,46 @@ +class HallOfFamersController < ApplicationController + allow_unauthenticated_access + + SORTABLE_COLUMNS = %w[name xanax_taken stat_enhancers_taken energy_drinks_used networth daily_se_average].freeze + + def index + @table_rows = TornUser.hof_stats_users.includes(:personal_stat_snapshots).map do |torn_user| + snapshots = torn_user.personal_stat_snapshots.order(:created_at) + latest = snapshots.last + first = snapshots.first + + if latest && first && snapshots.size > 1 + days_tracked = (latest.created_at.to_date - first.created_at.to_date).to_i + se_gained = (latest.items_used_stat_enhancers || 0) - (first.items_used_stat_enhancers || 0) + daily_se_average = days_tracked > 0 ? (se_gained.to_f / days_tracked).round(2) : 0 + else + daily_se_average = 0 + end + + { + name: torn_user.name, + torn_id: torn_user.torn_id, + xanax_taken: latest&.drugs_xanax || 0, + stat_enhancers_taken: latest&.items_used_stat_enhancers || 0, + energy_drinks_used: latest&.items_used_energy_drinks || 0, + networth: latest&.networth_total || 0, + daily_se_average: daily_se_average + } + end + + @sort_column = SORTABLE_COLUMNS.include?(params[:sort]) ? params[:sort] : "stat_enhancers_taken" + @sort_direction = params[:direction] == "asc" ? "asc" : "desc" + + @table_rows = @table_rows.sort_by { |row| row[@sort_column.to_sym] || 0 } + @table_rows = @table_rows.reverse if @sort_direction == "desc" + end + + helper_method :sort_link + + private + + def sort_link(column, label) + direction = (@sort_column == column && @sort_direction == "asc") ? "desc" : "asc" + { column: column, label: label, direction: direction, current: @sort_column == column, current_direction: @sort_direction } + end +end diff --git a/app/jobs/daily/personal_stats_job.rb b/app/jobs/daily/personal_stats_job.rb index c81294b..f0bf08e 100644 --- a/app/jobs/daily/personal_stats_job.rb +++ b/app/jobs/daily/personal_stats_job.rb @@ -2,14 +2,14 @@ module Daily class PersonalStatsJob < ApplicationJob queue_as :default - def perform(*args) + def perform(delay = 1) batch_size = 60 - delay_time = 30.minutes + run_at = delay.minutes.from_now - TornUser.find_in_batches(batch_size:) do |users| + TornUser.hof_stats_users.find_in_batches(batch_size:) do |users| users.each do |torn_user| FetchPersonalStatsJob.set(wait_until: delay_time.from_now).perform_later(torn_user) - delay_time += 1.second + run_at += 1.second end end end diff --git a/app/views/hall_of_famers/index.html.erb b/app/views/hall_of_famers/index.html.erb new file mode 100644 index 0000000..c345480 --- /dev/null +++ b/app/views/hall_of_famers/index.html.erb @@ -0,0 +1,40 @@ +

Hall of Famers

+ +<% + def sortable_header(sort_info) + if sort_info[:current] + arrow = sort_info[:current_direction] == "asc" ? "▲" : "▼" + arrow_html = "#{arrow}" + else + arrow_html = "" + end + link_to "#{sort_info[:label]} #{arrow_html}".html_safe, hall_of_famers_path(sort: sort_info[:column], direction: sort_info[:direction]), class: "sort-link" + end +%> + + + + + + + + + + + + + + <% @table_rows.each do |row| %> + + + + + + + + + <% end %> + +
<%= sortable_header(sort_link("name", "Player")) %><%= sortable_header(sort_link("xanax_taken", "Xanax Taken")) %><%= sortable_header(sort_link("stat_enhancers_taken", "Stat Enhancers")) %><%= sortable_header(sort_link("energy_drinks_used", "Energy Drinks")) %><%= sortable_header(sort_link("networth", "Networth")) %><%= sortable_header(sort_link("daily_se_average", "Daily SE Avg")) %>
+ <%= row[:name] %> + <%= number_with_delimiter(row[:xanax_taken]) %><%= number_with_delimiter(row[:stat_enhancers_taken]) %><%= number_with_delimiter(row[:energy_drinks_used]) %><%= number_to_currency(row[:networth], precision: 0) %><%= row[:daily_se_average] %>
diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index 02339e5..7dd6120 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -4,5 +4,6 @@ <%= link_to "[p] My Progress", progress_index_path, class: "navbar-link" %> <%= link_to "[f] My Faction", faction_index_path, class: "navbar-link" %> <%= link_to "[r] Ranked War", ranked_war_index_path, class: "navbar-link" %> + <%= link_to "[h] Hall of Famers", hall_of_famers_path, class: "navbar-link" %> <% end %> diff --git a/config/routes.rb b/config/routes.rb index 692fe44..d68b304 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,6 +5,7 @@ resources :progress, only: [ :index ] resources :faction, only: [ :index ] resources :ranked_war, only: [ :index ] + resources :hall_of_famers, only: [ :index ] resources :welcome, only: [ :index ] # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. From 325f5d4ce26d3d327d0b75c33c332116157d3f40 Mon Sep 17 00:00:00 2001 From: Bram Date: Mon, 26 Jan 2026 20:23:17 +0100 Subject: [PATCH 008/266] harvest hof_users --- app/controllers/hall_of_famers_controller.rb | 4 +++- app/jobs/daily/personal_stats_job.rb | 4 ++-- app/views/hall_of_famers/index.html.erb | 8 +++++--- config/recurring.yml | 8 ++++---- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/app/controllers/hall_of_famers_controller.rb b/app/controllers/hall_of_famers_controller.rb index 18897bc..1cb7bf0 100644 --- a/app/controllers/hall_of_famers_controller.rb +++ b/app/controllers/hall_of_famers_controller.rb @@ -14,6 +14,7 @@ def index se_gained = (latest.items_used_stat_enhancers || 0) - (first.items_used_stat_enhancers || 0) daily_se_average = days_tracked > 0 ? (se_gained.to_f / days_tracked).round(2) : 0 else + days_tracked = 0 daily_se_average = 0 end @@ -24,7 +25,8 @@ def index stat_enhancers_taken: latest&.items_used_stat_enhancers || 0, energy_drinks_used: latest&.items_used_energy_drinks || 0, networth: latest&.networth_total || 0, - daily_se_average: daily_se_average + daily_se_average: daily_se_average, + days_tracked: days_tracked } end diff --git a/app/jobs/daily/personal_stats_job.rb b/app/jobs/daily/personal_stats_job.rb index f0bf08e..b70816e 100644 --- a/app/jobs/daily/personal_stats_job.rb +++ b/app/jobs/daily/personal_stats_job.rb @@ -2,13 +2,13 @@ module Daily class PersonalStatsJob < ApplicationJob queue_as :default - def perform(delay = 1) + def perform(delay = 15) batch_size = 60 run_at = delay.minutes.from_now TornUser.hof_stats_users.find_in_batches(batch_size:) do |users| users.each do |torn_user| - FetchPersonalStatsJob.set(wait_until: delay_time.from_now).perform_later(torn_user) + FetchPersonalStatsJob.set(wait_until: run_at).perform_later(torn_user) run_at += 1.second end end diff --git a/app/views/hall_of_famers/index.html.erb b/app/views/hall_of_famers/index.html.erb index c345480..d97e87c 100644 --- a/app/views/hall_of_famers/index.html.erb +++ b/app/views/hall_of_famers/index.html.erb @@ -16,11 +16,11 @@ <%= sortable_header(sort_link("name", "Player")) %> - <%= sortable_header(sort_link("xanax_taken", "Xanax Taken")) %> + <%= sortable_header(sort_link("xanax_taken", "Xanax")) %> <%= sortable_header(sort_link("stat_enhancers_taken", "Stat Enhancers")) %> <%= sortable_header(sort_link("energy_drinks_used", "Energy Drinks")) %> <%= sortable_header(sort_link("networth", "Networth")) %> - <%= sortable_header(sort_link("daily_se_average", "Daily SE Avg")) %> + <%= sortable_header(sort_link("daily_se_average", "Daily SE Average")) %> @@ -33,7 +33,9 @@ <%= number_with_delimiter(row[:stat_enhancers_taken]) %> <%= number_with_delimiter(row[:energy_drinks_used]) %> <%= number_to_currency(row[:networth], precision: 0) %> - <%= row[:daily_se_average] %> + + <%= row[:daily_se_average] %> SE avg <% if row[:days_tracked] > 0 %> (past <%= row[:days_tracked] %> days)<% end %> + <% end %> diff --git a/config/recurring.yml b/config/recurring.yml index c048966..ca7dc02 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -11,10 +11,10 @@ production: # class: Daily::StockDividendJob # queue: default # schedule: at 11am every day - # daily_stats_harvest: - # class: Daily::PersonalStatsJob - # queue: default - # schedule: at 11am every day + daily_stats_harvest: + class: Daily::PersonalStatsJob + queue: default + schedule: at 11am every day # development: # daily_stock_dividend_job: # class: Daily::StockDividendJob From 96e3364646293502f9a32d095bfda4caf82b4223 Mon Sep 17 00:00:00 2001 From: Bram Date: Thu, 12 Feb 2026 21:02:11 +0100 Subject: [PATCH 009/266] new styles and update the table --- app/assets/stylesheets/application.css | 489 ++++++++++++++----- app/controllers/hall_of_famers_controller.rb | 56 ++- app/views/hall_of_famers/index.html.erb | 122 +++-- app/views/home/index.html.erb | 5 +- app/views/layouts/_header.html.erb | 8 +- app/views/sessions/new.html.erb | 41 +- 6 files changed, 542 insertions(+), 179 deletions(-) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index e1beec6..d06ab75 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1,49 +1,89 @@ /* - * This is a manifest file that'll be compiled into application.css. - * - * With Propshaft, assets are served efficiently without preprocessing steps. You can still include - * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard - * cascading order, meaning styles declared later in the document or manifest will override earlier ones, - * depending on specificity. - * - * Consider organizing styles into separate files for maintainability. + * Vercel-inspired design system */ +:root { + /* Vercel Colors */ + --gray-50: #fafafa; + --gray-100: #f5f5f5; + --gray-200: #e5e5e5; + --gray-300: #d4d4d4; + --gray-400: #a3a3a3; + --gray-500: #737373; + --gray-600: #525252; + --gray-700: #404040; + --gray-800: #262626; + --gray-900: #171717; + --gray-1000: #0a0a0a; + + --blue-500: #0070f3; + --blue-600: #0061d5; + + /* Spacing */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-12: 48px; + --space-16: 64px; + + /* Border radius */ + --radius-sm: 5px; + --radius-md: 8px; + --radius-lg: 12px; +} + +* { + box-sizing: border-box; +} + html, body { height: 100%; margin: 0; + padding: 0; } + body { min-height: 100vh; display: flex; flex-direction: column; - align-items: center; - background: #111; - color: #d1d5db; + background: var(--gray-1000); + color: var(--gray-200); font-family: "Geist Mono", monospace; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + line-height: 1.6; } header { - padding-top: 2rem; + position: sticky; + top: 0; + z-index: 100; + width: 100%; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--gray-900); } main { flex: 1; - display: flex; - flex-direction: column; - align-items: center; width: 100%; - max-width: 800px; - padding-top: 2rem; - box-sizing: border-box; + max-width: 1200px; + margin: 0 auto; + padding: var(--space-12) var(--space-6); } nav { display: flex; + align-items: center; justify-content: center; - margin-bottom: 3rem; - font-size: 0.9rem; - gap: 1.5rem; + gap: var(--space-6); + padding: var(--space-4) var(--space-6); + font-size: 0.875rem; + font-weight: 500; } footer { @@ -51,91 +91,154 @@ footer { display: flex; text-align: center; justify-content: center; - gap: 1.5rem; + padding: var(--space-12) var(--space-6); + border-top: 1px solid var(--gray-900); + font-size: 0.875rem; + color: var(--gray-500); } -footer img:hover { - filter: brightness(1.2); - transform: scale(1.1); - transition: 0.2s; +h1, h2, h3 { + color: #ffffff; + font-weight: 600; + letter-spacing: -0.025em; + margin: 0; } -h1, h2, h3 { - color: #ffff; +h1 { + font-size: 2.5rem; + line-height: 1.2; + margin-bottom: var(--space-8); +} + +h2 { + font-size: 1.875rem; + line-height: 1.3; } h3 { - margin-bottom: 0px; + font-size: 1.25rem; + line-height: 1.4; } section { - margin-bottom: 4rem; + margin-bottom: var(--space-16); } article { - margin-bottom: 2rem; + margin-bottom: var(--space-8); } a { text-decoration: none; - color: #ffff; + color: var(--gray-200); + transition: color 0.15s ease; } -a:hover h3{ - color: #e63946; +a:hover { + color: #ffffff; } - -li:hover a { - color: inherit; +/* Navigation */ +.navbar-link { + color: var(--gray-400); text-decoration: none; + transition: color 0.15s ease; + font-size: 0.875rem; + font-weight: 500; +} + +.navbar-link:hover { + color: #ffffff; } -li:hover { - color: #e63946; +.navbar-title { + font-size: 1.125rem; + font-weight: 600; + color: #ffffff; } -/* Table Styling */ + +.navbar-title:hover { + color: #ffffff; +} + +/* Tables - Vercel Style */ table { width: 100%; - border-collapse: collapse; - margin: 20px 0; - font-size: 16px; - background-color: #333; /* Background for table body */ - color: #fff; /* Text color for dark background */ + border-collapse: separate; + border-spacing: 0; + font-size: 0.875rem; + background: transparent; } th, td { - padding: 12px 15px; /* Increase padding for readability */ + padding: var(--space-4) var(--space-3); text-align: left; - border: 1px solid #444; /* Dark border to fit theme */ - white-space: nowrap; /* Prevent text wrapping */ + border-bottom: 1px solid var(--gray-900); } th { - background-color: #444; /* Header background */ - font-weight: bold; - text-transform: uppercase; + background: transparent; + font-weight: 600; + color: var(--gray-400); + text-transform: none; + font-size: 0.75rem; + letter-spacing: 0.05em; + padding-top: var(--space-3); + padding-bottom: var(--space-3); + border-bottom: 1px solid var(--gray-800); } -tr:nth-child(even) { - background-color: #3a3a3a; /* Alternating row color for better readability */ +td { + color: var(--gray-200); } -.navbar-link { - color: inherit; +tr { + transition: background-color 0.15s ease; +} + +tbody tr:hover { + background-color: rgba(255, 255, 255, 0.03); +} + +/* Sort Links */ +.sort-link { + color: var(--gray-400); text-decoration: none; - transition: color 0.2s; - margin-top: auto; + display: inline-flex; + align-items: center; + gap: var(--space-2); + transition: color 0.15s ease; + cursor: pointer; } -.navbar-link:hover { - color: #e63946; +.sort-link:hover { + color: var(--gray-200); } -.navbar-title { - font-size: 1.5rem; +.sort-arrow { + opacity: 0.2; + font-size: 0.75rem; + transition: opacity 0.15s ease; +} + +.sort-arrow.active { + opacity: 1; + color: #ffffff; +} + +/* Player Links */ +.player-link { + color: var(--gray-200); + text-decoration: none; + transition: color 0.15s ease; + font-weight: 500; } +.player-link:hover { + color: #ffffff; +} + +/* Login Form - Vercel Style */ .login-container { flex: 1; display: flex; @@ -143,106 +246,268 @@ tr:nth-child(even) { justify-content: center; align-items: center; min-height: 70vh; - width: 100vw; + width: 100%; } .login-form { - background: #181818; - padding: 2.5rem 3rem; - border-radius: 8px; - box-shadow: 0 0 20px 0 #0002; + background: var(--gray-900); + padding: var(--space-8); + border-radius: var(--radius-lg); + border: 1px solid var(--gray-800); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); display: flex; flex-direction: column; align-items: center; + max-width: 400px; + width: 100%; } .login-form fieldset { border: none; - margin: 1rem 0 2.5rem 0; + margin: 0; padding: 0; width: 100%; display: flex; flex-direction: column; - align-items: flex-start; + align-items: stretch; } .login-form legend { - margin-bottom: 2rem; - font-size: 1.1rem; - color: #fff; - font-weight: bold; - letter-spacing: 0.01em; + margin-bottom: var(--space-8); + font-size: 1.5rem; + color: #ffffff; + font-weight: 600; + letter-spacing: -0.025em; } .login-form label { - font-size: 1rem; - margin-bottom: .5rem; - color: #d1d5db; + font-size: 0.875rem; + margin-bottom: var(--space-2); + color: var(--gray-300); + font-weight: 500; } .login-input { - padding: 0.75rem 1rem; - border: none; - border-radius: 4px; - margin-bottom: 1rem; - background: #222; - color: #d1d5db; - width: 230px; - font-size: 1rem; + padding: var(--space-3); + border: 1px solid var(--gray-800); + border-radius: var(--radius-sm); + margin-bottom: var(--space-4); + background: var(--gray-1000); + color: var(--gray-100); + width: 100%; + font-size: 0.875rem; + font-family: "Geist Mono", monospace; + transition: border-color 0.15s ease, background-color 0.15s ease; } .login-input:focus { - outline: 2px solid #e63946; - background: #1a1a1a; + outline: none; + border-color: var(--gray-600); + background: var(--gray-900); } .login-submit { - background-color: #e63946; - color: #fff; + background-color: #ffffff; + color: #000000; border: none; - border-radius: 4px; - padding: 0.7rem 2.2rem; - font-size: 1.1rem; + border-radius: var(--radius-sm); + padding: var(--space-3) var(--space-6); + font-size: 0.875rem; + font-weight: 600; cursor: pointer; - transition: background 0.18s; - margin-top: 0.5rem; + transition: background-color 0.15s ease, transform 0.15s ease; + margin-top: var(--space-4); + font-family: "Geist Mono", monospace; +} + +.login-submit:hover { + background-color: var(--gray-100); + transform: translateY(-1px); } -.login-submit:hover, .login-submit:focus { - background-color: #ff6b81; +.login-submit:active { + transform: translateY(0); } +/* Flash Messages - Vercel Style */ .flash { - margin-bottom: 1.25rem; - padding: 0.7rem 1.5rem; - border-radius: 4px; + margin-bottom: var(--space-6); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); width: 100%; text-align: center; - font-size: 1rem; + font-size: 0.875rem; + border: 1px solid; } + .flash-alert { - background: #fff1f1; - color: #e63946; + background: rgba(239, 68, 68, 0.1); + color: #fca5a5; + border-color: rgba(239, 68, 68, 0.3); } + .flash-notice { - background: #e0fbec; - color: #2c8e47; + background: rgba(34, 197, 94, 0.1); + color: #86efac; + border-color: rgba(34, 197, 94, 0.3); } -.player-link { - color: #fff; - text-decoration: none; - transition: color 0.2s; +/* Page Headers */ +.page-header { + margin-bottom: var(--space-12); } -.player-link:hover { - color: #e63946; +.page-subtitle { + color: var(--gray-400); + font-size: 0.875rem; + margin-top: var(--space-2); } -.sort-arrow { - opacity: 0.3; +/* Table Container */ +.table-container { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + overflow: hidden; } -.sort-arrow.active { +.table-info { + padding: var(--space-4) var(--space-6); + border-bottom: 1px solid var(--gray-800); + background: rgba(255, 255, 255, 0.02); +} + +.table-filter-form { + width: 100%; +} + +.filter-inline { + display: flex; + align-items: center; + gap: var(--space-3); + font-size: 0.875rem; + flex-wrap: wrap; +} + +.date-inputs-inline { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.date-input-inline { + padding: var(--space-2) var(--space-3); + border: 1px solid var(--gray-800); + border-radius: var(--radius-sm); + background: var(--gray-1000); + color: var(--gray-200); + font-size: 0.8125rem; + font-family: "Geist Mono", monospace; + transition: border-color 0.15s ease, background-color 0.15s ease; + min-width: 130px; +} + +.date-input-inline::-webkit-calendar-picker-indicator { + filter: invert(1) brightness(0.8); + cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s ease; +} + +.date-input-inline::-webkit-calendar-picker-indicator:hover { opacity: 1; } + +.date-input-inline:hover { + border-color: var(--gray-700); +} + +.date-input-inline:focus { + outline: none; + border-color: var(--gray-600); + background: var(--gray-900); +} + +.date-separator { + color: var(--gray-600); + font-weight: 400; +} + +.filter-button-inline { + background-color: var(--gray-800); + color: var(--gray-200); + border: 1px solid var(--gray-700); + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-4); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease; + font-family: "Geist Mono", monospace; +} + +.filter-button-inline:hover { + background-color: var(--gray-700); + border-color: var(--gray-600); +} + +.filter-button-inline:active { + transform: translateY(0); +} + +.filter-reset-inline { + color: var(--gray-500); + text-decoration: none; + font-size: 0.8125rem; + transition: color 0.15s ease; + font-weight: 400; +} + +.filter-reset-inline:hover { + color: var(--gray-300); +} + +.date-range-display { + display: flex; + align-items: center; + gap: var(--space-3); + font-size: 0.875rem; +} + +.info-label { + color: var(--gray-400); + font-weight: 600; +} + +.info-value { + color: var(--gray-100); + font-weight: 500; +} + +.info-days { + color: var(--gray-500); + font-weight: 400; +} + +/* Stat Values */ +.stat-value { + color: var(--gray-100); + font-weight: 500; +} + +.stat-daily { + color: var(--gray-500); + font-size: 0.75rem; + margin-left: var(--space-2); + display: inline-block; +} + +/* Centered Content */ +.centered-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + text-align: center; +} diff --git a/app/controllers/hall_of_famers_controller.rb b/app/controllers/hall_of_famers_controller.rb index 1cb7bf0..54d07c8 100644 --- a/app/controllers/hall_of_famers_controller.rb +++ b/app/controllers/hall_of_famers_controller.rb @@ -1,36 +1,66 @@ class HallOfFamersController < ApplicationController allow_unauthenticated_access - SORTABLE_COLUMNS = %w[name xanax_taken stat_enhancers_taken energy_drinks_used networth daily_se_average].freeze + SORTABLE_COLUMNS = %w[name xanax_gained energy_drinks_gained networth_gained total_se se_gained].freeze def index + # Determine date range for filtering + all_snapshots = PersonalStatSnapshot.joins(:torn_user) + .where(torn_users: { hof_stats_user: true }) + .order(:created_at) + + @earliest_date = all_snapshots.first&.created_at&.to_date + @latest_date = all_snapshots.last&.created_at&.to_date + + # Parse date parameters + @start_date = params[:start_date].present? ? Date.parse(params[:start_date]) : @earliest_date + @end_date = params[:end_date].present? ? Date.parse(params[:end_date]) : @latest_date + + # Calculate total days for the selected range + @total_days_tracked = (@end_date - @start_date).to_i + @table_rows = TornUser.hof_stats_users.includes(:personal_stat_snapshots).map do |torn_user| - snapshots = torn_user.personal_stat_snapshots.order(:created_at) + # Filter snapshots by date range + snapshots = torn_user.personal_stat_snapshots + .where("DATE(created_at) >= ? AND DATE(created_at) <= ?", @start_date, @end_date) + .order(:created_at) + latest = snapshots.last first = snapshots.first if latest && first && snapshots.size > 1 - days_tracked = (latest.created_at.to_date - first.created_at.to_date).to_i + # Calculate gains + xanax_gained = (latest.drugs_xanax || 0) - (first.drugs_xanax || 0) se_gained = (latest.items_used_stat_enhancers || 0) - (first.items_used_stat_enhancers || 0) - daily_se_average = days_tracked > 0 ? (se_gained.to_f / days_tracked).round(2) : 0 + energy_drinks_gained = (latest.items_used_energy_drinks || 0) - (first.items_used_energy_drinks || 0) + networth_gained = (latest.networth_total || 0) - (first.networth_total || 0) + + # Calculate daily averages using the overall tracking period + xanax_daily = @total_days_tracked > 0 ? (xanax_gained.to_f / @total_days_tracked).round(2) : 0 + se_daily = @total_days_tracked > 0 ? (se_gained.to_f / @total_days_tracked).round(2) : 0 + energy_drinks_daily = @total_days_tracked > 0 ? (energy_drinks_gained.to_f / @total_days_tracked).round(2) : 0 + networth_daily = @total_days_tracked > 0 ? (networth_gained.to_f / @total_days_tracked).round(0) : 0 else - days_tracked = 0 - daily_se_average = 0 + xanax_gained = energy_drinks_gained = se_gained = networth_gained = 0 + xanax_daily = se_daily = energy_drinks_daily = networth_daily = 0 end { name: torn_user.name, torn_id: torn_user.torn_id, - xanax_taken: latest&.drugs_xanax || 0, - stat_enhancers_taken: latest&.items_used_stat_enhancers || 0, - energy_drinks_used: latest&.items_used_energy_drinks || 0, - networth: latest&.networth_total || 0, - daily_se_average: daily_se_average, - days_tracked: days_tracked + xanax_gained: xanax_gained, + xanax_daily: xanax_daily, + energy_drinks_gained: energy_drinks_gained, + energy_drinks_daily: energy_drinks_daily, + networth_gained: networth_gained, + networth_daily: networth_daily, + total_se: latest&.items_used_stat_enhancers || 0, + se_gained: se_gained, + se_daily: se_daily } end - @sort_column = SORTABLE_COLUMNS.include?(params[:sort]) ? params[:sort] : "stat_enhancers_taken" + @sort_column = SORTABLE_COLUMNS.include?(params[:sort]) ? params[:sort] : "se_gained" @sort_direction = params[:direction] == "asc" ? "asc" : "desc" @table_rows = @table_rows.sort_by { |row| row[@sort_column.to_sym] || 0 } diff --git a/app/views/hall_of_famers/index.html.erb b/app/views/hall_of_famers/index.html.erb index d97e87c..a94b611 100644 --- a/app/views/hall_of_famers/index.html.erb +++ b/app/views/hall_of_famers/index.html.erb @@ -1,4 +1,7 @@ -

Hall of Famers

+ <% def sortable_header(sort_info) @@ -8,35 +11,98 @@ else arrow_html = "" end - link_to "#{sort_info[:label]} #{arrow_html}".html_safe, hall_of_famers_path(sort: sort_info[:column], direction: sort_info[:direction]), class: "sort-link" + # Preserve date filter params when sorting + url_params = { sort: sort_info[:column], direction: sort_info[:direction] } + url_params[:start_date] = params[:start_date] if params[:start_date].present? + url_params[:end_date] = params[:end_date] if params[:end_date].present? + link_to "#{sort_info[:label]} #{arrow_html}".html_safe, hall_of_famers_path(url_params), class: "sort-link" end %> - - - - - - - - - - - - - <% @table_rows.each do |row| %> +
+
+ <%= form_with url: hall_of_famers_path, method: :get, class: "table-filter-form" do |form| %> +
+ Tracking Period: + +
+ <%= form.date_field :start_date, + value: params[:start_date] || @earliest_date, + min: @earliest_date, + max: @latest_date, + class: "date-input-inline" %> + + + + <%= form.date_field :end_date, + value: params[:end_date] || @latest_date, + min: @earliest_date, + max: @latest_date, + class: "date-input-inline" %> +
+ + (<%= @total_days_tracked %> days) + + <%= hidden_field_tag :sort, params[:sort] %> + <%= hidden_field_tag :direction, params[:direction] %> + + + <% if params[:start_date].present? || params[:end_date].present? %> + <%= link_to "Reset", hall_of_famers_path, class: "filter-reset-inline" %> + <% end %> +
+ <% end %> +
+ +
<%= sortable_header(sort_link("name", "Player")) %><%= sortable_header(sort_link("xanax_taken", "Xanax")) %><%= sortable_header(sort_link("stat_enhancers_taken", "Stat Enhancers")) %><%= sortable_header(sort_link("energy_drinks_used", "Energy Drinks")) %><%= sortable_header(sort_link("networth", "Networth")) %><%= sortable_header(sort_link("daily_se_average", "Daily SE Average")) %>
+ - - - - - - + + + + + + - <% end %> - -
- <%= row[:name] %> - <%= number_with_delimiter(row[:xanax_taken]) %><%= number_with_delimiter(row[:stat_enhancers_taken]) %><%= number_with_delimiter(row[:energy_drinks_used]) %><%= number_to_currency(row[:networth], precision: 0) %> - <%= row[:daily_se_average] %> SE avg <% if row[:days_tracked] > 0 %> (past <%= row[:days_tracked] %> days)<% end %> - <%= sortable_header(sort_link("name", "PLAYER")) %><%= sortable_header(sort_link("xanax_gained", "XANAX GAINED")) %><%= sortable_header(sort_link("energy_drinks_gained", "ENERGY DRINKS")) %><%= sortable_header(sort_link("networth_gained", "NETWORTH GAINED")) %><%= sortable_header(sort_link("total_se", "TOTAL SE")) %><%= sortable_header(sort_link("se_gained", "SE GAINED")) %>
+ + + <% @table_rows.each do |row| %> + + + <%= row[:name] %> + + + <%= number_with_delimiter(row[:xanax_gained]) %> + <% if @total_days_tracked > 0 %> + <%= row[:xanax_daily] %>/day + <% end %> + + + <%= number_with_delimiter(row[:energy_drinks_gained]) %> + <% if @total_days_tracked > 0 %> + <%= row[:energy_drinks_daily] %>/day + <% end %> + + + <%= number_to_currency(row[:networth_gained], precision: 0) %> + <% if @total_days_tracked > 0 %> + <%= number_to_currency(row[:networth_daily], precision: 0) %>/day + <% end %> + + + <%= number_with_delimiter(row[:total_se]) %> + <% if @total_days_tracked > 0 %> + <%= row[:se_daily] %>/day + <% end %> + + + <%= number_with_delimiter(row[:se_gained]) %> + <% if @total_days_tracked > 0 %> + <%= row[:se_daily] %> taken/day + <% end %> + + + <% end %> + + + diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index 59b8e5e..aa78acf 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -1,2 +1,3 @@ - -

Comming Soon!

+
+

Coming Soon!

+
diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 66f791d..720a318 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -1,3 +1,5 @@ -
- <%= render "layouts/navbar" %> -
+<% if authenticated? %> +
+ <%= render "layouts/navbar" %> +
+<% end %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 4da037a..e2edfd6 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,22 +1,21 @@ -
-
+ + <% end %> + + From dc1889b9f89bbde16a458c6dac43c2838eb322e2 Mon Sep 17 00:00:00 2001 From: Bram Date: Thu, 12 Feb 2026 21:48:50 +0100 Subject: [PATCH 010/266] allow authentication --- app/assets/stylesheets/application.css | 92 +++++++++++++++++++++++++- app/controllers/home_controller.rb | 1 + app/controllers/sessions_controller.rb | 26 ++++++-- app/models/torn_api/user/profile.rb | 22 +++++- app/views/faction/index.html.erb | 3 + app/views/layouts/_header.html.erb | 8 +-- app/views/layouts/_navbar.html.erb | 26 ++++++-- app/views/progress/index.html.erb | 69 +++++++++++++------ app/views/ranked_war/index.html.erb | 3 + db/development.sqlite3 | 0 10 files changed, 209 insertions(+), 41 deletions(-) create mode 100644 db/development.sqlite3 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index d06ab75..6f94629 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -68,6 +68,10 @@ header { border-bottom: 1px solid var(--gray-900); } +nav { + position: relative; +} + main { flex: 1; width: 100%; @@ -79,13 +83,35 @@ main { nav { display: flex; align-items: center; - justify-content: center; - gap: var(--space-6); + justify-content: space-between; padding: var(--space-4) var(--space-6); font-size: 0.875rem; font-weight: 500; } +.navbar-left { + display: flex; + align-items: center; + flex: 1; +} + +.navbar-center { + display: flex; + align-items: center; + gap: var(--space-6); + position: absolute; + left: 50%; + transform: translateX(-50%); + min-height: 1.5rem; +} + +.navbar-right { + display: flex; + align-items: center; + flex: 1; + justify-content: flex-end; +} + footer { width: 100%; display: flex; @@ -151,6 +177,22 @@ a:hover { color: #ffffff; } +.navbar-link-active { + color: #ffffff; + position: relative; +} + +.navbar-link-active::after { + content: ''; + position: absolute; + bottom: -17px; + left: 0; + right: 0; + height: 2px; + background: #ffffff; + border-radius: 2px 2px 0 0; +} + .navbar-title { font-size: 1.125rem; font-weight: 600; @@ -161,6 +203,52 @@ a:hover { color: #ffffff; } +.navbar-signout { + background: transparent; + border: 1px solid var(--gray-800); + color: var(--gray-400); + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 500; + font-family: "Geist Mono", monospace; + cursor: pointer; + transition: all 0.15s ease; + line-height: 1.5; + height: 2rem; + display: inline-flex; + align-items: center; +} + +.navbar-signout:hover { + background: var(--gray-900); + color: #ffffff; + border-color: var(--gray-700); +} + +.navbar-signin { + background: transparent; + border: 1px solid var(--gray-800); + color: var(--gray-400); + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 500; + font-family: "Geist Mono", monospace; + text-decoration: none; + transition: all 0.15s ease; + display: inline-flex; + align-items: center; + line-height: 1.5; + height: 2rem; +} + +.navbar-signin:hover { + background: var(--gray-900); + color: #ffffff; + border-color: var(--gray-700); +} + /* Tables - Vercel Style */ table { width: 100%; diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index f643db5..fcf4921 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -1,5 +1,6 @@ class HomeController < ApplicationController allow_unauthenticated_access def index + redirect_to progress_index_path if authenticated? end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index f2f3336..ed40bca 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -10,12 +10,26 @@ def create begin profile = TornApi::User::Profile.new(api_key).fetch - if user = TornUser.find_by(torn_id: profile["id"]).user - start_new_session_for user - redirect_to after_authentication_url - else - redirect_to new_session_path, alert: "Currently not accepting anyone" + + return redirect_to new_session_path, alert: "Could not fetch profile from Torn API." unless profile + + torn_user = TornUser.find_or_create_by!(torn_id: profile.id) do |tu| + tu.name = profile.name + tu.level = profile.level end + + torn_user.update!(name: profile.name, level: profile.level) + + # Find or create user by API key (which is unique), then associate with torn_user + user = User.find_or_create_by(api_key: api_key) do |u| + u.torn_user_id = torn_user.id + end + + # Update the torn_user association if it changed + user.update!(torn_user_id: torn_user.id) if user.torn_user_id != torn_user.id + + start_new_session_for user + redirect_to after_authentication_url rescue TornApi::InvalidKeyError redirect_to new_session_path, alert: "Invalid Torn API key." rescue ActiveRecord::RecordInvalid => e @@ -29,6 +43,6 @@ def create def destroy terminate_session - redirect_to new_session_path, status: :see_other + redirect_to root_path, status: :see_other end end diff --git a/app/models/torn_api/user/profile.rb b/app/models/torn_api/user/profile.rb index 1f75b49..9727436 100644 --- a/app/models/torn_api/user/profile.rb +++ b/app/models/torn_api/user/profile.rb @@ -3,14 +3,34 @@ module User class Profile < Base ENDPOINT = "v2/user/basic".freeze + ProfileData = Data.define( + :id, + :name, + :level, + :gender, + :status + ) + def fetch response = get(ENDPOINT, striptags: false) if response["profile"].present? - response["profile"] + parse_profile(response["profile"]) else raise InvalidKeyError, "Torn API authentication failed: #{response}" end end + + private + + def parse_profile(data) + ProfileData.new( + id: data["id"], + name: data["name"], + level: data["level"], + gender: data["gender"], + status: data["status"] + ) + end end end end diff --git a/app/views/faction/index.html.erb b/app/views/faction/index.html.erb index e69de29..aa78acf 100644 --- a/app/views/faction/index.html.erb +++ b/app/views/faction/index.html.erb @@ -0,0 +1,3 @@ +
+

Coming Soon!

+
diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 720a318..66f791d 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -1,5 +1,3 @@ -<% if authenticated? %> -
- <%= render "layouts/navbar" %> -
-<% end %> +
+ <%= render "layouts/navbar" %> +
diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index 7dd6120..4780b8b 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -1,9 +1,25 @@ diff --git a/app/views/progress/index.html.erb b/app/views/progress/index.html.erb index fdc2922..6919398 100644 --- a/app/views/progress/index.html.erb +++ b/app/views/progress/index.html.erb @@ -1,24 +1,49 @@ - - - - - - - - - - - - - <% @table_rows.each do |row| %> + + +
+
Stock NameIncrementMarket PriceBlock CostDays to Break EvenOwned
+ - - - - - - + + + + + + - <% end %> - -
<%= row[:stock_name] %><%= row[:increment] %><%= row[:dividend_value].nil? ? 'N/A' : number_to_currency(row[:dividend_value], precision: 2) %><%= number_to_currency(row[:block_cost], precision: 2) %><%= row[:days_to_break_even].infinite? ? 'Infinity' : row[:days_to_break_even].to_i %><%= row[:owned] ? 'Yes' : 'No' %>STOCK NAMEINCREMENTDIVIDEND VALUEBLOCK COSTDAYS TO BREAK EVENOWNED
+ + + <% @table_rows.each do |row| %> + + <%= row[:stock_name] %> + <%= row[:increment] %> + + <% if row[:dividend_value].nil? %> + N/A + <% else %> + <%= number_to_currency(row[:dividend_value], precision: 2) %> + <% end %> + + <%= number_to_currency(row[:block_cost], precision: 2) %> + + <% if row[:days_to_break_even].infinite? %> + + <% else %> + <%= number_with_delimiter(row[:days_to_break_even].to_i) %> + <% end %> + + + <% if row[:owned] %> + ✓ Yes + <% else %> + No + <% end %> + + + <% end %> + + + diff --git a/app/views/ranked_war/index.html.erb b/app/views/ranked_war/index.html.erb index e69de29..aa78acf 100644 --- a/app/views/ranked_war/index.html.erb +++ b/app/views/ranked_war/index.html.erb @@ -0,0 +1,3 @@ +
+

Coming Soon!

+
diff --git a/db/development.sqlite3 b/db/development.sqlite3 new file mode 100644 index 0000000..e69de29 From 9f1d5bd2d452f2ea3a8d4671813f81771081e661 Mon Sep 17 00:00:00 2001 From: Bram Date: Thu, 12 Feb 2026 22:12:11 +0100 Subject: [PATCH 011/266] allow much more --- app/assets/stylesheets/application.css | 346 ++++++++++++++++-- app/controllers/pages_controller.rb | 9 + app/controllers/sessions_controller.rb | 8 + .../controllers/login_controller.js | 17 + app/models/torn_api/key/info.rb | 56 +++ app/views/home/index.html.erb | 7 +- app/views/layouts/_header.html.erb | 8 +- app/views/pages/privacy_policy.html.erb | 205 +++++++++++ app/views/pages/terms_of_service.html.erb | 322 ++++++++++++++++ app/views/sessions/new.html.erb | 43 ++- config/routes.rb | 3 + 11 files changed, 980 insertions(+), 44 deletions(-) create mode 100644 app/controllers/pages_controller.rb create mode 100644 app/javascript/controllers/login_controller.js create mode 100644 app/models/torn_api/key/info.rb create mode 100644 app/views/pages/privacy_policy.html.erb create mode 100644 app/views/pages/terms_of_service.html.erb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 6f94629..8fa0fd1 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -46,6 +46,10 @@ html, body { padding: 0; } +html { + scroll-behavior: smooth; +} + body { min-height: 100vh; display: flex; @@ -333,88 +337,195 @@ tbody tr:hover { flex-direction: column; justify-content: center; align-items: center; - min-height: 70vh; + min-height: 100vh; width: 100%; + opacity: 0; + animation: fadeIn 0.6s ease-out forwards; +} + +@keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +@keyframes slideUp { + 0% { + transform: translateY(20px); + opacity: 0; + } + 100% { + transform: translateY(0); + opacity: 1; + } } .login-form { background: var(--gray-900); - padding: var(--space-8); + padding: var(--space-12); border-radius: var(--radius-lg); border: 1px solid var(--gray-800); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); display: flex; flex-direction: column; - align-items: center; - max-width: 400px; + align-items: stretch; + max-width: 420px; width: 100%; + opacity: 0; + transform: translateY(20px); + animation: slideUp 0.6s ease-out 0.2s forwards; } -.login-form fieldset { - border: none; +.login-header { + text-align: center; + margin-bottom: var(--space-8); +} + +.login-title { + font-size: 1.75rem; + font-weight: 600; + color: #ffffff; + margin-bottom: var(--space-2); + letter-spacing: -0.025em; +} + +.login-subtitle { + font-size: 0.875rem; + color: var(--gray-400); margin: 0; - padding: 0; - width: 100%; +} + +.login-field { display: flex; flex-direction: column; - align-items: stretch; + margin-bottom: var(--space-6); } -.login-form legend { - margin-bottom: var(--space-8); - font-size: 1.5rem; +.login-checkbox-field { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-6); +} + +.login-checkbox { + width: 18px; + height: 18px; + border: 2px solid var(--gray-700); + border-radius: 4px; + background: var(--gray-1000); + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; + appearance: none; + -webkit-appearance: none; + position: relative; +} + +.login-checkbox:checked { + background: #ffffff; + border-color: #ffffff; +} + +.login-checkbox:checked::after { + content: ''; + position: absolute; + left: 5px; + top: 2px; + width: 4px; + height: 8px; + border: solid #000000; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.login-checkbox:hover { + border-color: var(--gray-600); +} + +.login-checkbox-label { + font-size: 0.8125rem; + color: var(--gray-400); + cursor: pointer; + user-select: none; + line-height: 1.4; +} + +.login-checkbox-link { color: #ffffff; - font-weight: 600; - letter-spacing: -0.025em; + text-decoration: underline; + transition: color 0.2s ease; } -.login-form label { - font-size: 0.875rem; +.login-checkbox-link:hover { + color: var(--gray-300); +} + +.login-label { + font-size: 0.8125rem; margin-bottom: var(--space-2); color: var(--gray-300); font-weight: 500; + letter-spacing: 0.01em; } .login-input { - padding: var(--space-3); + padding: var(--space-3) var(--space-4); border: 1px solid var(--gray-800); - border-radius: var(--radius-sm); - margin-bottom: var(--space-4); + border-radius: var(--radius-md); background: var(--gray-1000); color: var(--gray-100); width: 100%; - font-size: 0.875rem; + font-size: 0.9375rem; font-family: "Geist Mono", monospace; - transition: border-color 0.15s ease, background-color 0.15s ease; + transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease; +} + +.login-input::placeholder { + color: var(--gray-600); } .login-input:focus { outline: none; border-color: var(--gray-600); background: var(--gray-900); + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05); } .login-submit { background-color: #ffffff; color: #000000; border: none; - border-radius: var(--radius-sm); + border-radius: var(--radius-md); padding: var(--space-3) var(--space-6); - font-size: 0.875rem; + font-size: 0.9375rem; font-weight: 600; cursor: pointer; - transition: background-color 0.15s ease, transform 0.15s ease; - margin-top: var(--space-4); + transition: background-color 0.2s ease, transform 0.1s ease, box-shadow 0.2s ease, opacity 0.2s ease; font-family: "Geist Mono", monospace; + width: 100%; + letter-spacing: 0.01em; +} + +.login-submit:disabled { + background-color: var(--gray-800); + color: var(--gray-600); + cursor: not-allowed; + opacity: 0.5; } -.login-submit:hover { +.login-submit:not(:disabled):hover { background-color: var(--gray-100); transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(255, 255, 255, 0.15); } -.login-submit:active { +.login-submit:not(:disabled):active { transform: translateY(0); + box-shadow: 0 2px 8px rgba(255, 255, 255, 0.1); } /* Flash Messages - Vercel Style */ @@ -599,3 +710,182 @@ tbody tr:hover { min-height: 60vh; text-align: center; } + +.hero-container { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + text-align: center; +} + +.hero-wrapper { + position: relative; + display: inline-block; +} + +.hero-title { + font-size: 4rem; + font-weight: 700; + color: #ffffff; + letter-spacing: 0.01em; + opacity: 0; + animation: fadeInUp 1.2s ease-out forwards; +} + +.hero-signin-button { + position: absolute; + top: -8px; + right: -80px; + background: #e63946; + color: #ffffff; + padding: 0.5rem 1rem; + border-radius: 999px; + font-size: 0.875rem; + font-weight: 600; + text-decoration: none; + opacity: 0; + animation: fadeInPulse 1s ease-out 1.8s forwards, pulse 2s infinite 2.8s; + box-shadow: 0 0 0 0 rgba(230, 57, 70, 0.7); +} + +.hero-signin-button:hover { + background: #d32f3c; +} + +@keyframes fadeInUp { + 0% { + opacity: 0; + transform: translateY(30px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeInPulse { + 0% { + opacity: 0; + transform: scale(0.8); + } + 50% { + transform: scale(1.05); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(230, 57, 70, 0.7); + } + 50% { + box-shadow: 0 0 0 8px rgba(230, 57, 70, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(230, 57, 70, 0); + } +} + +@media (max-width: 768px) { + .hero-title { + font-size: 3rem; + } + + .hero-signin-button { + position: static; + margin-top: 2rem; + animation: fadeInPulse 1s ease-out 1.8s forwards, pulse 2s infinite 2.8s; + } +} + +@media (max-width: 480px) { + .hero-title { + font-size: 2.5rem; + } +} + +/* Content Pages (Privacy Policy, Terms of Service) */ +.content-wrapper { + max-width: 800px; + margin: 0 auto; +} + +.content-section { + margin-bottom: var(--space-12); +} + +.content-section h2 { + font-size: 1.5rem; + font-weight: 600; + color: #ffffff; + margin-bottom: var(--space-4); + margin-top: var(--space-8); + letter-spacing: -0.025em; +} + +.content-section h3 { + font-size: 1.125rem; + font-weight: 600; + color: var(--gray-200); + margin-bottom: var(--space-3); + margin-top: var(--space-6); +} + +.content-section p { + color: var(--gray-400); + line-height: 1.7; + margin-bottom: var(--space-4); +} + +.content-section ul { + color: var(--gray-400); + line-height: 1.7; + margin-bottom: var(--space-4); + padding-left: var(--space-6); +} + +.content-section li { + margin-bottom: var(--space-2); +} + +.content-section strong { + color: var(--gray-200); + font-weight: 600; +} + +.content-section a { + color: #ffffff; + text-decoration: underline; + transition: color 0.2s ease; +} + +.content-section a:hover { + color: var(--gray-300); +} + +.tos-table { + overflow-x: auto; + margin: var(--space-6) 0; +} + +.tos-table table { + min-width: 100%; + font-size: 0.8125rem; +} + +.tos-table th { + background: var(--gray-900); + color: var(--gray-300); + font-weight: 600; + padding: var(--space-3); + white-space: nowrap; +} + +.tos-table td { + padding: var(--space-3); + color: var(--gray-400); +} diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb new file mode 100644 index 0000000..17ed849 --- /dev/null +++ b/app/controllers/pages_controller.rb @@ -0,0 +1,9 @@ +class PagesController < ApplicationController + allow_unauthenticated_access + + def privacy_policy + end + + def terms_of_service + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index ed40bca..b6d9506 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -9,6 +9,14 @@ def create api_key = params[:api_key].to_s.strip begin + # First, validate the key access level + key_info = TornApi::Key::Info.new(api_key).fetch + + unless key_info.access.type == "Limited Access" + return redirect_to new_session_path, alert: "Please use a Limited Access API key. Your key has #{key_info.access.type}." + end + + # Fetch profile using the validated key profile = TornApi::User::Profile.new(api_key).fetch return redirect_to new_session_path, alert: "Could not fetch profile from Torn API." unless profile diff --git a/app/javascript/controllers/login_controller.js b/app/javascript/controllers/login_controller.js new file mode 100644 index 0000000..111be31 --- /dev/null +++ b/app/javascript/controllers/login_controller.js @@ -0,0 +1,17 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["checkbox", "submit"] + + connect() { + this.updateButtonState() + } + + toggle() { + this.updateButtonState() + } + + updateButtonState() { + this.submitTarget.disabled = !this.checkboxTarget.checked + } +} diff --git a/app/models/torn_api/key/info.rb b/app/models/torn_api/key/info.rb new file mode 100644 index 0000000..30fafe6 --- /dev/null +++ b/app/models/torn_api/key/info.rb @@ -0,0 +1,56 @@ +module TornApi + module Key + class Info + BASE_URL = "https://api.torn.com/v2" + + AccessData = Data.define( + :level, + :type, + :faction, + :company + ) + + UserData = Data.define( + :id, + :faction_id, + :company_id + ) + + InfoData = Data.define( + :access, + :user + ) + + def initialize(api_key) + @api_key = api_key + end + + def fetch + response = Net::HTTP.get_response(URI("#{BASE_URL}/key/info?key=#{@api_key}")) + data = JSON.parse(response.body) + + raise TornApi::InvalidKeyError if data["error"] + + info_data = data["info"] + + access = AccessData.new( + level: info_data.dig("access", "level"), + type: info_data.dig("access", "type"), + faction: info_data.dig("access", "faction"), + company: info_data.dig("access", "company") + ) + + user = UserData.new( + id: info_data.dig("user", "id"), + faction_id: info_data.dig("user", "faction_id"), + company_id: info_data.dig("user", "company_id") + ) + + InfoData.new( + access: access, + user: user + ) + end + end + end +end diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index aa78acf..2196bec 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -1,3 +1,6 @@ -
-

Coming Soon!

+
+
+

TornManager

+ <%= link_to "Sign In", new_session_path, class: "hero-signin-button" %> +
diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 66f791d..720a318 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -1,3 +1,5 @@ -
- <%= render "layouts/navbar" %> -
+<% if authenticated? %> +
+ <%= render "layouts/navbar" %> +
+<% end %> diff --git a/app/views/pages/privacy_policy.html.erb b/app/views/pages/privacy_policy.html.erb new file mode 100644 index 0000000..21dc33b --- /dev/null +++ b/app/views/pages/privacy_policy.html.erb @@ -0,0 +1,205 @@ + + +
+
+

Introduction

+

+ Hi, I'm Bram, the creator of TornManager. I respect your privacy and am committed to protecting your personal information. + This Privacy Policy explains how I collect, use, and safeguard your data when you use my service. +

+
+ +
+

Torn API Usage & Compliance

+

+ I built TornManager in accordance with the Torn API Terms of Service. I use the Torn API to provide + gameplay enhancement features while ensuring fair play and data security. +

+ +

API Data Usage Summary

+
+ + + + + + + + + + + + + + + + + + + +
Data StorageData SharingPurpose of UseKey Storage & SharingKey Access Level
Persistent - until account deletionNobody (data is private to you)Track personal progress, statistics, and stock investmentsStored securely / Used only for automationLimited Access
+
+
+ +
+

Information I Collect

+ +

Torn API Key

+

+ When you sign in, you provide your Torn API key. I securely store this key in my database and use it + exclusively to fetch your game data from Torn's official API. I require Limited Access level permissions. +

+ +

Game Data

+

+ I collect and store data retrieved from the Torn API including: +

+
    +
  • Your Torn user ID, name, and level
  • +
  • Personal statistics (stat enhancer usage, networth, energy drinks consumed, etc.)
  • +
  • Stock portfolio information
  • +
  • Daily snapshots of your progress for historical tracking
  • +
+ +

Usage Data

+

+ I automatically collect certain information when you use TornManager, including: +

+
    +
  • IP address
  • +
  • Browser type and version
  • +
  • Pages visited and features used
  • +
  • Date and time of access
  • +
+
+ +
+

How I Use Your Information

+

I use the collected information for the following purposes:

+
    +
  • Service Provision: To provide and maintain TornManager's features, including progress tracking, statistics analysis, and stock investment recommendations
  • +
  • Authentication: To verify your identity and maintain your session
  • +
  • Data Analysis: To track your personal progress over time and generate insights
  • +
  • Service Improvement: To understand how users interact with my service and improve functionality
  • +
  • Security: To detect, prevent, and address technical issues or abuse
  • +
+
+ +
+

Data Sharing and Disclosure

+

+ I do not share, sell, or disclose your personal data to third parties. Your Torn API key + and game data remain private and are only accessible to you when logged in. +

+ +

I may disclose your information only in the following limited circumstances:

+
    +
  • Legal Requirements: If required by law or in response to valid legal processes
  • +
  • Protection of Rights: To protect my rights, property, or safety, or that of my users or the public
  • +
  • With Your Consent: With your explicit permission for specific purposes
  • +
+
+ +
+

Data Security

+

+ I implement appropriate security measures to protect your information from unauthorized access, alteration, + disclosure, or destruction. Your Torn API key is stored securely in my encrypted database and transmitted + over secure HTTPS connections. +

+

+ However, no method of transmission over the internet or electronic storage is 100% secure. While I strive + to protect your data, I cannot guarantee absolute security. +

+
+ +
+

Data Retention

+

+ I retain your data as long as your account is active. Historical statistics are maintained to provide + long-term progress tracking and trend analysis. +

+

+ If you wish to delete your account and all associated data, please contact me or revoke your API key access. + Upon account deletion, I will remove all personal data from my systems within 30 days. +

+
+ +
+

Your API Key Security

+

+ Important: Your Torn API key grants access to your game data. Please follow these best practices: +

+
    +
  • Never share your API key with anyone
  • +
  • Use a Limited Access key (not Full Access) when possible
  • +
  • Monitor your key usage at Torn.com using the 'key' → 'log' selection
  • +
  • Revoke and regenerate your key if you suspect misuse
  • +
  • You can pause or delete your API key at any time on Torn.com
  • +
+
+ +
+

Cookies and Tracking

+

+ I use essential cookies to maintain your login session. These cookies are necessary for the service to + function and cannot be disabled. I do not use third-party tracking cookies or analytics services. +

+
+ +
+

Third-Party Services

+

+ TornManager uses the official Torn API provided by Torn.com. I do not integrate with any other third-party + services. Your data is only shared with Torn's servers when fetching updated information via the API. +

+
+ +
+

Age Requirement

+

+ TornManager is intended for users of Torn.com. In accordance with Torn's Terms of Service, you must be + at least 13 years old to use this service. I do not knowingly collect information from children under 13. + If you are under 13, please do not use TornManager. +

+
+ +
+

Changes to This Privacy Policy

+

+ I may update this Privacy Policy from time to time. I will notify users of any material changes by + updating the "Last updated" date at the top of this policy. Your continued use of TornManager after + changes constitutes acceptance of the updated policy. +

+
+ +
+

Contact Me

+

+ If you have questions about this Privacy Policy or how I handle your data, please reach out to me on Torn: +

+

+ <%= link_to "Bram [2728237]", "https://www.torn.com/profiles.php?XID=2728237", target: "_blank", class: "player-link" %> +

+
+ +
+

Compliance with Torn API Terms

+

+ TornManager is fully compliant with Torn's API Terms of Service. I follow all acceptable usage guidelines, + including: +

+
    +
  • Never requesting Torn passwords from users
  • +
  • Keeping API keys and data securely protected
  • +
  • Optimizing API requests to minimize server load
  • +
  • Respecting rate limits (100 requests per minute per user)
  • +
  • Providing transparent disclosure of data usage and storage
  • +
  • Not providing unfair competitive advantages
  • +
  • Not selling or monetizing user data
  • +
+
+
diff --git a/app/views/pages/terms_of_service.html.erb b/app/views/pages/terms_of_service.html.erb new file mode 100644 index 0000000..dfa140b --- /dev/null +++ b/app/views/pages/terms_of_service.html.erb @@ -0,0 +1,322 @@ + + +
+
+

Acceptance of Terms

+

+ By accessing and using TornManager, you accept and agree to be bound by these Terms of Service and my + Privacy Policy. If you do not agree to these terms, please do not use my service. +

+
+ +
+

Description of Service

+

+ TornManager is a web-based tool I created for players of Torn.com. My service uses the official Torn API + to help you: +

+
    +
  • Track your personal statistics and progress over time
  • +
  • Monitor stat enhancer usage and gains
  • +
  • Analyze dividend stock investments and break-even calculations
  • +
  • View Hall of Fame statistics for top players
  • +
+

+ TornManager is a read-only service that displays and analyzes data from Torn. I do not modify your + Torn account or perform any actions on your behalf. +

+
+ +
+

Torn API Terms of Service Compliance

+

+ I built TornManager in full compliance with Torn's API Terms of Service. Below is my complete disclosure + of how I use your API key and data: +

+ +

API Data Usage Declaration

+
+ + + + + + + + + + + + + + + + + + + +
Data StorageData SharingPurpose of UseKey Storage & SharingKey Access Level
Persistent - until account deletionNobody (your data is private)Track personal progress, statistics, and investment analysisStored securely in encrypted database / Used only for automationLimited Access
+
+ +

What This Means

+
    +
  • Data Storage: I store your game data persistently in my database to track historical progress. Data is retained until you delete your account.
  • +
  • Data Sharing: Your data is completely private and never shared with other users, services, or third parties.
  • +
  • Purpose: I use your data solely to provide progress tracking, statistical analysis, and investment insights to you.
  • +
  • Key Storage: Your API key is stored securely in my encrypted database and used only for automated data fetching.
  • +
  • Access Level: I require Limited Access level API keys, which provide access to personal statistics while limiting sensitive data exposure.
  • +
+
+ +
+

User Responsibilities

+ +

API Key Management

+

You are responsible for:

+
    +
  • Creating and managing your Torn API key
  • +
  • Providing an API key with appropriate access level (Limited Access recommended)
  • +
  • Monitoring your API key usage through Torn.com ('key' → 'log' selection)
  • +
  • Revoking or regenerating your key if you suspect misuse
  • +
  • Understanding that sharing your API key with anyone gives them access to your game data
  • +
+ +

Acceptable Use

+

You agree to:

+
    +
  • Use TornManager only for personal, non-commercial purposes
  • +
  • Comply with all Torn.com rules and policies
  • +
  • Not attempt to hack, exploit, or abuse my service
  • +
  • Not use automated tools or bots to access TornManager
  • +
  • Not share your account credentials with others
  • +
  • Not use the service in any way that violates Torn's scripting rules
  • +
+
+ +
+

API Key Security

+

+ Never share your Torn password with anyone, including me. I only require your + API key for authentication. Your Torn password should never be entered into any third-party service. +

+

+ I implement industry-standard security measures to protect your API key, including: +

+
    +
  • Encrypted storage in my database
  • +
  • Secure HTTPS transmission
  • +
  • Access logging and monitoring
  • +
  • Regular security audits
  • +
+

+ However, you maintain ultimate responsibility for your API key's security. If you believe your key has + been compromised, revoke it immediately on Torn.com. +

+
+ +
+

Service Availability

+

+ I strive to maintain TornManager's availability but do not guarantee uninterrupted service. I may: +

+
    +
  • Temporarily suspend service for maintenance or updates
  • +
  • Modify or discontinue features without notice
  • +
  • Experience downtime due to technical issues or Torn API limitations
  • +
+

+ I am not responsible for any data loss or service interruptions caused by factors beyond my control, + including Torn API changes, server issues, or network problems. +

+
+ +
+

Rate Limits and API Usage

+

+ I respect Torn's API rate limits (100 requests per minute per user). My service is optimized to: +

+
    +
  • Minimize API requests by caching data appropriately
  • +
  • Request only necessary data for specific features
  • +
  • Avoid unnecessary strain on Torn's servers
  • +
+

+ If your API key is temporarily blocked by Torn due to rate limiting, TornManager will handle this + gracefully and retry requests appropriately. +

+
+ +
+

No Unfair Advantage

+

+ TornManager is designed to enhance gameplay by providing data visualization and progress tracking. + It does not: +

+
    +
  • Automate gameplay actions
  • +
  • Provide real-time competitive advantages
  • +
  • Violate Torn's fair play policies
  • +
  • Offer features that bypass game mechanics
  • +
+

+ All data displayed is historical or publicly available through the Torn API. I comply with Torn's + guidelines on maintaining fair gameplay. +

+
+ +
+

Intellectual Property

+

+ TornManager and its original content, features, and functionality are owned by me and protected by + international copyright, trademark, and other intellectual property laws. +

+

+ Torn, Torn.com, and all related game data remain the property of Torn City Ltd. I use the Torn API + under the terms provided by Torn. +

+
+ +
+

Account Termination

+

+ I reserve the right to suspend or terminate your access to TornManager at my discretion, without + notice, for conduct that I believe: +

+
    +
  • Violates these Terms of Service
  • +
  • Violates Torn's Terms of Service or API guidelines
  • +
  • Harms TornManager, other users, or third parties
  • +
  • Involves fraudulent or illegal activity
  • +
+

+ You may delete your account at any time by revoking your API key or contacting me. Upon deletion, + I will remove all your data from my systems within 30 days. +

+
+ +
+

No Monetization

+

+ TornManager is provided free of charge. I do not: +

+
    +
  • Charge fees for service usage
  • +
  • Display advertisements
  • +
  • Sell user data
  • +
  • Accept real-money donations without Torn staff approval
  • +
+

+ This aligns with Torn's no-advertising policy for API-based services. +

+
+ +
+

Disclaimer of Warranties

+

+ TornManager is provided "as is" and "as available" without warranties of any kind, either express or + implied, including but not limited to warranties of merchantability, fitness for a particular purpose, + or non-infringement. +

+

+ I do not warrant that: +

+
    +
  • The service will be uninterrupted, timely, secure, or error-free
  • +
  • The results obtained from using the service will be accurate or reliable
  • +
  • Any errors in the service will be corrected
  • +
  • The service will meet your specific requirements
  • +
+
+ +
+

Limitation of Liability

+

+ To the maximum extent permitted by law, I shall not be liable for any indirect, incidental, special, + consequential, or punitive damages, or any loss of profits or revenues, whether incurred directly or + indirectly, or any loss of data, use, goodwill, or other intangible losses resulting from: +

+
    +
  • Your use or inability to use TornManager
  • +
  • Unauthorized access to your data or API key
  • +
  • Any conduct or content of third parties
  • +
  • Any data loss or corruption
  • +
  • Errors, mistakes, or inaccuracies in displayed data
  • +
+
+ +
+

Indemnification

+

+ You agree to indemnify and hold harmless me and TornManager from any claims, damages, + obligations, losses, liabilities, costs, or expenses arising from: +

+
    +
  • Your use of the service
  • +
  • Your violation of these Terms of Service
  • +
  • Your violation of Torn's Terms of Service or API guidelines
  • +
  • Your violation of any third-party rights
  • +
+
+ +
+

Changes to Terms

+

+ I reserve the right to modify these Terms of Service at any time. Material changes will be indicated + by updating the "Last updated" date at the top of this document. +

+

+ Your continued use of TornManager after changes constitutes acceptance of the modified terms. If you + do not agree to the changes, you must stop using the service. +

+
+ +
+

Governing Law

+

+ These Terms of Service are governed by and construed in accordance with applicable laws, without regard + to conflict of law principles. +

+
+ +
+

Contact Me

+

+ If you have questions about these Terms of Service, please reach out to me on Torn: +

+

+ <%= link_to "Bram [2728237]", "https://www.torn.com/profiles.php?XID=2728237", target: "_blank", class: "player-link" %> +

+
+ +
+

Compliance Statement

+

+ TornManager is fully compliant with Torn's API Terms of Service and scripting rules. I: +

+
    +
  • Never request user passwords
  • +
  • Store API keys securely and use them only for authorized purposes
  • +
  • Respect Torn's rate limits and optimize API usage
  • +
  • Provide transparent disclosure of data usage
  • +
  • Do not provide unfair competitive advantages
  • +
  • Do not violate Torn's no-advertising policy
  • +
  • Log all API requests for security and abuse prevention
  • +
  • Immediately remove disabled or invalid API keys
  • +
+

+ Any suspected violations of these terms or Torn's policies should be reported immediately to both + me and Torn staff. +

+
+ +
+

Age Requirement

+

+ You must be at least 13 years old to use TornManager. This aligns with Torn.com's age requirement. + By using this service, you confirm that you meet this age requirement. +

+
+
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index e2edfd6..c555159 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,21 +1,42 @@ -
+ + + + <% end %> - diff --git a/config/routes.rb b/config/routes.rb index d68b304..2187dde 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,6 +8,9 @@ resources :hall_of_famers, only: [ :index ] resources :welcome, only: [ :index ] + get "privacy-policy", to: "pages#privacy_policy", as: :privacy_policy + get "terms-of-service", to: "pages#terms_of_service", as: :terms_of_service + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check From c529e90a2bc8268a25983ba94eaa7a005d21b867 Mon Sep 17 00:00:00 2001 From: Bram Date: Thu, 12 Feb 2026 22:52:41 +0100 Subject: [PATCH 012/266] key log --- app/assets/stylesheets/application.css | 549 +++++++++++++++++- app/controllers/key_log_controller.rb | 34 ++ .../controllers/keylog_controller.js | 19 + app/models/torn_api.rb | 2 +- app/models/torn_api/key/info.rb | 11 +- app/models/torn_api/key/log.rb | 48 ++ app/views/key_log/index.html.erb | 65 +++ app/views/key_log/show.html.erb | 234 ++++++++ app/views/layouts/_footer.html.erb | 16 +- app/views/layouts/_navbar.html.erb | 1 + app/views/pages/privacy_policy.html.erb | 16 + app/views/pages/terms_of_service.html.erb | 17 + config/routes.rb | 4 + 13 files changed, 1001 insertions(+), 15 deletions(-) create mode 100644 app/controllers/key_log_controller.rb create mode 100644 app/javascript/controllers/keylog_controller.js create mode 100644 app/models/torn_api/key/log.rb create mode 100644 app/views/key_log/index.html.erb create mode 100644 app/views/key_log/show.html.erb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 8fa0fd1..cb2a45d 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -119,14 +119,54 @@ nav { footer { width: 100%; display: flex; - text-align: center; - justify-content: center; + align-items: center; + justify-content: space-between; padding: var(--space-12) var(--space-6); border-top: 1px solid var(--gray-900); font-size: 0.875rem; color: var(--gray-500); } +.footer-left, +.footer-center, +.footer-right { + flex: 1; +} + +.footer-left { + display: flex; + align-items: center; + justify-content: flex-start; +} + +.footer-center { + display: flex; + justify-content: center; + text-align: center; +} + +.footer-right { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-3); +} + +.footer-link { + color: var(--gray-500); + text-decoration: none; + transition: color 0.15s ease; + font-size: 0.875rem; +} + +.footer-link:hover { + color: var(--gray-200); +} + +.footer-divider { + color: var(--gray-700); +} + h1, h2, h3 { color: #ffffff; font-weight: 600; @@ -562,6 +602,31 @@ tbody tr:hover { margin-top: var(--space-2); } +.api-key-inline { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; + padding: var(--space-1) var(--space-3); + border: 1px solid rgba(34, 197, 94, 0.3); + border-radius: var(--radius-sm); + font-family: "Geist Mono", monospace; + font-size: 0.875rem; + font-weight: 600; + letter-spacing: 0.025em; + animation: keyPulse 2s ease-in-out infinite; + display: inline-block; +} + +@keyframes keyPulse { + 0%, 100% { + border-color: rgba(34, 197, 94, 0.3); + box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); + } + 50% { + border-color: rgba(34, 197, 94, 0.5); + box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.1); + } +} + /* Table Container */ .table-container { background: var(--gray-900); @@ -889,3 +954,483 @@ tbody tr:hover { padding: var(--space-3); color: var(--gray-400); } + +/* Key Log Styles */ +.key-log-container { + max-width: 700px; + margin: 0 auto; + padding: var(--space-16) var(--space-6); +} + +.key-log-header { + text-align: center; + margin-bottom: var(--space-12); +} + +.key-log-header h1 { + font-size: 2.5rem; + margin-bottom: var(--space-3); +} + +.key-log-subtitle { + color: var(--gray-400); + font-size: 1rem; + margin: 0; +} + +.key-log-form-centered { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + padding: var(--space-8); + margin-bottom: var(--space-8); +} + +.key-log-form { + margin: var(--space-8) 0; + max-width: 600px; +} + +.key-log-confirmations { + margin: var(--space-6) 0; + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.key-log-checkbox-field { + display: flex; + align-items: flex-start; + gap: var(--space-3); +} + +.key-log-checkbox { + width: 18px; + height: 18px; + min-width: 18px; + margin-top: 2px; + cursor: pointer; + accent-color: #ffffff; +} + +.key-log-checkbox-label { + color: var(--gray-400); + font-size: 0.875rem; + line-height: 1.5; + cursor: pointer; + user-select: none; +} + +.key-log-checkbox-label strong { + color: var(--gray-200); +} + +.key-log-info { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + padding: var(--space-6); +} + +.key-log-info h3 { + font-size: 1rem; + color: var(--gray-300); + margin: 0 0 var(--space-4) 0; +} + +.key-log-info ul { + list-style: none; + padding: 0; + margin: 0; +} + +.key-log-info li { + color: var(--gray-400); + padding: var(--space-2) 0; + line-height: 1.6; + font-size: 0.875rem; +} + +.key-log-info li strong { + color: var(--gray-200); +} + +.btn-large { + width: 100%; + padding: var(--space-4) var(--space-6); + font-size: 1rem; +} + +.form-group { + margin-bottom: var(--space-6); +} + +.form-label { + display: block; + color: var(--gray-200); + font-weight: 600; + margin-bottom: var(--space-2); + font-size: 0.875rem; +} + +.form-input { + width: 100%; + padding: var(--space-3) var(--space-4); + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + color: var(--gray-200); + font-family: "Geist Mono", monospace; + font-size: 0.875rem; + transition: border-color 0.15s ease; +} + +.form-input:focus { + outline: none; + border-color: var(--gray-600); +} + +.btn { + padding: var(--space-3) var(--space-6); + border: none; + border-radius: var(--radius-md); + font-family: "Geist Mono", monospace; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.btn-primary { + background: #ffffff; + color: #000000; +} + +.btn-primary:hover { + background: var(--gray-200); +} + +.btn-primary:disabled { + background: var(--gray-800); + color: var(--gray-600); + cursor: not-allowed; +} + +.btn-primary:disabled:hover { + background: var(--gray-800); +} + +.key-log-notice { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + padding: var(--space-4); + margin: var(--space-6) 0; +} + +.key-log-notice p { + color: var(--gray-400); + font-size: 0.875rem; + margin: 0; +} + +.log-summary { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-6); + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--gray-900); +} + +.log-summary p { + color: var(--gray-400); + margin: 0; +} + +.back-link { + color: var(--gray-500); + text-decoration: none; + font-size: 0.875rem; + transition: color 0.15s ease; +} + +.back-link:hover { + color: var(--gray-200); +} + +.log-table-wrapper { + overflow-x: auto; + margin: var(--space-6) 0; +} + +.log-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + min-width: 900px; +} + +@media (max-width: 768px) { + .log-table { + font-size: 0.75rem; + min-width: 800px; + } + + .log-table th, + .log-table td { + padding: var(--space-2) var(--space-3); + } +} + +.log-table th { + background: var(--gray-900); + color: var(--gray-300); + font-weight: 600; + padding: var(--space-3) var(--space-4); + text-align: left; + border-bottom: 1px solid var(--gray-800); +} + +.log-table td { + padding: var(--space-3) var(--space-4); + color: var(--gray-400); + border-bottom: 1px solid var(--gray-900); +} + +.log-table tr:hover { + background: rgba(255, 255, 255, 0.02); +} + +.log-table code { + background: var(--gray-900); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + color: var(--gray-300); + font-family: "Geist Mono", monospace; + font-size: 0.8125rem; +} + +.empty-state { + text-align: center; + padding: var(--space-12) var(--space-6); + color: var(--gray-500); +} + +.empty-state p { + margin: 0; +} + +/* Log Statistics */ +.log-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-4); + margin: var(--space-8) 0; +} + +@media (max-width: 480px) { + .log-stats-grid { + grid-template-columns: 1fr; + } +} + +.log-stat-card { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + padding: var(--space-5); +} + +.log-stat-card h3 { + font-size: 0.875rem; + color: var(--gray-400); + font-weight: 500; + margin: 0 0 var(--space-3) 0; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.stat-value { + font-size: 2rem; + font-weight: 600; + color: #ffffff; + line-height: 1; + margin-bottom: var(--space-2); +} + +.stat-label { + font-size: 0.8125rem; + color: var(--gray-500); +} + +.stat-detail { + font-size: 0.75rem; + color: var(--gray-600); + margin-top: var(--space-2); +} + +.stat-warning { + font-size: 0.75rem; + color: #e63946; + font-weight: 600; + margin-top: var(--space-2); +} + +.rate-limit-warning { + background: rgba(230, 57, 70, 0.1); + border: 1px solid rgba(230, 57, 70, 0.3); + border-radius: var(--radius-md); + padding: var(--space-5); + margin: var(--space-6) 0; +} + +.rate-limit-warning h3 { + color: #e63946; + font-size: 1rem; + margin: 0 0 var(--space-3) 0; +} + +.rate-limit-warning p { + color: var(--gray-300); + margin: 0 0 var(--space-3) 0; + line-height: 1.6; +} + +.rate-limit-warning ul { + color: var(--gray-400); + margin: var(--space-3) 0; + padding-left: var(--space-6); + line-height: 1.7; +} + +.rate-limit-warning li { + margin-bottom: var(--space-2); +} + +.rate-limit-warning strong { + color: var(--gray-200); +} + +.log-breakdown { + margin: var(--space-8) 0; + display: grid; + gap: var(--space-6); +} + +.breakdown-section { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + padding: var(--space-5); +} + +.breakdown-section h3 { + font-size: 0.875rem; + color: var(--gray-300); + font-weight: 600; + margin: 0 0 var(--space-4) 0; +} + +.breakdown-list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.breakdown-item { + display: grid; + grid-template-columns: 250px 1fr auto; + gap: var(--space-3); + align-items: center; + font-size: 0.8125rem; +} + +@media (max-width: 768px) { + .breakdown-item { + grid-template-columns: 150px 1fr auto; + font-size: 0.75rem; + } +} + +@media (max-width: 480px) { + .breakdown-item { + grid-template-columns: 1fr; + gap: var(--space-2); + } + + .breakdown-bar { + order: 2; + } + + .breakdown-count { + order: 3; + text-align: left; + } +} + +.breakdown-label { + color: var(--gray-300); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + position: relative; + cursor: default; +} + +.breakdown-label:hover { + overflow: visible; + white-space: normal; + z-index: 10; +} + +.breakdown-label:hover::after { + content: attr(title); + position: absolute; + left: 0; + top: 0; + background: var(--gray-800); + border: 1px solid var(--gray-700); + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-3); + color: var(--gray-200); + white-space: normal; + word-break: break-word; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); + z-index: 100; + min-width: 200px; + max-width: 400px; +} + +.breakdown-label code { + background: var(--gray-800); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + color: var(--gray-300); + font-family: "Geist Mono", monospace; + font-size: 0.75rem; +} + +.breakdown-bar { + display: block; + height: 20px; + background: var(--gray-800); + border-radius: var(--radius-sm); + overflow: hidden; + position: relative; +} + +.breakdown-bar-fill { + display: block; + height: 100%; + background: linear-gradient(90deg, var(--blue-600), var(--blue-500)); + transition: width 0.3s ease; +} + +.breakdown-count { + color: var(--gray-500); + text-align: right; + min-width: 80px; + font-variant-numeric: tabular-nums; +} + diff --git a/app/controllers/key_log_controller.rb b/app/controllers/key_log_controller.rb new file mode 100644 index 0000000..438589e --- /dev/null +++ b/app/controllers/key_log_controller.rb @@ -0,0 +1,34 @@ +class KeyLogController < ApplicationController + allow_unauthenticated_access + + def index + # Just render the form - no data yet + end + + def show + api_key = params[:api_key]&.squish + + if api_key.blank? + flash.now[:alert] = "Please provide an API key" + render :index + return + end + + begin + # First validate the key exists and is valid + key_info = TornApi::Key::Info.new(api_key) + key_info.fetch + + # If key is valid, fetch the log + log_fetcher = TornApi::Key::Log.new(api_key) + @log_data = log_fetcher.fetch + @api_key = api_key + rescue TornApi::InvalidKeyError + flash.now[:alert] = "Invalid API key provided. Please check your key and try again." + render :index + rescue => e + flash.now[:alert] = "Error fetching key log: #{e.message}" + render :index + end + end +end diff --git a/app/javascript/controllers/keylog_controller.js b/app/javascript/controllers/keylog_controller.js new file mode 100644 index 0000000..85ed3b1 --- /dev/null +++ b/app/javascript/controllers/keylog_controller.js @@ -0,0 +1,19 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["input", "submit", "checkbox"] + + connect() { + this.updateButtonState() + } + + checkInput() { + this.updateButtonState() + } + + updateButtonState() { + const hasValue = this.inputTarget.value.trim().length > 0 + const allCheckboxesChecked = this.checkboxTargets.every(checkbox => checkbox.checked) + this.submitTarget.disabled = !(hasValue && allCheckboxesChecked) + } +} diff --git a/app/models/torn_api.rb b/app/models/torn_api.rb index 179412f..9bb7d52 100644 --- a/app/models/torn_api.rb +++ b/app/models/torn_api.rb @@ -6,7 +6,7 @@ class InvalidKeyError < StandardError; end class ApiError < StandardError; end class Base - DEFAULT_PARAMS = { comment: "tornmanager" }.freeze + DEFAULT_PARAMS = { comment: "tmanager" }.freeze attr_reader :api_key diff --git a/app/models/torn_api/key/info.rb b/app/models/torn_api/key/info.rb index 30fafe6..39a2b31 100644 --- a/app/models/torn_api/key/info.rb +++ b/app/models/torn_api/key/info.rb @@ -1,8 +1,6 @@ module TornApi module Key - class Info - BASE_URL = "https://api.torn.com/v2" - + class Info < TornApi::Base AccessData = Data.define( :level, :type, @@ -21,13 +19,8 @@ class Info :user ) - def initialize(api_key) - @api_key = api_key - end - def fetch - response = Net::HTTP.get_response(URI("#{BASE_URL}/key/info?key=#{@api_key}")) - data = JSON.parse(response.body) + data = get("v2/key/info") raise TornApi::InvalidKeyError if data["error"] diff --git a/app/models/torn_api/key/log.rb b/app/models/torn_api/key/log.rb new file mode 100644 index 0000000..2a416d8 --- /dev/null +++ b/app/models/torn_api/key/log.rb @@ -0,0 +1,48 @@ +module TornApi + module Key + class Log < TornApi::Base + LogEntry = Data.define( + :timestamp, + :type, + :selections, + :id, + :ip, + :comment + ) + + LogData = Data.define( + :log, + :_metadata + ) + + def fetch + all_entries = [] + + # Fetch 3 batches of 100 logs (300 total) + [ 0, 100, 200 ].each do |offset| + data = get("v2/key/log", limit: 100, offset: offset) + + raise TornApi::InvalidKeyError if data["error"] + + batch_entries = data["log"].map do |entry| + LogEntry.new( + timestamp: entry["timestamp"], + type: entry["type"], + selections: entry["selections"], + id: entry["id"], + ip: entry["ip"], + comment: entry["comment"] + ) + end + + all_entries.concat(batch_entries) + end + + LogData.new( + log: all_entries, + _metadata: nil + ) + end + end + end +end diff --git a/app/views/key_log/index.html.erb b/app/views/key_log/index.html.erb new file mode 100644 index 0000000..802766f --- /dev/null +++ b/app/views/key_log/index.html.erb @@ -0,0 +1,65 @@ +
+
+

API Key Activity Log

+

View what requests have been made with your API key

+
+ + <% if flash[:alert] %> + + <% end %> + + <%= form_with url: key_log_show_path, method: :post, class: "key-log-form-centered", data: { controller: "keylog", turbo: false } do |form| %> +
+ <%= form.label :api_key, "Your API Key", class: "form-label" %> + <%= form.text_field :api_key, + class: "form-input", + placeholder: "Enter your Torn API key", + autocomplete: "off", + data: { + keylog_target: "input", + action: "input->keylog#checkInput" + } %> +
+ +
+
+ + +
+ +
+ + +
+
+ + <%= form.submit "View Activity Log", + class: "btn btn-primary btn-large", + data: { keylog_target: "submit" }, + disabled: true %> + <% end %> + +
+

What you'll see:

+
    +
  • Peak Usage: Maximum requests per minute (rate limit is 100/min)
  • +
  • Request Breakdown: Types, selections, tools, and IP addresses
  • +
  • Detailed Log: Last 300 API requests with timestamps
  • +
  • Rate Limit Warnings: Alerts if you're approaching or exceeding limits
  • +
+
+
diff --git a/app/views/key_log/show.html.erb b/app/views/key_log/show.html.erb new file mode 100644 index 0000000..c4a908e --- /dev/null +++ b/app/views/key_log/show.html.erb @@ -0,0 +1,234 @@ + + +
+
+
+

+ Showing <%= @log_data.log.size %> recent API requests with key: + <%= @api_key %> +

+ <%= link_to "← Check Another Key", key_log_path, class: "back-link" %> +
+ + <% + # Calculate statistics + logs = @log_data.log + + # Group by minute to find peak usage + requests_per_minute = logs.group_by { |entry| Time.at(entry.timestamp).utc.strftime("%Y-%m-%d %H:%M") } + peak_minute = requests_per_minute.max_by { |minute, entries| entries.size } + peak_requests = peak_minute ? peak_minute.last.size : 0 + + # Group by type + by_type = logs.group_by(&:type).transform_values(&:size).sort_by { |k, v| -v } + + # Group by selections + by_selections = logs.group_by(&:selections).transform_values(&:size).sort_by { |k, v| -v }.first(10) + + # Group by comment (excluding nulls) + by_comment = logs.reject { |e| e.comment.nil? }.group_by(&:comment).transform_values(&:size).sort_by { |k, v| -v } + + # Group by IP + by_ip = logs.group_by(&:ip).transform_values(&:size).sort_by { |k, v| -v } + %> + +
+
+

Peak Usage

+
<%= peak_requests %>
+
requests/minute
+ <% if peak_minute %> +
at <%= peak_minute.first %> UTC
+ <% end %> + <% if peak_requests >= 100 %> +
⚠️ Rate limit exceeded!
+ <% elsif peak_requests >= 90 %> +
⚠️ Close to rate limit
+ <% end %> +
+ +
+

Total Requests

+
<%= logs.size %>
+
API calls
+
+ +
+

Unique IPs

+
<%= by_ip.size %>
+
IP addresses
+ <% if by_ip.size > 1 %> +
Multiple IPs detected
+ <% end %> +
+ +
+

API Categories

+
<%= by_type.size %>
+
types used
+
+
+ + <% if peak_requests >= 100 %> +
+

⚠️ Rate Limit Exceeded

+

+ Your API key exceeded Torn's rate limit of 100 requests per minute. +

+

+ When you make 100 or more API requests in a single minute, Torn will block your IP address + and you'll receive a "You are being rate limited" error message. This protection prevents + server overload. +

+

What to check:

+
    +
  • Multiple tools/scripts: Check if you're running multiple tools that use your API key simultaneously
  • +
  • Polling frequency: Reduce how often your tools fetch data from Torn's API
  • +
  • Shared IP addresses: If you're on a shared network, others may be using Torn's API on the same IP
  • +
  • Background scripts: Look for forgotten scripts or automation still running
  • +
+

+ Solution: Review the "Tools/Services" and "IP Addresses" sections below to identify which + tools are making the most requests. Consider spacing out API calls or reducing polling frequency to stay under 100 requests per minute. +

+
+ <% elsif peak_requests >= 90 %> +
+

⚠️ Approaching Rate Limit

+

+ You're getting close to Torn's rate limit of 100 requests per minute. +

+

+ Your peak usage is <%= peak_requests %> requests per minute. Torn will block your IP if you reach 100 or more + requests in a single minute. Review your API usage below to prevent being rate limited. +

+
+ <% end %> + +
+
+

Requests by Type

+
+ <% by_type.each do |type, count| %> +
+ <%= type %> + + + + <%= count %> (<%= (count.to_f / logs.size * 100).round(1) %>%) +
+ <% end %> +
+
+ +
+

Top Selections

+
+ <% by_selections.each do |selection, count| %> +
+ <%= selection %> + + + + <%= count %> (<%= (count.to_f / logs.size * 100).round(1) %>%) +
+ <% end %> +
+
+ + <% if by_comment.any? %> +
+

Tools/Services

+
+ <% by_comment.each do |comment, count| %> +
+ <%= comment %> + + + + <%= count %> (<%= (count.to_f / logs.size * 100).round(1) %>%) +
+ <% end %> +
+
+ <% end %> + +
+

IP Addresses

+
+ <% by_ip.each do |ip, count| %> +
+ <%= ip %> + + + + <%= count %> (<%= (count.to_f / logs.size * 100).round(1) %>%) +
+ <% end %> +
+
+
+ +

Detailed Request Log

+
+ + + + + + + + + + + + + <% @log_data.log.each do |entry| %> + + + + + + + + + <% end %> + +
TimestampTypeSelectionsIDIP AddressComment
<%= Time.at(entry.timestamp).utc.strftime("%Y-%m-%d %H:%M:%S") %><%= entry.type %><%= entry.selections %><%= entry.id || "-" %><%= entry.ip %><%= entry.comment || "-" %>
+
+ + <% if @log_data.log.empty? %> +
+

No recent API activity found for this key.

+
+ <% end %> +
+ +
+

What Am I Looking At?

+
    +
  • Peak Usage: Maximum number of requests made in any single minute
  • +
  • Requests by Type: Distribution of API calls across different categories (user, key, faction, etc.)
  • +
  • Top Selections: Most frequently accessed endpoints
  • +
  • Tools/Services: Applications identified by their comment field
  • +
  • IP Addresses: Which IPs are accessing your API key
  • +
+ +

Detailed Log Fields

+
    +
  • Timestamp: When the API request was made (UTC)
  • +
  • Type: The API category (user, key, faction, torn, etc.)
  • +
  • Selections: Which specific data was requested
  • +
  • ID: The target ID if applicable (e.g., user ID)
  • +
  • IP Address: The IP address that made the request
  • +
  • Comment: Optional comment identifying the tool/service
  • +
+ +
+

Security Tip: If you see unfamiliar IP addresses or selections you don't recognize, consider regenerating your API key on Torn.com.

+
+
+
diff --git a/app/views/layouts/_footer.html.erb b/app/views/layouts/_footer.html.erb index f5f20e6..100af3e 100644 --- a/app/views/layouts/_footer.html.erb +++ b/app/views/layouts/_footer.html.erb @@ -1,5 +1,15 @@ diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index 4780b8b..e554fab 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -17,6 +17,7 @@ <%= link_to "TornManager", root_path, class: "navbar-link navbar-title" %> diff --git a/app/views/pages/terms_of_service.html.erb b/app/views/pages/terms_of_service.html.erb index dfa140b..3ec3e43 100644 --- a/app/views/pages/terms_of_service.html.erb +++ b/app/views/pages/terms_of_service.html.erb @@ -306,6 +306,23 @@
  • Log all API requests for security and abuse prevention
  • Immediately remove disabled or invalid API keys
  • + +

    API Request Identification

    +

    + For transparency and accountability, all API requests made by TornManager include the comment parameter + tmanager. This identifier appears in your API key activity logs and allows you to: +

    +
      +
    • Monitor and audit all requests made by TornManager
    • +
    • Distinguish TornManager's API calls from other services
    • +
    • Verify the frequency and nature of API requests
    • +
    • Ensure compliance with rate limits and usage policies
    • +
    +

    + You can view these logs at any time through Torn.com's 'key' → 'log' selection or by using + TornManager's <%= link_to "Key Activity Log", key_log_path %> feature. +

    +

    Any suspected violations of these terms or Torn's policies should be reported immediately to both me and Torn staff. diff --git a/config/routes.rb b/config/routes.rb index 2187dde..36ae0e4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,6 +11,10 @@ get "privacy-policy", to: "pages#privacy_policy", as: :privacy_policy get "terms-of-service", to: "pages#terms_of_service", as: :terms_of_service + get "key-log", to: "key_log#index", as: :key_log + post "key-log/show", to: "key_log#show", as: :key_log_show + get "key-log/show", to: redirect("/key-log") + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check From a744e2b2376fe4cd9c5d22ef5f4fa9589abe9ab6 Mon Sep 17 00:00:00 2001 From: Bram Date: Thu, 12 Feb 2026 23:35:06 +0100 Subject: [PATCH 013/266] fix specs --- Gemfile.lock | 29 ++--- config/credentials/test.yml.enc | 1 + test/controllers/key_log_controller_test.rb | 112 +++++++++++++++++++ test/controllers/progress_controller_test.rb | 3 + test/controllers/sessions_controller_test.rb | 2 +- 5 files changed, 132 insertions(+), 15 deletions(-) create mode 100644 config/credentials/test.yml.enc create mode 100644 test/controllers/key_log_controller_test.rb diff --git a/Gemfile.lock b/Gemfile.lock index 6cf8961..45f1db9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -85,9 +85,9 @@ GEM bcrypt_pbkdf (1.1.2-x86_64-darwin) bigdecimal (4.0.1) bindex (0.8.1) - bootsnap (1.21.1) + bootsnap (1.23.0) msgpack (~> 1.2) - brakeman (7.1.2) + brakeman (8.0.2) racc builder (3.3.0) bundler-audit (0.9.3) @@ -139,14 +139,15 @@ GEM activesupport (>= 6.0.0) railties (>= 6.0.0) io-console (0.8.2) - irb (1.16.0) + irb (1.17.0) pp (>= 0.6.0) + prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jbuilder (2.14.1) actionview (>= 7.0.0) activesupport (>= 7.0.0) - json (2.18.0) + json (2.18.1) kamal (2.10.1) activesupport (>= 7.0) base64 (~> 0.2) @@ -229,7 +230,7 @@ GEM pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.8.0) + prism (1.9.0) propshaft (1.3.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) @@ -282,7 +283,7 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.1) - rdoc (7.1.0) + rdoc (7.2.0) erb psych (>= 4.0.0) tsort @@ -290,7 +291,7 @@ GEM reline (0.6.3) io-console (~> 0.5) rexml (3.4.4) - rubocop (1.82.1) + rubocop (1.84.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -298,7 +299,7 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.48.0, < 2.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) rubocop-ast (1.49.0) @@ -366,14 +367,14 @@ GEM railties (>= 6.0.0) stringio (3.2.0) thor (1.5.0) - thruster (0.1.17) - thruster (0.1.17-aarch64-linux) - thruster (0.1.17-arm64-darwin) - thruster (0.1.17-x86_64-darwin) - thruster (0.1.17-x86_64-linux) + thruster (0.1.18) + thruster (0.1.18-aarch64-linux) + thruster (0.1.18-arm64-darwin) + thruster (0.1.18-x86_64-darwin) + thruster (0.1.18-x86_64-linux) timeout (0.6.0) tsort (0.2.0) - turbo-rails (2.0.21) + turbo-rails (2.0.23) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) diff --git a/config/credentials/test.yml.enc b/config/credentials/test.yml.enc new file mode 100644 index 0000000..20da201 --- /dev/null +++ b/config/credentials/test.yml.enc @@ -0,0 +1 @@ +7aXbJX7dcuWQ5HHqItJMLrGzIa5KQ8tCe0Clzthhf1koded7Q6RhUVTc5ZJTjEi4RZjE9OENlBeKMzgFxPnncUHZYOKV1fcIZ5C7xJs+NkH1fx/Z5iDoZlU5PoONFqYbp9scNoGhbv4Dio/gXlD+oaC6ujpf2HKG0W88jPkAZlkxGx0=--a3x39qTSz5FvI0Ev--6vOCm5IyPDZ845wHsMATqQ== \ No newline at end of file diff --git a/test/controllers/key_log_controller_test.rb b/test/controllers/key_log_controller_test.rb new file mode 100644 index 0000000..d346070 --- /dev/null +++ b/test/controllers/key_log_controller_test.rb @@ -0,0 +1,112 @@ +require "test_helper" + +class KeyLogControllerTest < ActionDispatch::IntegrationTest + setup do + @valid_api_key = "test_api_key_12345" + @mock_key_info = TornApi::Key::Info::InfoData.new( + access: TornApi::Key::Info::AccessData.new( + level: 2, + type: "Limited Access", + faction: true, + company: false + ), + user: TornApi::Key::Info::UserData.new( + id: 123, + faction_id: 456, + company_id: nil + ) + ) + + @mock_log_data = TornApi::Key::Log::LogData.new( + log: [ + TornApi::Key::Log::LogEntry.new( + timestamp: Time.now.to_i - 3600, + type: "user", + selections: "profile,stocks", + id: 123, + ip: "192.168.1.1", + comment: "tmanager" + ), + TornApi::Key::Log::LogEntry.new( + timestamp: Time.now.to_i - 7200, + type: "key", + selections: "info", + id: nil, + ip: "192.168.1.1", + comment: "tmanager" + ) + ], + _metadata: nil + ) + end + + test "index shows form" do + get key_log_path + + assert_response :success + assert_select "h1", "API Key Activity Log" + assert_select "input[name='api_key']" + assert_select "input[type='checkbox']", 2 + end + + test "show with blank key redirects with error" do + post key_log_show_path, params: { api_key: "" } + + assert_response :success + assert_select ".flash-alert", text: /Please provide an API key/ + end + + test "show with invalid key redirects with error" do + TornApi::Key::Info.any_instance.stubs(:fetch).raises(TornApi::InvalidKeyError) + + post key_log_show_path, params: { api_key: "invalid_key" } + + assert_response :success + assert_select ".flash-alert", text: /Invalid API key/ + end + + test "show with valid key displays log data" do + TornApi::Key::Info.any_instance.stubs(:fetch).returns(@mock_key_info) + TornApi::Key::Log.any_instance.stubs(:fetch).returns(@mock_log_data) + + post key_log_show_path, params: { api_key: @valid_api_key } + + assert_response :success + assert_select "h1", "API Key Activity Log" + assert_select ".api-key-inline", text: @valid_api_key + assert_select ".log-stat-card", 4 # Peak Usage, Total Requests, Unique IPs, API Categories + assert_select ".log-table tbody tr", 2 # Two log entries + end + + test "show squishes whitespace from api key" do + TornApi::Key::Info.any_instance.stubs(:fetch).returns(@mock_key_info) + TornApi::Key::Log.any_instance.stubs(:fetch).returns(@mock_log_data) + + post key_log_show_path, params: { api_key: " #{@valid_api_key} " } + + assert_response :success + assert_select ".api-key-inline", text: @valid_api_key + end + + test "show handles API errors gracefully" do + TornApi::Key::Info.any_instance.stubs(:fetch).raises(StandardError.new("Connection error")) + + post key_log_show_path, params: { api_key: @valid_api_key } + + assert_response :success + assert_select ".flash-alert", text: /Error fetching key log/ + end + + test "GET to show redirects to index" do + get key_log_show_path + + assert_redirected_to key_log_path + end + + test "index is accessible without authentication" do + # Don't sign in + get key_log_path + + assert_response :success + end +end diff --git a/test/controllers/progress_controller_test.rb b/test/controllers/progress_controller_test.rb index 73103cd..fa0b9f7 100644 --- a/test/controllers/progress_controller_test.rb +++ b/test/controllers/progress_controller_test.rb @@ -29,5 +29,8 @@ class ProgressControllerTest < ActionDispatch::IntegrationTest TornApi::User::Stocks.any_instance.stubs(:fetch).returns(@mock_user_stocks) get progress_index_path + + assert_response :success + assert_select "h1", "Stock Progress" end end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index e82a661..27bb952 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -16,7 +16,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest delete session_path - assert_redirected_to new_session_path + assert_redirected_to root_path assert_empty cookies[:session_id] end end From 47ca40f8b0d382ed5a4410786af25ceb1eca05de Mon Sep 17 00:00:00 2001 From: Bram Date: Sun, 15 Feb 2026 17:53:01 +0100 Subject: [PATCH 014/266] consolidate user model with tornuser model --- app/controllers/hall_of_famers_controller.rb | 24 +++++++++++---- app/controllers/sessions_controller.rb | 20 ++++--------- app/jobs/daily/personal_stats_job.rb | 6 ++-- app/jobs/faction_members_job.rb | 3 +- app/jobs/fetch_personal_stats_job.rb | 8 ++--- app/models/personal_stat_snapshot.rb | 2 +- app/models/torn_user.rb | 5 ---- app/models/user.rb | 9 +++++- ...215163126_add_user_fields_to_torn_users.rb | 9 ++++++ ...60215163149_migrate_users_to_torn_users.rb | 19 ++++++++++++ ...60215163209_update_sessions_foreign_key.rb | 30 +++++++++++++++++++ db/migrate/20260215163230_drop_users_table.rb | 9 ++++++ ...260215163248_rename_torn_users_to_users.rb | 14 +++++++++ db/schema.rb | 25 ++++++---------- test/fixtures/personal_stat_snapshots.yml | 2 +- test/fixtures/torn_users.yml | 6 ---- test/fixtures/users.yml | 12 +++++++- test/models/torn_user_test.rb | 7 ----- 18 files changed, 144 insertions(+), 66 deletions(-) delete mode 100644 app/models/torn_user.rb create mode 100644 db/migrate/20260215163126_add_user_fields_to_torn_users.rb create mode 100644 db/migrate/20260215163149_migrate_users_to_torn_users.rb create mode 100644 db/migrate/20260215163209_update_sessions_foreign_key.rb create mode 100644 db/migrate/20260215163230_drop_users_table.rb create mode 100644 db/migrate/20260215163248_rename_torn_users_to_users.rb delete mode 100644 test/fixtures/torn_users.yml delete mode 100644 test/models/torn_user_test.rb diff --git a/app/controllers/hall_of_famers_controller.rb b/app/controllers/hall_of_famers_controller.rb index 54d07c8..fea6780 100644 --- a/app/controllers/hall_of_famers_controller.rb +++ b/app/controllers/hall_of_famers_controller.rb @@ -5,13 +5,22 @@ class HallOfFamersController < ApplicationController def index # Determine date range for filtering - all_snapshots = PersonalStatSnapshot.joins(:torn_user) - .where(torn_users: { hof_stats_user: true }) + all_snapshots = PersonalStatSnapshot.joins(:user) + .where(users: { hof_stats_user: true }) .order(:created_at) @earliest_date = all_snapshots.first&.created_at&.to_date @latest_date = all_snapshots.last&.created_at&.to_date + # If no snapshots exist, set defaults + if @earliest_date.nil? || @latest_date.nil? + @start_date = Date.today + @end_date = Date.today + @total_days_tracked = 0 + @table_rows = [] + return + end + # Parse date parameters @start_date = params[:start_date].present? ? Date.parse(params[:start_date]) : @earliest_date @end_date = params[:end_date].present? ? Date.parse(params[:end_date]) : @latest_date @@ -19,12 +28,15 @@ def index # Calculate total days for the selected range @total_days_tracked = (@end_date - @start_date).to_i - @table_rows = TornUser.hof_stats_users.includes(:personal_stat_snapshots).map do |torn_user| + @table_rows = User.hof_stats_users.includes(:personal_stat_snapshots).filter_map do |user| # Filter snapshots by date range - snapshots = torn_user.personal_stat_snapshots + snapshots = user.personal_stat_snapshots .where("DATE(created_at) >= ? AND DATE(created_at) <= ?", @start_date, @end_date) .order(:created_at) + # Skip users with no snapshots + next if snapshots.empty? + latest = snapshots.last first = snapshots.first @@ -46,8 +58,8 @@ def index end { - name: torn_user.name, - torn_id: torn_user.torn_id, + name: user.name, + torn_id: user.torn_id, xanax_gained: xanax_gained, xanax_daily: xanax_daily, energy_drinks_gained: energy_drinks_gained, diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index b6d9506..cbb015b 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -21,20 +21,12 @@ def create return redirect_to new_session_path, alert: "Could not fetch profile from Torn API." unless profile - torn_user = TornUser.find_or_create_by!(torn_id: profile.id) do |tu| - tu.name = profile.name - tu.level = profile.level - end - - torn_user.update!(name: profile.name, level: profile.level) - - # Find or create user by API key (which is unique), then associate with torn_user - user = User.find_or_create_by(api_key: api_key) do |u| - u.torn_user_id = torn_user.id - end - - # Update the torn_user association if it changed - user.update!(torn_user_id: torn_user.id) if user.torn_user_id != torn_user.id + # Find or create user by torn_id, update with latest info + user = User.find_or_initialize_by(torn_id: profile.id) + user.api_key = api_key + user.name = profile.name + user.level = profile.level + user.save! start_new_session_for user redirect_to after_authentication_url diff --git a/app/jobs/daily/personal_stats_job.rb b/app/jobs/daily/personal_stats_job.rb index b70816e..89fe1f7 100644 --- a/app/jobs/daily/personal_stats_job.rb +++ b/app/jobs/daily/personal_stats_job.rb @@ -6,9 +6,9 @@ def perform(delay = 15) batch_size = 60 run_at = delay.minutes.from_now - TornUser.hof_stats_users.find_in_batches(batch_size:) do |users| - users.each do |torn_user| - FetchPersonalStatsJob.set(wait_until: run_at).perform_later(torn_user) + User.hof_stats_users.find_in_batches(batch_size:) do |users| + users.each do |user| + FetchPersonalStatsJob.set(wait_until: run_at).perform_later(user) run_at += 1.second end end diff --git a/app/jobs/faction_members_job.rb b/app/jobs/faction_members_job.rb index e62b7d6..8c05bcc 100644 --- a/app/jobs/faction_members_job.rb +++ b/app/jobs/faction_members_job.rb @@ -10,9 +10,10 @@ def perform(batch_ids) end all_members.each do |member| - TornUser.find_or_create_by(torn_id: member.id) do |user| + User.find_or_create_by(torn_id: member.id) do |user| user.name = member.name user.level = member.level + # api_key is nil until they log in end end end diff --git a/app/jobs/fetch_personal_stats_job.rb b/app/jobs/fetch_personal_stats_job.rb index 8f4e0d0..30f22bc 100644 --- a/app/jobs/fetch_personal_stats_job.rb +++ b/app/jobs/fetch_personal_stats_job.rb @@ -3,10 +3,10 @@ class FetchPersonalStatsJob < ApplicationJob MIN_STAT_ENHANCER = 200 - def perform(torn_user) - stats = TornApi::User::PersonalStats.new(api_key, torn_user.torn_id).fetch - torn_user.update!(hof_stats_user: true) if stats.items_used_stat_enhancers > MIN_STAT_ENHANCER - torn_user.personal_stat_snapshots.create!(stats.to_h) + def perform(user) + stats = TornApi::User::PersonalStats.new(api_key, user.torn_id).fetch + user.update!(hof_stats_user: true) if stats.items_used_stat_enhancers > MIN_STAT_ENHANCER + user.personal_stat_snapshots.create!(stats.to_h) end private diff --git a/app/models/personal_stat_snapshot.rb b/app/models/personal_stat_snapshot.rb index 3db6ae5..8d289eb 100644 --- a/app/models/personal_stat_snapshot.rb +++ b/app/models/personal_stat_snapshot.rb @@ -1,3 +1,3 @@ class PersonalStatSnapshot < ApplicationRecord - belongs_to :torn_user + belongs_to :user end diff --git a/app/models/torn_user.rb b/app/models/torn_user.rb deleted file mode 100644 index c5c92f1..0000000 --- a/app/models/torn_user.rb +++ /dev/null @@ -1,5 +0,0 @@ -class TornUser < ApplicationRecord - has_one :user, dependent: :nullify - has_many :personal_stat_snapshots, dependent: :destroy - scope :hof_stats_users, -> { where(hof_stats_user: true) } -end diff --git a/app/models/user.rb b/app/models/user.rb index 73d7442..cc968f2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,4 +1,11 @@ class User < ApplicationRecord has_many :sessions, dependent: :destroy - belongs_to :torn_user, optional: true + has_many :personal_stat_snapshots, dependent: :destroy + + validates :api_key, uniqueness: true, allow_nil: true + validates :torn_id, presence: true, uniqueness: true + validates :name, presence: true + validates :level, presence: true + + scope :hof_stats_users, -> { where(hof_stats_user: true) } end diff --git a/db/migrate/20260215163126_add_user_fields_to_torn_users.rb b/db/migrate/20260215163126_add_user_fields_to_torn_users.rb new file mode 100644 index 0000000..68fc027 --- /dev/null +++ b/db/migrate/20260215163126_add_user_fields_to_torn_users.rb @@ -0,0 +1,9 @@ +class AddUserFieldsToTornUsers < ActiveRecord::Migration[8.1] + def change + # Add user authentication field to torn_users + add_column :torn_users, :api_key, :string + + # Add index (will be unique after migration) + add_index :torn_users, :api_key + end +end diff --git a/db/migrate/20260215163149_migrate_users_to_torn_users.rb b/db/migrate/20260215163149_migrate_users_to_torn_users.rb new file mode 100644 index 0000000..fa7578c --- /dev/null +++ b/db/migrate/20260215163149_migrate_users_to_torn_users.rb @@ -0,0 +1,19 @@ +class MigrateUsersToTornUsers < ActiveRecord::Migration[8.1] + def up + # Copy api_key from users to torn_users + execute <<-SQL + UPDATE torn_users + SET api_key = users.api_key + FROM users + WHERE torn_users.id = users.torn_user_id + SQL + + # Make api_key unique (but allow null) + remove_index :torn_users, :api_key + add_index :torn_users, :api_key, unique: true, where: "api_key IS NOT NULL" + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20260215163209_update_sessions_foreign_key.rb b/db/migrate/20260215163209_update_sessions_foreign_key.rb new file mode 100644 index 0000000..2869b10 --- /dev/null +++ b/db/migrate/20260215163209_update_sessions_foreign_key.rb @@ -0,0 +1,30 @@ +class UpdateSessionsForeignKey < ActiveRecord::Migration[8.1] + def up + # Add new foreign key to torn_users + add_column :sessions, :torn_user_id, :integer + + # Copy user_id to torn_user_id via the users table + execute <<-SQL + UPDATE sessions + SET torn_user_id = users.torn_user_id + FROM users + WHERE sessions.user_id = users.id + SQL + + # Delete orphaned sessions (where torn_user_id is still null) + execute "DELETE FROM sessions WHERE torn_user_id IS NULL" + + # Make torn_user_id not null and add index + change_column_null :sessions, :torn_user_id, false + add_index :sessions, :torn_user_id + + # Remove old foreign key and column + remove_foreign_key :sessions, :users + remove_index :sessions, :user_id + remove_column :sessions, :user_id + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20260215163230_drop_users_table.rb b/db/migrate/20260215163230_drop_users_table.rb new file mode 100644 index 0000000..2a35f43 --- /dev/null +++ b/db/migrate/20260215163230_drop_users_table.rb @@ -0,0 +1,9 @@ +class DropUsersTable < ActiveRecord::Migration[8.1] + def up + drop_table :users + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20260215163248_rename_torn_users_to_users.rb b/db/migrate/20260215163248_rename_torn_users_to_users.rb new file mode 100644 index 0000000..52c903c --- /dev/null +++ b/db/migrate/20260215163248_rename_torn_users_to_users.rb @@ -0,0 +1,14 @@ +class RenameTornUsersToUsers < ActiveRecord::Migration[8.1] + def change + rename_table :torn_users, :users + + # Rename foreign key columns in related tables + rename_column :sessions, :torn_user_id, :user_id + rename_column :personal_stat_snapshots, :torn_user_id, :user_id + + # Update foreign key constraints (remove old, add new) + remove_foreign_key :personal_stat_snapshots, :torn_users if foreign_key_exists?(:personal_stat_snapshots, :torn_users) + add_foreign_key :personal_stat_snapshots, :users + add_foreign_key :sessions, :users + end +end diff --git a/db/schema.rb b/db/schema.rb index 7bc6cfd..c87eba2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_01_21_162137) do +ActiveRecord::Schema[8.1].define(version: 2026_02_15_163248) do create_table "personal_stat_snapshots", force: :cascade do |t| t.integer "attacking_ammunition_hollow_point" t.integer "attacking_ammunition_incendiary" @@ -160,7 +160,6 @@ t.integer "racing_races_entered" t.integer "racing_races_won" t.integer "racing_skill" - t.integer "torn_user_id", null: false t.integer "trading_bazaar_customers" t.bigint "trading_bazaar_profit" t.integer "trading_bazaar_sales" @@ -194,7 +193,8 @@ t.integer "travel_united_arab_emirates" t.integer "travel_united_kingdom" t.datetime "updated_at", null: false - t.index ["torn_user_id"], name: "index_personal_stat_snapshots_on_torn_user_id" + t.integer "user_id", null: false + t.index ["user_id"], name: "index_personal_stat_snapshots_on_user_id" end create_table "sessions", force: :cascade do |t| @@ -230,27 +230,20 @@ t.index ["torn_id"], name: "index_torn_stocks_on_torn_id", unique: true end - create_table "torn_users", force: :cascade do |t| + create_table "users", force: :cascade do |t| + t.string "api_key" t.datetime "created_at", null: false t.boolean "hof_stats_user", default: false, null: false t.integer "level", null: false t.string "name", null: false t.integer "torn_id", null: false t.datetime "updated_at", null: false - t.index ["hof_stats_user"], name: "index_torn_users_on_hof_stats_user" - t.index ["torn_id"], name: "index_torn_users_on_torn_id", unique: true - end - - create_table "users", force: :cascade do |t| - t.string "api_key", null: false - t.datetime "created_at", null: false - t.integer "torn_user_id" - t.datetime "updated_at", null: false t.index ["api_key"], name: "index_users_on_api_key", unique: true - t.index ["torn_user_id"], name: "index_users_on_torn_user_id" + t.index ["hof_stats_user"], name: "index_users_on_hof_stats_user" + t.index ["torn_id"], name: "index_users_on_torn_id", unique: true end - add_foreign_key "personal_stat_snapshots", "torn_users" + add_foreign_key "personal_stat_snapshots", "users" + add_foreign_key "personal_stat_snapshots", "users" add_foreign_key "sessions", "users" - add_foreign_key "users", "torn_users" end diff --git a/test/fixtures/personal_stat_snapshots.yml b/test/fixtures/personal_stat_snapshots.yml index 99f335f..e3f1f80 100644 --- a/test/fixtures/personal_stat_snapshots.yml +++ b/test/fixtures/personal_stat_snapshots.yml @@ -1,2 +1,2 @@ one: - torn_user: bram + user: one diff --git a/test/fixtures/torn_users.yml b/test/fixtures/torn_users.yml deleted file mode 100644 index 5287a35..0000000 --- a/test/fixtures/torn_users.yml +++ /dev/null @@ -1,6 +0,0 @@ -bram: - name: Bram - torn_id: 2728237 - level: 69 - hof_stats_user: false - diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 5ef2121..9eeda21 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -1,3 +1,13 @@ -bram: +one: + name: Bram + torn_id: 2728237 + level: 69 + hof_stats_user: false api_key: ABCDEF +two: + name: TestUser + torn_id: 1234567 + level: 50 + hof_stats_user: true + api_key: nil diff --git a/test/models/torn_user_test.rb b/test/models/torn_user_test.rb deleted file mode 100644 index a3e86b3..0000000 --- a/test/models/torn_user_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class TornUserTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end -end From fb01fc9cab702160ed6879f210b7bcbe14ce0d08 Mon Sep 17 00:00:00 2001 From: Bram Date: Sun, 15 Feb 2026 22:20:50 +0100 Subject: [PATCH 015/266] admin payment stuff --- app/assets/stylesheets/application.css | 424 ++++++++++++++++++ app/controllers/sessions_controller.rb | 2 +- app/controllers/subscriptions_controller.rb | 125 ++++++ .../controllers/collapsible_controller.js | 18 + .../subscription_days_controller.js | 67 +++ app/jobs/daily/xanax_payments_job.rb | 68 +++ app/models/faction_subscription_grant.rb | 12 + app/models/subscription_grant.rb | 6 + app/models/torn_api.rb | 121 ++++- app/models/torn_api/faction/basic.rb | 29 ++ .../torn_api/user/{profile.rb => basic.rb} | 23 +- app/models/torn_api/user/log.rb | 61 +++ app/models/user.rb | 19 + app/models/xanax_payment.rb | 11 + app/views/layouts/_navbar.html.erb | 3 + .../_faction_grant_form.html.erb | 19 + .../subscriptions/faction_grant.html.erb | 42 ++ app/views/subscriptions/index.html.erb | 136 ++++++ config/credentials.yml.enc | 2 +- config/recurring.yml | 4 + config/routes.rb | 10 + ...170237_add_subscription_fields_to_users.rb | 5 + .../20260215170255_create_xanax_payments.rb | 16 + ...0320_create_faction_subscription_grants.rb | 15 + ...260215170355_create_faction_grant_users.rb | 12 + ...tion_grant_users_to_subscription_grants.rb | 5 + ...er_id_to_recipient_id_in_xanax_payments.rb | 5 + ...27_add_index_to_subscription_expires_at.rb | 5 + db/schema.rb | 45 +- test/fixtures/faction_subscription_grants.yml | 15 + test/fixtures/xanax_payments.yml | 17 + .../models/faction_subscription_grant_test.rb | 7 + test/models/xanax_payment_test.rb | 7 + 33 files changed, 1339 insertions(+), 17 deletions(-) create mode 100644 app/controllers/subscriptions_controller.rb create mode 100644 app/javascript/controllers/collapsible_controller.js create mode 100644 app/javascript/controllers/subscription_days_controller.js create mode 100644 app/jobs/daily/xanax_payments_job.rb create mode 100644 app/models/faction_subscription_grant.rb create mode 100644 app/models/subscription_grant.rb create mode 100644 app/models/torn_api/faction/basic.rb rename app/models/torn_api/user/{profile.rb => basic.rb} (58%) create mode 100644 app/models/torn_api/user/log.rb create mode 100644 app/models/xanax_payment.rb create mode 100644 app/views/subscriptions/_faction_grant_form.html.erb create mode 100644 app/views/subscriptions/faction_grant.html.erb create mode 100644 app/views/subscriptions/index.html.erb create mode 100644 db/migrate/20260215170237_add_subscription_fields_to_users.rb create mode 100644 db/migrate/20260215170255_create_xanax_payments.rb create mode 100644 db/migrate/20260215170320_create_faction_subscription_grants.rb create mode 100644 db/migrate/20260215170355_create_faction_grant_users.rb create mode 100644 db/migrate/20260215204026_rename_faction_grant_users_to_subscription_grants.rb create mode 100644 db/migrate/20260215204922_rename_user_id_to_recipient_id_in_xanax_payments.rb create mode 100644 db/migrate/20260215210827_add_index_to_subscription_expires_at.rb create mode 100644 test/fixtures/faction_subscription_grants.yml create mode 100644 test/fixtures/xanax_payments.yml create mode 100644 test/models/faction_subscription_grant_test.rb create mode 100644 test/models/xanax_payment_test.rb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index cb2a45d..9974b18 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -633,6 +633,35 @@ tbody tr:hover { border: 1px solid var(--gray-800); border-radius: var(--radius-lg); overflow: hidden; + margin-bottom: var(--space-8); +} + +.table-container h2 { + padding: var(--space-6); + margin: 0; +} + +.collapsible-header { + cursor: pointer; + user-select: none; + transition: background-color 0.15s ease; + display: flex; + align-items: center; + gap: var(--space-3); +} + +.collapsible-header:hover { + background: rgba(255, 255, 255, 0.02); +} + +.collapsible-header span { + font-size: 0.875rem; + color: var(--gray-500); + transition: color 0.15s ease; +} + +.collapsible-header:hover span { + color: var(--gray-300); } .table-info { @@ -1434,3 +1463,398 @@ tbody tr:hover { font-variant-numeric: tabular-nums; } +/* Admin Dashboard Styles */ +.admin-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-12); +} + +.admin-title-section { + flex: 1; +} + +.admin-title { + font-size: 2.5rem; + font-weight: 700; + color: #ffffff; + margin: 0 0 var(--space-2) 0; + letter-spacing: -0.05em; +} + +.admin-subtitle { + color: var(--gray-500); + font-size: 0.875rem; + margin: 0; +} + +.admin-actions { + display: flex; + gap: var(--space-3); +} + +.button { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-5); + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 600; + font-family: "Geist Mono", monospace; + text-decoration: none; + transition: all 0.15s ease; + cursor: pointer; + border: none; +} + +.button-primary { + background: #ffffff; + color: #000000; +} + +.button-primary:hover { + background: var(--gray-200); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(255, 255, 255, 0.15); +} + +.button-secondary { + background: var(--gray-800); + color: var(--gray-200); + border: 1px solid var(--gray-700); +} + +.button-secondary:hover { + background: var(--gray-700); + border-color: var(--gray-600); +} + +/* Stats Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: var(--space-4); + margin-bottom: var(--space-12); +} + +.stat-card { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + padding: var(--space-6); + transition: all 0.2s ease; +} + +.stat-card:hover { + border-color: var(--gray-700); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.stat-card-header { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-4); +} + +.stat-icon { + width: 40px; + height: 40px; + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.05); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; +} + +.stat-card-title { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--gray-500); + font-weight: 600; + margin: 0; +} + +.stat-card-value { + font-size: 2.5rem; + font-weight: 700; + color: #ffffff; + line-height: 1; + margin-bottom: var(--space-2); +} + +.stat-card-label { + font-size: 0.875rem; + color: var(--gray-400); +} + +.stat-card-trend { + font-size: 0.75rem; + color: var(--gray-500); + margin-top: var(--space-2); +} + +.stat-card-trend.positive { + color: #22c55e; +} + +/* Section Headers */ +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-6); + background: rgba(255, 255, 255, 0.02); + border-bottom: 1px solid var(--gray-800); +} + +.section-title { + font-size: 1.125rem; + font-weight: 600; + color: #ffffff; + margin: 0; + display: flex; + align-items: center; + gap: var(--space-3); +} + +.section-badge { + display: inline-flex; + align-items: center; + padding: var(--space-1) var(--space-3); + background: rgba(255, 255, 255, 0.1); + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + color: var(--gray-300); +} + +.section-actions { + display: flex; + gap: var(--space-2); +} + +/* Table Enhancements */ +.table-container h2 { + margin-bottom: var(--space-6); +} + +.table-wrapper { + overflow-x: auto; +} + +.table-empty { + padding: var(--space-12); + text-align: center; + color: var(--gray-500); +} + +.table-empty-icon { + font-size: 3rem; + margin-bottom: var(--space-4); + opacity: 0.3; +} + +.table-empty-text { + font-size: 0.875rem; + margin: 0; +} + +/* Badge Components */ +.badge { + display: inline-flex; + align-items: center; + padding: var(--space-1) var(--space-3); + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; +} + +.badge-success { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; + border: 1px solid rgba(34, 197, 94, 0.3); +} + +.badge-warning { + background: rgba(251, 191, 36, 0.15); + color: #fbbf24; + border: 1px solid rgba(251, 191, 36, 0.3); +} + +.badge-info { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; + border: 1px solid rgba(59, 130, 246, 0.3); +} + +/* Form Container */ +.form-container { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + padding: var(--space-8); + max-width: 600px; + margin: 0 0 var(--space-8) 0; +} + +.form-group { + margin-bottom: var(--space-6); +} + +.form-group:last-child { + margin-bottom: 0; +} + +.form-input, +.form-select { + width: 100%; + padding: var(--space-3) var(--space-4); + background: var(--gray-1000); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + color: var(--gray-200); + font-family: "Geist Mono", monospace; + font-size: 0.875rem; + transition: all 0.15s ease; +} + +.form-input:focus, +.form-select:focus { + outline: none; + border-color: var(--gray-600); + background: var(--gray-900); + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05); +} + +.form-help { + font-size: 0.75rem; + color: var(--gray-500); + margin-top: var(--space-2); + margin-bottom: 0; +} + +.form-actions { + display: flex; + gap: var(--space-3); + margin-top: var(--space-8); +} + +.form-actions .button { + flex: 1; +} + +/* Info Box */ +.info-box { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + padding: var(--space-6); + margin-top: 0; + margin-bottom: var(--space-8); + max-width: 600px; +} + +.info-box h3 { + font-size: 1rem; + font-weight: 600; + color: var(--gray-200); + margin: 0 0 var(--space-4) 0; +} + +.info-box ul { + list-style: none; + padding: 0; + margin: 0; +} + +.info-box li { + padding: var(--space-2) 0; + padding-left: var(--space-6); + color: var(--gray-400); + font-size: 0.875rem; + line-height: 1.6; + position: relative; +} + +.info-box li::before { + content: "→"; + position: absolute; + left: 0; + color: var(--gray-600); +} + +/* Utility Classes */ +.text-muted { + color: var(--gray-500); +} + +.text-small { + font-size: 0.75rem; +} + +/* Inline Editing Styles */ +.days-cell { + display: flex; + align-items: center; +} + +.days-input { + width: 80px; + padding: var(--space-1) var(--space-2); + background: var(--gray-1000); + border: 1px solid var(--gray-700); + border-radius: var(--radius-sm); + color: var(--gray-200); + font-family: "Geist Mono", monospace; + font-size: 0.875rem; + transition: border-color 0.15s ease; +} + +.days-input:focus { + outline: none; + border-color: var(--gray-500); + background: var(--gray-900); +} + +.edit-days-btn { + padding: var(--space-1) var(--space-3); + background: var(--gray-800); + border: 1px solid var(--gray-700); + border-radius: var(--radius-sm); + color: var(--gray-300); + font-family: "Geist Mono", monospace; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + margin-left: var(--space-2); +} + +.edit-days-btn:hover:not(:disabled) { + background: var(--gray-700); + border-color: var(--gray-600); + color: var(--gray-100); +} + +.edit-days-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.mt-2 { margin-top: var(--space-2); } +.mt-4 { margin-top: var(--space-4); } +.mt-6 { margin-top: var(--space-6); } +.mt-8 { margin-top: var(--space-8); } +.mt-12 { margin-top: var(--space-12); } + +.mb-2 { margin-bottom: var(--space-2); } +.mb-4 { margin-bottom: var(--space-4); } +.mb-6 { margin-bottom: var(--space-6); } +.mb-8 { margin-bottom: var(--space-8); } +.mb-12 { margin-bottom: var(--space-12); } + diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index cbb015b..f3bf702 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -17,7 +17,7 @@ def create end # Fetch profile using the validated key - profile = TornApi::User::Profile.new(api_key).fetch + profile = TornApi::User::Basic.new(api_key).fetch return redirect_to new_session_path, alert: "Could not fetch profile from Torn API." unless profile diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb new file mode 100644 index 0000000..29b2fb0 --- /dev/null +++ b/app/controllers/subscriptions_controller.rb @@ -0,0 +1,125 @@ +class SubscriptionsController < ApplicationController + before_action :require_admin + + def index + @active_subscribers = User.active_subscribers.order(subscription_expires_at: :desc) + @recent_payments = XanaxPayment.includes(:sender, :recipient).recent.limit(50) + @recent_faction_grants = FactionSubscriptionGrant.includes(:granted_by).recent.limit(20) + end + + def faction_grant + end + + def create_faction_grant + Rails.logger.info("Creating faction grant with params: faction_id=#{params[:faction_id]}, weeks=#{params[:weeks]}") + Rails.logger.info("Valid params? #{valid_params?}") + + return redirect_with_error("Invalid faction ID or weeks.") unless valid_params? + + grant_faction_subscription + rescue TornApi::InvalidKeyError => e + Rails.logger.error("Invalid API key error: #{e.message}") + redirect_with_error("Invalid API key.") + rescue TornApi::ApiError => e + Rails.logger.error("Torn API error: #{e.class} - #{e.message}") + redirect_with_error("Torn API error: #{e.message}") + rescue => e + Rails.logger.error("Faction grant error: #{e.class} - #{e.message}") + Rails.logger.error(e.backtrace.join("\n")) + redirect_with_error("An error occurred: #{e.message}") + end + + def update_days + user = User.find(params[:id]) + days = params[:days].to_i + + if days >= 0 + user.update!(subscription_expires_at: days.days.from_now) + render json: { success: true, new_expires_at: user.subscription_expires_at.strftime("%Y-%m-%d %H:%M"), days: days } + else + render json: { success: false, error: "Days must be a positive number" }, status: :unprocessable_entity + end + rescue => e + render json: { success: false, error: e.message }, status: :unprocessable_entity + end + + private + + def valid_params? + faction_id.positive? && weeks.positive? + end + + def grant_faction_subscription + faction_info = fetch_faction_info + members = fetch_faction_members + + ActiveRecord::Base.transaction do + grant = create_grant_record(faction_info["name"], members.count) + members.each { |member| grant_to_member(grant, member) } + end + + redirect_to subscriptions_path, notice: "Successfully granted #{weeks} week(s) to #{members.count} members of #{faction_info['name']}." + end + + def fetch_faction_info + TornApi::Faction::Basic.new(api_key, faction_id).fetch + end + + def fetch_faction_members + TornApi::Faction::Members.new(api_key, faction_id).fetch + end + + def create_grant_record(faction_name, member_count) + FactionSubscriptionGrant.create!( + faction_id: faction_id, + faction_name: faction_name, + weeks_granted: weeks, + granted_by: Current.user, + granted_at: Time.current + ) + end + + def grant_to_member(grant, member) + user = find_or_create_user(member) + + SubscriptionGrant.create!( + faction_subscription_grant: grant, + user: user + ) + + user.extend_subscription!(weeks) + end + + def find_or_create_user(member) + User.find_by(torn_id: member.id) || create_user_from_member(member) + end + + def create_user_from_member(member) + User.create!( + torn_id: member.id, + name: member.name, + level: member.level, + api_key: nil + ) + end + + def redirect_with_error(message) + redirect_to faction_grant_subscriptions_path, alert: message + end + + def faction_id + @faction_id ||= params[:faction_id].to_i + end + + def weeks + @weeks ||= params[:weeks].to_i + end + + def require_admin + redirect_to root_path, alert: "Access denied." unless Current.user&.admin? + end + + def api_key + Rails.application.credentials.dig(:bram, :full_api_key) + end +end diff --git a/app/javascript/controllers/collapsible_controller.js b/app/javascript/controllers/collapsible_controller.js new file mode 100644 index 0000000..7111595 --- /dev/null +++ b/app/javascript/controllers/collapsible_controller.js @@ -0,0 +1,18 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["content", "icon"] + + toggle() { + const content = this.contentTarget + const icon = this.iconTarget + + if (content.style.display === "none") { + content.style.display = "block" + icon.textContent = "▼" + } else { + content.style.display = "none" + icon.textContent = "▶" + } + } +} diff --git a/app/javascript/controllers/subscription_days_controller.js b/app/javascript/controllers/subscription_days_controller.js new file mode 100644 index 0000000..6e58858 --- /dev/null +++ b/app/javascript/controllers/subscription_days_controller.js @@ -0,0 +1,67 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["display", "input", "button", "expiresAt"] + static values = { userId: Number } + + edit(event) { + event.preventDefault() + + if (this.buttonTarget.textContent === "Edit") { + this.displayTarget.style.display = "none" + this.inputTarget.style.display = "inline-block" + this.buttonTarget.textContent = "Save" + this.inputTarget.focus() + this.inputTarget.select() + } else { + this.save() + } + } + + keypress(event) { + if (event.key === "Enter") { + event.preventDefault() + this.save() + } + } + + async save() { + const days = parseInt(this.inputTarget.value) + + if (isNaN(days) || days < 0) { + alert("Please enter a valid number of days (0 or greater)") + return + } + + this.buttonTarget.disabled = true + this.buttonTarget.textContent = "Saving..." + + try { + const response = await fetch(`/subscriptions/${this.userIdValue}/update_days`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content + }, + body: JSON.stringify({ days }) + }) + + const data = await response.json() + + if (data.success) { + this.displayTarget.textContent = `${data.days} days` + this.inputTarget.value = data.days + this.expiresAtTarget.textContent = data.new_expires_at + this.displayTarget.style.display = "inline" + this.inputTarget.style.display = "none" + this.buttonTarget.textContent = "Edit" + } else { + alert(`Error: ${data.error}`) + } + } catch (error) { + alert(`Error updating subscription: ${error}`) + } finally { + this.buttonTarget.disabled = false + } + } +} diff --git a/app/jobs/daily/xanax_payments_job.rb b/app/jobs/daily/xanax_payments_job.rb new file mode 100644 index 0000000..47dddfa --- /dev/null +++ b/app/jobs/daily/xanax_payments_job.rb @@ -0,0 +1,68 @@ +module Daily + class XanaxPaymentsJob < ApplicationJob + queue_as :default + PAYMENT_RECIPIENT_TORN_ID = 2728237 + + def perform + fetch_and_process_payments + end + + private + + def fetch_and_process_payments + log_entries = TornApi::User::Log.new(api_key).fetch_xanax_payments(limit: 100) + + log_entries.each do |entry| + process_payment(entry) unless XanaxPayment.exists?(log_id: entry.id) + rescue ActiveRecord::RecordNotUnique + Rails.logger.info "Payment #{entry.id} already processed, skipping" + next + end + end + + def process_payment(entry) + sender = find_or_create_sender(entry.sender_torn_id) + recipient = User.find_by!(torn_id: PAYMENT_RECIPIENT_TORN_ID) + + XanaxPayment.create!( + recipient: recipient, + sender: sender, + log_id: entry.id, + xanax_amount: entry.xanax_quantity, + weeks_granted: entry.xanax_quantity, + processed_at: Time.at(entry.timestamp) + ) + + sender.extend_subscription!(entry.xanax_quantity) + + Rails.logger.info "Xanax payment processed: #{entry.xanax_quantity} from #{sender.name || sender.torn_id}" + end + + def find_or_create_sender(torn_id) + User.find_by(torn_id: torn_id) || create_sender(torn_id) + end + + def create_sender(torn_id) + profile = TornApi::User::Basic.new(api_key, torn_id).fetch + + User.create!( + torn_id: profile.id, + name: profile.name, + level: profile.level, + api_key: nil + ) + rescue TornApi::ApiError => e + Rails.logger.warn "Could not fetch profile for #{torn_id}: #{e.message}. Creating minimal user record." + User.create!( + torn_id: torn_id, + name: "User #{torn_id}", + level: 1, + api_key: nil + ) + end + + def api_key + Rails.application.credentials.dig(:bram, :full_api_key) + end + end +end diff --git a/app/models/faction_subscription_grant.rb b/app/models/faction_subscription_grant.rb new file mode 100644 index 0000000..503bcc6 --- /dev/null +++ b/app/models/faction_subscription_grant.rb @@ -0,0 +1,12 @@ +class FactionSubscriptionGrant < ApplicationRecord + belongs_to :granted_by, class_name: "User" + has_many :subscription_grants, dependent: :destroy + has_many :users, through: :subscription_grants + + validates :faction_id, presence: true + validates :faction_name, presence: true + validates :weeks_granted, presence: true, numericality: { greater_than: 0 } + validates :granted_at, presence: true + + scope :recent, -> { order(granted_at: :desc) } +end diff --git a/app/models/subscription_grant.rb b/app/models/subscription_grant.rb new file mode 100644 index 0000000..c95b53f --- /dev/null +++ b/app/models/subscription_grant.rb @@ -0,0 +1,6 @@ +class SubscriptionGrant < ApplicationRecord + belongs_to :faction_subscription_grant + belongs_to :user + + validates :user_id, uniqueness: { scope: :faction_subscription_grant_id } +end diff --git a/app/models/torn_api.rb b/app/models/torn_api.rb index 9bb7d52..352cd37 100644 --- a/app/models/torn_api.rb +++ b/app/models/torn_api.rb @@ -4,9 +4,16 @@ module TornApi class InvalidKeyError < StandardError; end class ApiError < StandardError; end + class RateLimitError < ApiError; end + class NotFoundError < ApiError; end + class TimeoutError < ApiError; end class Base DEFAULT_PARAMS = { comment: "tmanager" }.freeze + BASE_URL = "https://api.torn.com" + DEFAULT_READ_TIMEOUT = 10 + DEFAULT_OPEN_TIMEOUT = 5 + MAX_RETRIES = 2 attr_reader :api_key @@ -15,21 +22,117 @@ def initialize(api_key) @api_key = api_key end - def get(path, params = {}) + def get(path, params = {}, retries: 0) merged_params = DEFAULT_PARAMS.merge(params) - uri = URI("https://api.torn.com/#{path}") - uri.query = URI.encode_www_form(merged_params) if params.any? + uri = URI("#{BASE_URL}/#{path}") + uri.query = URI.encode_www_form(merged_params) - Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| + log_request(uri) + + response = perform_request(uri) + body = parse_response(response) + + check_for_errors(body) + + log_success(uri) + body + rescue Net::ReadTimeout, Net::OpenTimeout => e + handle_timeout(uri, e, retries) + rescue JSON::ParserError => e + Rails.logger.error("JSON parse error for #{uri}: #{e.message}") + raise ApiError, "Invalid JSON response from Torn API" + rescue Net::HTTPError, SocketError => e + handle_network_error(uri, e, retries) + end + + private + + def perform_request(uri) + Net::HTTP.start( + uri.host, + uri.port, + use_ssl: true, + read_timeout: DEFAULT_READ_TIMEOUT, + open_timeout: DEFAULT_OPEN_TIMEOUT + ) do |http| req = Net::HTTP::Get.new(uri) req["accept"] = "application/json" req["Authorization"] = "ApiKey #{api_key}" - resp = http.request(req) - unless resp.code.to_i == 200 - raise ApiError, "Torn API request failed (HTTP #{resp.code})" - end - JSON.parse(resp.body) + http.request(req) + end + end + + def parse_response(response) + unless response.code.to_i == 200 + Rails.logger.error("HTTP #{response.code} from Torn API: #{response.body[0..500]}") + raise ApiError, "Torn API request failed (HTTP #{response.code})" + end + + JSON.parse(response.body) + end + + def check_for_errors(body) + return unless body["error"] + handle_api_error(body["error"]) + end + + def handle_api_error(error) + error_code = error["code"] + error_msg = error["error"] + + case error_code + when 2 + raise InvalidKeyError, "Invalid API key" + when 5 + raise RateLimitError, "Too many requests: #{error_msg}" + when 6 + raise NotFoundError, "Incorrect ID: #{error_msg}" + when 8 + raise ApiError, "IP block: #{error_msg}" + when 9 + raise ApiError, "API disabled: #{error_msg}" + when 10 + raise InvalidKeyError, "Key owner is in federal jail" + when 13 + raise RateLimitError, "Key rate limit reached: #{error_msg}" + else + raise ApiError, "API error #{error_code}: #{error_msg}" + end + end + + def handle_timeout(uri, error, retries) + if retries < MAX_RETRIES + Rails.logger.warn("Timeout for #{uri}, retrying (#{retries + 1}/#{MAX_RETRIES}): #{error.message}") + sleep(1 * (retries + 1)) # Exponential backoff: 1s, 2s + get(uri.path.sub("#{BASE_URL}/", ""), parse_query(uri.query), retries: retries + 1) + else + Rails.logger.error("Timeout for #{uri} after #{MAX_RETRIES} retries: #{error.message}") + raise TimeoutError, "Torn API request timed out after #{MAX_RETRIES} retries" + end + end + + def handle_network_error(uri, error, retries) + if retries < MAX_RETRIES + Rails.logger.warn("Network error for #{uri}, retrying (#{retries + 1}/#{MAX_RETRIES}): #{error.message}") + sleep(1 * (retries + 1)) + get(uri.path.sub("#{BASE_URL}/", ""), parse_query(uri.query), retries: retries + 1) + else + Rails.logger.error("Network error for #{uri} after #{MAX_RETRIES} retries: #{error.message}") + raise ApiError, "Network error: #{error.message}" end end + + def parse_query(query_string) + return {} unless query_string + URI.decode_www_form(query_string).to_h.symbolize_keys + end + + def log_request(uri) + Rails.logger.info("TornAPI request: #{uri.path}?#{uri.query&.gsub(/key=[^&]+/, 'key=[REDACTED]')}") + end + + def log_success(uri) + Rails.logger.debug("TornAPI success: #{uri.path}") + end end end diff --git a/app/models/torn_api/faction/basic.rb b/app/models/torn_api/faction/basic.rb new file mode 100644 index 0000000..4519281 --- /dev/null +++ b/app/models/torn_api/faction/basic.rb @@ -0,0 +1,29 @@ +module TornApi + module Faction + class Basic < Base + attr_reader :torn_id + + def initialize(api_key, torn_id) + super(api_key) + @torn_id = torn_id + end + + def endpoint + "v2/faction/#{@torn_id}" + end + + def fetch + response = get(endpoint) + if response["basic"].present? + response["basic"] + else + raise InvalidKeyError, "Torn API authentication failed: #{response}" + end + end + + def name + fetch["name"] + end + end + end +end diff --git a/app/models/torn_api/user/profile.rb b/app/models/torn_api/user/basic.rb similarity index 58% rename from app/models/torn_api/user/profile.rb rename to app/models/torn_api/user/basic.rb index 9727436..c37414f 100644 --- a/app/models/torn_api/user/profile.rb +++ b/app/models/torn_api/user/basic.rb @@ -1,9 +1,9 @@ module TornApi module User - class Profile < Base - ENDPOINT = "v2/user/basic".freeze + class Basic < Base + attr_reader :torn_id - ProfileData = Data.define( + BasicData = Data.define( :id, :name, :level, @@ -11,8 +11,21 @@ class Profile < Base :status ) + def initialize(api_key, torn_id = nil) + super(api_key) + @torn_id = torn_id + end + + def endpoint + if torn_id + "v2/user/#{torn_id}/basic" + else + "v2/user/basic" + end + end + def fetch - response = get(ENDPOINT, striptags: false) + response = get(endpoint, striptags: true) if response["profile"].present? parse_profile(response["profile"]) else @@ -23,7 +36,7 @@ def fetch private def parse_profile(data) - ProfileData.new( + BasicData.new( id: data["id"], name: data["name"], level: data["level"], diff --git a/app/models/torn_api/user/log.rb b/app/models/torn_api/user/log.rb new file mode 100644 index 0000000..dc26c0b --- /dev/null +++ b/app/models/torn_api/user/log.rb @@ -0,0 +1,61 @@ +module TornApi + module User + class Log < Base + ENDPOINT = "v2/user/log".freeze + XANAX_ITEM_ID = 206 + + LogEntry = Data.define( + :id, + :timestamp, + :sender_torn_id, + :xanax_quantity + ) + + def fetch_xanax_payments(limit: 100) + response = get(ENDPOINT, log: 4103, limit: limit) + response["log"].present? ? parse_logs(response["log"]) : [] + end + + private + + def parse_logs(logs) + logs.filter_map do |log| + next unless contains_xanax?(log) + + LogEntry.new( + id: log["id"], + timestamp: log["timestamp"], + sender_torn_id: log["data"]["sender"], + xanax_quantity: extract_xanax_quantity(log["data"]["items"]) + ) + end + end + + def contains_xanax?(log) + return false unless log["data"]&.[]("items") + items = log["data"]["items"] + + case items + when Array + items.any? { |item| item.is_a?(Hash) && item["id"] == XANAX_ITEM_ID } + when Hash + items.key?(XANAX_ITEM_ID.to_s) + else + false + end + end + + def extract_xanax_quantity(items) + case items + when Array + xanax_item = items.find { |item| item.is_a?(Hash) && item["id"] == XANAX_ITEM_ID } + xanax_item&.[]("qty") || 0 + when Hash + items[XANAX_ITEM_ID.to_s].to_i + else + 0 + end + end + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index cc968f2..363ff65 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,11 @@ class User < ApplicationRecord has_many :sessions, dependent: :destroy has_many :personal_stat_snapshots, dependent: :destroy + has_many :received_xanax_payments, class_name: "XanaxPayment", foreign_key: :recipient_id, dependent: :destroy + has_many :sent_xanax_payments, class_name: "XanaxPayment", foreign_key: :sender_id + has_many :subscription_grants, dependent: :destroy + has_many :faction_subscription_grants, through: :subscription_grants + has_many :granted_faction_subscriptions, class_name: "FactionSubscriptionGrant", foreign_key: :granted_by_id validates :api_key, uniqueness: true, allow_nil: true validates :torn_id, presence: true, uniqueness: true @@ -8,4 +13,18 @@ class User < ApplicationRecord validates :level, presence: true scope :hof_stats_users, -> { where(hof_stats_user: true) } + scope :active_subscribers, -> { where("subscription_expires_at > ?", Time.current) } + + def subscribed? + subscription_expires_at.present? && subscription_expires_at > Time.current + end + + def extend_subscription!(weeks) + new_expiry = subscribed? ? subscription_expires_at + weeks.weeks : Time.current + weeks.weeks + update!(subscription_expires_at: new_expiry) + end + + def admin? + torn_id == 2728237 + end end diff --git a/app/models/xanax_payment.rb b/app/models/xanax_payment.rb new file mode 100644 index 0000000..444613e --- /dev/null +++ b/app/models/xanax_payment.rb @@ -0,0 +1,11 @@ +class XanaxPayment < ApplicationRecord + belongs_to :recipient, class_name: "User" + belongs_to :sender, class_name: "User" + + validates :log_id, presence: true, uniqueness: true + validates :xanax_amount, presence: true, numericality: { greater_than: 0 } + validates :weeks_granted, presence: true, numericality: { greater_than: 0 } + validates :processed_at, presence: true + + scope :recent, -> { order(processed_at: :desc) } +end diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index e554fab..63a8f9c 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -8,6 +8,9 @@ <%= link_to "[f] My Faction", faction_index_path, class: "navbar-link #{'navbar-link-active' if request.path.start_with?('/faction')}" %> <%= link_to "[r] Ranked War", ranked_war_index_path, class: "navbar-link #{'navbar-link-active' if request.path.start_with?('/ranked_war')}" %> <%= link_to "[h] Hall of Famers", hall_of_famers_path, class: "navbar-link #{'navbar-link-active' if request.path.start_with?('/hall_of_famers')}" %> + <% if Current.user&.admin? %> + <%= link_to "[a] Admin", subscriptions_path, class: "navbar-link #{'navbar-link-active' if request.path.start_with?('/subscriptions')}" %> + <% end %>

    -

    API Endpoints Used

    -

    TornManager accesses the following Torn API v2 endpoints:

    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    EndpointPurposeAccess Level Required
    v2/key/infoVerify API key validity and access levelBasic Access
    v2/key/logDisplay your API key usage historyBasic Access
    v2/user/profileFetch your name, level, and profile imageBasic Access
    v2/user/{id}/basicFetch basic info for payment sendersBasic Access
    v2/user/{id}/personalstatsFetch and track your personal statisticsBasic Access
    v2/user/stocksFetch your stock portfolio for dividend analysisLimited Access
    v2/user/logProcess Xanax subscription payments (admin only)Limited Access
    v2/faction/{id}Fetch faction name for subscription grantsBasic Access
    v2/faction/{id}/membersVerify faction membership for grantsBasic Access
    v2/torn/stocksFetch current stock prices and dividend infoPublic Only
    v2/torn/factionhofDisplay faction Hall of Fame rankingsPublic Only
    v2/marketFetch points market prices for calculationsBasic Access
    -
    - -

    What This Means

    -
      -
    • Data Storage: I store your game data persistently in my database to track historical progress. Data is retained until you delete your account.
    • -
    • Data Sharing: Your data is completely private and never shared with other users, services, or third parties.
    • -
    • Purpose: I use your data solely to provide progress tracking, statistical analysis, and investment insights to you.
    • -
    • Key Storage: Your API key is stored securely in my encrypted database and used only for automated data fetching.
    • -
    • Access Level: I require a minimum of Basic Access API keys for core features. Limited Access is required for stock portfolio analysis.
    • -
    +

    + For a complete list of API endpoints used and detailed information about data collection, please see my + <%= link_to "Privacy Policy", privacy_policy_path %>. +

    @@ -154,7 +74,7 @@

    You are responsible for:

    • Creating and managing your Torn API key
    • -
    • Providing an API key with appropriate access level (Basic Access minimum, Limited Access for stock features)
    • +
    • Providing an API key with appropriate access level (Public Only minimum, Limited Access for stock features)
    • Monitoring your API key usage through Torn.com ('key' → 'log' selection)
    • Revoking or regenerating your key if you suspect misuse
    • Understanding that sharing your API key with anyone gives them access to your game data
    • @@ -181,20 +101,10 @@

      Regarding your API key: While you should never share your API key with other individuals, providing it to a trusted third-party service like TornManager is different. When you sign in with your API key, - you are explicitly authorizing TornManager to access the Torn API on your behalf for the documented purposes. - This is similar to authorizing any app or service to access your data. + you are explicitly authorizing TornManager to access the Torn API on your behalf for the documented purposes.

      - I implement industry-standard security measures to protect your API key, including: -

      -
        -
      • Encrypted storage in my database
      • -
      • Secure HTTPS transmission
      • -
      • Access logging and monitoring
      • -
      • Regular security audits
      • -
      -

      - However, you maintain ultimate responsibility for your API key's security. If you believe your key has + You maintain ultimate responsibility for your API key's security. If you believe your key has been compromised, revoke it immediately on Torn.com.

    @@ -215,22 +125,6 @@

    -
    -

    Rate Limits and API Usage

    -

    - I respect Torn's API rate limits (100 requests per minute per user). My service is optimized to: -

    -
      -
    • Minimize API requests by caching data appropriately
    • -
    • Request only necessary data for specific features
    • -
    • Avoid unnecessary strain on Torn's servers
    • -
    -

    - If your API key is temporarily blocked by Torn due to rate limiting, TornManager will handle this - gracefully and retry requests appropriately. -

    -
    -

    No Unfair Advantage

    @@ -250,33 +144,18 @@

    -

    Intellectual Property

    -

    - TornManager and its original content, features, and functionality are owned by me and protected by - international copyright, trademark, and other intellectual property laws. -

    -

    - Torn, Torn.com, and all related game data remain the property of Torn City Ltd. I use the Torn API - under the terms provided by Torn. -

    -
    - -
    -

    Account Termination

    +

    Subscription Model

    - I reserve the right to suspend or terminate your access to TornManager at my discretion, without - notice, for conduct that I believe: + TornManager uses an in-game subscription model. Access to certain features requires an active subscription, + which can be obtained through:

      -
    • Violates these Terms of Service
    • -
    • Violates Torn's Terms of Service or API guidelines
    • -
    • Harms TornManager, other users, or third parties
    • -
    • Involves fraudulent or illegal activity
    • +
    • Xanax Payment: Send Xanax to <%= link_to "Bram [2728237]", "https://www.torn.com/profiles.php?XID=2728237", target: "_blank" %>. Each Xanax sent equals 1 week of subscription. Payments are automatically processed daily at 12 PM TCT.
    • +
    • Faction Grant: Faction leaders can arrange bulk subscriptions for their members by contacting <%= link_to "Bram [2728237]", "https://www.torn.com/profiles.php?XID=2728237", target: "_blank" %>.

    - You may delete your collected data at any time through the Settings page when logged in. This will remove - all login sessions, API call logs, and your stored API key. Your subscription time will be preserved and - tied to your Torn ID, so you can log back in anytime and your subscription will still be active. + Subscription status is tied to your Torn ID, not your stored data. If you delete your collected data, + your subscription time will be preserved and available when you log back in.

    @@ -297,18 +176,31 @@
    -

    Subscription Model

    +

    Account Termination

    - TornManager uses an in-game subscription model. Access to certain features requires an active subscription, - which can be obtained through: + I reserve the right to suspend or terminate your access to TornManager at my discretion, without + notice, for conduct that I believe:

      -
    • Xanax Payment: Send Xanax to <%= link_to "Bram [2728237]", "https://www.torn.com/profiles.php?XID=2728237", target: "_blank" %>. Each Xanax sent equals 1 week of subscription. Payments are automatically processed daily at 12 PM TCT.
    • -
    • Faction Grant: Faction leaders can arrange bulk subscriptions for their members by contacting <%= link_to "Bram [2728237]", "https://www.torn.com/profiles.php?XID=2728237", target: "_blank" %>.
    • +
    • Violates these Terms of Service
    • +
    • Violates Torn's Terms of Service or API guidelines
    • +
    • Harms TornManager, other users, or third parties
    • +
    • Involves fraudulent or illegal activity

    - Subscription status is tied to your Torn ID, not your stored data. If you delete your collected data, - your subscription time will be preserved and available when you log back in. + You may delete your collected data at any time through the Settings page when logged in. +

    +
    + +
    +

    Intellectual Property

    +

    + TornManager and its original content, features, and functionality are owned by me and protected by + international copyright, trademark, and other intellectual property laws. +

    +

    + Torn, Torn.com, and all related game data remain the property of Torn City Ltd. I use the Torn API + under the terms provided by Torn.

    @@ -360,6 +252,14 @@ +
    +

    Age Requirement

    +

    + You must be at least 13 years old to use TornManager. This aligns with Torn.com's age requirement. + By using this service, you confirm that you meet this age requirement. +

    +
    +

    Changes to Terms

    @@ -389,50 +289,4 @@ <%= link_to "Bram [2728237]", "https://www.torn.com/profiles.php?XID=2728237", target: "_blank", class: "player-link" %>

    - -
    -

    Compliance Statement

    -

    - TornManager is fully compliant with Torn's API Terms of Service and scripting rules. I: -

    -
      -
    • Never request user passwords
    • -
    • Store API keys securely and use them only for authorized purposes
    • -
    • Respect Torn's rate limits and optimize API usage
    • -
    • Provide transparent disclosure of data usage
    • -
    • Do not provide unfair competitive advantages
    • -
    • Do not violate Torn's no-advertising policy
    • -
    • Log all API requests for security and abuse prevention
    • -
    • Immediately remove disabled or invalid API keys
    • -
    - -

    API Request Identification

    -

    - For transparency and accountability, all API requests made by TornManager include the comment parameter - tmanager. This identifier appears in your API key activity logs and allows you to: -

    -
      -
    • Monitor and audit all requests made by TornManager
    • -
    • Distinguish TornManager's API calls from other services
    • -
    • Verify the frequency and nature of API requests
    • -
    • Ensure compliance with rate limits and usage policies
    • -
    -

    - You can view these logs at any time through Torn.com's 'key' → 'log' selection or by using - TornManager's <%= link_to "Key Activity Log", key_log_path %> feature. -

    - -

    - Any suspected violations of these terms or Torn's policies should be reported immediately to both - me and Torn staff. -

    -
    - -
    -

    Age Requirement

    -

    - You must be at least 13 years old to use TornManager. This aligns with Torn.com's age requirement. - By using this service, you confirm that you meet this age requirement. -

    -
    diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 9f58c2c..692a889 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -18,7 +18,7 @@ placeholder: "Enter your Torn API key", value: params[:api_key], class: "login-input" %> -

    +

    diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb index 5fc8800..ec7b491 100644 --- a/app/views/settings/index.html.erb +++ b/app/views/settings/index.html.erb @@ -152,6 +152,12 @@

    Data Management

    +
    +

    Export Your Data

    +

    Download a complete copy of all your data stored by TornManager in JSON format. This includes your profile, sessions, and API call logs.

    + <%= link_to "Download My Data", settings_export_data_path, class: "btn-secondary", data: { turbo: false } %> +
    +

    ⚠️ Delete Your Data

    This will permanently delete:

    diff --git a/config/recurring.yml b/config/recurring.yml index e5e5934..a51e0d2 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -19,6 +19,10 @@ production: class: Daily::XanaxPaymentsJob queue: default schedule: at 12pm every day + data_retention_cleanup: + class: Daily::DataRetentionCleanupJob + queue: default + schedule: at 2am every day # development: # daily_stock_dividend_job: # class: Daily::StockDividendJob diff --git a/config/routes.rb b/config/routes.rb index 4a82aa3..b405d15 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,6 +16,7 @@ delete "settings/purge_data", to: "settings#purge_data", as: :settings_purge_data post "settings/refresh_subscription", to: "settings#refresh_subscription", as: :settings_refresh_subscription patch "settings/update_api_key", to: "settings#update_api_key", as: :settings_update_api_key + get "settings/export_data", to: "settings#export_data", as: :settings_export_data resources :subscriptions, only: [ :index ] do collection do diff --git a/db/seeds.rb b/db/seeds.rb index 11a57fe..fca4626 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -6,7 +6,7 @@ bram = User.find_or_create_by!(torn_id: 2728237) do |user| user.name = "Bram" user.level = 69 - user.api_key = Rails.application.credentials.dig(:bram, :api_key) + user.api_key = OwnerCredentials.api_key end print "Finished!" From 332e930e536f044055fe6e0fc93a66fe261a1f09 Mon Sep 17 00:00:00 2001 From: Bram Date: Mon, 16 Feb 2026 17:22:15 +0100 Subject: [PATCH 030/266] huge changes --- app/assets/stylesheets/application.css | 2635 +---------------- app/assets/stylesheets/base/reset.css | 143 + app/assets/stylesheets/base/utilities.css | 115 + app/assets/stylesheets/base/variables.css | 37 + app/assets/stylesheets/components/badges.css | 143 + app/assets/stylesheets/components/buttons.css | 165 ++ app/assets/stylesheets/components/cards.css | 540 ++++ app/assets/stylesheets/components/flash.css | 194 ++ app/assets/stylesheets/components/forms.css | 206 ++ .../stylesheets/components/navigation.css | 262 ++ app/assets/stylesheets/components/tables.css | 262 ++ app/assets/stylesheets/components/toggle.css | 48 + app/assets/stylesheets/pages/admin.css | 197 ++ app/assets/stylesheets/pages/content.css | 92 + app/assets/stylesheets/pages/key_log.css | 316 ++ app/assets/stylesheets/pages/login.css | 316 ++ app/assets/stylesheets/pages/settings.css | 62 + app/controllers/admin/dashboard_controller.rb | 8 + app/controllers/admin/factions_controller.rb | 85 + .../admin/subscriptions_controller.rb | 129 + app/controllers/concerns/authentication.rb | 5 + app/controllers/settings_controller.rb | 16 +- app/controllers/subscriptions_controller.rb | 131 - .../controllers/api_key_controller.js | 41 +- .../controllers/faction_toggle_controller.js | 38 + .../controllers/flash_item_controller.js | 36 + .../flash_notifications_controller.js | 55 + .../subscription_days_controller.js | 2 +- app/jobs/daily/faction_membership_sync_job.rb | 40 + app/jobs/daily/personal_stats_job.rb | 2 +- app/models/faction.rb | 13 + app/models/faction_subscription_grant.rb | 3 +- app/models/user.rb | 6 + app/views/admin/dashboard/index.html.erb | 29 + app/views/admin/factions/_faction.html.erb | 22 + app/views/admin/factions/_form.html.erb | 14 + .../admin/factions/create.turbo_stream.erb | 4 + app/views/admin/factions/index.html.erb | 52 + app/views/admin/factions/new.html.erb | 12 + .../_faction_grant_form.html.erb | 2 +- .../subscriptions/faction_grant.html.erb | 11 +- .../{ => admin}/subscriptions/index.html.erb | 11 +- app/views/key_log/index.html.erb | 4 - app/views/layouts/_flash.html.erb | 26 + app/views/layouts/_navbar.html.erb | 2 +- app/views/layouts/application.html.erb | 6 + app/views/sessions/new.html.erb | 6 +- app/views/settings/_api_key_card.html.erb | 42 + app/views/settings/index.html.erb | 42 +- config/appsignal.rb | 2 +- config/recurring.yml | 21 +- config/routes.rb | 22 +- db/migrate/20260216152023_create_factions.rb | 13 + .../20260216152046_add_faction_to_users.rb | 5 + ...eference_to_faction_subscription_grants.rb | 10 + db/schema.rb | 20 +- test/controllers/key_log_controller_test.rb | 6 +- test/fixtures/faction_subscription_grants.yml | 12 +- 58 files changed, 3871 insertions(+), 2868 deletions(-) create mode 100644 app/assets/stylesheets/base/reset.css create mode 100644 app/assets/stylesheets/base/utilities.css create mode 100644 app/assets/stylesheets/base/variables.css create mode 100644 app/assets/stylesheets/components/badges.css create mode 100644 app/assets/stylesheets/components/buttons.css create mode 100644 app/assets/stylesheets/components/cards.css create mode 100644 app/assets/stylesheets/components/flash.css create mode 100644 app/assets/stylesheets/components/forms.css create mode 100644 app/assets/stylesheets/components/navigation.css create mode 100644 app/assets/stylesheets/components/tables.css create mode 100644 app/assets/stylesheets/components/toggle.css create mode 100644 app/assets/stylesheets/pages/admin.css create mode 100644 app/assets/stylesheets/pages/content.css create mode 100644 app/assets/stylesheets/pages/key_log.css create mode 100644 app/assets/stylesheets/pages/login.css create mode 100644 app/assets/stylesheets/pages/settings.css create mode 100644 app/controllers/admin/dashboard_controller.rb create mode 100644 app/controllers/admin/factions_controller.rb create mode 100644 app/controllers/admin/subscriptions_controller.rb delete mode 100644 app/controllers/subscriptions_controller.rb create mode 100644 app/javascript/controllers/faction_toggle_controller.js create mode 100644 app/javascript/controllers/flash_item_controller.js create mode 100644 app/javascript/controllers/flash_notifications_controller.js create mode 100644 app/jobs/daily/faction_membership_sync_job.rb create mode 100644 app/models/faction.rb create mode 100644 app/views/admin/dashboard/index.html.erb create mode 100644 app/views/admin/factions/_faction.html.erb create mode 100644 app/views/admin/factions/_form.html.erb create mode 100644 app/views/admin/factions/create.turbo_stream.erb create mode 100644 app/views/admin/factions/index.html.erb create mode 100644 app/views/admin/factions/new.html.erb rename app/views/{ => admin}/subscriptions/_faction_grant_form.html.erb (87%) rename app/views/{ => admin}/subscriptions/faction_grant.html.erb (75%) rename app/views/{ => admin}/subscriptions/index.html.erb (92%) create mode 100644 app/views/layouts/_flash.html.erb create mode 100644 app/views/settings/_api_key_card.html.erb create mode 100644 db/migrate/20260216152023_create_factions.rb create mode 100644 db/migrate/20260216152046_add_faction_to_users.rb create mode 100644 db/migrate/20260216152151_rename_faction_id_and_add_faction_reference_to_faction_subscription_grants.rb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index bfd8e96..66ab6b2 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1,2613 +1,26 @@ /* - * Vercel-inspired design system + * TornManager - Vercel-inspired design system + * + * This file serves as the main CSS entry point. + * Propshaft automatically loads all CSS files in this directory. + * + * CSS Organization: + * - base/variables.css : CSS custom properties (colors, spacing, radius) + * - base/reset.css : Base element styles (body, header, footer, typography) + * - base/utilities.css : Utility classes (.text-muted, margins, etc.) + * + * - components/navigation.css : Navbar styles + * - components/buttons.css : All button variants + * - components/tables.css : Table styles, sorting, filtering + * - components/badges.css : Badge components + * - components/cards.css : Stat cards, info cards, data cards + * - components/flash.css : Toast notifications + * - components/forms.css : Form inputs, groups, labels + * - components/toggle.css : Toggle switch + * + * - pages/admin.css : Admin dashboard, admin cards + * - pages/content.css : Privacy policy, ToS pages + * - pages/key_log.css : Key log specific styles + * - pages/login.css : Login form styles + * - pages/settings.css : Settings page styles */ - -:root { - /* Vercel Colors */ - --gray-50: #fafafa; - --gray-100: #f5f5f5; - --gray-200: #e5e5e5; - --gray-300: #d4d4d4; - --gray-400: #a3a3a3; - --gray-500: #737373; - --gray-600: #525252; - --gray-700: #404040; - --gray-800: #262626; - --gray-900: #171717; - --gray-1000: #0a0a0a; - - --blue-500: #0070f3; - --blue-600: #0061d5; - - /* Spacing */ - --space-1: 4px; - --space-2: 8px; - --space-3: 12px; - --space-4: 16px; - --space-5: 20px; - --space-6: 24px; - --space-8: 32px; - --space-12: 48px; - --space-16: 64px; - - /* Border radius */ - --radius-sm: 5px; - --radius-md: 8px; - --radius-lg: 12px; -} - -* { - box-sizing: border-box; -} - -html, body { - height: 100%; - margin: 0; - padding: 0; -} - -html { - scroll-behavior: smooth; -} - -body { - min-height: 100vh; - display: flex; - flex-direction: column; - background: var(--gray-1000); - color: var(--gray-200); - font-family: "Geist Mono", monospace; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - line-height: 1.6; -} - -header { - position: sticky; - top: 0; - z-index: 100; - width: 100%; - background: rgba(0, 0, 0, 0.8); - backdrop-filter: blur(10px); - border-bottom: 1px solid var(--gray-900); -} - -nav { - position: relative; -} - -main { - flex: 1; - width: 100%; - max-width: 1200px; - margin: 0 auto; - padding: var(--space-12) var(--space-6); -} - -nav { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-4) var(--space-6); - font-size: 0.875rem; - font-weight: 500; -} - -.navbar-left { - display: flex; - align-items: center; - flex: 1; -} - -.navbar-center { - display: flex; - align-items: center; - gap: var(--space-6); - position: absolute; - left: 50%; - transform: translateX(-50%); - min-height: 1.5rem; -} - -.navbar-right { - display: flex; - align-items: center; - flex: 1; - justify-content: flex-end; -} - -footer { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-12) var(--space-6); - border-top: 1px solid var(--gray-900); - font-size: 0.875rem; - color: var(--gray-500); -} - -.footer-left, -.footer-center, -.footer-right { - flex: 1; -} - -.footer-left { - display: flex; - align-items: center; - justify-content: flex-start; -} - -.footer-center { - display: flex; - justify-content: center; - text-align: center; -} - -.footer-right { - display: flex; - align-items: center; - justify-content: flex-end; - gap: var(--space-3); -} - -.footer-link { - color: var(--gray-500); - text-decoration: none; - transition: color 0.15s ease; - font-size: 0.875rem; -} - -.footer-link:hover { - color: var(--gray-200); -} - -.footer-divider { - color: var(--gray-700); -} - -h1, h2, h3 { - color: #ffffff; - font-weight: 600; - letter-spacing: -0.025em; - margin: 0; -} - -h1 { - font-size: 2rem; - line-height: 1.2; - margin-bottom: var(--space-6); -} - -h2 { - font-size: 1.875rem; - line-height: 1.3; -} - -h3 { - font-size: 1.25rem; - line-height: 1.4; -} - -section { - margin-bottom: var(--space-16); -} - -article { - margin-bottom: var(--space-8); -} - -a { - text-decoration: none; - color: var(--gray-200); - transition: color 0.15s ease; -} - -a:hover { - color: #ffffff; -} - -/* Navigation */ -.navbar-link { - color: var(--gray-400); - text-decoration: none; - transition: color 0.15s ease; - font-size: 0.875rem; - font-weight: 500; -} - -.navbar-link:hover { - color: #ffffff; -} - -.navbar-link-disabled { - color: var(--gray-600); - cursor: not-allowed; - font-style: italic; -} - -.navbar-link-disabled:hover { - color: var(--gray-600); -} - -.navbar-link-active { - color: #ffffff; - position: relative; -} - -.navbar-link-active::after { - content: ''; - position: absolute; - bottom: -17px; - left: 0; - right: 0; - height: 2px; - background: #ffffff; - border-radius: 2px 2px 0 0; -} - -.navbar-title { - font-size: 1.125rem; - font-weight: 600; - color: #ffffff; -} - -.navbar-title:hover { - color: #ffffff; -} - -.navbar-user { - display: flex; - align-items: center; - gap: var(--space-3); - position: relative; -} - -.navbar-user-avatar { - width: 32px; - height: 32px; - border-radius: var(--radius-sm); - border: 1px solid var(--gray-800); - object-fit: cover; -} - -.navbar-user-button { - display: flex; - align-items: center; - gap: var(--space-2); - background: transparent; - border: none; - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-sm); - cursor: pointer; - transition: background-color 0.15s ease; - font-family: inherit; -} - -.navbar-user-button:hover { - background: rgba(255, 255, 255, 0.05); -} - -.navbar-user-name { - color: var(--gray-200); - font-size: 0.875rem; - font-weight: 500; -} - -.navbar-user-arrow { - color: var(--gray-400); - transition: transform 0.2s ease, color 0.15s ease; -} - -.navbar-user-arrow-open { - transform: rotate(180deg); - color: var(--gray-200); -} - -.navbar-dropdown { - position: absolute; - top: calc(100% + 8px); - right: 0; - background: var(--gray-900); - border: 1px solid var(--gray-800); - border-radius: var(--radius-md); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); - min-width: 160px; - opacity: 0; - visibility: hidden; - transform: translateY(-8px); - transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s; - z-index: 1000; -} - -.navbar-dropdown-open { - opacity: 1; - visibility: visible; - transform: translateY(0); -} - -.navbar-dropdown-item { - display: block; - width: 100%; - padding: var(--space-3) var(--space-4); - color: var(--gray-200); - text-decoration: none; - font-size: 0.875rem; - font-weight: 500; - font-family: "Geist Mono", monospace; - transition: background-color 0.15s ease, color 0.15s ease; - border: none; - background: transparent; - text-align: left; - cursor: pointer; -} - -.navbar-dropdown-item:first-child { - border-radius: var(--radius-md) var(--radius-md) 0 0; -} - -.navbar-dropdown-item:last-child { - border-radius: 0 0 var(--radius-md) var(--radius-md); -} - -.navbar-dropdown-item:only-child { - border-radius: var(--radius-md); -} - -.navbar-dropdown-item:hover { - background: rgba(255, 255, 255, 0.05); - color: #ffffff; -} - -.navbar-dropdown-disabled { - color: var(--gray-600); - cursor: not-allowed; - font-style: italic; -} - -.navbar-dropdown-disabled:hover { - background: transparent; - color: var(--gray-600); -} - -.navbar-dropdown-signout:hover { - background: rgba(239, 68, 68, 0.1); - color: #fca5a5; -} - -.navbar-signout { - background: transparent; - border: 1px solid var(--gray-800); - color: var(--gray-400); - padding: var(--space-2) var(--space-4); - border-radius: var(--radius-sm); - font-size: 0.875rem; - font-weight: 500; - font-family: "Geist Mono", monospace; - cursor: pointer; - transition: all 0.15s ease; - line-height: 1.5; - height: 2rem; - display: inline-flex; - align-items: center; -} - -.navbar-signout:hover { - background: var(--gray-900); - color: #ffffff; - border-color: var(--gray-700); -} - -.navbar-signin { - background: transparent; - border: 1px solid var(--gray-800); - color: var(--gray-400); - padding: var(--space-2) var(--space-4); - border-radius: var(--radius-sm); - font-size: 0.875rem; - font-weight: 500; - font-family: "Geist Mono", monospace; - text-decoration: none; - transition: all 0.15s ease; - display: inline-flex; - align-items: center; - line-height: 1.5; - height: 2rem; -} - -.navbar-signin:hover { - background: var(--gray-900); - color: #ffffff; - border-color: var(--gray-700); -} - -/* Tables - Vercel Style */ -table { - width: 100%; - border-collapse: separate; - border-spacing: 0; - font-size: 0.8125rem; - background: transparent; -} - -th, td { - padding: var(--space-3) var(--space-3); - text-align: left; - border-bottom: 1px solid var(--gray-900); -} - -th { - background: transparent; - font-weight: 600; - color: var(--gray-400); - text-transform: none; - font-size: 0.6875rem; - letter-spacing: 0.05em; - padding-top: var(--space-2); - padding-bottom: var(--space-2); - border-bottom: 1px solid var(--gray-800); - white-space: nowrap; -} - -td { - color: var(--gray-200); -} - -tr { - transition: background-color 0.15s ease; -} - -tbody tr:hover { - background-color: rgba(255, 255, 255, 0.03); -} - -/* Sort Links */ -.sort-link { - color: var(--gray-400); - text-decoration: none; - display: inline-flex; - align-items: center; - gap: var(--space-2); - transition: color 0.15s ease; - cursor: pointer; -} - -.sort-link:hover { - color: var(--gray-200); -} - -.sort-arrow { - opacity: 0.2; - font-size: 0.75rem; - transition: opacity 0.15s ease; -} - -.sort-arrow.active { - opacity: 1; - color: #ffffff; -} - -/* Player Links */ -.player-link { - color: var(--gray-200); - text-decoration: none; - transition: color 0.15s ease; - font-weight: 500; -} - -.player-link:hover { - color: #ffffff; -} - -/* Login Form - Vercel Style */ -.login-container { - flex: 1; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - min-height: 100vh; - width: 100%; - opacity: 0; - animation: fadeIn 0.6s ease-out forwards; -} - -@keyframes fadeIn { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } -} - -@keyframes slideUp { - 0% { - transform: translateY(20px); - opacity: 0; - } - 100% { - transform: translateY(0); - opacity: 1; - } -} - -.login-form { - background: var(--gray-900); - padding: var(--space-12); - border-radius: var(--radius-lg); - border: 1px solid var(--gray-800); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); - display: flex; - flex-direction: column; - align-items: stretch; - max-width: 420px; - width: 100%; - opacity: 0; - transform: translateY(20px); - animation: slideUp 0.6s ease-out 0.2s forwards; -} - -.login-header { - text-align: center; - margin-bottom: var(--space-8); -} - -.login-title { - font-size: 1.75rem; - font-weight: 600; - color: #ffffff; - margin-bottom: var(--space-2); - letter-spacing: -0.025em; -} - -.login-subtitle { - font-size: 0.875rem; - color: var(--gray-400); - margin: 0; -} - -.login-field { - display: flex; - flex-direction: column; - margin-bottom: var(--space-6); -} - -.login-hint { - font-size: 0.75rem; - color: var(--gray-500); - margin-top: var(--space-2); -} - -.login-checkbox-field { - display: flex; - align-items: center; - gap: var(--space-3); - margin-bottom: var(--space-6); -} - -.login-checkbox { - width: 18px; - height: 18px; - border: 2px solid var(--gray-700); - border-radius: 4px; - background: var(--gray-1000); - cursor: pointer; - transition: all 0.2s ease; - flex-shrink: 0; - appearance: none; - -webkit-appearance: none; - position: relative; -} - -.login-checkbox:checked { - background: #ffffff; - border-color: #ffffff; -} - -.login-checkbox:checked::after { - content: ''; - position: absolute; - left: 5px; - top: 2px; - width: 4px; - height: 8px; - border: solid #000000; - border-width: 0 2px 2px 0; - transform: rotate(45deg); -} - -.login-checkbox:hover { - border-color: var(--gray-600); -} - -.login-checkbox-label { - font-size: 0.8125rem; - color: var(--gray-400); - cursor: pointer; - user-select: none; - line-height: 1.4; -} - -.login-checkbox-link { - color: #ffffff; - text-decoration: underline; - transition: color 0.2s ease; -} - -.login-checkbox-link:hover { - color: var(--gray-300); -} - -.login-label { - font-size: 0.8125rem; - margin-bottom: var(--space-2); - color: var(--gray-300); - font-weight: 500; - letter-spacing: 0.01em; -} - -.login-input { - padding: var(--space-3) var(--space-4); - border: 1px solid var(--gray-800); - border-radius: var(--radius-md); - background: var(--gray-1000); - color: var(--gray-100); - width: 100%; - font-size: 0.9375rem; - font-family: "Geist Mono", monospace; - transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease; -} - -.login-input::placeholder { - color: var(--gray-600); -} - -.login-input:focus { - outline: none; - border-color: var(--gray-600); - background: var(--gray-900); - box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05); -} - -.login-submit { - background-color: #ffffff; - color: #000000; - border: none; - border-radius: var(--radius-md); - padding: var(--space-3) var(--space-6); - font-size: 0.9375rem; - font-weight: 600; - cursor: pointer; - transition: background-color 0.2s ease, transform 0.1s ease, box-shadow 0.2s ease, opacity 0.2s ease; - font-family: "Geist Mono", monospace; - width: 100%; - letter-spacing: 0.01em; -} - -.login-submit:disabled { - background-color: var(--gray-800); - color: var(--gray-600); - cursor: not-allowed; - opacity: 0.5; -} - -.login-submit:not(:disabled):hover { - background-color: var(--gray-100); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(255, 255, 255, 0.15); -} - -.login-submit:not(:disabled):active { - transform: translateY(0); - box-shadow: 0 2px 8px rgba(255, 255, 255, 0.1); -} - -/* Flash Messages - Vercel Style */ -.flash { - margin-bottom: var(--space-6); - padding: var(--space-3) var(--space-4); - border-radius: var(--radius-md); - width: 100%; - text-align: center; - font-size: 0.875rem; - border: 1px solid; -} - -.flash-alert { - background: rgba(239, 68, 68, 0.1); - color: #fca5a5; - border-color: rgba(239, 68, 68, 0.3); -} - -.flash-notice { - background: rgba(34, 197, 94, 0.1); - color: #86efac; - border-color: rgba(34, 197, 94, 0.3); -} - -/* Alert Boxes */ -.alert { - padding: var(--space-4) var(--space-5); - border-radius: var(--radius-md); - font-size: 0.875rem; - border: 1px solid; -} - -.alert strong { - display: block; - margin-bottom: var(--space-2); - color: inherit; -} - -.alert a { - color: inherit; - text-decoration: underline; - font-weight: 600; -} - -.alert a:hover { - opacity: 0.8; -} - -.alert-info { - background: rgba(59, 130, 246, 0.1); - color: #93c5fd; - border-color: rgba(59, 130, 246, 0.3); -} - -/* Page Headers */ -.page-header { - margin-bottom: var(--space-8); -} - -.page-subtitle { - color: var(--gray-400); - font-size: 0.8125rem; - margin-top: var(--space-2); -} - -.api-key-inline { - background: rgba(34, 197, 94, 0.15); - color: #22c55e; - padding: var(--space-1) var(--space-3); - border: 1px solid rgba(34, 197, 94, 0.3); - border-radius: var(--radius-sm); - font-family: "Geist Mono", monospace; - font-size: 0.875rem; - font-weight: 600; - letter-spacing: 0.025em; - animation: keyPulse 2s ease-in-out infinite; - display: inline-block; -} - -@keyframes keyPulse { - 0%, 100% { - border-color: rgba(34, 197, 94, 0.3); - box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); - } - 50% { - border-color: rgba(34, 197, 94, 0.5); - box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.1); - } -} - -/* Table Container */ -.table-container { - background: var(--gray-900); - border: 1px solid var(--gray-800); - border-radius: var(--radius-lg); - overflow: hidden; - margin-bottom: var(--space-8); -} - -.table-container h2 { - padding: var(--space-6); - margin: 0; -} - -.collapsible-header { - cursor: pointer; - user-select: none; - transition: background-color 0.15s ease; - display: flex; - align-items: center; - gap: var(--space-3); -} - -.collapsible-header:hover { - background: rgba(255, 255, 255, 0.02); -} - -.collapsible-header span { - font-size: 0.875rem; - color: var(--gray-500); - transition: color 0.15s ease; -} - -.collapsible-header:hover span { - color: var(--gray-300); -} - -.table-info { - padding: var(--space-4) var(--space-6); - border-bottom: 1px solid var(--gray-800); - background: rgba(255, 255, 255, 0.02); -} - -.table-filter-form { - width: 100%; -} - -.filter-inline { - display: flex; - align-items: center; - gap: var(--space-3); - font-size: 0.875rem; - flex-wrap: wrap; -} - -.date-inputs-inline { - display: flex; - align-items: center; - gap: var(--space-2); -} - -.date-input-inline { - padding: var(--space-2) var(--space-3); - border: 1px solid var(--gray-800); - border-radius: var(--radius-sm); - background: var(--gray-1000); - color: var(--gray-200); - font-size: 0.8125rem; - font-family: "Geist Mono", monospace; - transition: border-color 0.15s ease, background-color 0.15s ease; - min-width: 130px; -} - -.date-input-inline::-webkit-calendar-picker-indicator { - filter: invert(1) brightness(0.8); - cursor: pointer; - opacity: 0.7; - transition: opacity 0.15s ease; -} - -.date-input-inline::-webkit-calendar-picker-indicator:hover { - opacity: 1; -} - -.date-input-inline:hover { - border-color: var(--gray-700); -} - -.date-input-inline:focus { - outline: none; - border-color: var(--gray-600); - background: var(--gray-900); -} - -.date-separator { - color: var(--gray-600); - font-weight: 400; -} - -.filter-button-inline { - background-color: var(--gray-800); - color: var(--gray-200); - border: 1px solid var(--gray-700); - border-radius: var(--radius-sm); - padding: var(--space-2) var(--space-4); - font-size: 0.8125rem; - font-weight: 500; - cursor: pointer; - transition: background-color 0.15s ease, border-color 0.15s ease; - font-family: "Geist Mono", monospace; -} - -.filter-button-inline:hover { - background-color: var(--gray-700); - border-color: var(--gray-600); -} - -.filter-button-inline:active { - transform: translateY(0); -} - -.filter-reset-inline { - color: var(--gray-500); - text-decoration: none; - font-size: 0.8125rem; - transition: color 0.15s ease; - font-weight: 400; -} - -.filter-reset-inline:hover { - color: var(--gray-300); -} - -.date-range-display { - display: flex; - align-items: center; - gap: var(--space-3); - font-size: 0.875rem; -} - -.info-label { - color: var(--gray-400); - font-weight: 600; -} - -.info-value { - color: var(--gray-100); - font-weight: 500; -} - -.info-days { - color: var(--gray-500); - font-weight: 400; -} - -/* Stat Values */ -.stat-value { - color: var(--gray-100); - font-weight: 500; - font-size: 1rem; - white-space: nowrap; -} - -.stat-daily { - color: var(--gray-500); - font-size: 0.75rem; - margin-left: var(--space-2); - display: inline-block; -} - -/* Centered Content */ -.centered-content { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 60vh; - text-align: center; -} - -.hero-container { - display: flex; - align-items: center; - justify-content: center; - min-height: 100vh; - text-align: center; -} - -.hero-wrapper { - position: relative; - display: inline-block; -} - -.hero-title { - font-size: 4rem; - font-weight: 700; - color: #ffffff; - letter-spacing: 0.01em; - opacity: 0; - animation: fadeInUp 1.2s ease-out forwards; -} - -.hero-signin-button { - position: absolute; - top: -8px; - right: -80px; - background: #e63946; - color: #ffffff; - padding: 0.5rem 1rem; - border-radius: 999px; - font-size: 0.875rem; - font-weight: 600; - text-decoration: none; - opacity: 0; - animation: fadeInPulse 1s ease-out 1.8s forwards, pulse 2s infinite 2.8s; - box-shadow: 0 0 0 0 rgba(230, 57, 70, 0.7); -} - -.hero-signin-button:hover { - background: #d32f3c; -} - -@keyframes fadeInUp { - 0% { - opacity: 0; - transform: translateY(30px); - } - 100% { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes fadeInPulse { - 0% { - opacity: 0; - transform: scale(0.8); - } - 50% { - transform: scale(1.05); - } - 100% { - opacity: 1; - transform: scale(1); - } -} - -@keyframes pulse { - 0% { - box-shadow: 0 0 0 0 rgba(230, 57, 70, 0.7); - } - 50% { - box-shadow: 0 0 0 8px rgba(230, 57, 70, 0); - } - 100% { - box-shadow: 0 0 0 0 rgba(230, 57, 70, 0); - } -} - -@media (max-width: 768px) { - .hero-title { - font-size: 3rem; - } - - .hero-signin-button { - position: static; - margin-top: 2rem; - animation: fadeInPulse 1s ease-out 1.8s forwards, pulse 2s infinite 2.8s; - } -} - -@media (max-width: 480px) { - .hero-title { - font-size: 2.5rem; - } -} - -/* Content Pages (Privacy Policy, Terms of Service) */ -.content-wrapper { - max-width: 800px; - margin: 0 auto; -} - -.content-section { - margin-bottom: var(--space-12); -} - -.content-section h2 { - font-size: 1.5rem; - font-weight: 600; - color: #ffffff; - margin-bottom: var(--space-4); - margin-top: var(--space-8); - letter-spacing: -0.025em; -} - -.content-section h3 { - font-size: 1.125rem; - font-weight: 600; - color: var(--gray-200); - margin-bottom: var(--space-3); - margin-top: var(--space-6); -} - -.content-section p { - color: var(--gray-400); - line-height: 1.7; - margin-bottom: var(--space-4); -} - -.content-section ul { - color: var(--gray-400); - line-height: 1.7; - margin-bottom: var(--space-4); - padding-left: var(--space-6); -} - -.content-section li { - margin-bottom: var(--space-2); -} - -.content-section strong { - color: var(--gray-200); - font-weight: 600; -} - -.content-section a { - color: #ffffff; - text-decoration: underline; - transition: color 0.2s ease; -} - -.content-section a:hover { - color: var(--gray-300); -} - -.tos-table { - overflow-x: auto; - margin: var(--space-6) 0; -} - -.tos-table table { - min-width: 100%; - font-size: 0.8125rem; -} - -.tos-table th { - background: var(--gray-900); - color: var(--gray-300); - font-weight: 600; - padding: var(--space-3); - white-space: nowrap; -} - -.tos-table td { - padding: var(--space-3); - color: var(--gray-400); -} - -.table-note { - font-size: 0.8125rem; - color: var(--gray-500); - font-style: italic; - margin-top: var(--space-2); -} - -/* Data Collection Cards */ -.data-collection-cards { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--space-4); - margin: var(--space-6) 0; -} - -.data-card { - background: var(--gray-900); - border: 1px solid var(--gray-800); - border-radius: var(--radius-md); - padding: var(--space-4); -} - -.data-card h4 { - color: var(--gray-100); - font-size: 0.875rem; - font-weight: 600; - margin: 0 0 var(--space-3) 0; - padding-bottom: var(--space-2); - border-bottom: 1px solid var(--gray-800); -} - -.data-card ul { - margin: 0; - padding-left: var(--space-4); -} - -.data-card li { - color: var(--gray-400); - font-size: 0.8125rem; - margin-bottom: var(--space-2); -} - -.data-card li:last-child { - margin-bottom: 0; -} - -.card-note { - font-size: 0.75rem; - color: var(--gray-500); - font-style: italic; - margin: var(--space-3) 0 0 0; - padding-top: var(--space-2); - border-top: 1px solid var(--gray-800); -} - -@media (max-width: 768px) { - .data-collection-cards { - grid-template-columns: 1fr; - } -} - -/* Highlight Pulse Animation */ -.highlight-pulse { - background: rgba(34, 197, 94, 0.15); - color: #22c55e; - padding: 2px 6px; - border-radius: 4px; - border: 1px solid rgba(34, 197, 94, 0.3); - animation: keyPulse 2s ease-in-out infinite; -} - -/* Key Log Styles */ -.key-log-container { - max-width: 700px; - margin: 0 auto; - padding: var(--space-16) var(--space-6); -} - -.key-log-header { - text-align: center; - margin-bottom: var(--space-12); -} - -.key-log-header h1 { - font-size: 2.5rem; - margin-bottom: var(--space-3); -} - -.key-log-subtitle { - color: var(--gray-400); - font-size: 1rem; - margin: 0; -} - -.key-log-form-centered { - background: var(--gray-900); - border: 1px solid var(--gray-800); - border-radius: var(--radius-lg); - padding: var(--space-8); - margin-bottom: var(--space-8); -} - -.key-log-form { - margin: var(--space-8) 0; - max-width: 600px; -} - -.key-log-confirmations { - margin: var(--space-6) 0; - display: flex; - flex-direction: column; - gap: var(--space-4); -} - -.key-log-checkbox-field { - display: flex; - align-items: flex-start; - gap: var(--space-3); -} - -.key-log-checkbox { - width: 18px; - height: 18px; - min-width: 18px; - margin-top: 2px; - cursor: pointer; - accent-color: #ffffff; -} - -.key-log-checkbox-label { - color: var(--gray-400); - font-size: 0.875rem; - line-height: 1.5; - cursor: pointer; - user-select: none; -} - -.key-log-checkbox-label strong { - color: var(--gray-200); -} - -.key-log-info { - background: var(--gray-900); - border: 1px solid var(--gray-800); - border-radius: var(--radius-lg); - padding: var(--space-6); -} - -.key-log-info h3 { - font-size: 1rem; - color: var(--gray-300); - margin: 0 0 var(--space-4) 0; -} - -.key-log-info ul { - list-style: none; - padding: 0; - margin: 0; -} - -.key-log-info li { - color: var(--gray-400); - padding: var(--space-2) 0; - line-height: 1.6; - font-size: 0.875rem; -} - -.key-log-info li strong { - color: var(--gray-200); -} - -.btn-large { - width: 100%; - padding: var(--space-4) var(--space-6); - font-size: 1rem; -} - -.form-group { - margin-bottom: var(--space-6); -} - -.form-label { - display: block; - color: var(--gray-200); - font-weight: 600; - margin-bottom: var(--space-2); - font-size: 0.875rem; -} - -.form-input { - width: 100%; - padding: var(--space-3) var(--space-4); - background: var(--gray-900); - border: 1px solid var(--gray-800); - border-radius: var(--radius-md); - color: var(--gray-200); - font-family: "Geist Mono", monospace; - font-size: 0.875rem; - transition: border-color 0.15s ease; -} - -.form-input:focus { - outline: none; - border-color: var(--gray-600); -} - -.btn { - padding: var(--space-3) var(--space-6); - border: none; - border-radius: var(--radius-md); - font-family: "Geist Mono", monospace; - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; -} - -.btn-primary { - background: #ffffff; - color: #000000; -} - -.btn-primary:hover { - background: var(--gray-200); -} - -.btn-primary:disabled { - background: var(--gray-800); - color: var(--gray-600); - cursor: not-allowed; -} - -.btn-primary:disabled:hover { - background: var(--gray-800); -} - -.key-log-notice { - background: var(--gray-900); - border: 1px solid var(--gray-800); - border-radius: var(--radius-md); - padding: var(--space-4); - margin: var(--space-6) 0; -} - -.key-log-notice p { - color: var(--gray-400); - font-size: 0.875rem; - margin: 0; -} - -.log-summary { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--space-6); - padding-bottom: var(--space-4); - border-bottom: 1px solid var(--gray-900); -} - -.log-summary p { - color: var(--gray-400); - margin: 0; -} - -.back-link { - color: var(--gray-500); - text-decoration: none; - font-size: 0.875rem; - transition: color 0.15s ease; -} - -.back-link:hover { - color: var(--gray-200); -} - -.log-table-wrapper { - overflow-x: auto; - margin: var(--space-6) 0; -} - -.log-table { - width: 100%; - border-collapse: collapse; - font-size: 0.875rem; - min-width: 900px; -} - -@media (max-width: 768px) { - .log-table { - font-size: 0.75rem; - min-width: 800px; - } - - .log-table th, - .log-table td { - padding: var(--space-2) var(--space-3); - } -} - -.log-table th { - background: var(--gray-900); - color: var(--gray-300); - font-weight: 600; - padding: var(--space-3) var(--space-4); - text-align: left; - border-bottom: 1px solid var(--gray-800); -} - -.log-table td { - padding: var(--space-3) var(--space-4); - color: var(--gray-400); - border-bottom: 1px solid var(--gray-900); -} - -.log-table tr:hover { - background: rgba(255, 255, 255, 0.02); -} - -.log-table code { - background: var(--gray-900); - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-sm); - color: var(--gray-300); - font-family: "Geist Mono", monospace; - font-size: 0.8125rem; -} - -.empty-state { - text-align: center; - padding: var(--space-12) var(--space-6); - color: var(--gray-500); -} - -.empty-state p { - margin: 0; -} - -/* Log Statistics */ -.log-stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: var(--space-4); - margin: var(--space-8) 0; -} - -@media (max-width: 480px) { - .log-stats-grid { - grid-template-columns: 1fr; - } -} - -.log-stat-card { - background: var(--gray-900); - border: 1px solid var(--gray-800); - border-radius: var(--radius-md); - padding: var(--space-5); -} - -.log-stat-card h3 { - font-size: 0.875rem; - color: var(--gray-400); - font-weight: 500; - margin: 0 0 var(--space-3) 0; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.log-stat-value { - font-size: 2rem; - font-weight: 600; - color: #ffffff; - line-height: 1; - margin-bottom: var(--space-2); -} - -.stat-label { - font-size: 0.8125rem; - color: var(--gray-500); -} - -.stat-detail { - font-size: 0.75rem; - color: var(--gray-600); - margin-top: var(--space-2); -} - -.stat-warning { - font-size: 0.75rem; - color: #e63946; - font-weight: 600; - margin-top: var(--space-2); -} - -.rate-limit-warning { - background: rgba(230, 57, 70, 0.1); - border: 1px solid rgba(230, 57, 70, 0.3); - border-radius: var(--radius-md); - padding: var(--space-5); - margin: var(--space-6) 0; -} - -.rate-limit-warning h3 { - color: #e63946; - font-size: 1rem; - margin: 0 0 var(--space-3) 0; -} - -.rate-limit-warning p { - color: var(--gray-300); - margin: 0 0 var(--space-3) 0; - line-height: 1.6; -} - -.rate-limit-warning ul { - color: var(--gray-400); - margin: var(--space-3) 0; - padding-left: var(--space-6); - line-height: 1.7; -} - -.rate-limit-warning li { - margin-bottom: var(--space-2); -} - -.rate-limit-warning strong { - color: var(--gray-200); -} - -.log-breakdown { - margin: var(--space-8) 0; - display: grid; - gap: var(--space-6); -} - -.breakdown-section { - background: var(--gray-900); - border: 1px solid var(--gray-800); - border-radius: var(--radius-md); - padding: var(--space-5); -} - -.breakdown-section h3 { - font-size: 0.875rem; - color: var(--gray-300); - font-weight: 600; - margin: 0 0 var(--space-4) 0; -} - -.breakdown-list { - display: flex; - flex-direction: column; - gap: var(--space-3); -} - -.breakdown-item { - display: grid; - grid-template-columns: 250px 1fr auto; - gap: var(--space-3); - align-items: center; - font-size: 0.8125rem; -} - -@media (max-width: 768px) { - .breakdown-item { - grid-template-columns: 150px 1fr auto; - font-size: 0.75rem; - } -} - -@media (max-width: 480px) { - .breakdown-item { - grid-template-columns: 1fr; - gap: var(--space-2); - } - - .breakdown-bar { - order: 2; - } - - .breakdown-count { - order: 3; - text-align: left; - } -} - -.breakdown-label { - color: var(--gray-300); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - position: relative; - cursor: default; -} - -.breakdown-label:hover { - overflow: visible; - white-space: normal; - z-index: 10; -} - -.breakdown-label:hover::after { - content: attr(title); - position: absolute; - left: 0; - top: 0; - background: var(--gray-800); - border: 1px solid var(--gray-700); - border-radius: var(--radius-sm); - padding: var(--space-2) var(--space-3); - color: var(--gray-200); - white-space: normal; - word-break: break-word; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); - z-index: 100; - min-width: 200px; - max-width: 400px; -} - -.breakdown-label code { - background: var(--gray-800); - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-sm); - color: var(--gray-300); - font-family: "Geist Mono", monospace; - font-size: 0.75rem; -} - -.breakdown-bar { - display: block; - height: 20px; - background: var(--gray-800); - border-radius: var(--radius-sm); - overflow: hidden; - position: relative; -} - -.breakdown-bar-fill { - display: block; - height: 100%; - background: linear-gradient(90deg, var(--blue-600), var(--blue-500)); - transition: width 0.3s ease; -} - -.breakdown-count { - color: var(--gray-500); - text-align: right; - min-width: 80px; - font-variant-numeric: tabular-nums; -} - -/* Admin Dashboard Styles */ -.admin-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: var(--space-12); -} - -.admin-title-section { - flex: 1; -} - -.admin-title { - font-size: 2.5rem; - font-weight: 700; - color: #ffffff; - margin: 0 0 var(--space-2) 0; - letter-spacing: -0.05em; -} - -.admin-subtitle { - color: var(--gray-500); - font-size: 0.875rem; - margin: 0; -} - -.admin-actions { - display: flex; - gap: var(--space-3); -} - -.button { - display: inline-flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-3) var(--space-5); - border-radius: var(--radius-md); - font-size: 0.875rem; - font-weight: 600; - font-family: "Geist Mono", monospace; - text-decoration: none; - transition: all 0.15s ease; - cursor: pointer; - border: none; -} - -.button-primary { - background: #ffffff; - color: #000000; -} - -.button-primary:hover { - background: var(--gray-200); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(255, 255, 255, 0.15); -} - -.button-secondary { - background: var(--gray-800); - color: var(--gray-200); - border: 1px solid var(--gray-700); -} - -.button-secondary:hover { - background: var(--gray-700); - border-color: var(--gray-600); -} - -/* Stats Grid */ -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: var(--space-4); - margin-bottom: var(--space-12); -} - -.stat-card { - background: var(--gray-900); - border: 1px solid var(--gray-800); - border-radius: var(--radius-lg); - padding: var(--space-6); - transition: all 0.2s ease; -} - -.stat-card:hover { - border-color: var(--gray-700); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); -} - -.stat-card-header { - display: flex; - align-items: center; - gap: var(--space-3); - margin-bottom: var(--space-4); -} - -.stat-icon { - width: 40px; - height: 40px; - border-radius: var(--radius-md); - background: rgba(255, 255, 255, 0.05); - display: flex; - align-items: center; - justify-content: center; - font-size: 1.25rem; -} - -.stat-card-title { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--gray-500); - font-weight: 600; - margin: 0; -} - -.stat-card-value { - font-size: 2.5rem; - font-weight: 700; - color: #ffffff; - line-height: 1; - margin-bottom: var(--space-2); -} - -.stat-card-label { - font-size: 0.875rem; - color: var(--gray-400); -} - -.stat-card-trend { - font-size: 0.75rem; - color: var(--gray-500); - margin-top: var(--space-2); -} - -.stat-card-trend.positive { - color: #22c55e; -} - -/* Section Headers */ -.section-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-6); - background: rgba(255, 255, 255, 0.02); - border-bottom: 1px solid var(--gray-800); -} - -.section-title { - font-size: 1.125rem; - font-weight: 600; - color: #ffffff; - margin: 0; - display: flex; - align-items: center; - gap: var(--space-3); -} - -.section-badge { - display: inline-flex; - align-items: center; - padding: var(--space-1) var(--space-3); - background: rgba(255, 255, 255, 0.1); - border-radius: 999px; - font-size: 0.75rem; - font-weight: 600; - color: var(--gray-300); -} - -.section-actions { - display: flex; - gap: var(--space-2); -} - -/* Table Enhancements */ -.table-container h2 { - margin-bottom: var(--space-6); -} - -.table-wrapper { - overflow-x: auto; -} - -.table-empty { - padding: var(--space-12); - text-align: center; - color: var(--gray-500); -} - -.table-empty-icon { - font-size: 3rem; - margin-bottom: var(--space-4); - opacity: 0.3; -} - -.table-empty-text { - font-size: 0.875rem; - margin: 0; -} - -/* Badge Components */ -.badge { - display: inline-flex; - align-items: center; - padding: var(--space-1) var(--space-3); - border-radius: 999px; - font-size: 0.75rem; - font-weight: 600; -} - -.badge-success { - background: rgba(34, 197, 94, 0.15); - color: #22c55e; - border: 1px solid rgba(34, 197, 94, 0.3); -} - -.badge-warning { - background: rgba(251, 191, 36, 0.15); - color: #fbbf24; - border: 1px solid rgba(251, 191, 36, 0.3); -} - -.badge-info { - background: rgba(59, 130, 246, 0.15); - color: #3b82f6; - border: 1px solid rgba(59, 130, 246, 0.3); -} - -/* Form Container */ -.form-container { - background: var(--gray-900); - border: 1px solid var(--gray-800); - border-radius: var(--radius-lg); - padding: var(--space-8); - max-width: 600px; - margin: 0 0 var(--space-8) 0; -} - -.form-group { - margin-bottom: var(--space-6); -} - -.form-group:last-child { - margin-bottom: 0; -} - -.form-input, -.form-select { - width: 100%; - padding: var(--space-3) var(--space-4); - background: var(--gray-1000); - border: 1px solid var(--gray-800); - border-radius: var(--radius-md); - color: var(--gray-200); - font-family: "Geist Mono", monospace; - font-size: 0.875rem; - transition: all 0.15s ease; -} - -.form-input:focus, -.form-select:focus { - outline: none; - border-color: var(--gray-600); - background: var(--gray-900); - box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05); -} - -.form-help { - font-size: 0.75rem; - color: var(--gray-500); - margin-top: var(--space-2); - margin-bottom: 0; -} - -.form-actions { - display: flex; - gap: var(--space-3); - margin-top: var(--space-8); -} - -.form-actions .button { - flex: 1; -} - -/* Info Box */ -.info-box { - background: var(--gray-900); - border: 1px solid var(--gray-800); - border-radius: var(--radius-lg); - padding: var(--space-6); - margin-top: 0; - margin-bottom: var(--space-8); - max-width: 600px; -} - -.info-box h3 { - font-size: 1rem; - font-weight: 600; - color: var(--gray-200); - margin: 0 0 var(--space-4) 0; -} - -.info-box ul { - list-style: none; - padding: 0; - margin: 0; -} - -.info-box li { - padding: var(--space-2) 0; - padding-left: var(--space-6); - color: var(--gray-400); - font-size: 0.875rem; - line-height: 1.6; - position: relative; -} - -.info-box li::before { - content: "→"; - position: absolute; - left: 0; - color: var(--gray-600); -} - -/* Utility Classes */ -.text-muted { - color: var(--gray-500); -} - -.text-small { - font-size: 0.75rem; -} - -/* Inline Editing Styles */ -.days-cell { - display: flex; - align-items: center; -} - -.days-input { - width: 80px; - padding: var(--space-1) var(--space-2); - background: var(--gray-1000); - border: 1px solid var(--gray-700); - border-radius: var(--radius-sm); - color: var(--gray-200); - font-family: "Geist Mono", monospace; - font-size: 0.875rem; - transition: border-color 0.15s ease; -} - -.days-input:focus { - outline: none; - border-color: var(--gray-500); - background: var(--gray-900); -} - -.edit-days-btn { - padding: var(--space-1) var(--space-3); - background: var(--gray-800); - border: 1px solid var(--gray-700); - border-radius: var(--radius-sm); - color: var(--gray-300); - font-family: "Geist Mono", monospace; - font-size: 0.75rem; - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; - margin-left: var(--space-2); -} - -.edit-days-btn:hover:not(:disabled) { - background: var(--gray-700); - border-color: var(--gray-600); - color: var(--gray-100); -} - -.edit-days-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.mt-2 { margin-top: var(--space-2); } -.mt-4 { margin-top: var(--space-4); } -.mt-6 { margin-top: var(--space-6); } -.mt-8 { margin-top: var(--space-8); } -.mt-12 { margin-top: var(--space-12); } - -.mb-2 { margin-bottom: var(--space-2); } -.mb-4 { margin-bottom: var(--space-4); } -.mb-6 { margin-bottom: var(--space-6); } -.mb-8 { margin-bottom: var(--space-8); } -.mb-12 { margin-bottom: var(--space-12); } - -/* Live API Feed */ -.live-api-feed { - position: absolute; - top: calc(100% + 8px); - right: 24px; - z-index: 1000; - pointer-events: none; -} - -.live-api-feed-list { - display: flex; - flex-direction: column; - gap: var(--space-2); - width: 350px; -} - -.live-api-item { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-2) var(--space-3); - background: var(--gray-900); - border: 1px solid var(--gray-800); - border-radius: var(--radius-sm); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); - font-size: 0.8125rem; - opacity: 0; - transform: translateY(-8px); - transition: opacity 0.5s ease, transform 0.5s ease; - pointer-events: auto; -} - -.live-api-item-visible { - opacity: 1; - transform: translateY(0); -} - -.live-api-item-fade-out { - opacity: 0; - transform: translateY(-8px); -} - -.live-api-endpoint { - flex: 1; - color: var(--gray-200); - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.live-api-status { - font-weight: 600; - font-size: 0.875rem; -} - -.live-api-status-success { - color: #10b981; -} - -.live-api-status-error { - color: #ef4444; -} - -.live-api-time { - color: var(--gray-400); - font-variant-numeric: tabular-nums; - font-size: 0.75rem; - white-space: nowrap; -} - -/* API Peak Cards */ -.api-peak-card { - display: flex; - flex-direction: column; - gap: var(--space-3); -} - -.api-peak-header { - display: flex; - align-items: center; - gap: var(--space-2); - flex-wrap: wrap; -} - -.api-peak-title { - font-size: 0.8125rem; - font-weight: 500; - color: var(--gray-400); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.api-peak-badge { - display: inline-flex; - align-items: center; - padding: 2px 8px; - background: rgba(16, 185, 129, 0.1); - color: #10b981; - font-size: 0.6875rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - border-radius: 3px; - border: 1px solid rgba(16, 185, 129, 0.2); -} - -.api-peak-value { - font-size: 2.5rem; - font-weight: 700; - color: var(--gray-100); - line-height: 1; - font-variant-numeric: tabular-nums; -} - -.api-peak-time { - display: flex; - align-items: center; - font-size: 0.875rem; - color: var(--gray-400); - font-variant-numeric: tabular-nums; -} - -.api-peak-empty { - font-size: 0.875rem; - color: var(--gray-500); - font-style: italic; -} - -/* Settings Page */ -.settings-section { - margin-bottom: 3rem; -} - -.settings-section h2 { - font-size: 1.5rem; - font-weight: 600; - margin-bottom: 1.5rem; - color: var(--gray-100); -} - -/* Subscription Card */ -.subscription-card { - background: var(--gray-900); - border: 1px solid var(--gray-800); - border-radius: var(--radius-md); - padding: 2rem; - margin-bottom: 1.5rem; -} - -.subscription-active, -.subscription-inactive { - margin-bottom: 1.5rem; -} - -.subscription-badge { - display: inline-block; - padding: 0.5rem 1rem; - background: #10b981; - color: white; - border-radius: var(--radius-sm); - font-weight: 600; - font-size: 0.875rem; - margin-bottom: 1rem; -} - -.subscription-badge-inactive { - background: transparent; - color: #ef4444; - padding: 0; - font-size: 1.25rem; - animation: pulse-red 2s ease-in-out infinite; -} - -@keyframes pulse-red { - 0%, 100% { - opacity: 1; - } - 50% { - opacity: 0.6; - } -} - -.subscription-days { - font-size: 2rem; - font-weight: 700; - color: var(--gray-100); - margin-bottom: 0.5rem; - font-variant-numeric: tabular-nums; -} - -.subscription-expires { - color: var(--gray-400); - font-size: 0.875rem; - margin-bottom: 1rem; -} - -/* Progress Bar */ -.progress-bar-container { - width: 100%; - height: 8px; - background: var(--gray-800); - border-radius: var(--radius-sm); - overflow: hidden; - margin-bottom: 1rem; -} - -.progress-bar-fill { - height: 100%; - background: linear-gradient(90deg, #10b981 0%, #059669 100%); - transition: width 0.3s ease; -} - -/* Info Cards */ -.info-card { - background: var(--gray-900); - border: 1px solid var(--gray-800); - border-radius: var(--radius-md); - padding: 1.5rem; -} - -.info-card h3 { - font-size: 1.125rem; - font-weight: 600; - margin-bottom: 1rem; - color: var(--gray-100); -} - -.info-card h4 { - font-size: 1rem; - font-weight: 600; - margin-bottom: 0.5rem; - margin-top: 1rem; - color: var(--gray-200); -} - -.info-card p { - margin-bottom: 0.75rem; - color: var(--gray-300); - line-height: 1.6; -} - -.info-card a { - color: var(--blue-500); - text-decoration: none; -} - -.info-card a:hover { - text-decoration: underline; -} - -.renewal-method { - margin-bottom: 1rem; -} - -.renewal-method:last-child { - margin-bottom: 0; -} - -/* Settings Page Stat Cards */ -.settings-section .stat-card .stat-label { - color: #ffffff; -} - -.settings-section .stat-card .stat-value { - font-size: 2.5rem; - font-weight: 700; - color: var(--gray-100); - margin-bottom: 0.25rem; -} - -.settings-section .stat-card .stat-description { - font-size: 0.75rem; - color: var(--gray-600); - line-height: 1.4; -} - -/* Danger Zone */ -.danger-zone { - background: rgba(239, 68, 68, 0.1); - border: 2px solid rgba(239, 68, 68, 0.3); - border-radius: var(--radius-md); - padding: 2rem; -} - -.danger-zone h3 { - font-size: 1.125rem; - font-weight: 600; - color: #fca5a5; - margin-bottom: 1rem; -} - -.danger-zone ul { - margin-left: 1.5rem; - margin-bottom: 1rem; - color: var(--gray-300); -} - -.danger-zone li { - margin-bottom: 0.5rem; -} - -.danger-zone p { - color: var(--gray-300); - margin-bottom: 0.75rem; -} - -/* Buttons */ -.btn-secondary { - background: var(--gray-700); - color: white; - padding: 0.625rem 1.25rem; - border-radius: var(--radius-sm); - border: 1px solid var(--gray-600); - font-weight: 500; - cursor: pointer; - transition: background 0.2s ease; - font-family: inherit; - font-size: 0.875rem; - text-decoration: none; - display: inline-block; -} - -a.btn-secondary { - color: white; -} - -a.btn-secondary:hover { - color: white; -} - -.btn-secondary:hover { - background: var(--gray-600); -} - -.btn-secondary:disabled { - background: var(--gray-800); - border-color: var(--gray-700); - color: var(--gray-500); - cursor: not-allowed; - opacity: 0.6; -} - -.btn-danger { - background: #dc2626; - color: white; - padding: 0.75rem 1.5rem; - border-radius: var(--radius-sm); - border: none; - font-weight: 600; - cursor: pointer; - transition: background 0.2s ease; - margin-top: 1rem; - font-family: inherit; - font-size: 0.875rem; -} - -.btn-danger:hover { - background: #b91c1c; -} - -.mt-4 { - margin-top: 1.5rem; -} - -/* API Key Management */ -.api-key-card { - background: var(--gray-900); - border: 1px solid var(--gray-800); - border-radius: var(--radius-md); - padding: 2rem; -} - -.api-key-info { - margin-bottom: 1.5rem; -} - -.api-key-row { - display: flex; - align-items: center; - gap: 1rem; - margin-bottom: 0.75rem; -} - -.api-key-row:last-child { - margin-bottom: 0; -} - -.api-key-label { - color: var(--gray-400); - font-size: 0.875rem; - min-width: 100px; -} - -.api-key-value { - font-family: "Geist Mono", monospace; - color: var(--gray-200); - font-size: 0.875rem; - background: var(--gray-800); - padding: 0.25rem 0.75rem; - border-radius: var(--radius-sm); -} - -.api-key-access-badge { - display: inline-block; - padding: 0.25rem 0.75rem; - border-radius: var(--radius-sm); - font-size: 0.875rem; - font-weight: 600; -} - -.api-key-access-good { - background: rgba(16, 185, 129, 0.15); - color: #10b981; - border: 1px solid rgba(16, 185, 129, 0.3); -} - -.api-key-access-limited { - background: rgba(251, 191, 36, 0.15); - color: #fbbf24; - border: 1px solid rgba(251, 191, 36, 0.3); -} - -.api-key-warning { - background: rgba(251, 191, 36, 0.1); - border: 1px solid rgba(251, 191, 36, 0.3); - border-radius: var(--radius-sm); - padding: 1rem; - margin-bottom: 1.5rem; - color: var(--gray-300); - font-size: 0.875rem; - line-height: 1.6; -} - -.api-key-warning strong { - color: #fbbf24; -} - -.api-key-form-container { - margin-top: 1.5rem; - padding-top: 1.5rem; - border-top: 1px solid var(--gray-800); -} - -.api-key-form-inline { - display: flex; - gap: 0.75rem; - margin-bottom: 0.5rem; -} - -.api-key-form-inline .api-key-form-input { - flex: 1; -} - -.api-key-form-input { - padding: 0.75rem 1rem; - background: var(--gray-1000); - border: 1px solid var(--gray-700); - border-radius: var(--radius-md); - color: var(--gray-200); - font-family: "Geist Mono", monospace; - font-size: 0.9375rem; - transition: border-color 0.15s ease, background-color 0.15s ease; -} - -.api-key-form-input:focus { - outline: none; - border-color: var(--gray-500); - background: var(--gray-900); -} - -.api-key-form-input::placeholder { - color: var(--gray-600); -} - -.api-key-form-hint { - margin-top: 0.5rem; - margin-bottom: 0; - font-size: 0.75rem; - color: var(--gray-500); -} - -.api-key-form-hint a { - color: var(--blue-500); - text-decoration: none; -} - -.api-key-form-hint a:hover { - text-decoration: underline; -} - -.api-key-feedback { - margin-top: 0.75rem; - padding: 0.75rem 1rem; - border-radius: var(--radius-sm); - font-size: 0.875rem; - display: none; -} - -.api-key-feedback:not(:empty) { - display: block; -} - -.api-key-feedback-success { - background: rgba(16, 185, 129, 0.15); - color: #10b981; - border: 1px solid rgba(16, 185, 129, 0.3); -} - -.api-key-feedback-error { - background: rgba(239, 68, 68, 0.15); - color: #fca5a5; - border: 1px solid rgba(239, 68, 68, 0.3); -} diff --git a/app/assets/stylesheets/base/reset.css b/app/assets/stylesheets/base/reset.css new file mode 100644 index 0000000..9b1d711 --- /dev/null +++ b/app/assets/stylesheets/base/reset.css @@ -0,0 +1,143 @@ +/* + * Base reset and element styles + */ + +* { + box-sizing: border-box; +} + +html, body { + height: 100%; + margin: 0; + padding: 0; +} + +html { + scroll-behavior: smooth; +} + +body { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--gray-1000); + color: var(--gray-200); + font-family: "Geist Mono", monospace; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + line-height: 1.6; +} + +header { + position: sticky; + top: 0; + z-index: 100; + width: 100%; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--gray-900); +} + +nav { + position: relative; +} + +main { + flex: 1; + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: var(--space-12) var(--space-6); +} + +footer { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-12) var(--space-6); + border-top: 1px solid var(--gray-900); + font-size: 0.875rem; + color: var(--gray-500); +} + +.footer-left, +.footer-center, +.footer-right { + flex: 1; +} + +.footer-left { + display: flex; + align-items: center; + justify-content: flex-start; +} + +.footer-center { + display: flex; + justify-content: center; + text-align: center; +} + +.footer-right { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-3); +} + +.footer-link { + color: var(--gray-500); + text-decoration: none; + transition: color 0.15s ease; + font-size: 0.875rem; +} + +.footer-link:hover { + color: var(--gray-200); +} + +.footer-divider { + color: var(--gray-700); +} + +h1, h2, h3 { + color: #ffffff; + font-weight: 600; + letter-spacing: -0.025em; + margin: 0; +} + +h1 { + font-size: 2rem; + line-height: 1.2; + margin-bottom: var(--space-6); +} + +h2 { + font-size: 1.875rem; + line-height: 1.3; +} + +h3 { + font-size: 1.25rem; + line-height: 1.4; +} + +section { + margin-bottom: var(--space-16); +} + +article { + margin-bottom: var(--space-8); +} + +a { + text-decoration: none; + color: var(--gray-200); + transition: color 0.15s ease; +} + +a:hover { + color: #ffffff; +} diff --git a/app/assets/stylesheets/base/utilities.css b/app/assets/stylesheets/base/utilities.css new file mode 100644 index 0000000..221b91f --- /dev/null +++ b/app/assets/stylesheets/base/utilities.css @@ -0,0 +1,115 @@ +/* + * Utility classes + */ + +.text-muted { + color: var(--gray-500); +} + +.text-small { + font-size: 0.75rem; +} + +.mt-2 { margin-top: var(--space-2); } +.mt-4 { margin-top: var(--space-4); } +.mt-6 { margin-top: var(--space-6); } +.mt-8 { margin-top: var(--space-8); } +.mt-12 { margin-top: var(--space-12); } + +.mb-2 { margin-bottom: var(--space-2); } +.mb-3 { margin-bottom: var(--space-3); } +.mb-4 { margin-bottom: var(--space-4); } +.mb-6 { margin-bottom: var(--space-6); } +.mb-8 { margin-bottom: var(--space-8); } +.mb-12 { margin-bottom: var(--space-12); } + +/* Centered Content */ +.centered-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + text-align: center; +} + +/* Page Headers */ +.page-header { + margin-bottom: var(--space-8); +} + +.page-subtitle { + color: var(--gray-400); + font-size: 0.8125rem; + margin-top: var(--space-2); +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: var(--space-12) var(--space-6); + color: var(--gray-500); +} + +.empty-state p { + margin: 0; +} + +/* Highlight Pulse Animation */ +.highlight-pulse { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; + padding: 2px 6px; + border-radius: 4px; + border: 1px solid rgba(34, 197, 94, 0.3); + animation: keyPulse 2s ease-in-out infinite; +} + +@keyframes keyPulse { + 0%, 100% { + border-color: rgba(34, 197, 94, 0.3); + box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); + } + 50% { + border-color: rgba(34, 197, 94, 0.5); + box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.1); + } +} + +.api-key-inline { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; + padding: var(--space-1) var(--space-3); + border: 1px solid rgba(34, 197, 94, 0.3); + border-radius: var(--radius-sm); + font-family: "Geist Mono", monospace; + font-size: 0.875rem; + font-weight: 600; + letter-spacing: 0.025em; + animation: keyPulse 2s ease-in-out infinite; + display: inline-block; +} + +/* Player Links */ +.player-link { + color: var(--gray-200); + text-decoration: none; + transition: color 0.15s ease; + font-weight: 500; +} + +.player-link:hover { + color: #ffffff; +} + +/* Back Link */ +.back-link { + color: var(--gray-500); + text-decoration: none; + font-size: 0.875rem; + transition: color 0.15s ease; +} + +.back-link:hover { + color: var(--gray-200); +} diff --git a/app/assets/stylesheets/base/variables.css b/app/assets/stylesheets/base/variables.css new file mode 100644 index 0000000..b90e574 --- /dev/null +++ b/app/assets/stylesheets/base/variables.css @@ -0,0 +1,37 @@ +/* + * Vercel-inspired design system - CSS Variables + */ + +:root { + /* Vercel Colors */ + --gray-50: #fafafa; + --gray-100: #f5f5f5; + --gray-200: #e5e5e5; + --gray-300: #d4d4d4; + --gray-400: #a3a3a3; + --gray-500: #737373; + --gray-600: #525252; + --gray-700: #404040; + --gray-800: #262626; + --gray-900: #171717; + --gray-1000: #0a0a0a; + + --blue-500: #0070f3; + --blue-600: #0061d5; + + /* Spacing */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-12: 48px; + --space-16: 64px; + + /* Border radius */ + --radius-sm: 5px; + --radius-md: 8px; + --radius-lg: 12px; +} diff --git a/app/assets/stylesheets/components/badges.css b/app/assets/stylesheets/components/badges.css new file mode 100644 index 0000000..e3bd4ff --- /dev/null +++ b/app/assets/stylesheets/components/badges.css @@ -0,0 +1,143 @@ +/* + * Badge Components + */ + +.badge { + display: inline-flex; + align-items: center; + padding: var(--space-1) var(--space-3); + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; +} + +.badge-success { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; + border: 1px solid rgba(34, 197, 94, 0.3); +} + +.badge-warning { + background: rgba(251, 191, 36, 0.15); + color: #fbbf24; + border: 1px solid rgba(251, 191, 36, 0.3); +} + +.badge-info { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; + border: 1px solid rgba(59, 130, 246, 0.3); +} + +/* Section Badge */ +.section-badge { + display: inline-flex; + align-items: center; + padding: var(--space-1) var(--space-3); + background: rgba(255, 255, 255, 0.1); + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + color: var(--gray-300); +} + +/* API Peak Badge */ +.api-peak-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + background: rgba(16, 185, 129, 0.1); + color: #10b981; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + border-radius: 3px; + border: 1px solid rgba(16, 185, 129, 0.2); +} + +/* Subscription Badge */ +.subscription-badge { + display: inline-block; + padding: 0.5rem 1rem; + background: #10b981; + color: white; + border-radius: var(--radius-sm); + font-weight: 600; + font-size: 0.875rem; + margin-bottom: 1rem; +} + +.subscription-badge-inactive { + background: transparent; + color: #ef4444; + padding: 0; + font-size: 1.25rem; + animation: pulse-red 2s ease-in-out infinite; +} + +@keyframes pulse-red { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } +} + +/* API Key Access Badge */ +.api-key-access-badge { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 600; +} + +.api-key-access-good { + background: rgba(16, 185, 129, 0.15); + color: #10b981; + border: 1px solid rgba(16, 185, 129, 0.3); +} + +.api-key-access-limited { + background: rgba(251, 191, 36, 0.15); + color: #fbbf24; + border: 1px solid rgba(251, 191, 36, 0.3); +} + +/* Highlight Pulse Animation */ +.highlight-pulse { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; + padding: 2px 6px; + border-radius: 4px; + border: 1px solid rgba(34, 197, 94, 0.3); + animation: keyPulse 2s ease-in-out infinite; +} + +/* API Key Inline Badge */ +.api-key-inline { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; + padding: var(--space-1) var(--space-3); + border: 1px solid rgba(34, 197, 94, 0.3); + border-radius: var(--radius-sm); + font-family: "Geist Mono", monospace; + font-size: 0.875rem; + font-weight: 600; + letter-spacing: 0.025em; + animation: keyPulse 2s ease-in-out infinite; + display: inline-block; +} + +@keyframes keyPulse { + 0%, 100% { + border-color: rgba(34, 197, 94, 0.3); + box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); + } + 50% { + border-color: rgba(34, 197, 94, 0.5); + box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.1); + } +} diff --git a/app/assets/stylesheets/components/buttons.css b/app/assets/stylesheets/components/buttons.css new file mode 100644 index 0000000..e3f8558 --- /dev/null +++ b/app/assets/stylesheets/components/buttons.css @@ -0,0 +1,165 @@ +/* + * Button component styles + */ + +.btn { + padding: var(--space-3) var(--space-6); + border: none; + border-radius: var(--radius-md); + font-family: "Geist Mono", monospace; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.btn-large { + width: 100%; + padding: var(--space-4) var(--space-6); + font-size: 1rem; +} + +.btn-primary { + background: var(--gray-100); + color: var(--gray-900); + padding: 0.625rem 1.25rem; + border-radius: var(--radius-sm); + border: 1px solid var(--gray-100); + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease; + font-family: inherit; + font-size: 0.875rem; +} + +.btn-primary:hover { + background: var(--gray-200); +} + +.btn-primary:disabled { + background: var(--gray-800); + color: var(--gray-600); + cursor: not-allowed; +} + +.btn-primary:disabled:hover { + background: var(--gray-800); +} + +.btn-secondary { + background: var(--gray-700); + color: white; + padding: 0.625rem 1.25rem; + border-radius: var(--radius-sm); + border: 1px solid var(--gray-600); + font-weight: 500; + cursor: pointer; + transition: background 0.2s ease; + font-family: inherit; + font-size: 0.875rem; + text-decoration: none; + display: inline-block; +} + +a.btn-secondary { + color: white; +} + +a.btn-secondary:hover { + color: white; +} + +.btn-secondary:hover { + background: var(--gray-600); +} + +.btn-secondary:disabled { + background: var(--gray-800); + border-color: var(--gray-700); + color: var(--gray-500); + cursor: not-allowed; + opacity: 0.6; +} + +.btn-danger { + background: #dc2626; + color: white; + padding: 0.75rem 1.5rem; + border-radius: var(--radius-sm); + border: none; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease; + margin-top: 1rem; + font-family: inherit; + font-size: 0.875rem; +} + +.btn-danger:hover { + background: #b91c1c; +} + +.btn-small { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; + background: var(--gray-700); + color: white; + border: 1px solid var(--gray-600); + border-radius: var(--radius-sm); + text-decoration: none; + display: inline-block; + cursor: pointer; + font-family: inherit; + transition: background 0.15s ease; +} + +.btn-small:hover { + background: var(--gray-600); + color: white; +} + +.btn-small-danger { + background: #7f1d1d; + border-color: #991b1b; +} + +.btn-small-danger:hover { + background: #991b1b; +} + +.button { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-5); + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 600; + font-family: "Geist Mono", monospace; + text-decoration: none; + transition: all 0.15s ease; + cursor: pointer; + border: none; +} + +.button-primary { + background: #ffffff; + color: #000000; +} + +.button-primary:hover { + background: var(--gray-200); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(255, 255, 255, 0.15); +} + +.button-secondary { + background: var(--gray-800); + color: var(--gray-200); + border: 1px solid var(--gray-700); +} + +.button-secondary:hover { + background: var(--gray-700); + border-color: var(--gray-600); +} diff --git a/app/assets/stylesheets/components/cards.css b/app/assets/stylesheets/components/cards.css new file mode 100644 index 0000000..cbe5fd2 --- /dev/null +++ b/app/assets/stylesheets/components/cards.css @@ -0,0 +1,540 @@ +/* + * Card Components + */ + +/* Stats Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: var(--space-4); + margin-bottom: var(--space-12); +} + +/* Stat Card */ +.stat-card { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + padding: var(--space-6); + transition: all 0.2s ease; +} + +.stat-card:hover { + border-color: var(--gray-700); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.stat-card-header { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-4); +} + +.stat-icon { + width: 40px; + height: 40px; + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.05); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; +} + +.stat-card-title { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--gray-500); + font-weight: 600; + margin: 0; +} + +.stat-card-value { + font-size: 2.5rem; + font-weight: 700; + color: #ffffff; + line-height: 1; + margin-bottom: var(--space-2); +} + +.stat-card-label { + font-size: 0.875rem; + color: var(--gray-400); +} + +.stat-card-trend { + font-size: 0.75rem; + color: var(--gray-500); + margin-top: var(--space-2); +} + +.stat-card-trend.positive { + color: #22c55e; +} + +/* Info Card */ +.info-card { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + padding: 1.5rem; +} + +.info-card h3 { + font-size: 1.125rem; + font-weight: 600; + margin-bottom: 1rem; + color: var(--gray-100); +} + +.info-card h4 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.5rem; + margin-top: 1rem; + color: var(--gray-200); +} + +.info-card p { + margin-bottom: 0.75rem; + color: var(--gray-300); + line-height: 1.6; +} + +.info-card a { + color: var(--blue-500); + text-decoration: none; +} + +.info-card a:hover { + text-decoration: underline; +} + +/* Data Collection Cards (Privacy Policy) */ +.data-collection-cards { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-4); + margin: var(--space-6) 0; +} + +.data-card { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + padding: var(--space-4); +} + +.data-card h4 { + color: var(--gray-100); + font-size: 0.875rem; + font-weight: 600; + margin: 0 0 var(--space-3) 0; + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--gray-800); +} + +.data-card ul { + margin: 0; + padding-left: var(--space-4); +} + +.data-card li { + color: var(--gray-400); + font-size: 0.8125rem; + margin-bottom: var(--space-2); +} + +.data-card li:last-child { + margin-bottom: 0; +} + +.card-note { + font-size: 0.75rem; + color: var(--gray-500); + font-style: italic; + margin: var(--space-3) 0 0 0; + padding-top: var(--space-2); + border-top: 1px solid var(--gray-800); +} + +@media (max-width: 768px) { + .data-collection-cards { + grid-template-columns: 1fr; + } +} + +/* Info Box */ +.info-box { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + padding: var(--space-6); + margin-top: 0; + margin-bottom: var(--space-8); + max-width: 600px; +} + +.info-box h3 { + font-size: 1rem; + font-weight: 600; + color: var(--gray-200); + margin: 0 0 var(--space-4) 0; +} + +.info-box ul { + list-style: none; + padding: 0; + margin: 0; +} + +.info-box li { + padding: var(--space-2) 0; + padding-left: var(--space-6); + color: var(--gray-400); + font-size: 0.875rem; + line-height: 1.6; + position: relative; +} + +.info-box li::before { + content: "→"; + position: absolute; + left: 0; + color: var(--gray-600); +} + +/* Log Stat Cards */ +.log-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-4); + margin: var(--space-8) 0; +} + +@media (max-width: 480px) { + .log-stats-grid { + grid-template-columns: 1fr; + } +} + +.log-stat-card { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + padding: var(--space-5); +} + +.log-stat-card h3 { + font-size: 0.875rem; + color: var(--gray-400); + font-weight: 500; + margin: 0 0 var(--space-3) 0; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.log-stat-value { + font-size: 2rem; + font-weight: 600; + color: #ffffff; + line-height: 1; + margin-bottom: var(--space-2); +} + +.stat-label { + font-size: 0.8125rem; + color: var(--gray-500); +} + +.stat-detail { + font-size: 0.75rem; + color: var(--gray-600); + margin-top: var(--space-2); +} + +.stat-warning { + font-size: 0.75rem; + color: #e63946; + font-weight: 600; + margin-top: var(--space-2); +} + +/* Stat Values (inline) */ +.stat-value { + color: var(--gray-100); + font-weight: 500; + font-size: 1rem; + white-space: nowrap; +} + +.stat-daily { + color: var(--gray-500); + font-size: 0.75rem; + margin-left: var(--space-2); + display: inline-block; +} + +/* API Peak Cards */ +.api-peak-card { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.api-peak-header { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.api-peak-title { + font-size: 0.8125rem; + font-weight: 500; + color: var(--gray-400); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.api-peak-value { + font-size: 2.5rem; + font-weight: 700; + color: var(--gray-100); + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.api-peak-time { + display: flex; + align-items: center; + font-size: 0.875rem; + color: var(--gray-400); + font-variant-numeric: tabular-nums; +} + +.api-peak-empty { + font-size: 0.875rem; + color: var(--gray-500); + font-style: italic; +} + +/* Subscription Card */ +.subscription-card { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + padding: 2rem; + margin-bottom: 1.5rem; +} + +.subscription-active, +.subscription-inactive { + margin-bottom: 1.5rem; +} + +.subscription-days { + font-size: 2rem; + font-weight: 700; + color: var(--gray-100); + margin-bottom: 0.5rem; + font-variant-numeric: tabular-nums; +} + +.subscription-expires { + color: var(--gray-400); + font-size: 0.875rem; + margin-bottom: 1rem; +} + +/* Progress Bar */ +.progress-bar-container { + width: 100%; + height: 8px; + background: var(--gray-800); + border-radius: var(--radius-sm); + overflow: hidden; + margin-bottom: 1rem; +} + +.progress-bar-fill { + height: 100%; + background: linear-gradient(90deg, #10b981 0%, #059669 100%); + transition: width 0.3s ease; +} + +.renewal-method { + margin-bottom: 1rem; +} + +.renewal-method:last-child { + margin-bottom: 0; +} + +/* API Key Card */ +.api-key-card { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + padding: 2rem; +} + +.api-key-info { + margin-bottom: 1.5rem; +} + +.api-key-row { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 0.75rem; +} + +.api-key-row:last-child { + margin-bottom: 0; +} + +.api-key-label { + color: var(--gray-400); + font-size: 0.875rem; + min-width: 100px; +} + +.api-key-value { + font-family: "Geist Mono", monospace; + color: var(--gray-200); + font-size: 0.875rem; + background: var(--gray-800); + padding: 0.25rem 0.75rem; + border-radius: var(--radius-sm); +} + +.api-key-warning { + background: rgba(251, 191, 36, 0.1); + border: 1px solid rgba(251, 191, 36, 0.3); + border-radius: var(--radius-sm); + padding: 1rem; + margin-bottom: 1.5rem; + color: var(--gray-300); + font-size: 0.875rem; + line-height: 1.6; +} + +.api-key-warning strong { + color: #fbbf24; +} + +.api-key-form-container { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--gray-800); +} + +.api-key-form-inline { + display: flex; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.api-key-form-inline .api-key-form-input { + flex: 1; +} + +.api-key-form-input { + padding: 0.75rem 1rem; + background: var(--gray-1000); + border: 1px solid var(--gray-700); + border-radius: var(--radius-md); + color: var(--gray-200); + font-family: "Geist Mono", monospace; + font-size: 0.9375rem; + transition: border-color 0.15s ease, background-color 0.15s ease; +} + +.api-key-form-input:focus { + outline: none; + border-color: var(--gray-500); + background: var(--gray-900); +} + +.api-key-form-input::placeholder { + color: var(--gray-600); +} + +.api-key-form-hint { + margin-top: 0.5rem; + margin-bottom: 0; + font-size: 0.75rem; + color: var(--gray-500); +} + +.api-key-form-hint a { + color: var(--blue-500); + text-decoration: none; +} + +.api-key-form-hint a:hover { + text-decoration: underline; +} + +.api-key-feedback { + margin-top: 0.75rem; + padding: 0.75rem 1rem; + border-radius: var(--radius-sm); + font-size: 0.875rem; + display: none; +} + +.api-key-feedback:not(:empty) { + display: block; +} + +.api-key-feedback-success { + background: rgba(16, 185, 129, 0.15); + color: #10b981; + border: 1px solid rgba(16, 185, 129, 0.3); +} + +.api-key-feedback-error { + background: rgba(239, 68, 68, 0.15); + color: #fca5a5; + border: 1px solid rgba(239, 68, 68, 0.3); +} + +/* Rate Limit Warning Card */ +.rate-limit-warning { + background: rgba(230, 57, 70, 0.1); + border: 1px solid rgba(230, 57, 70, 0.3); + border-radius: var(--radius-md); + padding: var(--space-5); + margin: var(--space-6) 0; +} + +.rate-limit-warning h3 { + color: #e63946; + font-size: 1rem; + margin: 0 0 var(--space-3) 0; +} + +.rate-limit-warning p { + color: var(--gray-300); + margin: 0 0 var(--space-3) 0; + line-height: 1.6; +} + +.rate-limit-warning ul { + color: var(--gray-400); + margin: var(--space-3) 0; + padding-left: var(--space-6); + line-height: 1.7; +} + +.rate-limit-warning li { + margin-bottom: var(--space-2); +} + +.rate-limit-warning strong { + color: var(--gray-200); +} diff --git a/app/assets/stylesheets/components/flash.css b/app/assets/stylesheets/components/flash.css new file mode 100644 index 0000000..5f9d230 --- /dev/null +++ b/app/assets/stylesheets/components/flash.css @@ -0,0 +1,194 @@ +/* + * Flash Messages & Toast Notifications + */ + +/* Inline Flash Messages */ +.flash { + margin-bottom: var(--space-6); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + width: 100%; + text-align: center; + font-size: 0.875rem; + border: 1px solid; +} + +.flash-alert { + background: rgba(239, 68, 68, 0.1); + color: #fca5a5; + border-color: rgba(239, 68, 68, 0.3); +} + +.flash-notice { + background: rgba(34, 197, 94, 0.1); + color: #86efac; + border-color: rgba(34, 197, 94, 0.3); +} + +/* Alert Boxes */ +.alert { + padding: var(--space-4) var(--space-5); + border-radius: var(--radius-md); + font-size: 0.875rem; + border: 1px solid; +} + +.alert strong { + display: block; + margin-bottom: var(--space-2); + color: inherit; +} + +.alert a { + color: inherit; + text-decoration: underline; + font-weight: 600; +} + +.alert a:hover { + opacity: 0.8; +} + +.alert-info { + background: rgba(59, 130, 246, 0.1); + color: #93c5fd; + border-color: rgba(59, 130, 246, 0.3); +} + +/* Global Flash Notifications (Toast) */ +.flash-notifications { + position: fixed; + bottom: var(--space-6); + right: var(--space-6); + z-index: 9999; + display: flex; + flex-direction: column-reverse; + gap: var(--space-3); + pointer-events: none; + max-width: 400px; +} + +.flash-notification { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + font-size: 0.875rem; + pointer-events: auto; + opacity: 0; + transform: translateX(100%); + transition: none; +} + +.flash-notification-visible { + animation: flashSlideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +.flash-notification-fade-out { + animation: flashSlideOut 0.2s ease-in forwards; +} + +@keyframes flashSlideIn { + 0% { + opacity: 0; + transform: translateX(100%); + } + 100% { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes flashSlideOut { + 0% { + opacity: 1; + transform: translateX(0); + } + 100% { + opacity: 0; + transform: translateX(100%); + } +} + +.flash-notification-content { + display: flex; + align-items: center; + gap: var(--space-3); + flex: 1; + min-width: 0; +} + +.flash-notification-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.flash-notification-message { + flex: 1; + line-height: 1.4; +} + +.flash-notification-close { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: none; + border-radius: var(--radius-sm); + color: var(--gray-500); + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.flash-notification-close:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--gray-200); +} + +/* Notice (success) style */ +.flash-notification-notice { + border-color: rgba(34, 197, 94, 0.3); + background: linear-gradient(135deg, var(--gray-900) 0%, rgba(34, 197, 94, 0.1) 100%); +} + +.flash-notification-notice .flash-notification-icon { + color: #22c55e; +} + +.flash-notification-notice .flash-notification-message { + color: #86efac; +} + +/* Alert (error) style */ +.flash-notification-alert { + border-color: rgba(239, 68, 68, 0.3); + background: linear-gradient(135deg, var(--gray-900) 0%, rgba(239, 68, 68, 0.1) 100%); +} + +.flash-notification-alert .flash-notification-icon { + color: #ef4444; +} + +.flash-notification-alert .flash-notification-message { + color: #fca5a5; +} + +@media (max-width: 480px) { + .flash-notifications { + left: var(--space-4); + right: var(--space-4); + bottom: var(--space-4); + max-width: none; + } +} diff --git a/app/assets/stylesheets/components/forms.css b/app/assets/stylesheets/components/forms.css new file mode 100644 index 0000000..976087d --- /dev/null +++ b/app/assets/stylesheets/components/forms.css @@ -0,0 +1,206 @@ +/* + * Form Components + */ + +/* Form Container */ +.form-container { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + padding: var(--space-8); + max-width: 600px; + margin: 0 0 var(--space-8) 0; +} + +/* Form Group */ +.form-group { + margin-bottom: var(--space-6); +} + +.form-group:last-child { + margin-bottom: 0; +} + +/* Form Label */ +.form-label { + display: block; + color: var(--gray-200); + font-weight: 600; + margin-bottom: var(--space-2); + font-size: 0.875rem; +} + +/* Form Input */ +.form-input, +.form-select { + width: 100%; + padding: var(--space-3) var(--space-4); + background: var(--gray-1000); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + color: var(--gray-200); + font-family: "Geist Mono", monospace; + font-size: 0.875rem; + transition: all 0.15s ease; +} + +.form-input:focus, +.form-select:focus { + outline: none; + border-color: var(--gray-600); + background: var(--gray-900); + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05); +} + +.form-input::placeholder { + color: var(--gray-600); +} + +/* Form Help Text */ +.form-help { + font-size: 0.75rem; + color: var(--gray-500); + margin-top: var(--space-2); + margin-bottom: 0; +} + +.form-hint { + margin-top: 0.5rem; + font-size: 0.75rem; + color: var(--gray-500); +} + +/* Form Static (read-only display) */ +.form-static { + color: var(--gray-400); + font-family: "Geist Mono", monospace; +} + +/* Form Actions */ +.form-actions { + display: flex; + gap: var(--space-3); + margin-top: var(--space-8); +} + +.form-actions .button { + flex: 1; +} + +/* Simple Form */ +.simple-form { + max-width: 500px; +} + +/* Inline Add Form */ +.inline-add-form { + margin-bottom: var(--space-6); +} + +.inline-add-form .flash { + margin-bottom: var(--space-4); +} + +.inline-form-row { + display: flex; + gap: var(--space-3); + align-items: center; +} + +.inline-form-row .form-input { + max-width: 250px; +} + +/* Date Input Inline (for filters) */ +.date-inputs-inline { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.date-input-inline { + padding: var(--space-2) var(--space-3); + border: 1px solid var(--gray-800); + border-radius: var(--radius-sm); + background: var(--gray-1000); + color: var(--gray-200); + font-size: 0.8125rem; + font-family: "Geist Mono", monospace; + transition: border-color 0.15s ease, background-color 0.15s ease; + min-width: 130px; +} + +.date-input-inline::-webkit-calendar-picker-indicator { + filter: invert(1) brightness(0.8); + cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s ease; +} + +.date-input-inline::-webkit-calendar-picker-indicator:hover { + opacity: 1; +} + +.date-input-inline:hover { + border-color: var(--gray-700); +} + +.date-input-inline:focus { + outline: none; + border-color: var(--gray-600); + background: var(--gray-900); +} + +.date-separator { + color: var(--gray-600); + font-weight: 400; +} + +/* Inline Editing Styles */ +.days-cell { + display: flex; + align-items: center; +} + +.days-input { + width: 80px; + padding: var(--space-1) var(--space-2); + background: var(--gray-1000); + border: 1px solid var(--gray-700); + border-radius: var(--radius-sm); + color: var(--gray-200); + font-family: "Geist Mono", monospace; + font-size: 0.875rem; + transition: border-color 0.15s ease; +} + +.days-input:focus { + outline: none; + border-color: var(--gray-500); + background: var(--gray-900); +} + +.edit-days-btn { + padding: var(--space-1) var(--space-3); + background: var(--gray-800); + border: 1px solid var(--gray-700); + border-radius: var(--radius-sm); + color: var(--gray-300); + font-family: "Geist Mono", monospace; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + margin-left: var(--space-2); +} + +.edit-days-btn:hover:not(:disabled) { + background: var(--gray-700); + border-color: var(--gray-600); + color: var(--gray-100); +} + +.edit-days-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/app/assets/stylesheets/components/navigation.css b/app/assets/stylesheets/components/navigation.css new file mode 100644 index 0000000..5d05d6b --- /dev/null +++ b/app/assets/stylesheets/components/navigation.css @@ -0,0 +1,262 @@ +/* + * Navigation component styles + */ + +nav { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4) var(--space-6); + font-size: 0.875rem; + font-weight: 500; +} + +.navbar-left { + display: flex; + align-items: center; + flex: 1; +} + +.navbar-center { + display: flex; + align-items: center; + gap: var(--space-6); + position: absolute; + left: 50%; + transform: translateX(-50%); + min-height: 1.5rem; +} + +.navbar-right { + display: flex; + align-items: center; + flex: 1; + justify-content: flex-end; +} + +.navbar-link { + color: var(--gray-400); + text-decoration: none; + transition: color 0.15s ease; + font-size: 0.875rem; + font-weight: 500; +} + +.navbar-link:hover { + color: #ffffff; +} + +.navbar-link-disabled { + color: var(--gray-600); + cursor: not-allowed; + font-style: italic; +} + +.navbar-link-disabled:hover { + color: var(--gray-600); +} + +.navbar-link-active { + color: #ffffff; + position: relative; +} + +.navbar-link-active::after { + content: ''; + position: absolute; + bottom: -17px; + left: 0; + right: 0; + height: 2px; + background: #ffffff; + border-radius: 2px 2px 0 0; +} + +.navbar-title { + font-size: 1.125rem; + font-weight: 600; + color: #ffffff; +} + +.navbar-title:hover { + color: #ffffff; +} + +.navbar-user { + display: flex; + align-items: center; + gap: var(--space-3); + position: relative; +} + +.navbar-admin-dropdown { + position: relative; +} + +.navbar-link-button { + display: flex; + align-items: center; + gap: var(--space-2); + background: transparent; + border: none; + cursor: pointer; + font-family: inherit; +} + +.navbar-user-avatar { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + border: 1px solid var(--gray-800); + object-fit: cover; +} + +.navbar-user-button { + display: flex; + align-items: center; + gap: var(--space-2); + background: transparent; + border: none; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background-color 0.15s ease; + font-family: inherit; +} + +.navbar-user-button:hover { + background: rgba(255, 255, 255, 0.05); +} + +.navbar-user-name { + color: var(--gray-200); + font-size: 0.875rem; + font-weight: 500; +} + +.navbar-user-arrow { + color: var(--gray-400); + transition: transform 0.2s ease, color 0.15s ease; +} + +.navbar-user-arrow-open { + transform: rotate(180deg); + color: var(--gray-200); +} + +.navbar-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + min-width: 160px; + opacity: 0; + visibility: hidden; + transform: translateY(-8px); + transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s; + z-index: 1000; +} + +.navbar-dropdown-open { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.navbar-dropdown-item { + display: block; + width: 100%; + padding: var(--space-3) var(--space-4); + color: var(--gray-200); + text-decoration: none; + font-size: 0.875rem; + font-weight: 500; + font-family: "Geist Mono", monospace; + transition: background-color 0.15s ease, color 0.15s ease; + border: none; + background: transparent; + text-align: left; + cursor: pointer; +} + +.navbar-dropdown-item:first-child { + border-radius: var(--radius-md) var(--radius-md) 0 0; +} + +.navbar-dropdown-item:last-child { + border-radius: 0 0 var(--radius-md) var(--radius-md); +} + +.navbar-dropdown-item:only-child { + border-radius: var(--radius-md); +} + +.navbar-dropdown-item:hover { + background: rgba(255, 255, 255, 0.05); + color: #ffffff; +} + +.navbar-dropdown-disabled { + color: var(--gray-600); + cursor: not-allowed; + font-style: italic; +} + +.navbar-dropdown-disabled:hover { + background: transparent; + color: var(--gray-600); +} + +.navbar-dropdown-signout:hover { + background: rgba(239, 68, 68, 0.1); + color: #fca5a5; +} + +.navbar-signout { + background: transparent; + border: 1px solid var(--gray-800); + color: var(--gray-400); + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 500; + font-family: "Geist Mono", monospace; + cursor: pointer; + transition: all 0.15s ease; + line-height: 1.5; + height: 2rem; + display: inline-flex; + align-items: center; +} + +.navbar-signout:hover { + background: var(--gray-900); + color: #ffffff; + border-color: var(--gray-700); +} + +.navbar-signin { + background: transparent; + border: 1px solid var(--gray-800); + color: var(--gray-400); + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 500; + font-family: "Geist Mono", monospace; + text-decoration: none; + transition: all 0.15s ease; + display: inline-flex; + align-items: center; + line-height: 1.5; + height: 2rem; +} + +.navbar-signin:hover { + background: var(--gray-900); + color: #ffffff; + border-color: var(--gray-700); +} diff --git a/app/assets/stylesheets/components/tables.css b/app/assets/stylesheets/components/tables.css new file mode 100644 index 0000000..66a9411 --- /dev/null +++ b/app/assets/stylesheets/components/tables.css @@ -0,0 +1,262 @@ +/* + * Table component styles + */ + +table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 0.8125rem; + background: transparent; +} + +th, td { + padding: var(--space-3) var(--space-3); + text-align: left; + border-bottom: 1px solid var(--gray-900); +} + +th { + background: transparent; + font-weight: 600; + color: var(--gray-400); + text-transform: none; + font-size: 0.6875rem; + letter-spacing: 0.05em; + padding-top: var(--space-2); + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--gray-800); + white-space: nowrap; +} + +td { + color: var(--gray-200); +} + +tr { + transition: background-color 0.15s ease; +} + +tbody tr:hover { + background-color: rgba(255, 255, 255, 0.03); +} + +/* Sort Links */ +.sort-link { + color: var(--gray-400); + text-decoration: none; + display: inline-flex; + align-items: center; + gap: var(--space-2); + transition: color 0.15s ease; + cursor: pointer; +} + +.sort-link:hover { + color: var(--gray-200); +} + +.sort-arrow { + opacity: 0.2; + font-size: 0.75rem; + transition: opacity 0.15s ease; +} + +.sort-arrow.active { + opacity: 1; + color: #ffffff; +} + +/* Table Container */ +.table-container { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + overflow: hidden; + margin-bottom: var(--space-8); +} + +.table-container h2 { + padding: var(--space-6); + margin: 0; + margin-bottom: var(--space-6); +} + +.table-wrapper { + overflow-x: auto; +} + +.table-empty { + padding: var(--space-12); + text-align: center; + color: var(--gray-500); +} + +.table-empty-icon { + font-size: 3rem; + margin-bottom: var(--space-4); + opacity: 0.3; +} + +.table-empty-text { + font-size: 0.875rem; + margin: 0; +} + +/* Collapsible Header */ +.collapsible-header { + cursor: pointer; + user-select: none; + transition: background-color 0.15s ease; + display: flex; + align-items: center; + gap: var(--space-3); +} + +.collapsible-header:hover { + background: rgba(255, 255, 255, 0.02); +} + +.collapsible-header span { + font-size: 0.875rem; + color: var(--gray-500); + transition: color 0.15s ease; +} + +.collapsible-header:hover span { + color: var(--gray-300); +} + +/* Table Info/Filter */ +.table-info { + padding: var(--space-4) var(--space-6); + border-bottom: 1px solid var(--gray-800); + background: rgba(255, 255, 255, 0.02); +} + +.table-filter-form { + width: 100%; +} + +.filter-inline { + display: flex; + align-items: center; + gap: var(--space-3); + font-size: 0.875rem; + flex-wrap: wrap; +} + +.date-inputs-inline { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.date-input-inline { + padding: var(--space-2) var(--space-3); + border: 1px solid var(--gray-800); + border-radius: var(--radius-sm); + background: var(--gray-1000); + color: var(--gray-200); + font-size: 0.8125rem; + font-family: "Geist Mono", monospace; + transition: border-color 0.15s ease, background-color 0.15s ease; + min-width: 130px; +} + +.date-input-inline::-webkit-calendar-picker-indicator { + filter: invert(1) brightness(0.8); + cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s ease; +} + +.date-input-inline::-webkit-calendar-picker-indicator:hover { + opacity: 1; +} + +.date-input-inline:hover { + border-color: var(--gray-700); +} + +.date-input-inline:focus { + outline: none; + border-color: var(--gray-600); + background: var(--gray-900); +} + +.date-separator { + color: var(--gray-600); + font-weight: 400; +} + +.filter-button-inline { + background-color: var(--gray-800); + color: var(--gray-200); + border: 1px solid var(--gray-700); + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-4); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease; + font-family: "Geist Mono", monospace; +} + +.filter-button-inline:hover { + background-color: var(--gray-700); + border-color: var(--gray-600); +} + +.filter-button-inline:active { + transform: translateY(0); +} + +.filter-reset-inline { + color: var(--gray-500); + text-decoration: none; + font-size: 0.8125rem; + transition: color 0.15s ease; + font-weight: 400; +} + +.filter-reset-inline:hover { + color: var(--gray-300); +} + +.date-range-display { + display: flex; + align-items: center; + gap: var(--space-3); + font-size: 0.875rem; +} + +.info-label { + color: var(--gray-400); + font-weight: 600; +} + +.info-value { + color: var(--gray-100); + font-weight: 500; +} + +.info-days { + color: var(--gray-500); + font-weight: 400; +} + +/* Stat Values in Tables */ +.stat-value { + color: var(--gray-100); + font-weight: 500; + font-size: 1rem; + white-space: nowrap; +} + +.stat-daily { + color: var(--gray-500); + font-size: 0.75rem; + margin-left: var(--space-2); + display: inline-block; +} diff --git a/app/assets/stylesheets/components/toggle.css b/app/assets/stylesheets/components/toggle.css new file mode 100644 index 0000000..3d07813 --- /dev/null +++ b/app/assets/stylesheets/components/toggle.css @@ -0,0 +1,48 @@ +/* + * Toggle Switch Component + */ + +.toggle-switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--gray-700); + transition: 0.2s; + border-radius: 24px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + transition: 0.2s; + border-radius: 50%; +} + +input:checked + .toggle-slider { + background-color: #10b981; +} + +input:checked + .toggle-slider:before { + transform: translateX(20px); +} diff --git a/app/assets/stylesheets/pages/admin.css b/app/assets/stylesheets/pages/admin.css new file mode 100644 index 0000000..a55370d --- /dev/null +++ b/app/assets/stylesheets/pages/admin.css @@ -0,0 +1,197 @@ +/* + * Admin Pages + */ + +/* Admin Header */ +.admin-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-12); +} + +.admin-title-section { + flex: 1; +} + +.admin-title { + font-size: 2.5rem; + font-weight: 700; + color: #ffffff; + margin: 0 0 var(--space-2) 0; + letter-spacing: -0.05em; +} + +.admin-subtitle { + color: var(--gray-500); + font-size: 0.875rem; + margin: 0; +} + +.admin-actions { + display: flex; + gap: var(--space-3); +} + +/* Admin Dashboard Cards */ +.admin-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: var(--space-6); + margin-top: var(--space-8); +} + +.admin-card { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + padding: var(--space-8); + text-decoration: none; + transition: all 0.2s ease; + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.admin-card:hover { + border-color: var(--gray-700); + background: var(--gray-800); + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +.admin-card-icon { + width: 64px; + height: 64px; + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.05); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: var(--space-6); + color: var(--gray-400); + transition: all 0.2s ease; +} + +.admin-card:hover .admin-card-icon { + background: rgba(255, 255, 255, 0.1); + color: #ffffff; +} + +.admin-card h2 { + font-size: 1.25rem; + font-weight: 600; + color: #ffffff; + margin: 0 0 var(--space-3) 0; +} + +.admin-card p { + font-size: 0.875rem; + color: var(--gray-400); + margin: 0; + line-height: 1.6; +} + +.admin-card:hover p { + color: var(--gray-300); +} + +/* Section Header */ +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-6); + background: rgba(255, 255, 255, 0.02); + border-bottom: 1px solid var(--gray-800); +} + +.section-header h2 { + margin: 0; +} + +.section-title { + font-size: 1.125rem; + font-weight: 600; + color: #ffffff; + margin: 0; + display: flex; + align-items: center; + gap: var(--space-3); +} + +.section-actions { + display: flex; + gap: var(--space-2); +} + +/* Live API Feed */ +.live-api-feed { + position: absolute; + top: calc(100% + 8px); + right: 24px; + z-index: 1000; + pointer-events: none; +} + +.live-api-feed-list { + display: flex; + flex-direction: column; + gap: var(--space-2); + width: 350px; +} + +.live-api-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-sm); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + font-size: 0.8125rem; + opacity: 0; + transform: translateY(-8px); + transition: opacity 0.5s ease, transform 0.5s ease; + pointer-events: auto; +} + +.live-api-item-visible { + opacity: 1; + transform: translateY(0); +} + +.live-api-item-fade-out { + opacity: 0; + transform: translateY(-8px); +} + +.live-api-endpoint { + flex: 1; + color: var(--gray-200); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.live-api-status { + font-weight: 600; + font-size: 0.875rem; +} + +.live-api-status-success { + color: #10b981; +} + +.live-api-status-error { + color: #ef4444; +} + +.live-api-time { + color: var(--gray-400); + font-variant-numeric: tabular-nums; + font-size: 0.75rem; + white-space: nowrap; +} diff --git a/app/assets/stylesheets/pages/content.css b/app/assets/stylesheets/pages/content.css new file mode 100644 index 0000000..fd6a692 --- /dev/null +++ b/app/assets/stylesheets/pages/content.css @@ -0,0 +1,92 @@ +/* + * Content Pages (Privacy Policy, Terms of Service) + */ + +.content-wrapper { + max-width: 800px; + margin: 0 auto; +} + +.content-section { + margin-bottom: var(--space-12); +} + +.content-section h2 { + font-size: 1.5rem; + font-weight: 600; + color: #ffffff; + margin-bottom: var(--space-4); + margin-top: var(--space-8); + letter-spacing: -0.025em; +} + +.content-section h3 { + font-size: 1.125rem; + font-weight: 600; + color: var(--gray-200); + margin-bottom: var(--space-3); + margin-top: var(--space-6); +} + +.content-section p { + color: var(--gray-400); + line-height: 1.7; + margin-bottom: var(--space-4); +} + +.content-section ul { + color: var(--gray-400); + line-height: 1.7; + margin-bottom: var(--space-4); + padding-left: var(--space-6); +} + +.content-section li { + margin-bottom: var(--space-2); +} + +.content-section strong { + color: var(--gray-200); + font-weight: 600; +} + +.content-section a { + color: #ffffff; + text-decoration: underline; + transition: color 0.2s ease; +} + +.content-section a:hover { + color: var(--gray-300); +} + +/* Terms of Service Table */ +.tos-table { + overflow-x: auto; + margin: var(--space-6) 0; +} + +.tos-table table { + min-width: 100%; + font-size: 0.8125rem; +} + +.tos-table th { + background: var(--gray-900); + color: var(--gray-300); + font-weight: 600; + padding: var(--space-3); + white-space: nowrap; +} + +.tos-table td { + padding: var(--space-3); + color: var(--gray-400); +} + +.table-note { + font-size: 0.8125rem; + color: var(--gray-500); + font-style: italic; + margin-top: var(--space-2); +} diff --git a/app/assets/stylesheets/pages/key_log.css b/app/assets/stylesheets/pages/key_log.css new file mode 100644 index 0000000..e2462cd --- /dev/null +++ b/app/assets/stylesheets/pages/key_log.css @@ -0,0 +1,316 @@ +/* + * Key Log Page Styles + */ + +.key-log-container { + max-width: 700px; + margin: 0 auto; + padding: var(--space-16) var(--space-6); +} + +.key-log-header { + text-align: center; + margin-bottom: var(--space-12); +} + +.key-log-header h1 { + font-size: 2.5rem; + margin-bottom: var(--space-3); +} + +.key-log-subtitle { + color: var(--gray-400); + font-size: 1rem; + margin: 0; +} + +.key-log-form-centered { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + padding: var(--space-8); + margin-bottom: var(--space-8); +} + +.key-log-form { + margin: var(--space-8) 0; + max-width: 600px; +} + +.key-log-confirmations { + margin: var(--space-6) 0; + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.key-log-checkbox-field { + display: flex; + align-items: flex-start; + gap: var(--space-3); +} + +.key-log-checkbox { + width: 18px; + height: 18px; + min-width: 18px; + margin-top: 2px; + cursor: pointer; + accent-color: #ffffff; +} + +.key-log-checkbox-label { + color: var(--gray-400); + font-size: 0.875rem; + line-height: 1.5; + cursor: pointer; + user-select: none; +} + +.key-log-checkbox-label strong { + color: var(--gray-200); +} + +.key-log-info { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + padding: var(--space-6); +} + +.key-log-info h3 { + font-size: 1rem; + color: var(--gray-300); + margin: 0 0 var(--space-4) 0; +} + +.key-log-info ul { + list-style: none; + padding: 0; + margin: 0; +} + +.key-log-info li { + color: var(--gray-400); + padding: var(--space-2) 0; + line-height: 1.6; + font-size: 0.875rem; +} + +.key-log-info li strong { + color: var(--gray-200); +} + +.key-log-notice { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + padding: var(--space-4); + margin: var(--space-6) 0; +} + +.key-log-notice p { + color: var(--gray-400); + font-size: 0.875rem; + margin: 0; +} + +.log-summary { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-6); + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--gray-900); +} + +.log-summary p { + color: var(--gray-400); + margin: 0; +} + +.back-link { + color: var(--gray-500); + text-decoration: none; + font-size: 0.875rem; + transition: color 0.15s ease; +} + +.back-link:hover { + color: var(--gray-200); +} + +.log-table-wrapper { + overflow-x: auto; + margin: var(--space-6) 0; +} + +.log-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + min-width: 900px; +} + +@media (max-width: 768px) { + .log-table { + font-size: 0.75rem; + min-width: 800px; + } + + .log-table th, + .log-table td { + padding: var(--space-2) var(--space-3); + } +} + +.log-table th { + background: var(--gray-900); + color: var(--gray-300); + font-weight: 600; + padding: var(--space-3) var(--space-4); + text-align: left; + border-bottom: 1px solid var(--gray-800); +} + +.log-table td { + padding: var(--space-3) var(--space-4); + color: var(--gray-400); + border-bottom: 1px solid var(--gray-900); +} + +.log-table tr:hover { + background: rgba(255, 255, 255, 0.02); +} + +.log-table code { + background: var(--gray-900); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + color: var(--gray-300); + font-family: "Geist Mono", monospace; + font-size: 0.8125rem; +} + +/* Log Breakdown */ +.log-breakdown { + margin: var(--space-8) 0; + display: grid; + gap: var(--space-6); +} + +.breakdown-section { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + padding: var(--space-5); +} + +.breakdown-section h3 { + font-size: 0.875rem; + color: var(--gray-300); + font-weight: 600; + margin: 0 0 var(--space-4) 0; +} + +.breakdown-list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.breakdown-item { + display: grid; + grid-template-columns: 250px 1fr auto; + gap: var(--space-3); + align-items: center; + font-size: 0.8125rem; +} + +@media (max-width: 768px) { + .breakdown-item { + grid-template-columns: 150px 1fr auto; + font-size: 0.75rem; + } +} + +@media (max-width: 480px) { + .breakdown-item { + grid-template-columns: 1fr; + gap: var(--space-2); + } + + .breakdown-bar { + order: 2; + } + + .breakdown-count { + order: 3; + text-align: left; + } +} + +.breakdown-label { + color: var(--gray-300); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + position: relative; + cursor: default; +} + +.breakdown-label:hover { + overflow: visible; + white-space: normal; + z-index: 10; +} + +.breakdown-label:hover::after { + content: attr(title); + position: absolute; + left: 0; + top: 0; + background: var(--gray-800); + border: 1px solid var(--gray-700); + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-3); + color: var(--gray-200); + white-space: normal; + word-break: break-word; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); + z-index: 100; + min-width: 200px; + max-width: 400px; +} + +.breakdown-label code { + background: var(--gray-800); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + color: var(--gray-300); + font-family: "Geist Mono", monospace; + font-size: 0.75rem; +} + +.breakdown-bar { + display: block; + height: 20px; + background: var(--gray-800); + border-radius: var(--radius-sm); + overflow: hidden; + position: relative; +} + +.breakdown-bar-fill { + display: block; + height: 100%; + background: linear-gradient(90deg, var(--blue-600), var(--blue-500)); + transition: width 0.3s ease; +} + +.breakdown-count { + color: var(--gray-500); + text-align: right; + min-width: 80px; + font-variant-numeric: tabular-nums; +} diff --git a/app/assets/stylesheets/pages/login.css b/app/assets/stylesheets/pages/login.css new file mode 100644 index 0000000..1c764cb --- /dev/null +++ b/app/assets/stylesheets/pages/login.css @@ -0,0 +1,316 @@ +/* + * Login Page Styles + */ + +.login-container { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 100vh; + width: 100%; + opacity: 0; + animation: fadeIn 0.6s ease-out forwards; +} + +@keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +@keyframes slideUp { + 0% { + transform: translateY(20px); + opacity: 0; + } + 100% { + transform: translateY(0); + opacity: 1; + } +} + +.login-form { + background: var(--gray-900); + padding: var(--space-12); + border-radius: var(--radius-lg); + border: 1px solid var(--gray-800); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + align-items: stretch; + max-width: 420px; + width: 100%; + opacity: 0; + transform: translateY(20px); + animation: slideUp 0.6s ease-out 0.2s forwards; +} + +/* Skip animations when returning from error (e.g., invalid API key) */ +.login-container--no-animation { + opacity: 1; + animation: none; +} + +.login-container--no-animation .login-form { + opacity: 1; + transform: none; + animation: none; +} + +.login-header { + text-align: center; + margin-bottom: var(--space-8); +} + +.login-title { + font-size: 1.75rem; + font-weight: 600; + color: #ffffff; + margin-bottom: var(--space-2); + letter-spacing: -0.025em; +} + +.login-subtitle { + font-size: 0.875rem; + color: var(--gray-400); + margin: 0; +} + +.login-field { + display: flex; + flex-direction: column; + margin-bottom: var(--space-6); +} + +.login-hint { + font-size: 0.75rem; + color: var(--gray-500); + margin-top: var(--space-2); +} + +.login-checkbox-field { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-6); +} + +.login-checkbox { + width: 18px; + height: 18px; + border: 2px solid var(--gray-700); + border-radius: 4px; + background: var(--gray-1000); + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; + appearance: none; + -webkit-appearance: none; + position: relative; +} + +.login-checkbox:checked { + background: #ffffff; + border-color: #ffffff; +} + +.login-checkbox:checked::after { + content: ''; + position: absolute; + left: 5px; + top: 2px; + width: 4px; + height: 8px; + border: solid #000000; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.login-checkbox:hover { + border-color: var(--gray-600); +} + +.login-checkbox-label { + font-size: 0.8125rem; + color: var(--gray-400); + cursor: pointer; + user-select: none; + line-height: 1.4; +} + +.login-checkbox-link { + color: #ffffff; + text-decoration: underline; + transition: color 0.2s ease; +} + +.login-checkbox-link:hover { + color: var(--gray-300); +} + +.login-label { + font-size: 0.8125rem; + margin-bottom: var(--space-2); + color: var(--gray-300); + font-weight: 500; + letter-spacing: 0.01em; +} + +.login-input { + padding: var(--space-3) var(--space-4); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + background: var(--gray-1000); + color: var(--gray-100); + width: 100%; + font-size: 0.9375rem; + font-family: "Geist Mono", monospace; + transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease; +} + +.login-input::placeholder { + color: var(--gray-600); +} + +.login-input:focus { + outline: none; + border-color: var(--gray-600); + background: var(--gray-900); + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05); +} + +.login-submit { + background-color: #ffffff; + color: #000000; + border: none; + border-radius: var(--radius-md); + padding: var(--space-3) var(--space-6); + font-size: 0.9375rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease, transform 0.1s ease, box-shadow 0.2s ease, opacity 0.2s ease; + font-family: "Geist Mono", monospace; + width: 100%; + letter-spacing: 0.01em; +} + +.login-submit:disabled { + background-color: var(--gray-800); + color: var(--gray-600); + cursor: not-allowed; + opacity: 0.5; +} + +.login-submit:not(:disabled):hover { + background-color: var(--gray-100); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(255, 255, 255, 0.15); +} + +.login-submit:not(:disabled):active { + transform: translateY(0); + box-shadow: 0 2px 8px rgba(255, 255, 255, 0.1); +} + +/* Hero Section (Home Page) */ +.hero-container { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + text-align: center; +} + +.hero-wrapper { + position: relative; + display: inline-block; +} + +.hero-title { + font-size: 4rem; + font-weight: 700; + color: #ffffff; + letter-spacing: 0.01em; + opacity: 0; + animation: fadeInUp 1.2s ease-out forwards; +} + +.hero-signin-button { + position: absolute; + top: -8px; + right: -80px; + background: #e63946; + color: #ffffff; + padding: 0.5rem 1rem; + border-radius: 999px; + font-size: 0.875rem; + font-weight: 600; + text-decoration: none; + opacity: 0; + animation: fadeInPulse 1s ease-out 1.8s forwards, pulse 2s infinite 2.8s; + box-shadow: 0 0 0 0 rgba(230, 57, 70, 0.7); +} + +.hero-signin-button:hover { + background: #d32f3c; +} + +@keyframes fadeInUp { + 0% { + opacity: 0; + transform: translateY(30px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeInPulse { + 0% { + opacity: 0; + transform: scale(0.8); + } + 50% { + transform: scale(1.05); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(230, 57, 70, 0.7); + } + 50% { + box-shadow: 0 0 0 8px rgba(230, 57, 70, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(230, 57, 70, 0); + } +} + +@media (max-width: 768px) { + .hero-title { + font-size: 3rem; + } + + .hero-signin-button { + position: static; + margin-top: 2rem; + animation: fadeInPulse 1s ease-out 1.8s forwards, pulse 2s infinite 2.8s; + } +} + +@media (max-width: 480px) { + .hero-title { + font-size: 2.5rem; + } +} diff --git a/app/assets/stylesheets/pages/settings.css b/app/assets/stylesheets/pages/settings.css new file mode 100644 index 0000000..c7de1c7 --- /dev/null +++ b/app/assets/stylesheets/pages/settings.css @@ -0,0 +1,62 @@ +/* + * Settings Page Styles + */ + +.settings-section { + margin-bottom: 3rem; +} + +.settings-section h2 { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 1.5rem; + color: var(--gray-100); +} + +/* Settings Page Stat Cards */ +.settings-section .stat-card .stat-label { + color: #ffffff; +} + +.settings-section .stat-card .stat-value { + font-size: 2.5rem; + font-weight: 700; + color: var(--gray-100); + margin-bottom: 0.25rem; +} + +.settings-section .stat-card .stat-description { + font-size: 0.75rem; + color: var(--gray-600); + line-height: 1.4; +} + +/* Danger Zone */ +.danger-zone { + background: rgba(239, 68, 68, 0.1); + border: 2px solid rgba(239, 68, 68, 0.3); + border-radius: var(--radius-md); + padding: 2rem; +} + +.danger-zone h3 { + font-size: 1.125rem; + font-weight: 600; + color: #fca5a5; + margin-bottom: 1rem; +} + +.danger-zone ul { + margin-left: 1.5rem; + margin-bottom: 1rem; + color: var(--gray-300); +} + +.danger-zone li { + margin-bottom: 0.5rem; +} + +.danger-zone p { + color: var(--gray-300); + margin-bottom: 0.75rem; +} diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb new file mode 100644 index 0000000..dc8078d --- /dev/null +++ b/app/controllers/admin/dashboard_controller.rb @@ -0,0 +1,8 @@ +module Admin + class DashboardController < ApplicationController + before_action :require_admin + + def index + end + end +end diff --git a/app/controllers/admin/factions_controller.rb b/app/controllers/admin/factions_controller.rb new file mode 100644 index 0000000..724f8ce --- /dev/null +++ b/app/controllers/admin/factions_controller.rb @@ -0,0 +1,85 @@ +module Admin + class FactionsController < ApplicationController + before_action :require_admin + before_action :set_faction, only: [ :destroy, :toggle_tracking, :sync_members ] + + def index + @factions = Faction.includes(:users).order(:name) + end + + def new + @faction = Faction.new + end + + def create + torn_id = params[:torn_id].to_i + + if torn_id <= 0 + @error = "Please enter a valid faction ID." + return render :new, status: :unprocessable_entity + end + + if Faction.exists?(torn_id: torn_id) + @error = "Faction #{torn_id} already exists." + return render :new, status: :unprocessable_entity + end + + begin + api_key = OwnerCredentials.api_key + faction_info = TornApi::Faction::Basic.new(api_key, torn_id).fetch + + @faction = Faction.new( + torn_id: torn_id, + name: faction_info["name"], + track_stats: false + ) + + if @faction.save + respond_to do |format| + format.turbo_stream + format.html { redirect_to admin_factions_path, notice: "Faction '#{@faction.name}' added successfully." } + end + else + @error = "Failed to save faction: #{@faction.errors.full_messages.join(', ')}" + render :new, status: :unprocessable_entity + end + rescue TornApi::ApiError => e + @error = "Failed to fetch faction info: #{e.message}" + render :new, status: :unprocessable_entity + end + end + + def destroy + name = @faction.name + @faction.destroy + redirect_to admin_factions_path, notice: "Faction '#{name}' removed." + end + + def toggle_tracking + @faction.update!(track_stats: !@faction.track_stats) + + if @faction.track_stats + # Sync members immediately when tracking is enabled + Daily::FactionMembershipSyncJob.new.sync_faction_members(@faction) + render json: { success: true, track_stats: @faction.track_stats, member_count: @faction.users.count } + else + render json: { success: true, track_stats: @faction.track_stats, member_count: @faction.users.count } + end + rescue => e + render json: { success: false, error: e.message }, status: :unprocessable_entity + end + + def sync_members + Daily::FactionMembershipSyncJob.new.sync_faction_members(@faction) + redirect_to admin_factions_path, notice: "Synced #{@faction.users.count} members for '#{@faction.name}'." + rescue => e + redirect_to admin_factions_path, alert: "Failed to sync members: #{e.message}" + end + + private + + def set_faction + @faction = Faction.find(params[:id]) + end + end +end diff --git a/app/controllers/admin/subscriptions_controller.rb b/app/controllers/admin/subscriptions_controller.rb new file mode 100644 index 0000000..fc2b26f --- /dev/null +++ b/app/controllers/admin/subscriptions_controller.rb @@ -0,0 +1,129 @@ +module Admin + class SubscriptionsController < ApplicationController + before_action :require_admin + + def index + @active_subscribers = User.active_subscribers.order(subscription_expires_at: :desc) + @recent_payments = XanaxPayment.includes(:sender, :recipient).recent.limit(50) + @recent_faction_grants = FactionSubscriptionGrant.includes(:granted_by).recent.limit(20) + end + + def faction_grant + end + + def create_faction_grant + Rails.logger.info("Creating faction grant with params: faction_id=#{params[:faction_id]}, weeks=#{params[:weeks]}") + Rails.logger.info("Valid params? #{valid_params?}") + + return redirect_with_error("Invalid faction ID or weeks.") unless valid_params? + + grant_faction_subscription + rescue TornApi::InvalidKeyError => e + Rails.logger.error("Invalid API key error: #{e.message}") + Appsignal.increment_counter("subscription.faction_grant_failed", 1, { reason: "invalid_key" }) + redirect_with_error("Invalid API key.") + rescue TornApi::ApiError => e + Rails.logger.error("Torn API error: #{e.class} - #{e.message}") + Appsignal.increment_counter("subscription.faction_grant_failed", 1, { reason: "api_error" }) + redirect_with_error("Torn API error: #{e.message}") + rescue => e + Rails.logger.error("Faction grant error: #{e.class} - #{e.message}") + Rails.logger.error(e.backtrace.join("\n")) + Appsignal.increment_counter("subscription.faction_grant_failed", 1, { reason: "unexpected_error" }) + Appsignal.send_error(e) + redirect_with_error("An error occurred: #{e.message}") + end + + def update_days + user = User.find(params[:id]) + days = params[:days].to_i + + if days >= 0 + user.update!(subscription_expires_at: days.days.from_now) + Appsignal.increment_counter("subscription.days_updated", 1) + render json: { success: true, new_expires_at: user.subscription_expires_at.strftime("%Y-%m-%d %H:%M"), days: days } + else + render json: { success: false, error: "Days must be a positive number" }, status: :unprocessable_entity + end + rescue => e + render json: { success: false, error: e.message }, status: :unprocessable_entity + end + + private + + def valid_params? + faction_id.positive? && weeks.positive? + end + + def grant_faction_subscription + faction_info = fetch_faction_info + members = fetch_faction_members + + ActiveRecord::Base.transaction do + grant = create_grant_record(faction_info["name"], members.count) + members.each { |member| grant_to_member(grant, member) } + end + + # Track faction grant + Appsignal.increment_counter("subscription.faction_grant", 1) + Appsignal.increment_counter("subscription.weeks_granted", weeks * members.count, { type: "faction_grant" }) + Appsignal.increment_counter("subscription.users_granted", members.count) + + redirect_to admin_subscriptions_path, notice: "Successfully granted #{weeks} week(s) to #{members.count} members of #{faction_info['name']}." + end + + def fetch_faction_info + TornApi::Faction::Basic.new(OwnerCredentials.api_key, faction_id).fetch + end + + def fetch_faction_members + TornApi::Faction::Members.new(OwnerCredentials.api_key, faction_id).fetch + end + + def create_grant_record(faction_name, member_count) + FactionSubscriptionGrant.create!( + torn_faction_id: faction_id, + faction_name: faction_name, + weeks_granted: weeks, + granted_by: Current.user, + granted_at: Time.current + ) + end + + def grant_to_member(grant, member) + user = find_or_create_user(member) + + SubscriptionGrant.create!( + faction_subscription_grant: grant, + user: user + ) + + user.extend_subscription!(weeks) + end + + def find_or_create_user(member) + User.find_by(torn_id: member.id) || create_user_from_member(member) + end + + def create_user_from_member(member) + User.create!( + torn_id: member.id, + name: member.name, + level: member.level, + api_key: nil + ) + end + + def redirect_with_error(message) + redirect_to faction_grant_admin_subscriptions_path, alert: message + end + + def faction_id + @faction_id ||= params[:faction_id].to_i + end + + def weeks + @weeks ||= params[:weeks].to_i + end + end +end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 3538f48..cb27193 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -21,6 +21,11 @@ def require_authentication resume_session || request_authentication end + def require_admin + require_authentication + redirect_to root_path, alert: "Access denied." unless Current.user&.admin? + end + def resume_session Current.session ||= find_session_by_cookie end diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 6abe20e..5185878 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -15,10 +15,12 @@ def index @can_refresh = can_refresh? @seconds_until_refresh = seconds_until_refresh - # API Key info - @api_key_masked = mask_api_key(Current.user.api_key) - @api_access_type = Current.user.api_access_type || "Unknown" - @has_limited_access = Current.user.has_limited_access? + load_api_key_info + end + + def api_key_card + load_api_key_info + render partial: "api_key_card" end def update_api_key @@ -181,4 +183,10 @@ def mask_api_key(api_key) return "Not set" if api_key.blank? "#{api_key[0..3]}********#{api_key[-4..]}" end + + def load_api_key_info + @api_key_masked = mask_api_key(Current.user.api_key) + @api_access_type = Current.user.api_access_type || "Unknown" + @has_limited_access = Current.user.has_limited_access? + end end diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb deleted file mode 100644 index 30aac4e..0000000 --- a/app/controllers/subscriptions_controller.rb +++ /dev/null @@ -1,131 +0,0 @@ -class SubscriptionsController < ApplicationController - before_action :require_admin - - def index - @active_subscribers = User.active_subscribers.order(subscription_expires_at: :desc) - @recent_payments = XanaxPayment.includes(:sender, :recipient).recent.limit(50) - @recent_faction_grants = FactionSubscriptionGrant.includes(:granted_by).recent.limit(20) - end - - def faction_grant - end - - def create_faction_grant - Rails.logger.info("Creating faction grant with params: faction_id=#{params[:faction_id]}, weeks=#{params[:weeks]}") - Rails.logger.info("Valid params? #{valid_params?}") - - return redirect_with_error("Invalid faction ID or weeks.") unless valid_params? - - grant_faction_subscription - rescue TornApi::InvalidKeyError => e - Rails.logger.error("Invalid API key error: #{e.message}") - Appsignal.increment_counter("subscription.faction_grant_failed", 1, { reason: "invalid_key" }) - redirect_with_error("Invalid API key.") - rescue TornApi::ApiError => e - Rails.logger.error("Torn API error: #{e.class} - #{e.message}") - Appsignal.increment_counter("subscription.faction_grant_failed", 1, { reason: "api_error" }) - redirect_with_error("Torn API error: #{e.message}") - rescue => e - Rails.logger.error("Faction grant error: #{e.class} - #{e.message}") - Rails.logger.error(e.backtrace.join("\n")) - Appsignal.increment_counter("subscription.faction_grant_failed", 1, { reason: "unexpected_error" }) - Appsignal.send_error(e) - redirect_with_error("An error occurred: #{e.message}") - end - - def update_days - user = User.find(params[:id]) - days = params[:days].to_i - - if days >= 0 - user.update!(subscription_expires_at: days.days.from_now) - Appsignal.increment_counter("subscription.days_updated", 1) - render json: { success: true, new_expires_at: user.subscription_expires_at.strftime("%Y-%m-%d %H:%M"), days: days } - else - render json: { success: false, error: "Days must be a positive number" }, status: :unprocessable_entity - end - rescue => e - render json: { success: false, error: e.message }, status: :unprocessable_entity - end - - private - - def valid_params? - faction_id.positive? && weeks.positive? - end - - def grant_faction_subscription - faction_info = fetch_faction_info - members = fetch_faction_members - - ActiveRecord::Base.transaction do - grant = create_grant_record(faction_info["name"], members.count) - members.each { |member| grant_to_member(grant, member) } - end - - # Track faction grant - Appsignal.increment_counter("subscription.faction_grant", 1) - Appsignal.increment_counter("subscription.weeks_granted", weeks * members.count, { type: "faction_grant" }) - Appsignal.increment_counter("subscription.users_granted", members.count) - - redirect_to subscriptions_path, notice: "Successfully granted #{weeks} week(s) to #{members.count} members of #{faction_info['name']}." - end - - def fetch_faction_info - TornApi::Faction::Basic.new(OwnerCredentials.api_key, faction_id).fetch - end - - def fetch_faction_members - TornApi::Faction::Members.new(OwnerCredentials.api_key, faction_id).fetch - end - - def create_grant_record(faction_name, member_count) - FactionSubscriptionGrant.create!( - faction_id: faction_id, - faction_name: faction_name, - weeks_granted: weeks, - granted_by: Current.user, - granted_at: Time.current - ) - end - - def grant_to_member(grant, member) - user = find_or_create_user(member) - - SubscriptionGrant.create!( - faction_subscription_grant: grant, - user: user - ) - - user.extend_subscription!(weeks) - end - - def find_or_create_user(member) - User.find_by(torn_id: member.id) || create_user_from_member(member) - end - - def create_user_from_member(member) - User.create!( - torn_id: member.id, - name: member.name, - level: member.level, - api_key: nil - ) - end - - def redirect_with_error(message) - redirect_to faction_grant_subscriptions_path, alert: message - end - - def faction_id - @faction_id ||= params[:faction_id].to_i - end - - def weeks - @weeks ||= params[:weeks].to_i - end - - def require_admin - redirect_to root_path, alert: "Access denied." unless Current.user&.admin? - end -end diff --git a/app/javascript/controllers/api_key_controller.js b/app/javascript/controllers/api_key_controller.js index 42c1614..0d85073 100644 --- a/app/javascript/controllers/api_key_controller.js +++ b/app/javascript/controllers/api_key_controller.js @@ -1,30 +1,23 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { - static targets = ["input", "submit", "feedback"] - - connect() { - console.log("api-key controller connected") - } + static targets = ["input", "submit"] validateInput() { const value = this.inputTarget.value.trim() // Torn API keys are 16 characters const isValid = value.length === 16 && /^[a-zA-Z0-9]+$/.test(value) this.submitTarget.disabled = !isValid - this.clearFeedback() } async submit(event) { event.preventDefault() - console.log("submit called") const form = event.target const formData = new FormData(form) this.submitTarget.disabled = true this.submitTarget.textContent = "Validating..." - this.clearFeedback() try { const response = await fetch(form.action, { @@ -37,38 +30,38 @@ export default class extends Controller { }) const data = await response.json() - console.log("response data:", data) if (data.success) { - this.showFeedback(data.message, "success") + this.showFlash("notice", data.message) this.inputTarget.value = "" + this.submitTarget.textContent = "Update" this.submitTarget.disabled = true - // Reload to update the masked key and access level - setTimeout(() => window.location.reload(), 1500) + // Refresh just the Turbo Frame to update masked key and access level + this.refreshApiKeyCard() } else { - this.showFeedback(data.message, "error") + this.showFlash("alert", data.message) this.submitTarget.textContent = "Update" this.submitTarget.disabled = true } } catch (error) { console.error("error:", error) - this.showFeedback("An error occurred. Please try again.", "error") + this.showFlash("alert", "An error occurred. Please try again.") this.submitTarget.textContent = "Update" this.submitTarget.disabled = true } } - showFeedback(message, type) { - console.log("showFeedback:", message, type) - this.feedbackTarget.textContent = message - this.feedbackTarget.className = "api-key-feedback" - this.feedbackTarget.classList.add(`api-key-feedback-${type}`) - this.feedbackTarget.style.display = "block" + showFlash(type, message) { + window.dispatchEvent(new CustomEvent("flash:show", { + detail: { type, message } + })) } - clearFeedback() { - this.feedbackTarget.textContent = "" - this.feedbackTarget.className = "api-key-feedback" - this.feedbackTarget.style.display = "" + refreshApiKeyCard() { + const frame = document.getElementById("api_key_card") + if (frame) { + frame.src = "/settings/api_key_card" + frame.reload() + } } } diff --git a/app/javascript/controllers/faction_toggle_controller.js b/app/javascript/controllers/faction_toggle_controller.js new file mode 100644 index 0000000..bf2cb59 --- /dev/null +++ b/app/javascript/controllers/faction_toggle_controller.js @@ -0,0 +1,38 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { url: String, row: String } + + async toggle(event) { + const checkbox = event.target + const row = document.getElementById(this.rowValue) + + try { + const response = await fetch(this.urlValue, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": document.querySelector("[name='csrf-token']").content + } + }) + + const data = await response.json() + + if (data.success) { + // Update member count in the row + const memberCountCell = row.querySelector(".member-count") + if (memberCountCell) { + memberCountCell.textContent = data.member_count + } + } else { + // Revert checkbox on error + checkbox.checked = !checkbox.checked + alert(`Error: ${data.error}`) + } + } catch (error) { + // Revert checkbox on error + checkbox.checked = !checkbox.checked + alert(`Failed to update: ${error.message}`) + } + } +} diff --git a/app/javascript/controllers/flash_item_controller.js b/app/javascript/controllers/flash_item_controller.js new file mode 100644 index 0000000..739d204 --- /dev/null +++ b/app/javascript/controllers/flash_item_controller.js @@ -0,0 +1,36 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { duration: { type: Number, default: 3000 } } + + connect() { + // Animate in + requestAnimationFrame(() => { + this.element.classList.add("flash-notification-visible") + }) + + // Auto-dismiss after duration + this.timeout = setTimeout(() => { + this.dismiss() + }, this.durationValue) + } + + disconnect() { + if (this.timeout) { + clearTimeout(this.timeout) + } + } + + dismiss() { + if (this.timeout) { + clearTimeout(this.timeout) + } + + this.element.classList.add("flash-notification-fade-out") + this.element.classList.remove("flash-notification-visible") + + this.element.addEventListener("animationend", () => { + this.element.remove() + }, { once: true }) + } +} diff --git a/app/javascript/controllers/flash_notifications_controller.js b/app/javascript/controllers/flash_notifications_controller.js new file mode 100644 index 0000000..7747867 --- /dev/null +++ b/app/javascript/controllers/flash_notifications_controller.js @@ -0,0 +1,55 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + // Listen for custom flash events from other controllers + window.addEventListener("flash:show", this.handleFlashEvent.bind(this)) + } + + disconnect() { + window.removeEventListener("flash:show", this.handleFlashEvent.bind(this)) + } + + handleFlashEvent(event) { + const { type, message } = event.detail + this.addFlash(type, message) + } + + addFlash(type, message) { + const duration = type === "alert" ? 5000 : 3000 + const iconSvg = type === "notice" + ? ` + + ` + : ` + + + + ` + + const html = ` + + ` + + this.element.insertAdjacentHTML("beforeend", html) + } + + escapeHtml(text) { + const div = document.createElement("div") + div.textContent = text + return div.innerHTML + } +} diff --git a/app/javascript/controllers/subscription_days_controller.js b/app/javascript/controllers/subscription_days_controller.js index 6e58858..4a75917 100644 --- a/app/javascript/controllers/subscription_days_controller.js +++ b/app/javascript/controllers/subscription_days_controller.js @@ -37,7 +37,7 @@ export default class extends Controller { this.buttonTarget.textContent = "Saving..." try { - const response = await fetch(`/subscriptions/${this.userIdValue}/update_days`, { + const response = await fetch(`/admin/subscriptions/${this.userIdValue}/update_days`, { method: "PATCH", headers: { "Content-Type": "application/json", diff --git a/app/jobs/daily/faction_membership_sync_job.rb b/app/jobs/daily/faction_membership_sync_job.rb new file mode 100644 index 0000000..585c9e5 --- /dev/null +++ b/app/jobs/daily/faction_membership_sync_job.rb @@ -0,0 +1,40 @@ +module Daily + class FactionMembershipSyncJob < ApplicationJob + queue_as :default + + def perform + Faction.tracked.find_each do |faction| + sync_faction_members(faction) + end + end + + def sync_faction_members(faction) + api_key = OwnerCredentials.api_key + members = TornApi::Faction::Members.new(api_key, faction.torn_id).fetch + + member_torn_ids = members.map(&:id) + + # Clear faction_id for users no longer in faction + User.where(faction_id: faction.id) + .where.not(torn_id: member_torn_ids) + .update_all(faction_id: nil) + + # Create/update users for current members + members.each do |member| + user = User.find_or_initialize_by(torn_id: member.id) + user.assign_attributes( + name: member.name, + level: member.level, + faction_id: faction.id + ) + user.save! + end + + Rails.logger.info "FactionMembershipSyncJob: Synced #{members.size} members for faction #{faction.name} [#{faction.torn_id}]" + Appsignal.set_gauge("faction_sync.members_synced", members.size, faction_id: faction.torn_id) + rescue TornApi::ApiError => e + Rails.logger.error "FactionMembershipSyncJob: Failed to sync faction #{faction.torn_id}: #{e.message}" + Appsignal.send_error(e) + end + end +end diff --git a/app/jobs/daily/personal_stats_job.rb b/app/jobs/daily/personal_stats_job.rb index ef6a32c..a7dcd02 100644 --- a/app/jobs/daily/personal_stats_job.rb +++ b/app/jobs/daily/personal_stats_job.rb @@ -7,7 +7,7 @@ def perform(delay = 15) run_at = delay.minutes.from_now total_users = 0 - User.hof_stats_users.find_in_batches(batch_size:) do |users| + User.tracked_for_stats.find_in_batches(batch_size:) do |users| users.each do |user| FetchPersonalStatsJob.set(wait_until: run_at).perform_later(user) run_at += 1.second diff --git a/app/models/faction.rb b/app/models/faction.rb new file mode 100644 index 0000000..a6aa82d --- /dev/null +++ b/app/models/faction.rb @@ -0,0 +1,13 @@ +class Faction < ApplicationRecord + has_many :users, dependent: :nullify + has_many :faction_subscription_grants, dependent: :nullify + + validates :torn_id, presence: true, uniqueness: true + validates :name, presence: true + + scope :tracked, -> { where(track_stats: true) } + + def member_count + users.count + end +end diff --git a/app/models/faction_subscription_grant.rb b/app/models/faction_subscription_grant.rb index 503bcc6..fd571b3 100644 --- a/app/models/faction_subscription_grant.rb +++ b/app/models/faction_subscription_grant.rb @@ -1,9 +1,10 @@ class FactionSubscriptionGrant < ApplicationRecord belongs_to :granted_by, class_name: "User" + belongs_to :faction, optional: true has_many :subscription_grants, dependent: :destroy has_many :users, through: :subscription_grants - validates :faction_id, presence: true + validates :torn_faction_id, presence: true validates :faction_name, presence: true validates :weeks_granted, presence: true, numericality: { greater_than: 0 } validates :granted_at, presence: true diff --git a/app/models/user.rb b/app/models/user.rb index ef04d50..6e90d95 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,4 +1,5 @@ class User < ApplicationRecord + belongs_to :faction, optional: true has_many :sessions, dependent: :destroy has_many :personal_stat_snapshots, dependent: :destroy has_many :received_xanax_payments, class_name: "XanaxPayment", foreign_key: :recipient_id, dependent: :destroy @@ -15,6 +16,11 @@ class User < ApplicationRecord scope :hof_stats_users, -> { where(hof_stats_user: true) } scope :active_subscribers, -> { where("subscription_expires_at > ?", Time.current) } + scope :tracked_for_stats, -> { + left_joins(:faction) + .where("hof_stats_user = ? OR factions.track_stats = ?", true, true) + .distinct + } LIMITED_ACCESS_TYPES = [ "Limited Access", "Full Access", "Custom" ].freeze diff --git a/app/views/admin/dashboard/index.html.erb b/app/views/admin/dashboard/index.html.erb new file mode 100644 index 0000000..29d91bb --- /dev/null +++ b/app/views/admin/dashboard/index.html.erb @@ -0,0 +1,29 @@ +

    Admin Dashboard

    + + diff --git a/app/views/admin/factions/_faction.html.erb b/app/views/admin/factions/_faction.html.erb new file mode 100644 index 0000000..6fb7806 --- /dev/null +++ b/app/views/admin/factions/_faction.html.erb @@ -0,0 +1,22 @@ + + + <%= link_to faction.name, "https://www.torn.com/factions.php?step=profile&ID=#{faction.torn_id}", target: "_blank" %> + + <%= faction.torn_id %> + <%= faction.users.count %> + + + + + <%= link_to "Sync", sync_members_admin_faction_path(faction), method: :post, class: "btn-small", data: { turbo_method: :post } %> + <%= button_to "Delete", admin_faction_path(faction), method: :delete, class: "btn-small btn-small-danger", data: { turbo_confirm: "Are you sure you want to remove '#{faction.name}'?" } %> + + diff --git a/app/views/admin/factions/_form.html.erb b/app/views/admin/factions/_form.html.erb new file mode 100644 index 0000000..101effe --- /dev/null +++ b/app/views/admin/factions/_form.html.erb @@ -0,0 +1,14 @@ +<%= form_with url: admin_factions_path, class: "inline-add-form", data: { turbo_frame: "add_faction" } do |form| %> + <% if @error.present? %> +
    <%= @error %>
    + <% end %> + +
    + <%= form.number_field :torn_id, + placeholder: "Faction ID (e.g. 9055)", + class: "form-input", + required: true, + min: 1 %> + <%= form.submit "Add Faction", class: "btn-primary" %> +
    +<% end %> diff --git a/app/views/admin/factions/create.turbo_stream.erb b/app/views/admin/factions/create.turbo_stream.erb new file mode 100644 index 0000000..f703247 --- /dev/null +++ b/app/views/admin/factions/create.turbo_stream.erb @@ -0,0 +1,4 @@ +<%= turbo_stream.append "factions", @faction %> +<%= turbo_stream.replace "add_faction" do %> + <%= render "form" %> +<% end %> diff --git a/app/views/admin/factions/index.html.erb b/app/views/admin/factions/index.html.erb new file mode 100644 index 0000000..aed9bca --- /dev/null +++ b/app/views/admin/factions/index.html.erb @@ -0,0 +1,52 @@ + + +
    +
    +

    Factions

    +
    + + <%= turbo_frame_tag "add_faction" do %> + <%= render "form" %> + <% end %> + +
    + + + + + + + + + + + + <%= render @factions %> + +
    NameTorn IDMembersTrack StatsActions
    +
    + + <% if @factions.empty? %> +
    +

    No factions added yet. Use the form above to start tracking a faction's members.

    +
    + <% end %> +
    + +
    +

    Stats Tracking Info

    +
    +

    How it works:

    +
      +
    • When "Track Stats" is enabled for a faction, all members are synced from the Torn API
    • +
    • Daily at 10:30 AM TCT, faction membership is automatically synced
    • +
    • Daily at 11:00 AM TCT, personal stats are fetched for all tracked users
    • +
    • Users are tracked if they are a HoF stats user OR in a tracked faction
    • +
    • Disabling tracking does NOT delete existing stats data
    • +
    +

    Currently tracking: <%= User.tracked_for_stats.count %> users

    +
    +
    diff --git a/app/views/admin/factions/new.html.erb b/app/views/admin/factions/new.html.erb new file mode 100644 index 0000000..7c35020 --- /dev/null +++ b/app/views/admin/factions/new.html.erb @@ -0,0 +1,12 @@ + + +
    + <%= turbo_frame_tag "add_faction" do %> + <%= render "form" %> + <% end %> + + <%= link_to "Back to Factions", admin_factions_path, class: "btn-secondary mt-4" %> +
    diff --git a/app/views/subscriptions/_faction_grant_form.html.erb b/app/views/admin/subscriptions/_faction_grant_form.html.erb similarity index 87% rename from app/views/subscriptions/_faction_grant_form.html.erb rename to app/views/admin/subscriptions/_faction_grant_form.html.erb index b0d3059..dfc4e14 100644 --- a/app/views/subscriptions/_faction_grant_form.html.erb +++ b/app/views/admin/subscriptions/_faction_grant_form.html.erb @@ -1,5 +1,5 @@
    - <%= form_with url: create_faction_grant_subscriptions_path, method: :post do |form| %> + <%= form_with url: create_faction_grant_admin_subscriptions_path, method: :post do |form| %>
    <%= form.label :faction_id, "Faction ID" %> <%= form.number_field :faction_id, required: true, min: 1, class: "form-input" %> diff --git a/app/views/subscriptions/faction_grant.html.erb b/app/views/admin/subscriptions/faction_grant.html.erb similarity index 75% rename from app/views/subscriptions/faction_grant.html.erb rename to app/views/admin/subscriptions/faction_grant.html.erb index a76ef98..eab0a64 100644 --- a/app/views/subscriptions/faction_grant.html.erb +++ b/app/views/admin/subscriptions/faction_grant.html.erb @@ -1,18 +1,11 @@
    - <% if flash[:notice] %> -
    <%= flash[:notice] %>
    - <% end %> - <% if flash[:alert] %> -
    <%= flash[:alert] %>
    - <% end %> - - <%= form_with url: create_faction_grant_subscriptions_path, method: :post do |form| %> + <%= form_with url: create_faction_grant_admin_subscriptions_path, method: :post do |form| %>
    <%= form.label :faction_id, "Faction ID" %> <%= form.number_field :faction_id, required: true, min: 1, class: "form-input" %> diff --git a/app/views/subscriptions/index.html.erb b/app/views/admin/subscriptions/index.html.erb similarity index 92% rename from app/views/subscriptions/index.html.erb rename to app/views/admin/subscriptions/index.html.erb index 5b1cc29..d5d41ae 100644 --- a/app/views/subscriptions/index.html.erb +++ b/app/views/admin/subscriptions/index.html.erb @@ -1,10 +1,3 @@ -<% if flash[:notice] %> -
    <%= flash[:notice] %>
    -<% end %> -<% if flash[:alert] %> -
    <%= flash[:alert] %>
    -<% end %> -

    Subscription Management

    <%= render "faction_grant_form" %> @@ -115,8 +108,8 @@ <% @recent_faction_grants.each do |grant| %> - - <%= grant.faction_name %> [<%= grant.faction_id %>] + + <%= grant.faction_name %> [<%= grant.torn_faction_id %>] <%= grant.weeks_granted %> diff --git a/app/views/key_log/index.html.erb b/app/views/key_log/index.html.erb index 802766f..48c41fd 100644 --- a/app/views/key_log/index.html.erb +++ b/app/views/key_log/index.html.erb @@ -4,10 +4,6 @@

    View what requests have been made with your API key

    - <% if flash[:alert] %> - - <% end %> - <%= form_with url: key_log_show_path, method: :post, class: "key-log-form-centered", data: { controller: "keylog", turbo: false } do |form| %>
    <%= form.label :api_key, "Your API Key", class: "form-label" %> diff --git a/app/views/layouts/_flash.html.erb b/app/views/layouts/_flash.html.erb new file mode 100644 index 0000000..56dcbf8 --- /dev/null +++ b/app/views/layouts/_flash.html.erb @@ -0,0 +1,26 @@ + diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index fb4319c..de10f32 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -8,7 +8,7 @@ [f] My Faction (Coming Soon) <%= link_to "[h] Hall of Famers", hall_of_famers_path, class: "navbar-link #{'navbar-link-active' if request.path.start_with?('/hall-of-famers')}" %> <% if Current.user&.admin? %> - <%= link_to "[a] Admin", subscriptions_path, class: "navbar-link #{'navbar-link-active' if request.path.start_with?('/subscriptions')}" %> + <%= link_to "[a] Admin", admin_dashboard_path, class: "navbar-link #{'navbar-link-active' if request.path.start_with?('/admin')}" %> <% end %>
    <% else %>
    - - - - - - - - - - - - - - <% @api_calls.each do |call| %> +
    +
    TIMESTAMPENDPOINTSELECTIONSRESPONSE TIMESTATUSERRORAPI KEY
    + - - - - - - - + + + + + + + - <% end %> - -
    <%= call.created_at.strftime("%b %d, %Y %I:%M:%S %p") %><%= call.endpoint %> - <% if call.selections.present? %> - <%= call.selections.is_a?(Hash) ? call.selections.to_json : call.selections %> - <% else %> - - <% end %> - - <% if call.response_time.present? %> - <%= call.response_time %>ms - <% else %> - - <% end %> - - <% if call.status == "success" %> - ✓ Success - <% else %> - ✗ Error - <% end %> - - <% if call.error_message.present? %> - <%= call.error_message %> - <% else %> - - <% end %> - - <% if call.api_key.present? %> - ....<%= call.api_key.last(4) %> - <% else %> - - <% end %> - TIMESTAMPENDPOINTSELECTIONSRESPONSE TIMESTATUSERRORAPI KEY
    + + + <% @api_calls.each do |call| %> + + <%= call.created_at.strftime("%b %d, %Y %I:%M:%S %p") %> + <%= call.endpoint %> + + <% if call.selections.present? %> + <% + # Parse JSON string to hash, extract all values and split by comma + parsed = JSON.parse(call.selections) rescue {} + tags = parsed.values.flat_map { |v| v.to_s.split(",").map(&:strip) }.reject(&:blank?) + %> + <% if tags.any? %> + <% tags.each do |tag| %> + <%= tag %> + <% end %> + <% else %> + + <% end %> + <% else %> + + <% end %> + + + <% if call.response_time.present? %> + <%= call.response_time %>ms + <% else %> + + <% end %> + + + <% if call.status == "success" %> + ✓ Success + <% else %> + ✗ Error + <% end %> + + + <% if call.error_message.present? %> + <%= call.error_message %> + <% else %> + + <% end %> + + + <% if call.api_key.present? %> + ....<%= call.api_key.last(4) %> + <% else %> + + <% end %> + + + <% end %> + + +
    <% end %> diff --git a/db/migrate/20260217084536_add_timestamp_to_personal_stat_snapshots.rb b/db/migrate/20260217084536_add_timestamp_to_personal_stat_snapshots.rb new file mode 100644 index 0000000..dbc0203 --- /dev/null +++ b/db/migrate/20260217084536_add_timestamp_to_personal_stat_snapshots.rb @@ -0,0 +1,5 @@ +class AddTimestampToPersonalStatSnapshots < ActiveRecord::Migration[8.1] + def change + add_column :personal_stat_snapshots, :timestamp, :integer + end +end diff --git a/db/migrate/20260217084617_replace_date_with_timestamp_in_personal_stat_snapshots.rb b/db/migrate/20260217084617_replace_date_with_timestamp_in_personal_stat_snapshots.rb new file mode 100644 index 0000000..50ee972 --- /dev/null +++ b/db/migrate/20260217084617_replace_date_with_timestamp_in_personal_stat_snapshots.rb @@ -0,0 +1,34 @@ +class ReplaceDateWithTimestampInPersonalStatSnapshots < ActiveRecord::Migration[8.1] + def up + # Backfill timestamp from date for existing records + execute <<-SQL + UPDATE personal_stat_snapshots + SET timestamp = CAST(strftime('%s', date || ' 12:00:00') AS INTEGER) + WHERE timestamp IS NULL AND date IS NOT NULL + SQL + + # Remove old index on date + remove_index :personal_stat_snapshots, [ :user_id, :date ], if_exists: true + + # Remove date column + remove_column :personal_stat_snapshots, :date, :date + + # Make timestamp non-null and add unique index + change_column_null :personal_stat_snapshots, :timestamp, false + add_index :personal_stat_snapshots, [ :user_id, :timestamp ], unique: true + end + + def down + remove_index :personal_stat_snapshots, [ :user_id, :timestamp ] + change_column_null :personal_stat_snapshots, :timestamp, true + add_column :personal_stat_snapshots, :date, :date + + execute <<-SQL + UPDATE personal_stat_snapshots + SET date = DATE(timestamp, 'unixepoch') + WHERE timestamp IS NOT NULL + SQL + + add_index :personal_stat_snapshots, [ :user_id, :date ], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 8b5ef84..08d1686 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_02_16_200710) do +ActiveRecord::Schema[8.1].define(version: 2026_02_17_084617) do create_table "api_calls", force: :cascade do |t| t.string "api_key", null: false t.datetime "created_at", null: false @@ -131,7 +131,6 @@ t.integer "crimes_skills_shoplifting" t.integer "crimes_total" t.string "crimes_version" - t.date "date" t.integer "drugs_cannabis" t.integer "drugs_ecstasy" t.integer "drugs_ketamine" @@ -204,6 +203,7 @@ t.integer "racing_races_entered" t.integer "racing_races_won" t.integer "racing_skill" + t.integer "timestamp", null: false t.integer "trading_bazaar_customers" t.bigint "trading_bazaar_profit" t.integer "trading_bazaar_sales" @@ -238,7 +238,7 @@ t.integer "travel_united_kingdom" t.datetime "updated_at", null: false t.integer "user_id", null: false - t.index ["user_id", "date"], name: "index_personal_stat_snapshots_on_user_id_and_date", unique: true + t.index ["user_id", "timestamp"], name: "index_personal_stat_snapshots_on_user_id_and_timestamp", unique: true t.index ["user_id"], name: "index_personal_stat_snapshots_on_user_id" end diff --git a/test/fixtures/personal_stat_snapshots.yml b/test/fixtures/personal_stat_snapshots.yml index e3f1f80..90d76c6 100644 --- a/test/fixtures/personal_stat_snapshots.yml +++ b/test/fixtures/personal_stat_snapshots.yml @@ -1,2 +1,3 @@ one: user: one + timestamp: 1739836800 From eb49ad62421fdcc9083cfdb8bb5cb1a607b4cc9e Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 17 Feb 2026 11:04:41 +0100 Subject: [PATCH 036/266] upgrades --- app/assets/stylesheets/pages/faction.css | 55 +++++++++ .../controllers/api_live_feed_controller.js | 10 -- .../backfill_countdown_controller.js | 60 +++++++++ app/jobs/backfill_personal_stats_job.rb | 37 +++++- app/jobs/backfill_user_stats_job.rb | 7 +- app/jobs/clear_backfill_status_job.rb | 10 ++ app/jobs/test_job.rb | 8 -- app/models/faction.rb | 13 ++ app/views/admin/factions/_faction.html.erb | 114 +++++++++--------- app/views/admin/factions/index.html.erb | 2 + app/views/faction/index.html.erb | 21 +++- ...94139_add_backfill_tracking_to_factions.rb | 7 ++ ...emove_backfill_started_at_from_factions.rb | 5 + db/schema.rb | 4 +- 14 files changed, 270 insertions(+), 83 deletions(-) create mode 100644 app/javascript/controllers/backfill_countdown_controller.js create mode 100644 app/jobs/clear_backfill_status_job.rb delete mode 100644 app/jobs/test_job.rb create mode 100644 db/migrate/20260217094139_add_backfill_tracking_to_factions.rb create mode 100644 db/migrate/20260217095236_remove_backfill_started_at_from_factions.rb diff --git a/app/assets/stylesheets/pages/faction.css b/app/assets/stylesheets/pages/faction.css index 9b60e05..273ae6d 100644 --- a/app/assets/stylesheets/pages/faction.css +++ b/app/assets/stylesheets/pages/faction.css @@ -204,3 +204,58 @@ tr.row-danger:hover { grid-template-columns: 1fr; } } + +/* Backfill Banner */ +.backfill-banner { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.15) 0%, rgba(99, 102, 241, 0.15) 100%); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: var(--radius-lg); + padding: var(--space-5); + margin-bottom: var(--space-6); +} + +.backfill-banner-content { + display: flex; + align-items: center; + gap: var(--space-4); +} + +.backfill-banner-icon { + flex-shrink: 0; + color: rgb(59, 130, 246); +} + +.backfill-banner-text { + display: flex; + flex-direction: column; + gap: var(--space-1); + color: var(--gray-300); + font-size: 0.875rem; +} + +.backfill-banner-text strong { + color: var(--gray-100); + font-size: 1rem; +} + +/* Disabled Table State */ +.table-container.table-disabled { + position: relative; + pointer-events: none; +} + +.table-container.table-disabled::after { + content: ""; + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); + border-radius: var(--radius-lg); + z-index: 10; +} + +.table-container.table-disabled table, +.table-container.table-disabled .compliance-summary, +.table-container.table-disabled .table-info { + opacity: 0.4; + filter: blur(1px); +} diff --git a/app/javascript/controllers/api_live_feed_controller.js b/app/javascript/controllers/api_live_feed_controller.js index bed34eb..47b0c14 100644 --- a/app/javascript/controllers/api_live_feed_controller.js +++ b/app/javascript/controllers/api_live_feed_controller.js @@ -5,9 +5,6 @@ export default class extends Controller { static targets = ["list"] connect() { - console.log("API Live Feed controller connected") - console.log("List target:", this.listTarget) - this.subscription = consumer.subscriptions.create("ApiRateMonitorChannel", { connected: this.connected.bind(this), disconnected: this.disconnected.bind(this), @@ -16,28 +13,23 @@ export default class extends Controller { } disconnect() { - console.log("API Live Feed controller disconnected") if (this.subscription) { this.subscription.unsubscribe() } } connected() { - console.log("WebSocket connected to ApiRateMonitorChannel") } disconnected() { - console.log("WebSocket disconnected from ApiRateMonitorChannel") } received(data) { - console.log("Received API call data:", data) this.addApiCallToFeed(data) } addApiCallToFeed(call) { if (!this.hasListTarget) { - console.error("List target not found!") return } @@ -54,7 +46,6 @@ export default class extends Controller { ${call.response_time}ms ` - console.log("Adding item to feed:", item) this.listTarget.prepend(item) requestAnimationFrame(() => { @@ -66,7 +57,6 @@ export default class extends Controller { item.classList.add("live-api-item-fade-out") setTimeout(() => { - console.log("Removing item from feed") item.remove() }, 300) }, 3000) diff --git a/app/javascript/controllers/backfill_countdown_controller.js b/app/javascript/controllers/backfill_countdown_controller.js new file mode 100644 index 0000000..c69199e --- /dev/null +++ b/app/javascript/controllers/backfill_countdown_controller.js @@ -0,0 +1,60 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { secondsRemaining: Number } + static targets = ["countdown"] + + connect() { + if (this.hasSecondsRemainingValue && this.secondsRemainingValue > 0) { + this.startCountdown() + } + } + + disconnect() { + this.stopCountdown() + } + + startCountdown() { + this.updateDisplay() + this.intervalId = setInterval(() => { + this.secondsRemainingValue -= 1 + + if (this.secondsRemainingValue <= 0) { + this.stopCountdown() + this.reloadPage() + } else { + this.updateDisplay() + } + }, 1000) + } + + stopCountdown() { + if (this.intervalId) { + clearInterval(this.intervalId) + this.intervalId = null + } + } + + updateDisplay() { + const hours = Math.floor(this.secondsRemainingValue / 3600) + const minutes = Math.floor((this.secondsRemainingValue % 3600) / 60) + const seconds = this.secondsRemainingValue % 60 + + let display = "" + if (hours > 0) { + display = `${hours}h ${minutes}m ${seconds}s` + } else if (minutes > 0) { + display = `${minutes}m ${seconds}s` + } else { + display = `${seconds}s` + } + + if (this.hasCountdownTarget) { + this.countdownTarget.textContent = display + } + } + + reloadPage() { + window.location.reload() + } +} diff --git a/app/jobs/backfill_personal_stats_job.rb b/app/jobs/backfill_personal_stats_job.rb index 226b124..0339f25 100644 --- a/app/jobs/backfill_personal_stats_job.rb +++ b/app/jobs/backfill_personal_stats_job.rb @@ -3,18 +3,43 @@ class BackfillPersonalStatsJob < ApplicationJob def perform(faction_id, start_date, end_date) faction = Faction.find(faction_id) + users = faction.users.to_a + dates = (start_date.to_date..end_date.to_date).to_a - Rails.logger.info("Scheduling backfill for faction #{faction.name}: #{faction.users.count} users") + Rails.logger.info("Scheduling backfill for faction #{faction.name}: #{users.count} users, #{dates.size} days") delay = 0.seconds jobs_scheduled = 0 - faction.users.find_each do |user| - BackfillUserStatsJob.set(wait: delay).perform_later(user.id, start_date, end_date) - delay += 1.second - jobs_scheduled += 1 + # Schedule all jobs with sequential delays to avoid rate limiting + users.each do |user| + # Get existing snapshots for this user + existing_snapshots = user.personal_stat_snapshots + .where(timestamp: dates.first.to_time.to_i..dates.last.end_of_day.to_i) + .pluck(:timestamp) + .map { |ts| Time.at(ts).utc.to_date } + .to_set + + dates.each do |date| + # Skip if we already have a snapshot for this date + next if existing_snapshots.include?(date) + + BackfillSingleStatJob.set(wait: delay).perform_later(user.id, date.to_s) + delay += 1.second + jobs_scheduled += 1 + end end - Rails.logger.info("Scheduled #{jobs_scheduled} user backfill jobs for faction #{faction.name}") + total_seconds = [ delay.to_i, 1 ].max + + faction.update!( + backfill_ends_at: Time.current + total_seconds.seconds, + backfill_target_date: start_date.to_date + ) + + # Schedule cleanup job to clear backfill status when done + ClearBackfillStatusJob.set(wait: total_seconds.seconds).perform_later(faction.id) + + Rails.logger.info("Scheduled #{jobs_scheduled} stat fetch jobs for faction #{faction.name}, completing in ~#{total_seconds}s") end end diff --git a/app/jobs/backfill_user_stats_job.rb b/app/jobs/backfill_user_stats_job.rb index 26eaf7f..8012d0f 100644 --- a/app/jobs/backfill_user_stats_job.rb +++ b/app/jobs/backfill_user_stats_job.rb @@ -1,6 +1,7 @@ class BackfillUserStatsJob < ApplicationJob queue_as :default + # Used for backfilling a single user (not called by BackfillPersonalStatsJob anymore) def perform(user_id, start_date, end_date) user = User.find(user_id) dates = (start_date.to_date..end_date.to_date).to_a @@ -13,11 +14,13 @@ def perform(user_id, start_date, end_date) # Get existing snapshots and index by date (derived from timestamp) existing_snapshots = user.personal_stat_snapshots .where(timestamp: dates.first.to_time.to_i..dates.last.end_of_day.to_i) - .index_by(&:date) + .pluck(:timestamp) + .map { |ts| Time.at(ts).utc.to_date } + .to_set dates.each do |date| # Skip if we already have a snapshot for this date - next if existing_snapshots[date].present? + next if existing_snapshots.include?(date) BackfillSingleStatJob.set(wait: delay).perform_later(user.id, date.to_s) delay += 1.second diff --git a/app/jobs/clear_backfill_status_job.rb b/app/jobs/clear_backfill_status_job.rb new file mode 100644 index 0000000..f83ed49 --- /dev/null +++ b/app/jobs/clear_backfill_status_job.rb @@ -0,0 +1,10 @@ +class ClearBackfillStatusJob < ApplicationJob + queue_as :default + + def perform(faction_id) + faction = Faction.find(faction_id) + faction.clear_backfill_status! + + Rails.logger.info("Cleared backfill status for faction #{faction.name}") + end +end diff --git a/app/jobs/test_job.rb b/app/jobs/test_job.rb deleted file mode 100644 index ccd6023..0000000 --- a/app/jobs/test_job.rb +++ /dev/null @@ -1,8 +0,0 @@ -class TestJob < ApplicationJob - queue_as :default - - def perform(*args) - Rails.logger.error("cake") - # Do something later - end -end diff --git a/app/models/faction.rb b/app/models/faction.rb index 6efe66c..c474b17 100644 --- a/app/models/faction.rb +++ b/app/models/faction.rb @@ -13,4 +13,17 @@ class Faction < ApplicationRecord def member_count users.count end + + def backfill_in_progress? + backfill_ends_at.present? && backfill_ends_at > Time.current + end + + def backfill_seconds_remaining + return 0 unless backfill_in_progress? + (backfill_ends_at - Time.current).to_i + end + + def clear_backfill_status! + update!(backfill_ends_at: nil, backfill_target_date: nil) + end end diff --git a/app/views/admin/factions/_faction.html.erb b/app/views/admin/factions/_faction.html.erb index d813440..b580a2b 100644 --- a/app/views/admin/factions/_faction.html.erb +++ b/app/views/admin/factions/_faction.html.erb @@ -49,35 +49,6 @@
    - - - <% end %>
    @@ -85,31 +56,64 @@ <% end %> - - + <%= yield :modals %> + <% if @factions.empty? %>

    No factions added yet. Use the form above to start tracking a faction's members.

    diff --git a/app/views/faction/index.html.erb b/app/views/faction/index.html.erb index a9adf75..8eadb33 100644 --- a/app/views/faction/index.html.erb +++ b/app/views/faction/index.html.erb @@ -15,6 +15,7 @@
    <% else %> + <% backfill_in_progress = @faction.backfill_in_progress? %> <% def sortable_header(sort_info) if sort_info[:current] @@ -37,7 +38,25 @@

    Stats tracking for <%= @faction.users.count %> members

    -
    + <% if backfill_in_progress %> +
    +
    +
    + + + + +
    +
    + Fetching historical data + Backfilling member stats up until <%= @faction.backfill_target_date&.strftime("%B %d, %Y") %>. + Table will be enabled in: +
    +
    +
    + <% end %> + +
    <% if Current.user.admin? %>
    <%= form_with url: faction_index_path, method: :get, class: "table-filter-form" do |form| %> diff --git a/db/migrate/20260217094139_add_backfill_tracking_to_factions.rb b/db/migrate/20260217094139_add_backfill_tracking_to_factions.rb new file mode 100644 index 0000000..b59bf34 --- /dev/null +++ b/db/migrate/20260217094139_add_backfill_tracking_to_factions.rb @@ -0,0 +1,7 @@ +class AddBackfillTrackingToFactions < ActiveRecord::Migration[8.1] + def change + add_column :factions, :backfill_started_at, :datetime + add_column :factions, :backfill_ends_at, :datetime + add_column :factions, :backfill_target_date, :date + end +end diff --git a/db/migrate/20260217095236_remove_backfill_started_at_from_factions.rb b/db/migrate/20260217095236_remove_backfill_started_at_from_factions.rb new file mode 100644 index 0000000..2e93c8b --- /dev/null +++ b/db/migrate/20260217095236_remove_backfill_started_at_from_factions.rb @@ -0,0 +1,5 @@ +class RemoveBackfillStartedAtFromFactions < ActiveRecord::Migration[8.1] + def change + remove_column :factions, :backfill_started_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 08d1686..d90dabe 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_02_17_084617) do +ActiveRecord::Schema[8.1].define(version: 2026_02_17_095236) do create_table "api_calls", force: :cascade do |t| t.string "api_key", null: false t.datetime "created_at", null: false @@ -42,6 +42,8 @@ end create_table "factions", force: :cascade do |t| + t.datetime "backfill_ends_at" + t.date "backfill_target_date" t.datetime "created_at", null: false t.decimal "energy_refill_target", default: "1.0", null: false t.string "name", null: false From 6650b639d7f4939bfa615d589e86d130832dae90 Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 17 Feb 2026 11:33:01 +0100 Subject: [PATCH 037/266] admin stats page --- app/controllers/admin/stats_controller.rb | 68 +++++ app/jobs/backfill_personal_stats_job.rb | 6 +- app/jobs/backfill_single_stat_job.rb | 33 ++- app/jobs/backfill_user_stats_job.rb | 6 +- app/jobs/daily/personal_stats_job.rb | 6 +- app/jobs/fetch_personal_stats_job.rb | 34 ++- app/models/personal_stat_snapshot.rb | 22 +- app/models/torn_api/user/personal_stats.rb | 26 +- app/views/admin/api_logs/index.html.erb | 1 + app/views/admin/dashboard/index.html.erb | 12 + app/views/admin/factions/edit.html.erb | 1 + app/views/admin/factions/index.html.erb | 32 +-- app/views/admin/stats/index.html.erb | 259 ++++++++++++++++++ .../subscriptions/faction_grant.html.erb | 2 +- app/views/admin/subscriptions/index.html.erb | 5 +- config/routes.rb | 1 + 16 files changed, 446 insertions(+), 68 deletions(-) create mode 100644 app/controllers/admin/stats_controller.rb create mode 100644 app/views/admin/stats/index.html.erb diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb new file mode 100644 index 0000000..1b7a6d5 --- /dev/null +++ b/app/controllers/admin/stats_controller.rb @@ -0,0 +1,68 @@ +module Admin + class StatsController < ApplicationController + before_action :require_admin + + def index + # User counts + @total_users = User.count + @tracked_users = User.tracked_for_stats.count + @active_subscribers = User.active_subscribers.count + @hof_stats_users = User.hof_stats_users.count + + # Faction overview + @total_factions = Faction.count + @tracked_factions = Faction.tracked.count + @factions_with_backfill = Faction.where("backfill_ends_at > ?", Time.current).count + @faction_stats = Faction.tracked.left_joins(:users).group("factions.id", "factions.name").count("users.id") + + # Snapshot coverage + @total_snapshots = PersonalStatSnapshot.count + @earliest_snapshot = PersonalStatSnapshot.minimum(:timestamp) + @latest_snapshot = PersonalStatSnapshot.maximum(:timestamp) + @unique_snapshot_days = PersonalStatSnapshot.distinct.pluck(Arel.sql("DATE(timestamp, 'unixepoch')")).count + + # Recent activity + today_start = Time.current.beginning_of_day.to_i + week_start = 7.days.ago.beginning_of_day.to_i + @snapshots_today = PersonalStatSnapshot.where("timestamp >= ?", today_start).count + @snapshots_this_week = PersonalStatSnapshot.where("timestamp >= ?", week_start).count + @api_calls_today = ApiCall.where("created_at >= ?", Time.current.beginning_of_day).count + @api_calls_this_week = ApiCall.where("created_at >= ?", 7.days.ago.beginning_of_day).count + + # Data health - users missing recent snapshots + yesterday_start = 1.day.ago.beginning_of_day.to_i + yesterday_end = 1.day.ago.end_of_day.to_i + users_with_yesterday_snapshot = PersonalStatSnapshot + .where(timestamp: yesterday_start..yesterday_end) + .distinct + .pluck(:user_id) + @users_missing_yesterday = User.tracked_for_stats.where.not(id: users_with_yesterday_snapshot).count + + # Snapshot completeness (users with all 11 stats filled) + @complete_snapshots = PersonalStatSnapshot + .where.not( + drugs_xanax: nil, + drugs_cannabis: nil, + other_refills_energy: nil, + other_refills_nerve: nil, + items_used_boosters: nil, + items_used_stat_enhancers: nil, + missions_contracts_total: nil, + crimes_offenses_total: nil, + other_activity_time: nil, + networth_total: nil, + attacking_networth_money_mugged: nil + ) + .count + @incomplete_snapshots = @total_snapshots - @complete_snapshots + + # Recent snapshot activity by day (last 7 days) + @daily_snapshots = PersonalStatSnapshot + .where("timestamp >= ?", week_start) + .group(Arel.sql("DATE(timestamp, 'unixepoch')")) + .count + .sort_by { |date, _| date } + .last(7) + end + end +end diff --git a/app/jobs/backfill_personal_stats_job.rb b/app/jobs/backfill_personal_stats_job.rb index 0339f25..d10fbec 100644 --- a/app/jobs/backfill_personal_stats_job.rb +++ b/app/jobs/backfill_personal_stats_job.rb @@ -1,6 +1,10 @@ class BackfillPersonalStatsJob < ApplicationJob queue_as :default + # Each backfill requires 2 API calls (batch 1 + batch 2), spaced 1 second apart + # So we schedule each user/date combo 2 seconds apart to avoid rate limiting + SECONDS_PER_BACKFILL = 2 + def perform(faction_id, start_date, end_date) faction = Faction.find(faction_id) users = faction.users.to_a @@ -25,7 +29,7 @@ def perform(faction_id, start_date, end_date) next if existing_snapshots.include?(date) BackfillSingleStatJob.set(wait: delay).perform_later(user.id, date.to_s) - delay += 1.second + delay += SECONDS_PER_BACKFILL.seconds jobs_scheduled += 1 end end diff --git a/app/jobs/backfill_single_stat_job.rb b/app/jobs/backfill_single_stat_job.rb index eac98b7..6669e2f 100644 --- a/app/jobs/backfill_single_stat_job.rb +++ b/app/jobs/backfill_single_stat_job.rb @@ -1,7 +1,10 @@ class BackfillSingleStatJob < ApplicationJob queue_as :default - def perform(user_id, date_str) + # @param user_id [Integer] User ID + # @param date_str [String] Date string (YYYY-MM-DD) + # @param batch [Integer] Which batch to fetch (1 or 2), defaults to 1 which schedules batch 2 + def perform(user_id, date_str, batch: 1) user = User.find(user_id) date = Date.parse(date_str) api_key = OwnerCredentials.api_key @@ -9,35 +12,41 @@ def perform(user_id, date_str) return Rails.logger.error("No API key found") if api_key.blank? request_timestamp = date.to_time.to_i + stat_batch = batch == 1 ? PersonalStatSnapshot::TRACKED_STATS_BATCH_1 : PersonalStatSnapshot::TRACKED_STATS_BATCH_2 - stats = fetch_stats(user.torn_id, request_timestamp, api_key) + stats = fetch_stats(user.torn_id, request_timestamp, api_key, stat_batch) return if stats.nil? - # Use the timestamp returned by the API - response_timestamp = stats.timestamp - response_date = Time.at(response_timestamp).utc.to_date - stats_data = stats.to_h.except(:timestamp) + # Use the original request date for snapshot lookup, not API response timestamp + # (API may return different timestamps between batch 1 and batch 2) + response_timestamp = stats[:timestamp] + stats_data = stats.except(:timestamp) - # Find existing snapshot for this day, or initialize new one with response timestamp - day_start = response_date.beginning_of_day.to_i - day_end = response_date.end_of_day.to_i + # Find existing snapshot for the target date, or initialize new one + day_start = date.beginning_of_day.to_i + day_end = date.end_of_day.to_i snapshot = user.personal_stat_snapshots.find_by(timestamp: day_start..day_end) snapshot ||= user.personal_stat_snapshots.new(timestamp: response_timestamp) snapshot.assign_attributes(stats_data) if snapshot.save - Rails.logger.debug("Upserted snapshot for user #{user.torn_id} on #{snapshot.date}") + Rails.logger.debug("Upserted snapshot for user #{user.torn_id} on #{snapshot.date} (batch #{batch})") else Rails.logger.error("Failed to save snapshot for user #{user.torn_id} on #{snapshot.date}: #{snapshot.errors.full_messages.join(', ')}") end + + # Schedule batch 2 if this was batch 1 + if batch == 1 + BackfillSingleStatJob.set(wait: 1.second).perform_later(user_id, date_str, batch: 2) + end end private - def fetch_stats(torn_id, timestamp, api_key) - api = TornApi::User::PersonalStats.new(api_key, torn_id, timestamp: timestamp) + def fetch_stats(torn_id, timestamp, api_key, stat_batch) + api = TornApi::User::PersonalStats.new(api_key, torn_id, timestamp: timestamp, stat_batch: stat_batch) api.fetch rescue TornApi::ApiError => e Rails.logger.error("API error fetching stats: #{e.message}") diff --git a/app/jobs/backfill_user_stats_job.rb b/app/jobs/backfill_user_stats_job.rb index 8012d0f..0b0f8b6 100644 --- a/app/jobs/backfill_user_stats_job.rb +++ b/app/jobs/backfill_user_stats_job.rb @@ -1,6 +1,10 @@ class BackfillUserStatsJob < ApplicationJob queue_as :default + # Each backfill requires 2 API calls (batch 1 + batch 2), spaced 1 second apart + # So we schedule each date 2 seconds apart to avoid rate limiting + SECONDS_PER_BACKFILL = 2 + # Used for backfilling a single user (not called by BackfillPersonalStatsJob anymore) def perform(user_id, start_date, end_date) user = User.find(user_id) @@ -23,7 +27,7 @@ def perform(user_id, start_date, end_date) next if existing_snapshots.include?(date) BackfillSingleStatJob.set(wait: delay).perform_later(user.id, date.to_s) - delay += 1.second + delay += SECONDS_PER_BACKFILL.seconds jobs_scheduled += 1 end diff --git a/app/jobs/daily/personal_stats_job.rb b/app/jobs/daily/personal_stats_job.rb index a363e9a..b2ee45e 100644 --- a/app/jobs/daily/personal_stats_job.rb +++ b/app/jobs/daily/personal_stats_job.rb @@ -2,6 +2,10 @@ module Daily class PersonalStatsJob < ApplicationJob queue_as :default + # Each stats fetch requires 2 API calls (batch 1 + batch 2), spaced 1 second apart + # So we schedule each user 2 seconds apart to avoid rate limiting + SECONDS_PER_FETCH = 2 + def perform(delay = 15) batch_size = 60 run_at = delay.minutes.from_now @@ -10,7 +14,7 @@ def perform(delay = 15) User.tracked_for_stats.find_in_batches(batch_size:) do |users| users.each do |user| FetchPersonalStatsJob.set(wait_until: run_at).perform_later(user) - run_at += 1.second + run_at += SECONDS_PER_FETCH.seconds total_users += 1 end end diff --git a/app/jobs/fetch_personal_stats_job.rb b/app/jobs/fetch_personal_stats_job.rb index 21ebaa4..ef8da36 100644 --- a/app/jobs/fetch_personal_stats_job.rb +++ b/app/jobs/fetch_personal_stats_job.rb @@ -3,21 +3,37 @@ class FetchPersonalStatsJob < ApplicationJob MIN_STAT_ENHANCER = 200 - def perform(user) - stats = TornApi::User::PersonalStats.new(OwnerCredentials.api_key, user.torn_id).fetch - user.update!(hof_stats_user: true) if stats.items_used_stat_enhancers.to_i > MIN_STAT_ENHANCER + # @param user [User] User to fetch stats for + # @param batch [Integer] Which batch to fetch (1 or 2), defaults to 1 which schedules batch 2 + # @param target_date [Date, nil] The date to save stats for (batch 2 uses this to find batch 1's snapshot) + def perform(user, batch: 1, target_date: nil) + stat_batch = batch == 1 ? PersonalStatSnapshot::TRACKED_STATS_BATCH_1 : PersonalStatSnapshot::TRACKED_STATS_BATCH_2 - # Find existing snapshot for this day, or create new one - response_timestamp = stats.timestamp - response_date = Time.at(response_timestamp).utc.to_date - day_start = response_date.beginning_of_day.to_i - day_end = response_date.end_of_day.to_i + stats = TornApi::User::PersonalStats.new(OwnerCredentials.api_key, user.torn_id, stat_batch: stat_batch).fetch + + # Check HoF eligibility only on batch 1 (which has stat_enhancers) + if batch == 1 && stats[:items_used_stat_enhancers].to_i > MIN_STAT_ENHANCER + user.update!(hof_stats_user: true) + end + + # Determine the date for this snapshot + # - Batch 1: use the API response timestamp + # - Batch 2: use the target_date passed from batch 1 (API may return different timestamp) + response_timestamp = stats[:timestamp] + snapshot_date = target_date || Time.at(response_timestamp).utc.to_date + day_start = snapshot_date.beginning_of_day.to_i + day_end = snapshot_date.end_of_day.to_i snapshot = user.personal_stat_snapshots.find_by(timestamp: day_start..day_end) snapshot ||= user.personal_stat_snapshots.new(timestamp: response_timestamp) - snapshot.assign_attributes(stats.to_h.except(:timestamp)) + snapshot.assign_attributes(stats.except(:timestamp)) snapshot.save! + # Schedule batch 2 if this was batch 1, passing the date so it finds the same snapshot + if batch == 1 + FetchPersonalStatsJob.set(wait: 1.second).perform_later(user, batch: 2, target_date: snapshot_date) + end + ::Appsignal.increment_counter("jobs.personal_stats_fetched", 1) if defined?(::Appsignal) rescue => e ::Appsignal.increment_counter("jobs.personal_stats_failed", 1) if defined?(::Appsignal) diff --git a/app/models/personal_stat_snapshot.rb b/app/models/personal_stat_snapshot.rb index ccb94b3..b98f95d 100644 --- a/app/models/personal_stat_snapshot.rb +++ b/app/models/personal_stat_snapshot.rb @@ -5,7 +5,8 @@ class PersonalStatSnapshot < ApplicationRecord validate :one_snapshot_per_day_per_user, on: :create # Central definition of stats we track - maps API stat name to DB column - TRACKED_STATS = { + # Split into batches of max 10 due to Torn API v2 limit + TRACKED_STATS_BATCH_1 = { "xantaken" => :drugs_xanax, "cantaken" => :drugs_cannabis, "refills" => :other_refills_energy, @@ -15,18 +16,35 @@ class PersonalStatSnapshot < ApplicationRecord "contractscompleted" => :missions_contracts_total, "criminaloffenses" => :crimes_offenses_total, "timeplayed" => :other_activity_time, - "networth" => :networth_total, + "networth" => :networth_total + }.freeze + + TRACKED_STATS_BATCH_2 = { "moneymugged" => :attacking_networth_money_mugged }.freeze + TRACKED_STATS = TRACKED_STATS_BATCH_1.merge(TRACKED_STATS_BATCH_2).freeze + def self.api_stat_names TRACKED_STATS.keys end + def self.api_stat_names_batch_1 + TRACKED_STATS_BATCH_1.keys + end + + def self.api_stat_names_batch_2 + TRACKED_STATS_BATCH_2.keys + end + def self.db_columns TRACKED_STATS.values end + def self.stat_batches + [ TRACKED_STATS_BATCH_1, TRACKED_STATS_BATCH_2 ] + end + def date Time.at(timestamp).utc.to_date if timestamp end diff --git a/app/models/torn_api/user/personal_stats.rb b/app/models/torn_api/user/personal_stats.rb index 3c3d757..28b9207 100644 --- a/app/models/torn_api/user/personal_stats.rb +++ b/app/models/torn_api/user/personal_stats.rb @@ -1,7 +1,7 @@ module TornApi module User class PersonalStats < Base - attr_reader :torn_id, :timestamp + attr_reader :torn_id, :timestamp, :stat_batch PersonalStatSnapshot = Data.define( :timestamp, @@ -18,10 +18,15 @@ class PersonalStats < Base :attacking_networth_money_mugged ) - def initialize(api_key, torn_id, timestamp: nil) + # @param api_key [String] API key + # @param torn_id [Integer] User's Torn ID + # @param timestamp [Integer, nil] Unix timestamp for historical data + # @param stat_batch [Hash, nil] Specific stats to fetch (api_name => db_column), defaults to all + def initialize(api_key, torn_id, timestamp: nil, stat_batch: nil) super(api_key) @torn_id = torn_id @timestamp = timestamp + @stat_batch = stat_batch || ::PersonalStatSnapshot::TRACKED_STATS end def endpoint @@ -29,14 +34,14 @@ def endpoint end def fetch - stat_names = ::PersonalStatSnapshot.api_stat_names.join(",") + stat_names = stat_batch.keys.join(",") params = { stat: stat_names } params[:timestamp] = timestamp if timestamp response = get(endpoint, params) - if response["personalstats"].present? - parse_personalstats(response["personalstats"]) + if response["personalstats"].present? || response["personalstats"].is_a?(Array) + parse_personalstats(response["personalstats"], stat_batch) else raise InvalidKeyError, "Torn API authentication failed: #{response}" end @@ -44,8 +49,9 @@ def fetch private - def parse_personalstats(stats) + def parse_personalstats(stats, requested_stats) # stats is an array of { "name" => "...", "value" => ..., "timestamp" => ... } + # Note: API omits stats with value 0, so we default missing stats to 0 stats_hash = stats.each_with_object({}) do |stat, hash| hash[stat["name"]] = stat["value"] end @@ -53,13 +59,15 @@ def parse_personalstats(stats) # Get timestamp from the first stat (they should all have the same timestamp) response_timestamp = stats.first&.dig("timestamp") + # Build result with only the requested stats, defaulting missing to 0 result = { timestamp: response_timestamp } - ::PersonalStatSnapshot::TRACKED_STATS.each do |api_name, db_column| - result[db_column] = stats_hash[api_name] + requested_stats.each do |api_name, db_column| + # Default to 0 if stat not in response (API omits stats with value 0) + result[db_column] = stats_hash[api_name] || 0 end - PersonalStatSnapshot.new(**result) + result end end end diff --git a/app/views/admin/api_logs/index.html.erb b/app/views/admin/api_logs/index.html.erb index d4fa21a..ad6b3a8 100644 --- a/app/views/admin/api_logs/index.html.erb +++ b/app/views/admin/api_logs/index.html.erb @@ -1,4 +1,5 @@ diff --git a/app/views/admin/dashboard/index.html.erb b/app/views/admin/dashboard/index.html.erb index d6d709e..e9da9e8 100644 --- a/app/views/admin/dashboard/index.html.erb +++ b/app/views/admin/dashboard/index.html.erb @@ -4,6 +4,18 @@
    - -
    -

    Stats Tracking Info

    -
    -

    How it works:

    -
      -
    • When "Track Stats" is enabled for a faction, all members are synced from the Torn API
    • -
    • Daily at 10:30 AM TCT, faction membership is automatically synced
    • -
    • Daily at 11:00 AM TCT, personal stats are fetched for all tracked users
    • -
    • Users are tracked if they are a HoF stats user OR in a tracked faction
    • -
    • Disabling tracking does NOT delete existing stats data
    • -
    -

    Currently tracking: <%= User.tracked_for_stats.count %> users

    -
    - -

    Backfill Historical Data

    -
    -

    What is backfilling?

    -

    The backfill feature allows you to populate historical stats data for faction members. This is useful if you recently enabled tracking for a faction and want to import past data.

    -

    How to use:

    -
      -
    • Click the "Backfill" button next to any faction
    • -
    • Select the date range (default: Jan 1-20, 2026)
    • -
    • Jobs are scheduled per user, then per date at 60 API calls/minute
    • -
    • Each date requires up to 2 API calls (batch of 10 stats + batch of 1 stat)
    • -
    • The system will skip dates that already have complete snapshot data
    • -
    -

    Stats backfilled: Xanax, Cannabis, Energy Refills, Nerve Refills, Boosters, Stat Enhancers, Contracts, Crimes, Activity Time, Net Worth, Money Mugged (10 total)

    -

    Note: Merits are not backfilled as they don't support historical data via the Torn API.

    -
    -
    diff --git a/app/views/admin/stats/index.html.erb b/app/views/admin/stats/index.html.erb new file mode 100644 index 0000000..4b4f604 --- /dev/null +++ b/app/views/admin/stats/index.html.erb @@ -0,0 +1,259 @@ + + + +
    +

    Users

    +
    +
    +
    <%= @total_users %>
    +
    Total Users
    +
    +
    +
    <%= @tracked_users %>
    +
    Tracked for Stats
    +
    +
    +
    <%= @active_subscribers %>
    +
    Active Subscribers
    +
    +
    +
    <%= @hof_stats_users %>
    +
    HoF Stats Users
    +
    +
    +
    + + +
    +

    Factions

    +
    +
    +
    <%= @total_factions %>
    +
    Total Factions
    +
    +
    +
    <%= @tracked_factions %>
    +
    Tracked
    +
    +
    +
    <%= @factions_with_backfill %>
    +
    Backfill In Progress
    +
    +
    + + <% if @faction_stats.any? %> +
    +

    Tracked Factions

    + + + + + + + + + <% @faction_stats.each do |(faction_id, faction_name), count| %> + + + + + <% end %> + +
    FactionMembers
    <%= faction_name %><%= count %>
    +
    + <% end %> +
    + + +
    +

    Snapshot Coverage

    +
    +
    +
    <%= number_with_delimiter(@total_snapshots) %>
    +
    Total Snapshots
    +
    +
    +
    <%= @unique_snapshot_days %>
    +
    Days with Data
    +
    +
    +
    <%= @earliest_snapshot ? Time.at(@earliest_snapshot).utc.strftime("%Y-%m-%d") : "N/A" %>
    +
    Earliest Snapshot
    +
    +
    +
    <%= @latest_snapshot ? Time.at(@latest_snapshot).utc.strftime("%Y-%m-%d") : "N/A" %>
    +
    Latest Snapshot
    +
    +
    +
    + + +
    +

    Recent Activity

    +
    +
    +
    <%= @snapshots_today %>
    +
    Snapshots Today
    +
    +
    +
    <%= @snapshots_this_week %>
    +
    Snapshots This Week
    +
    +
    +
    <%= @api_calls_today %>
    +
    API Calls Today
    +
    +
    +
    <%= @api_calls_this_week %>
    +
    API Calls This Week
    +
    +
    + + <% if @daily_snapshots.any? %> +
    +

    Snapshots by Day (Last 7 Days)

    + + + + + + + + + <% @daily_snapshots.each do |date, count| %> + + + + + <% end %> + +
    DateSnapshots
    <%= date %><%= count %>
    +
    + <% end %> +
    + + +
    +

    Data Health

    +
    +
    +
    <%= @users_missing_yesterday %>
    +
    Users Missing Yesterday's Snapshot
    +
    +
    +
    <%= number_with_delimiter(@complete_snapshots) %>
    +
    Complete Snapshots
    +
    +
    +
    <%= number_with_delimiter(@incomplete_snapshots) %>
    +
    Incomplete Snapshots
    +
    +
    +
    <%= @total_snapshots > 0 ? "#{((@complete_snapshots.to_f / @total_snapshots) * 100).round(1)}%" : "N/A" %>
    +
    Completeness Rate
    +
    +
    +
    + + diff --git a/app/views/admin/subscriptions/faction_grant.html.erb b/app/views/admin/subscriptions/faction_grant.html.erb index eab0a64..2ad73b3 100644 --- a/app/views/admin/subscriptions/faction_grant.html.erb +++ b/app/views/admin/subscriptions/faction_grant.html.erb @@ -1,7 +1,7 @@
    diff --git a/app/views/admin/subscriptions/index.html.erb b/app/views/admin/subscriptions/index.html.erb index d5d41ae..39471ec 100644 --- a/app/views/admin/subscriptions/index.html.erb +++ b/app/views/admin/subscriptions/index.html.erb @@ -1,4 +1,7 @@ -

    Subscription Management

    + <%= render "faction_grant_form" %> diff --git a/config/routes.rb b/config/routes.rb index 0d25ed7..c37d4e0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -25,6 +25,7 @@ namespace :admin do get "/", to: "dashboard#index", as: :dashboard + resources :stats, only: [ :index ] resources :subscriptions, only: [ :index ] do collection do get :faction_grant From 8d653319ca8e59c3d424317aac1ad1c7e65eda52 Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 17 Feb 2026 13:20:16 +0100 Subject: [PATCH 038/266] fix tornapijobs --- app/jobs/backfill_personal_stats_job.rb | 26 ++++++++------- app/jobs/backfill_single_stat_job.rb | 8 ++--- app/jobs/backfill_user_stats_job.rb | 9 ++--- app/jobs/daily/faction_membership_sync_job.rb | 33 ++----------------- app/jobs/daily/factionhof_members_job.rb | 17 +++++----- app/jobs/daily/personal_stats_job.rb | 18 +++------- app/jobs/daily/stock_dividend_job.rb | 4 +-- app/jobs/daily/xanax_payments_job.rb | 3 +- app/jobs/faction_members_job.rb | 18 ---------- app/jobs/fetch_faction_hof_page_job.rb | 14 ++++++++ app/jobs/fetch_faction_members_job.rb | 18 ++++++++++ app/jobs/fetch_personal_stats_job.rb | 8 ++--- app/jobs/sync_faction_members_job.rb | 32 ++++++++++++++++++ app/jobs/torn_api_job.rb | 13 ++++++++ config/queue.yml | 9 ++++- 15 files changed, 126 insertions(+), 104 deletions(-) delete mode 100644 app/jobs/faction_members_job.rb create mode 100644 app/jobs/fetch_faction_hof_page_job.rb create mode 100644 app/jobs/fetch_faction_members_job.rb create mode 100644 app/jobs/sync_faction_members_job.rb create mode 100644 app/jobs/torn_api_job.rb diff --git a/app/jobs/backfill_personal_stats_job.rb b/app/jobs/backfill_personal_stats_job.rb index d10fbec..5ab0419 100644 --- a/app/jobs/backfill_personal_stats_job.rb +++ b/app/jobs/backfill_personal_stats_job.rb @@ -1,9 +1,8 @@ class BackfillPersonalStatsJob < ApplicationJob queue_as :default - # Each backfill requires 2 API calls (batch 1 + batch 2), spaced 1 second apart - # So we schedule each user/date combo 2 seconds apart to avoid rate limiting - SECONDS_PER_BACKFILL = 2 + # Seconds per API call (polling_interval of torn_api queue) + SECONDS_PER_API_CALL = 1.1 def perform(faction_id, start_date, end_date) faction = Faction.find(faction_id) @@ -12,10 +11,9 @@ def perform(faction_id, start_date, end_date) Rails.logger.info("Scheduling backfill for faction #{faction.name}: #{users.count} users, #{dates.size} days") - delay = 0.seconds jobs_scheduled = 0 - # Schedule all jobs with sequential delays to avoid rate limiting + # Enqueue all jobs - torn_api queue handles rate limiting (~1 req/sec) users.each do |user| # Get existing snapshots for this user existing_snapshots = user.personal_stat_snapshots @@ -28,22 +26,28 @@ def perform(faction_id, start_date, end_date) # Skip if we already have a snapshot for this date next if existing_snapshots.include?(date) - BackfillSingleStatJob.set(wait: delay).perform_later(user.id, date.to_s) - delay += SECONDS_PER_BACKFILL.seconds + BackfillSingleStatJob.perform_later(user.id, date.to_s) jobs_scheduled += 1 end end - total_seconds = [ delay.to_i, 1 ].max + # Count jobs already in the torn_api queue (not yet finished) + # These will be processed before our newly enqueued jobs + existing_queued_jobs = SolidQueue::Job.where(queue_name: "torn_api", finished_at: nil).count + + # Each BackfillSingleStatJob triggers 2 API calls (batch 1 + batch 2) + # Total API calls = existing jobs + (new jobs * 2) + total_api_calls = existing_queued_jobs + (jobs_scheduled * 2) + estimated_seconds = [ total_api_calls * SECONDS_PER_API_CALL, 1 ].max.to_i faction.update!( - backfill_ends_at: Time.current + total_seconds.seconds, + backfill_ends_at: Time.current + estimated_seconds.seconds, backfill_target_date: start_date.to_date ) # Schedule cleanup job to clear backfill status when done - ClearBackfillStatusJob.set(wait: total_seconds.seconds).perform_later(faction.id) + ClearBackfillStatusJob.set(wait: estimated_seconds.seconds).perform_later(faction.id) - Rails.logger.info("Scheduled #{jobs_scheduled} stat fetch jobs for faction #{faction.name}, completing in ~#{total_seconds}s") + Rails.logger.info("Scheduled #{jobs_scheduled} stat fetch jobs for faction #{faction.name} (#{existing_queued_jobs} jobs already queued), completing in ~#{estimated_seconds}s") end end diff --git a/app/jobs/backfill_single_stat_job.rb b/app/jobs/backfill_single_stat_job.rb index 6669e2f..c0b49a3 100644 --- a/app/jobs/backfill_single_stat_job.rb +++ b/app/jobs/backfill_single_stat_job.rb @@ -1,6 +1,4 @@ -class BackfillSingleStatJob < ApplicationJob - queue_as :default - +class BackfillSingleStatJob < TornApiJob # @param user_id [Integer] User ID # @param date_str [String] Date string (YYYY-MM-DD) # @param batch [Integer] Which batch to fetch (1 or 2), defaults to 1 which schedules batch 2 @@ -37,9 +35,9 @@ def perform(user_id, date_str, batch: 1) Rails.logger.error("Failed to save snapshot for user #{user.torn_id} on #{snapshot.date}: #{snapshot.errors.full_messages.join(', ')}") end - # Schedule batch 2 if this was batch 1 + # Enqueue batch 2 if this was batch 1 (queue handles rate limiting) if batch == 1 - BackfillSingleStatJob.set(wait: 1.second).perform_later(user_id, date_str, batch: 2) + BackfillSingleStatJob.perform_later(user_id, date_str, batch: 2) end end diff --git a/app/jobs/backfill_user_stats_job.rb b/app/jobs/backfill_user_stats_job.rb index 0b0f8b6..ec9fb77 100644 --- a/app/jobs/backfill_user_stats_job.rb +++ b/app/jobs/backfill_user_stats_job.rb @@ -1,10 +1,6 @@ class BackfillUserStatsJob < ApplicationJob queue_as :default - # Each backfill requires 2 API calls (batch 1 + batch 2), spaced 1 second apart - # So we schedule each date 2 seconds apart to avoid rate limiting - SECONDS_PER_BACKFILL = 2 - # Used for backfilling a single user (not called by BackfillPersonalStatsJob anymore) def perform(user_id, start_date, end_date) user = User.find(user_id) @@ -12,7 +8,6 @@ def perform(user_id, start_date, end_date) Rails.logger.info("Scheduling backfill for user #{user.name} (#{user.torn_id}): #{dates.size} days") - delay = 0.seconds jobs_scheduled = 0 # Get existing snapshots and index by date (derived from timestamp) @@ -26,8 +21,8 @@ def perform(user_id, start_date, end_date) # Skip if we already have a snapshot for this date next if existing_snapshots.include?(date) - BackfillSingleStatJob.set(wait: delay).perform_later(user.id, date.to_s) - delay += SECONDS_PER_BACKFILL.seconds + # Jobs go to torn_api queue which handles rate limiting (~1 req/sec) + BackfillSingleStatJob.perform_later(user.id, date.to_s) jobs_scheduled += 1 end diff --git a/app/jobs/daily/faction_membership_sync_job.rb b/app/jobs/daily/faction_membership_sync_job.rb index 77e02a4..e0aef67 100644 --- a/app/jobs/daily/faction_membership_sync_job.rb +++ b/app/jobs/daily/faction_membership_sync_job.rb @@ -1,40 +1,13 @@ module Daily + # Orchestrator job that schedules individual sync jobs per faction + # Runs on default queue, schedules API jobs to torn_api queue class FactionMembershipSyncJob < ApplicationJob queue_as :default def perform Faction.tracked.find_each do |faction| - sync_faction_members(faction) + SyncFactionMembersJob.perform_later(faction.id) end end - - def sync_faction_members(faction) - api_key = OwnerCredentials.api_key - members = TornApi::Faction::Members.new(api_key, faction.torn_id).fetch - - member_torn_ids = members.map(&:id) - - # Clear faction_id for users no longer in faction - User.where(faction_id: faction.id) - .where.not(torn_id: member_torn_ids) - .update_all(faction_id: nil) - - # Create/update users for current members - members.each do |member| - user = User.find_or_initialize_by(torn_id: member.id) - user.assign_attributes( - name: member.name, - level: member.level, - faction_id: faction.id - ) - user.save! - end - - Rails.logger.info "FactionMembershipSyncJob: Synced #{members.size} members for faction #{faction.name} [#{faction.torn_id}]" - ::Appsignal.set_gauge("faction_sync.members_synced", members.size, faction_id: faction.torn_id) if defined?(::Appsignal) - rescue TornApi::ApiError => e - Rails.logger.error "FactionMembershipSyncJob: Failed to sync faction #{faction.torn_id}: #{e.message}" - ::Appsignal.send_error(e) if defined?(::Appsignal) - end end end diff --git a/app/jobs/daily/factionhof_members_job.rb b/app/jobs/daily/factionhof_members_job.rb index f2956ca..15a6af6 100644 --- a/app/jobs/daily/factionhof_members_job.rb +++ b/app/jobs/daily/factionhof_members_job.rb @@ -1,19 +1,18 @@ +# Orchestrator job that schedules HOF page fetches +# Runs on default queue, schedules API jobs to torn_api queue class Daily::FactionhofMembersJob < ApplicationJob queue_as :default - FACTION_BATCH_SIZE = 50 TOP_FACTIONS_COUNT = 4000 def perform - # 20 calls - top_factions = (0...TOP_FACTIONS_COUNT).step(100).flat_map do |offset| - TornApi::Torn::Factionhof.new(OwnerCredentials.api_key, offset:).fetch + # Schedule 40 page fetches (100 factions per page, 4000 total) + # Each page fetch will then schedule member fetches for each faction + # The torn_api queue handles rate limiting automatically + (0...TOP_FACTIONS_COUNT).step(100).each do |offset| + FetchFactionHofPageJob.perform_later(offset) end - faction_ids = top_factions.map(&:torn_id) - # enqueue 50 api calls 40 times, so spread over 20 minutes - faction_ids.each_slice(FACTION_BATCH_SIZE).with_index do |batch_ids, i| - FactionMembersJob.set(wait: i.minutes).perform_later(batch_ids) - end + Rails.logger.info "FactionhofMembersJob: Scheduled #{TOP_FACTIONS_COUNT / 100} HOF page fetch jobs" end end diff --git a/app/jobs/daily/personal_stats_job.rb b/app/jobs/daily/personal_stats_job.rb index b2ee45e..4ba0865 100644 --- a/app/jobs/daily/personal_stats_job.rb +++ b/app/jobs/daily/personal_stats_job.rb @@ -2,21 +2,13 @@ module Daily class PersonalStatsJob < ApplicationJob queue_as :default - # Each stats fetch requires 2 API calls (batch 1 + batch 2), spaced 1 second apart - # So we schedule each user 2 seconds apart to avoid rate limiting - SECONDS_PER_FETCH = 2 - - def perform(delay = 15) - batch_size = 60 - run_at = delay.minutes.from_now + def perform total_users = 0 - User.tracked_for_stats.find_in_batches(batch_size:) do |users| - users.each do |user| - FetchPersonalStatsJob.set(wait_until: run_at).perform_later(user) - run_at += SECONDS_PER_FETCH.seconds - total_users += 1 - end + User.tracked_for_stats.find_each do |user| + # Jobs go to torn_api queue which handles rate limiting (~1 req/sec) + FetchPersonalStatsJob.perform_later(user) + total_users += 1 end ::Appsignal.set_gauge("jobs.personal_stats_scheduled", total_users) if defined?(::Appsignal) diff --git a/app/jobs/daily/stock_dividend_job.rb b/app/jobs/daily/stock_dividend_job.rb index 03d49ae..36337a7 100644 --- a/app/jobs/daily/stock_dividend_job.rb +++ b/app/jobs/daily/stock_dividend_job.rb @@ -1,7 +1,5 @@ module Daily - class StockDividendJob < ApplicationJob - queue_as :default - + class StockDividendJob < TornApiJob def perform(*args) stocks = TornApi::Torn::Stocks.new(OwnerCredentials.api_key).fetch items = Torn::Item.money_makers.index_by(&:torn_id) diff --git a/app/jobs/daily/xanax_payments_job.rb b/app/jobs/daily/xanax_payments_job.rb index 62139d5..62cb9c5 100644 --- a/app/jobs/daily/xanax_payments_job.rb +++ b/app/jobs/daily/xanax_payments_job.rb @@ -1,6 +1,5 @@ module Daily - class XanaxPaymentsJob < ApplicationJob - queue_as :default + class XanaxPaymentsJob < TornApiJob PAYMENT_RECIPIENT_TORN_ID = 2728237 def perform diff --git a/app/jobs/faction_members_job.rb b/app/jobs/faction_members_job.rb deleted file mode 100644 index 3b23ac6..0000000 --- a/app/jobs/faction_members_job.rb +++ /dev/null @@ -1,18 +0,0 @@ -class FactionMembersJob < ApplicationJob - queue_as :default - - def perform(batch_ids) - # 50 api calls and generating max 5000 members - all_members = batch_ids.flat_map do |faction_id| - TornApi::Faction::Members.new(OwnerCredentials.api_key, faction_id).fetch - end - - all_members.each do |member| - User.find_or_create_by(torn_id: member.id) do |user| - user.name = member.name - user.level = member.level - # api_key is nil until they log in - end - end - end -end diff --git a/app/jobs/fetch_faction_hof_page_job.rb b/app/jobs/fetch_faction_hof_page_job.rb new file mode 100644 index 0000000..57b3e3f --- /dev/null +++ b/app/jobs/fetch_faction_hof_page_job.rb @@ -0,0 +1,14 @@ +# Fetches a single page of faction HOF rankings from the Torn API +class FetchFactionHofPageJob < TornApiJob + # @param offset [Integer] The offset for pagination (0, 100, 200, etc.) + def perform(offset) + factions = TornApi::Torn::Factionhof.new(OwnerCredentials.api_key, offset:).fetch + + # Schedule individual member fetch jobs for each faction + factions.each do |faction| + FetchFactionMembersJob.perform_later(faction.torn_id) + end + + Rails.logger.info "FetchFactionHofPageJob: Fetched #{factions.size} factions at offset #{offset}" + end +end diff --git a/app/jobs/fetch_faction_members_job.rb b/app/jobs/fetch_faction_members_job.rb new file mode 100644 index 0000000..471a2d9 --- /dev/null +++ b/app/jobs/fetch_faction_members_job.rb @@ -0,0 +1,18 @@ +# Fetches members for a single faction from the Torn API and creates/updates user records +class FetchFactionMembersJob < TornApiJob + def perform(faction_torn_id) + members = TornApi::Faction::Members.new(OwnerCredentials.api_key, faction_torn_id).fetch + + members.each do |member| + User.find_or_create_by(torn_id: member.id) do |user| + user.name = member.name + user.level = member.level + # api_key is nil until they log in + end + end + + Rails.logger.debug "FetchFactionMembersJob: Fetched #{members.size} members for faction #{faction_torn_id}" + rescue TornApi::ApiError => e + Rails.logger.error "FetchFactionMembersJob: Failed to fetch faction #{faction_torn_id}: #{e.message}" + end +end diff --git a/app/jobs/fetch_personal_stats_job.rb b/app/jobs/fetch_personal_stats_job.rb index ef8da36..0901b42 100644 --- a/app/jobs/fetch_personal_stats_job.rb +++ b/app/jobs/fetch_personal_stats_job.rb @@ -1,6 +1,4 @@ -class FetchPersonalStatsJob < ApplicationJob - queue_as :default - +class FetchPersonalStatsJob < TornApiJob MIN_STAT_ENHANCER = 200 # @param user [User] User to fetch stats for @@ -29,9 +27,9 @@ def perform(user, batch: 1, target_date: nil) snapshot.assign_attributes(stats.except(:timestamp)) snapshot.save! - # Schedule batch 2 if this was batch 1, passing the date so it finds the same snapshot + # Enqueue batch 2 if this was batch 1 (queue handles rate limiting) if batch == 1 - FetchPersonalStatsJob.set(wait: 1.second).perform_later(user, batch: 2, target_date: snapshot_date) + FetchPersonalStatsJob.perform_later(user, batch: 2, target_date: snapshot_date) end ::Appsignal.increment_counter("jobs.personal_stats_fetched", 1) if defined?(::Appsignal) diff --git a/app/jobs/sync_faction_members_job.rb b/app/jobs/sync_faction_members_job.rb new file mode 100644 index 0000000..e16b47a --- /dev/null +++ b/app/jobs/sync_faction_members_job.rb @@ -0,0 +1,32 @@ +# Syncs members for a single faction from the Torn API +class SyncFactionMembersJob < TornApiJob + def perform(faction_id) + faction = Faction.find(faction_id) + api_key = OwnerCredentials.api_key + members = TornApi::Faction::Members.new(api_key, faction.torn_id).fetch + + member_torn_ids = members.map(&:id) + + # Clear faction_id for users no longer in faction + User.where(faction_id: faction.id) + .where.not(torn_id: member_torn_ids) + .update_all(faction_id: nil) + + # Create/update users for current members + members.each do |member| + user = User.find_or_initialize_by(torn_id: member.id) + user.assign_attributes( + name: member.name, + level: member.level, + faction_id: faction.id + ) + user.save! + end + + Rails.logger.info "SyncFactionMembersJob: Synced #{members.size} members for faction #{faction.name} [#{faction.torn_id}]" + ::Appsignal.set_gauge("faction_sync.members_synced", members.size, faction_id: faction.torn_id) if defined?(::Appsignal) + rescue TornApi::ApiError => e + Rails.logger.error "SyncFactionMembersJob: Failed to sync faction #{faction.torn_id}: #{e.message}" + ::Appsignal.send_error(e) if defined?(::Appsignal) + end +end diff --git a/app/jobs/torn_api_job.rb b/app/jobs/torn_api_job.rb new file mode 100644 index 0000000..f208dc6 --- /dev/null +++ b/app/jobs/torn_api_job.rb @@ -0,0 +1,13 @@ +# Base class for jobs that call the Torn API +# Automatically sleeps after execution to respect rate limits +class TornApiJob < ApplicationJob + queue_as :torn_api + + # Sleep duration after each job to stay under 60 requests/minute + RATE_LIMIT_SLEEP = 1.0 + + around_perform do |_job, block| + block.call + sleep(RATE_LIMIT_SLEEP) + end +end diff --git a/config/queue.yml b/config/queue.yml index 9eace59..b9168f7 100644 --- a/config/queue.yml +++ b/config/queue.yml @@ -3,7 +3,14 @@ default: &default - polling_interval: 1 batch_size: 500 workers: - - queues: "*" + # Torn API queue: rate-limited to ~1 request per second (60/min API limit) + # Single thread ensures sequential execution, polling_interval adds delay between jobs + - queues: torn_api + threads: 1 + processes: 1 + polling_interval: 1.1 + # Default queue for all other jobs (explicitly named to avoid picking up torn_api) + - queues: default threads: 3 processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> polling_interval: 0.1 From e2ed9e24f6b135617710e8edafb5947deb22985c Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 17 Feb 2026 13:36:03 +0100 Subject: [PATCH 039/266] improve api logs ui --- app/controllers/admin/api_logs_controller.rb | 41 +++++++----- app/views/admin/api_logs/index.html.erb | 67 ++++++++++++++++++-- 2 files changed, 86 insertions(+), 22 deletions(-) diff --git a/app/controllers/admin/api_logs_controller.rb b/app/controllers/admin/api_logs_controller.rb index 59e9c68..2aed7b4 100644 --- a/app/controllers/admin/api_logs_controller.rb +++ b/app/controllers/admin/api_logs_controller.rb @@ -6,28 +6,37 @@ def index owner_api_key = OwnerCredentials.api_key admin_user = User.find_by(torn_id: 2728237) - @api_logs = ApiCall + base_scope = ApiCall .where(api_key: owner_api_key) .where(user_id: admin_user&.id) - .recent - .limit(500) - @total_calls = @api_logs.count - @successful_calls = @api_logs.successful.count - @failed_calls = @api_logs.failed.count - @avg_response_time = @api_logs.where.not(response_time: nil).average(:response_time)&.round(0) + @api_logs = base_scope.recent.limit(500) - @calls_today = ApiCall - .where(api_key: owner_api_key) - .where(user_id: admin_user&.id) - .today - .count + @total_calls = base_scope.count + @successful_calls = base_scope.successful.count + @failed_calls = base_scope.failed.count + @avg_response_time = base_scope.where.not(response_time: nil).average(:response_time)&.round(0) - @calls_last_24h = ApiCall - .where(api_key: owner_api_key) - .where(user_id: admin_user&.id) - .last_24_hours + @calls_today = base_scope.today.count + @calls_last_24h = base_scope.last_24_hours.count + + # Peak calls/min today using SQL grouping for efficiency + @peak_rate_today = calculate_peak_rate(base_scope.today) + end + + private + + def calculate_peak_rate(scope) + # Group by minute and find the maximum count + # SQLite: strftime('%Y-%m-%d %H:%M', created_at) + counts_by_minute = scope + .group("strftime('%Y-%m-%d %H:%M', created_at)") .count + + return { rate: 0, minute_start: nil } if counts_by_minute.empty? + + max_minute, max_rate = counts_by_minute.max_by { |_, count| count } + { rate: max_rate, minute_start: max_minute } end end end diff --git a/app/views/admin/api_logs/index.html.erb b/app/views/admin/api_logs/index.html.erb index ad6b3a8..4fb12de 100644 --- a/app/views/admin/api_logs/index.html.erb +++ b/app/views/admin/api_logs/index.html.erb @@ -6,18 +6,18 @@
    -
    Total Calls (Last 500)
    -
    <%= @total_calls %>
    +
    Total Calls
    +
    <%= number_with_delimiter(@total_calls) %>
    Calls Today
    -
    <%= @calls_today %>
    +
    <%= number_with_delimiter(@calls_today) %>
    Last 24 Hours
    -
    <%= @calls_last_24h %>
    +
    <%= number_with_delimiter(@calls_last_24h) %>
    @@ -34,12 +34,22 @@
    Failed Calls
    -
    <%= @failed_calls %>
    +
    <%= number_with_delimiter(@failed_calls) %>
    +
    + +
    +
    Peak Rate Today
    +
    + <%= @peak_rate_today[:rate] %>/min +
    + <% if @peak_rate_today[:minute_start].present? %> +
    at <%= @peak_rate_today[:minute_start] %>
    + <% end %>
    -

    Recent API Calls

    +

    Recent API Calls (Last 500)

    <% if @api_logs.any? %> @@ -47,6 +57,7 @@ + @@ -54,9 +65,26 @@ <% @api_logs.each do |log| %> + <% selections = log.selections.present? ? JSON.parse(log.selections) : {} %> + - + - + diff --git a/config/routes.rb b/config/routes.rb index c37d4e0..3b7c0b0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -44,6 +44,11 @@ end end resources :api_logs, only: [ :index ] + resources :snapshot_management, only: [ :index ] do + member do + post :backfill_user + end + end end get "privacy-policy", to: "pages#privacy_policy", as: :privacy_policy diff --git a/db/migrate/20260218094005_rename_cannabis_tp_energy_drinks.rb b/db/migrate/20260218094005_rename_cannabis_tp_energy_drinks.rb new file mode 100644 index 0000000..defdc37 --- /dev/null +++ b/db/migrate/20260218094005_rename_cannabis_tp_energy_drinks.rb @@ -0,0 +1,5 @@ +class RenameCannabisTpEnergyDrinks < ActiveRecord::Migration[8.1] + def change + rename_column :personal_stat_snapshots, :drugs_cannabis, :items_used_energy_drinks + end +end diff --git a/db/schema.rb b/db/schema.rb index 7f8b8ae..a6934ae 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_02_18_085746) do +ActiveRecord::Schema[8.1].define(version: 2026_02_18_094005) do create_table "api_calls", force: :cascade do |t| t.string "api_key", null: false t.datetime "created_at", null: false @@ -61,9 +61,9 @@ t.datetime "created_at", null: false t.integer "crimes_offenses_total" t.date "date", null: false - t.integer "drugs_cannabis" t.integer "drugs_xanax" t.integer "items_used_boosters" + t.integer "items_used_energy_drinks" t.integer "items_used_stat_enhancers" t.integer "missions_contracts_total" t.bigint "networth_total" From ced6d64dc7c83c8d8b4f7f114f00a5a1bc9b6b39 Mon Sep 17 00:00:00 2001 From: Bram Date: Wed, 18 Feb 2026 10:52:23 +0100 Subject: [PATCH 047/266] hof users update --- app/controllers/hall_of_famers_controller.rb | 23 ++++-------------- app/jobs/backfill_hof_stats_job.rb | 25 ++++++++------------ 2 files changed, 15 insertions(+), 33 deletions(-) diff --git a/app/controllers/hall_of_famers_controller.rb b/app/controllers/hall_of_famers_controller.rb index 8871498..95e78f6 100644 --- a/app/controllers/hall_of_famers_controller.rb +++ b/app/controllers/hall_of_famers_controller.rb @@ -2,22 +2,9 @@ class HallOfFamersController < ApplicationController SORTABLE_COLUMNS = %w[name xanax_gained energy_drinks_gained networth_gained total_se se_gained].freeze def index - # Determine date range for filtering - all_snapshots = PersonalStatSnapshot.joins(:user) - .where(users: { hof_stats_user: true }) - .order(:created_at) - - @earliest_date = all_snapshots.first&.created_at&.to_date - @latest_date = all_snapshots.last&.created_at&.to_date - - # If no snapshots exist, set defaults - if @earliest_date.nil? || @latest_date.nil? - @start_date = Date.today - @end_date = Date.today - @total_days_tracked = 0 - @table_rows = [] - return - end + # Use tracking constants for date range + @earliest_date = PersonalStatSnapshot::TRACKING_START_DATE + @latest_date = PersonalStatSnapshot.tracking_end_date # Parse date parameters @start_date = params[:start_date].present? ? Date.parse(params[:start_date]) : @earliest_date @@ -29,8 +16,8 @@ def index @table_rows = User.hof_stats_users.includes(:personal_stat_snapshots).filter_map do |user| # Filter snapshots by date range snapshots = user.personal_stat_snapshots - .where("DATE(created_at) >= ? AND DATE(created_at) <= ?", @start_date, @end_date) - .order(:created_at) + .where(date: @start_date..@end_date) + .order(:date) # Skip users with no snapshots next if snapshots.empty? diff --git a/app/jobs/backfill_hof_stats_job.rb b/app/jobs/backfill_hof_stats_job.rb index 8ead3b1..84e501d 100644 --- a/app/jobs/backfill_hof_stats_job.rb +++ b/app/jobs/backfill_hof_stats_job.rb @@ -1,28 +1,23 @@ class BackfillHofStatsJob < ApplicationJob queue_as :default - # Seconds per API call (polling_interval of torn_api queue) SECONDS_PER_API_CALL = 1.1 - def perform(start_date, end_date) + def perform(start_date = nil, end_date = nil) + start_date = (start_date || PersonalStatSnapshot::TRACKING_START_DATE).to_date + end_date = (end_date || PersonalStatSnapshot.tracking_end_date).to_date + users = User.hof_stats_users.to_a - dates = (start_date.to_date..end_date.to_date).to_a + dates = (start_date..end_date).to_a Rails.logger.info("Scheduling HOF backfill: #{users.count} users, #{dates.size} days") jobs_scheduled = 0 - users.each do |user| - existing_snapshots = user.personal_stat_snapshots - .where(timestamp: dates.first.to_time.to_i..dates.last.end_of_day.to_i) - .pluck(:timestamp) - .map { |ts| Time.at(ts).utc.to_date } - .to_set - - dates.each do |date| - next if existing_snapshots.include?(date) - - BackfillSingleStatJob.perform_later(user.id, date.to_s) + users.each_with_index do |user, user_index| + dates.each_with_index do |date, date_index| + delay = (user_index * dates.size) + date_index + BackfillSingleStatJob.set(wait: delay.seconds).perform_later(user.id, date.to_s) jobs_scheduled += 1 end end @@ -31,6 +26,6 @@ def perform(start_date, end_date) total_api_calls = existing_queued_jobs + (jobs_scheduled * 2) estimated_seconds = [ total_api_calls * SECONDS_PER_API_CALL, 1 ].max.to_i - Rails.logger.info("Scheduled #{jobs_scheduled} HOF stat fetch jobs (#{existing_queued_jobs} jobs already queued), completing in ~#{estimated_seconds}s") + Rails.logger.info("Scheduled #{jobs_scheduled} HOF stat fetch jobs, completing in ~#{estimated_seconds}s") end end From d92a9e9e90a8af1f9ec9df578090ce6c8ae7813c Mon Sep 17 00:00:00 2001 From: Bram Date: Wed, 18 Feb 2026 10:58:03 +0100 Subject: [PATCH 048/266] limit on dates --- app/views/faction/index.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/faction/index.html.erb b/app/views/faction/index.html.erb index e601658..f131669 100644 --- a/app/views/faction/index.html.erb +++ b/app/views/faction/index.html.erb @@ -84,7 +84,7 @@ <%= form.date_field :start_date, value: params[:start_date] || @start_date, min: @earliest_date, - max: Date.today, + max: PersonalStatSnapshot.tracking_end_date, class: "date-input-inline" %> @@ -92,7 +92,7 @@ <%= form.date_field :end_date, value: params[:end_date] || @end_date, min: @earliest_date, - max: Date.today, + max: PersonalStatSnapshot.tracking_end_date, class: "date-input-inline" %> From 3332971557bcf150073b73ea1989731d2dd68cd1 Mon Sep 17 00:00:00 2001 From: Bram Date: Wed, 18 Feb 2026 11:22:01 +0100 Subject: [PATCH 049/266] refactor tracking dates --- .../admin/snapshot_management_controller.rb | 2 +- app/controllers/admin/stats_controller.rb | 2 +- app/controllers/faction_controller.rb | 30 ++++--------------- app/controllers/hall_of_famers_controller.rb | 2 +- app/jobs/backfill_hof_stats_job.rb | 2 +- app/models/personal_stat_snapshot.rb | 4 ++- app/views/admin/factions/_faction.html.erb | 8 ++--- app/views/faction/index.html.erb | 4 +-- app/views/hall_of_famers/index.html.erb | 8 ++--- 9 files changed, 23 insertions(+), 39 deletions(-) diff --git a/app/controllers/admin/snapshot_management_controller.rb b/app/controllers/admin/snapshot_management_controller.rb index 32034aa..939b8eb 100644 --- a/app/controllers/admin/snapshot_management_controller.rb +++ b/app/controllers/admin/snapshot_management_controller.rb @@ -57,7 +57,7 @@ def missing_dates_for_user(user) end def expected_date_range - PersonalStatSnapshot::TRACKING_START_DATE..PersonalStatSnapshot.tracking_end_date + PersonalStatSnapshot.tracking_start_date..PersonalStatSnapshot.tracking_end_date end def calculate_summary diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb index f930c17..9512a38 100644 --- a/app/controllers/admin/stats_controller.rb +++ b/app/controllers/admin/stats_controller.rb @@ -79,7 +79,7 @@ def calculate_snapshot_gaps .group_by(&:first) .transform_values { |pairs| pairs.map(&:last).to_set } - start_date = PersonalStatSnapshot::TRACKING_START_DATE + start_date = PersonalStatSnapshot.tracking_start_date end_date = PersonalStatSnapshot.tracking_end_date expected_dates = (start_date..end_date).to_a diff --git a/app/controllers/faction_controller.rb b/app/controllers/faction_controller.rb index 41cf8f7..99d5e6c 100644 --- a/app/controllers/faction_controller.rb +++ b/app/controllers/faction_controller.rb @@ -35,31 +35,13 @@ def index return end - # Determine date range for filtering - all_snapshots = PersonalStatSnapshot.joins(:user) - .where(users: { faction_id: @faction.id }) - .order(:timestamp) - - @earliest_date = all_snapshots.first&.date - @latest_date = all_snapshots.last&.date - - # If no snapshots exist, set defaults - if @earliest_date.nil? || @latest_date.nil? - @start_date = Date.today - @end_date = Date.today - @total_days_tracked = 0 - @member_rows = [] - @compliant_members_count = 0 - @warning_members_count = 0 - @non_compliant_members_count = 0 - return - end + # Use tracking constants for date bounds + @earliest_date = PersonalStatSnapshot.tracking_start_date + @latest_date = PersonalStatSnapshot.tracking_end_date - # Parse date parameters (default to Jan 1, 2026 through yesterday) - default_start = [ @earliest_date, Date.new(2026, 1, 1) ].max - default_end = [ @latest_date, Date.yesterday ].min - @start_date = params[:start_date].present? ? Date.parse(params[:start_date]) : default_start - @end_date = params[:end_date].present? ? Date.parse(params[:end_date]) : default_end + # Parse date parameters + @start_date = params[:start_date].present? ? Date.parse(params[:start_date]) : @earliest_date + @end_date = params[:end_date].present? ? Date.parse(params[:end_date]) : @latest_date # Calculate total days for the selected range @total_days_tracked = (@end_date - @start_date).to_i + 1 diff --git a/app/controllers/hall_of_famers_controller.rb b/app/controllers/hall_of_famers_controller.rb index 95e78f6..3f02311 100644 --- a/app/controllers/hall_of_famers_controller.rb +++ b/app/controllers/hall_of_famers_controller.rb @@ -3,7 +3,7 @@ class HallOfFamersController < ApplicationController def index # Use tracking constants for date range - @earliest_date = PersonalStatSnapshot::TRACKING_START_DATE + @earliest_date = PersonalStatSnapshot.tracking_start_date @latest_date = PersonalStatSnapshot.tracking_end_date # Parse date parameters diff --git a/app/jobs/backfill_hof_stats_job.rb b/app/jobs/backfill_hof_stats_job.rb index 84e501d..85104b9 100644 --- a/app/jobs/backfill_hof_stats_job.rb +++ b/app/jobs/backfill_hof_stats_job.rb @@ -4,7 +4,7 @@ class BackfillHofStatsJob < ApplicationJob SECONDS_PER_API_CALL = 1.1 def perform(start_date = nil, end_date = nil) - start_date = (start_date || PersonalStatSnapshot::TRACKING_START_DATE).to_date + start_date = (start_date || PersonalStatSnapshot.tracking_start_date).to_date end_date = (end_date || PersonalStatSnapshot.tracking_end_date).to_date users = User.hof_stats_users.to_a diff --git a/app/models/personal_stat_snapshot.rb b/app/models/personal_stat_snapshot.rb index 65176ed..1df095b 100644 --- a/app/models/personal_stat_snapshot.rb +++ b/app/models/personal_stat_snapshot.rb @@ -3,7 +3,9 @@ class PersonalStatSnapshot < ApplicationRecord validates :date, presence: true, uniqueness: { scope: :user_id } - TRACKING_START_DATE = Date.new(2026, 1, 1).freeze + def self.tracking_start_date + Date.new(2026, 1, 1) + end def self.tracking_end_date Date.current.yesterday diff --git a/app/views/admin/factions/_faction.html.erb b/app/views/admin/factions/_faction.html.erb index d0b8472..8d35d66 100644 --- a/app/views/admin/factions/_faction.html.erb +++ b/app/views/admin/factions/_faction.html.erb @@ -66,12 +66,12 @@ <%= form_with url: backfill_stats_admin_faction_path(faction), method: :post, data: { turbo: false } do |f| %>
    - <%= f.date_field :start_date, id: "start_date_#{faction.id}", value: "2026-01-01", min: "2026-01-01", max: Date.yesterday.to_s, style: "width:100%; padding:12px 16px; background:#0a0a0a; border:1px solid #262626; border-radius:8px; color:#e5e5e5; font-family:'Geist Mono',monospace; font-size:0.875rem; color-scheme:dark;" %> + <%= f.date_field :start_date, id: "start_date_#{faction.id}", value: PersonalStatSnapshot.tracking_start_date, min: PersonalStatSnapshot.tracking_start_date, max: PersonalStatSnapshot.tracking_end_date, style: "width:100%; padding:12px 16px; background:#0a0a0a; border:1px solid #262626; border-radius:8px; color:#e5e5e5; font-family:'Geist Mono',monospace; font-size:0.875rem; color-scheme:dark;" %>
    - <%= f.date_field :end_date, id: "end_date_#{faction.id}", value: Date.yesterday.to_s, min: "2026-01-01", max: Date.yesterday.to_s, style: "width:100%; padding:12px 16px; background:#0a0a0a; border:1px solid #262626; border-radius:8px; color:#e5e5e5; font-family:'Geist Mono',monospace; font-size:0.875rem; color-scheme:dark;" %> + <%= f.date_field :end_date, id: "end_date_#{faction.id}", value: PersonalStatSnapshot.tracking_end_date, min: PersonalStatSnapshot.tracking_start_date, max: PersonalStatSnapshot.tracking_end_date, style: "width:100%; padding:12px 16px; background:#0a0a0a; border:1px solid #262626; border-radius:8px; color:#e5e5e5; font-family:'Geist Mono',monospace; font-size:0.875rem; color-scheme:dark;" %>
    @@ -96,12 +96,12 @@ <%= form_with url: backfill_user_stats_admin_faction_path(faction, user_id: user.id), method: :post, data: { turbo: false } do |f| %>
    - <%= f.date_field :start_date, id: "user_start_date_#{user.id}", value: "2026-01-01", min: "2026-01-01", max: Date.yesterday.to_s, style: "width:100%; padding:12px 16px; background:#0a0a0a; border:1px solid #262626; border-radius:8px; color:#e5e5e5; font-family:'Geist Mono',monospace; font-size:0.875rem; color-scheme:dark;" %> + <%= f.date_field :start_date, id: "user_start_date_#{user.id}", value: PersonalStatSnapshot.tracking_start_date, min: PersonalStatSnapshot.tracking_start_date, max: PersonalStatSnapshot.tracking_end_date, style: "width:100%; padding:12px 16px; background:#0a0a0a; border:1px solid #262626; border-radius:8px; color:#e5e5e5; font-family:'Geist Mono',monospace; font-size:0.875rem; color-scheme:dark;" %>
    - <%= f.date_field :end_date, id: "user_end_date_#{user.id}", value: Date.yesterday.to_s, min: "2026-01-01", max: Date.yesterday.to_s, style: "width:100%; padding:12px 16px; background:#0a0a0a; border:1px solid #262626; border-radius:8px; color:#e5e5e5; font-family:'Geist Mono',monospace; font-size:0.875rem; color-scheme:dark;" %> + <%= f.date_field :end_date, id: "user_end_date_#{user.id}", value: PersonalStatSnapshot.tracking_end_date, min: PersonalStatSnapshot.tracking_start_date, max: PersonalStatSnapshot.tracking_end_date, style: "width:100%; padding:12px 16px; background:#0a0a0a; border:1px solid #262626; border-radius:8px; color:#e5e5e5; font-family:'Geist Mono',monospace; font-size:0.875rem; color-scheme:dark;" %>
    diff --git a/app/views/faction/index.html.erb b/app/views/faction/index.html.erb index f131669..c7a2f65 100644 --- a/app/views/faction/index.html.erb +++ b/app/views/faction/index.html.erb @@ -83,7 +83,7 @@
    <%= form.date_field :start_date, value: params[:start_date] || @start_date, - min: @earliest_date, + min: PersonalStatSnapshot.tracking_start_date, max: PersonalStatSnapshot.tracking_end_date, class: "date-input-inline" %> @@ -91,7 +91,7 @@ <%= form.date_field :end_date, value: params[:end_date] || @end_date, - min: @earliest_date, + min: PersonalStatSnapshot.tracking_start_date, max: PersonalStatSnapshot.tracking_end_date, class: "date-input-inline" %>
    diff --git a/app/views/hall_of_famers/index.html.erb b/app/views/hall_of_famers/index.html.erb index 1849929..5831b6d 100644 --- a/app/views/hall_of_famers/index.html.erb +++ b/app/views/hall_of_famers/index.html.erb @@ -28,16 +28,16 @@
    <%= form.date_field :start_date, value: params[:start_date] || @earliest_date, - min: @earliest_date, - max: @latest_date, + min: PersonalStatSnapshot.tracking_start_date, + max: PersonalStatSnapshot.tracking_end_date, class: "date-input-inline" %> <%= form.date_field :end_date, value: params[:end_date] || @latest_date, - min: @earliest_date, - max: @latest_date, + min: PersonalStatSnapshot.tracking_start_date, + max: PersonalStatSnapshot.tracking_end_date, class: "date-input-inline" %>
    From 8446ebde493c888baf90628b8929dc43e8387d13 Mon Sep 17 00:00:00 2001 From: Bram Date: Wed, 18 Feb 2026 11:43:19 +0100 Subject: [PATCH 050/266] fix bug --- app/controllers/admin/stats_controller.rb | 2 +- lib/tasks/backfill_stats.rake | 416 ---------------------- 2 files changed, 1 insertion(+), 417 deletions(-) delete mode 100644 lib/tasks/backfill_stats.rake diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb index 9512a38..7ffc63a 100644 --- a/app/controllers/admin/stats_controller.rb +++ b/app/controllers/admin/stats_controller.rb @@ -43,7 +43,7 @@ def index @complete_snapshots = PersonalStatSnapshot .where.not( drugs_xanax: nil, - drugs_cannabis: nil, + items_used_energy_drinks: nil, other_refills_energy: nil, other_refills_nerve: nil, items_used_boosters: nil, diff --git a/lib/tasks/backfill_stats.rake b/lib/tasks/backfill_stats.rake deleted file mode 100644 index 8738c8a..0000000 --- a/lib/tasks/backfill_stats.rake +++ /dev/null @@ -1,416 +0,0 @@ -namespace :stats do - desc "Backfill historical personal stats for 2026 (CORE STATS ONLY - fast)" - task backfill_2026_core: :environment do - # Core stats needed for faction page (only 7 attributes instead of 157) - CORE_STAT_MAPPING = { - "xantaken" => :drugs_xanax, - "refills" => :other_refills_energy, - "nerverefills" => :other_refills_nerve, - "meritsbought" => :other_merits_bought, - "boostersused" => :items_used_boosters, - "missionscompleted" => :missions_missions, - "timeplayed" => :other_activity_time - }.freeze - - run_backfill(CORE_STAT_MAPPING, "CORE STATS (Faction Page)") - end - - desc "Backfill historical personal stats for 2026 (ALL 157 ATTRIBUTES - slow)" - task backfill_2026_full: :environment do - # Complete stat name mapping from API to database columns (157 attributes) - FULL_STAT_MAPPING = { - # Attacking (36 attributes) - "attackswon" => :attacking_attacks_won, - "attackslost" => :attacking_attacks_lost, - "attacksdraw" => :attacking_attacks_stalemate, - "attacksassisted" => :attacking_attacks_assist, - "defendswon" => :attacking_defends_won, - "defendslost" => :attacking_defends_lost, - "defendsstalemated" => :attacking_defends_stalemate, - "elo" => :attacking_elo, - "yourunaway" => :attacking_escapes_player, - "theyrunaway" => :attacking_escapes_foes, - "unarmoredwon" => :attacking_unarmored_wins, - "bestkillstreak" => :attacking_killstreak_best, - "attackhits" => :attacking_hits_success, - "attackmisses" => :attacking_hits_miss, - "attackdamage" => :attacking_damage_total, - "bestdamage" => :attacking_damage_best, - "onehitkills" => :attacking_hits_one_hit_kills, - "attackcriticalhits" => :attacking_hits_critical, - "roundsfired" => :attacking_ammunition_total, - "specialammoused" => :attacking_ammunition_special, - "hollowammoused" => :attacking_ammunition_hollow_point, - "tracerammoused" => :attacking_ammunition_tracer, - "piercingammoused" => :attacking_ammunition_piercing, - "incendiaryammoused" => :attacking_ammunition_incendiary, - "attacksstealthed" => :attacking_attacks_stealth, - "retals" => :attacking_faction_retaliations, - "moneymugged" => :attacking_networth_money_mugged, - "largestmug" => :attacking_networth_largest_mug, - "itemslooted" => :attacking_networth_items_looted, - "highestbeaten" => :attacking_highest_level_beaten, - "respectforfaction" => :attacking_faction_respect, - "rankedwarhits" => :attacking_faction_ranked_war_hits, - "raidhits" => :attacking_faction_raid_hits, - "territoryjoins" => :attacking_faction_territory_wall_joins, - "territoryclears" => :attacking_faction_territory_wall_clears, - "territorytime" => :attacking_faction_territory_wall_time, - - # Jobs (2 attributes) - "jobpointsused" => :jobs_job_points_used, - "trainsreceived" => :jobs_trains_received, - - # Trading (11 attributes) - "marketitemsbought" => :trading_items_bought_market, - "cityitemsbought" => :trading_items_bought_shops, - "auctionswon" => :trading_items_auctions_won, - "auctionsells" => :trading_items_auctions_sold, - "itemssent" => :trading_items_sent, - "trades" => :trading_trades, - "pointsbought" => :trading_points_bought, - "pointssold" => :trading_points_sold, - "bazaarcustomers" => :trading_bazaar_customers, - "bazaarsales" => :trading_bazaar_sales, - "bazaarprofit" => :trading_bazaar_profit, - - # Jail (5 attributes) - "jailed" => :jail_times_jailed, - "peoplebusted" => :jail_busts_success, - "failedbusts" => :jail_busts_fails, - "peoplebought" => :jail_bails_amount, - "peopleboughtspent" => :jail_bails_fees, - - # Hospital (6 attributes) - "hospital" => :hospital_times_hospitalized, - "medicalitemsused" => :hospital_medical_items_used, - "bloodwithdrawn" => :hospital_blood_withdrawn, - "reviveskill" => :hospital_reviving_skill, - "revives" => :hospital_reviving_revives, - "revivesreceived" => :hospital_reviving_revives_received, - - # Finishing hits (12 attributes) - "heavyhits" => :finishing_hits_heavy_artillery, - "machinehits" => :finishing_hits_machine_guns, - "riflehits" => :finishing_hits_rifles, - "smghits" => :finishing_hits_sub_machine_guns, - "shotgunhits" => :finishing_hits_shotguns, - "pistolhits" => :finishing_hits_pistols, - "temphits" => :finishing_hits_temporary, - "piercinghits" => :finishing_hits_piercing, - "slashinghits" => :finishing_hits_slashing, - "clubbinghits" => :finishing_hits_clubbing, - "mechanicalhits" => :finishing_hits_mechanical, - "h2hhits" => :finishing_hits_hand_to_hand, - - # Communication (7 attributes) - "mailssent" => :communication_mails_sent_total, - "friendmailssent" => :communication_mails_sent_friends, - "factionmailssent" => :communication_mails_sent_faction, - "companymailssent" => :communication_mails_sent_colleagues, - "spousemailssent" => :communication_mails_sent_spouse, - "classifiedadsplaced" => :communication_classified_ads, - "personalsplaced" => :communication_personals, - - # Crimes (10 attributes) - "vandalism" => :crimes_offenses_vandalism, - "fraud" => :crimes_offenses_fraud, - "theft" => :crimes_offenses_theft, - "counterfeiting" => :crimes_offenses_counterfeiting, - "illicitservices" => :crimes_offenses_illicit_services, - "cybercrime" => :crimes_offenses_cybercrime, - "extortion" => :crimes_offenses_extortion, - "illegalproduction" => :crimes_offenses_illegal_production, - "organizedcrimes" => :crimes_offenses_organized_crimes, - "criminaloffenses" => :crimes_offenses_total, - - # Bounties (6 attributes) - "bountiesplaced" => :bounties_placed_amount, - "totalbountyspent" => :bounties_placed_value, - "bountiescollected" => :bounties_collected_amount, - "totalbountyreward" => :bounties_collected_value, - "bountiesreceived" => :bounties_received_amount, - "receivedbountyvalue" => :bounties_received_value, - - # Items (13 attributes) - "cityfinds" => :items_found_city, - "dumpfinds" => :items_found_dump, - "eastereggsfound" => :items_found_easter_eggs, - "itemsdumped" => :items_trashed, - "booksread" => :items_used_books, - "boostersused" => :items_used_boosters, - "consumablesused" => :items_used_consumables, - "candyused" => :items_used_candy, - "alcoholused" => :items_used_alcohol, - "energydrinkused" => :items_used_energy_drinks, - "statenhancersused" => :items_used_stat_enhancers, - "eastereggsused" => :items_used_easter_eggs, - "virusescoded" => :items_viruses_coded, - - # Travel (17 attributes) - "traveltimes" => :travel_total, - "timespenttraveling" => :travel_time_spent, - "itemsboughtabroad" => :travel_items_bought, - "huntingskill" => :travel_hunting_skill, - "attackswonabroad" => :travel_attacks_won, - "defendslostabroad" => :travel_defends_lost, - "argtravel" => :travel_argentina, - "cantravel" => :travel_canada, - "caytravel" => :travel_cayman_islands, - "chitravel" => :travel_china, - "hawtravel" => :travel_hawaii, - "japtravel" => :travel_japan, - "mextravel" => :travel_mexico, - "uaetravel" => :travel_united_arab_emirates, - "uktravel" => :travel_united_kingdom, - "satravel" => :travel_south_africa, - "switravel" => :travel_switzerland, - - # Drugs (14 attributes) - "cantaken" => :drugs_cannabis, - "exttaken" => :drugs_ecstasy, - "kettaken" => :drugs_ketamine, - "lsdtaken" => :drugs_lsd, - "opitaken" => :drugs_opium, - "pcptaken" => :drugs_pcp, - "shrtaken" => :drugs_shrooms, - "spetaken" => :drugs_speed, - "victaken" => :drugs_vicodin, - "xantaken" => :drugs_xanax, - "drugsused" => :drugs_total, - "overdosed" => :drugs_overdoses, - "rehabs" => :drugs_rehabilitations_amount, - "rehabcost" => :drugs_rehabilitations_fees, - - # Missions (4 attributes) - "missionscompleted" => :missions_missions, - "contractscompleted" => :missions_contracts_total, - "dukecontractscompleted" => :missions_contracts_duke, - "missioncreditsearned" => :missions_credits, - - # Racing (4 attributes) - "racingskill" => :racing_skill, - "racingpointsearned" => :racing_points, - "racesentered" => :racing_races_entered, - "raceswon" => :racing_races_won, - - # Networth (1 attribute) - "networth" => :networth_total, - - # Other (2 attributes) - "activestreak" => :other_activity_streak_current, - "bestactivestreak" => :other_activity_streak_best, - "awards" => :other_awards, - "tokenrefills" => :other_refills_token, - "daysbeendonator" => :other_donator_days - }.freeze - - run_backfill(FULL_STAT_MAPPING, "ALL STATS (Complete)") - end - - desc "Dry run of core stats backfill" - task backfill_2026_core_dry_run: :environment do - dry_run_backfill(7, "CORE") - end - - desc "Dry run of full stats backfill" - task backfill_2026_full_dry_run: :environment do - dry_run_backfill(157, "FULL") - end - - private - - def dry_run_backfill(stat_count, mode) - start_date = Date.new(2026, 1, 1) - end_date = Date.new(2026, 1, 20) - dates = (start_date..end_date).to_a - - factions = Faction.where(track_stats: true) - - puts "=" * 80 - puts "🔍 BACKFILL DRY RUN - #{mode} MODE" - puts "=" * 80 - puts - puts "This would backfill stats for:" - puts - - total_users = 0 - total_snapshots_needed = 0 - total_snapshots_existing = 0 - - factions.each do |faction| - puts "🏢 #{faction.name}" - - faction.users.find_each do |user| - total_users += 1 - - dates.each do |date| - existing = user.personal_stat_snapshots.where(date: date).first - - if existing - total_snapshots_existing += 1 - else - total_snapshots_needed += 1 - end - end - end - end - - puts - puts "📊 Summary:" - puts " - Total users: #{total_users}" - puts " - Date range: #{start_date} to #{end_date} (#{dates.size} days)" - puts " - Snapshots already existing: #{total_snapshots_existing}" - puts " - Snapshots to create: #{total_snapshots_needed}" - puts " - API calls required: #{total_snapshots_needed * stat_count} (#{stat_count} stats per snapshot)" - puts " - Estimated time: ~#{(total_snapshots_needed * stat_count * 0.3 / 60).round(1)} minutes" - puts - if mode == "CORE" - puts "To run the actual backfill, use: rails stats:backfill_2026_core" - puts "For complete backfill (all 157 stats): rails stats:backfill_2026_full" - else - puts "To run the actual backfill, use: rails stats:backfill_2026_full" - puts "For faster backfill (7 core stats only): rails stats:backfill_2026_core" - end - puts - end - - def run_backfill(stat_mapping, mode) - require "net/http" - require "json" - - api_key = OwnerCredentials.api_key - - if api_key.blank? - puts "❌ No API key found in OwnerCredentials" - exit 1 - end - - factions = Faction.where(track_stats: true) - - if factions.empty? - puts "❌ No factions with tracking enabled" - exit 1 - end - - start_date = Date.new(2026, 1, 1) - end_date = Date.new(2026, 1, 20) - dates = (start_date..end_date).to_a - - puts "=" * 80 - puts "🔄 BACKFILL PERSONAL STATS - #{mode}" - puts "=" * 80 - puts - puts "📅 Date range: #{start_date} to #{end_date} (#{dates.size} days)" - puts "📊 Factions: #{factions.map(&:name).join(', ')}" - puts "👥 Total users: #{factions.sum { |f| f.users.count }}" - puts "📈 Attributes per snapshot: #{stat_mapping.size}" - puts - puts "⏱️ Estimated time: ~#{(factions.sum { |f| f.users.count } * dates.size * stat_mapping.size * 0.3 / 60).round(1)} minutes" - puts - puts "=" * 80 - puts - - total_snapshots_created = 0 - total_snapshots_skipped = 0 - total_errors = 0 - - factions.each do |faction| - puts "🏢 Processing faction: #{faction.name}" - puts - - faction.users.find_each do |user| - user_snapshots_created = 0 - user_snapshots_skipped = 0 - - print " 👤 #{user.name.ljust(30)} (#{user.torn_id.to_s.ljust(8)})" - - dates.each do |date| - existing = user.personal_stat_snapshots.where(date: date).first - - if existing - user_snapshots_skipped += 1 - next - end - - timestamp = date.to_time.to_i - snapshot_data = {} - failed_stats = [] - - stat_mapping.each do |api_stat_name, db_column| - begin - uri = URI("https://api.torn.com/v2/user/#{user.torn_id}/personalstats?stat=#{api_stat_name}×tamp=#{timestamp}&comment=tmanager") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - http.read_timeout = 10 - http.open_timeout = 5 - - request = Net::HTTP::Get.new(uri) - request["Authorization"] = "ApiKey #{api_key}" - - response = http.request(request) - data = JSON.parse(response.body) - - if data["error"] - failed_stats << api_stat_name - break - end - - if data["personalstats"]&.any? - value = data["personalstats"].first["value"] - snapshot_data[db_column] = value - end - - sleep(0.3) - rescue => e - failed_stats << "#{api_stat_name} (#{e.message})" - break - end - end - - if snapshot_data.size == stat_mapping.size - snapshot = user.personal_stat_snapshots.build(snapshot_data) - snapshot.date = date - snapshot.created_at = date.to_time.midday - - if snapshot.save - user_snapshots_created += 1 - else - total_errors += 1 - print "\n ❌ #{date}: Failed to save: #{snapshot.errors.full_messages.join(', ')}\n" - end - else - if failed_stats.any? - total_errors += 1 - print "\n ⚠️ Skipping user - API error on #{date}: #{failed_stats.first}\n" - break - end - end - end - - total_snapshots_created += user_snapshots_created - total_snapshots_skipped += user_snapshots_skipped - - if user_snapshots_created > 0 - print " ✅ #{user_snapshots_created}" - end - if user_snapshots_skipped > 0 - print " ⏭️ #{user_snapshots_skipped}" - end - puts - end - - puts - end - - puts "=" * 80 - puts "✅ BACKFILL COMPLETE!" - puts "=" * 80 - puts - puts "📊 Summary:" - puts " - Snapshots created: #{total_snapshots_created}" - puts " - Snapshots skipped: #{total_snapshots_skipped}" - puts " - Errors: #{total_errors}" - puts - end -end From bbb8cc44b22542c970e931d5545be76a742ccb67 Mon Sep 17 00:00:00 2001 From: Bram Date: Wed, 18 Feb 2026 11:50:31 +0100 Subject: [PATCH 051/266] update stats --- app/controllers/admin/snapshot_management_controller.rb | 8 ++++++-- app/controllers/admin/stats_controller.rb | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/controllers/admin/snapshot_management_controller.rb b/app/controllers/admin/snapshot_management_controller.rb index 939b8eb..bae91b3 100644 --- a/app/controllers/admin/snapshot_management_controller.rb +++ b/app/controllers/admin/snapshot_management_controller.rb @@ -62,8 +62,12 @@ def expected_date_range def calculate_summary tracked_count = User.tracked_for_stats.count - total_expected = tracked_count * expected_date_range.count - total_existing = PersonalStatSnapshot.where(user_id: User.tracked_for_stats.select(:id)).count + date_range = expected_date_range + total_expected = tracked_count * date_range.count + total_existing = PersonalStatSnapshot + .where(user_id: User.tracked_for_stats.select(:id)) + .where(date: date_range) + .count { tracked_users: tracked_count, diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb index 7ffc63a..40d7b33 100644 --- a/app/controllers/admin/stats_controller.rb +++ b/app/controllers/admin/stats_controller.rb @@ -39,7 +39,7 @@ def index # Data health - snapshot gaps for tracked users calculate_snapshot_gaps - # Snapshot completeness (users with all 11 stats filled) + # Snapshot completeness (snapshots with all stats filled) @complete_snapshots = PersonalStatSnapshot .where.not( drugs_xanax: nil, From db1a01bfe95a2566baf8533c34eef4281d716add Mon Sep 17 00:00:00 2001 From: Bram Date: Wed, 18 Feb 2026 19:15:02 +0100 Subject: [PATCH 052/266] filter out fallen members --- app/controllers/admin/factions_controller.rb | 6 ++--- app/controllers/faction_controller.rb | 2 +- app/jobs/backfill_personal_stats_job.rb | 2 +- app/jobs/sync_faction_members_job.rb | 4 +-- app/models/user.rb | 3 +++ app/views/admin/factions/_faction.html.erb | 27 ++++++++++++++----- .../20260219100000_add_fallen_to_users.rb | 5 ++++ db/schema.rb | 3 ++- 8 files changed, 37 insertions(+), 15 deletions(-) create mode 100644 db/migrate/20260219100000_add_fallen_to_users.rb diff --git a/app/controllers/admin/factions_controller.rb b/app/controllers/admin/factions_controller.rb index 105b557..712b9e7 100644 --- a/app/controllers/admin/factions_controller.rb +++ b/app/controllers/admin/factions_controller.rb @@ -74,7 +74,7 @@ def toggle_tracking @faction.reload end - render json: { success: true, track_stats: @faction.track_stats, member_count: @faction.users.count } + render json: { success: true, track_stats: @faction.track_stats, member_count: @faction.users.active.count } rescue => e render json: { success: false, error: e.message }, status: :unprocessable_entity end @@ -82,7 +82,7 @@ def toggle_tracking def sync_members SyncFactionMembersJob.perform_now(@faction.id) @faction.reload - redirect_to admin_factions_path, notice: "Synced #{@faction.users.count} members for '#{@faction.name}'." + redirect_to admin_factions_path, notice: "Synced #{@faction.users.active.count} members for '#{@faction.name}'." rescue => e redirect_to admin_factions_path, alert: "Failed to sync members: #{e.message}" end @@ -109,7 +109,7 @@ def backfill_stats return end - user_count = @faction.users.count + user_count = @faction.users.active.count date_count = (end_date - start_date).to_i + 1 if user_count == 0 diff --git a/app/controllers/faction_controller.rb b/app/controllers/faction_controller.rb index 99d5e6c..7179398 100644 --- a/app/controllers/faction_controller.rb +++ b/app/controllers/faction_controller.rb @@ -51,7 +51,7 @@ def index start_timestamp = @start_date.beginning_of_day.to_i end_timestamp = @end_date.end_of_day.to_i - @member_rows = @faction.users.includes(:personal_stat_snapshots).filter_map do |user| + @member_rows = @faction.users.active.includes(:personal_stat_snapshots).filter_map do |user| # Get all snapshots in date range all_snapshots = user.personal_stat_snapshots .where(timestamp: start_timestamp..end_timestamp) diff --git a/app/jobs/backfill_personal_stats_job.rb b/app/jobs/backfill_personal_stats_job.rb index edd2e31..086c372 100644 --- a/app/jobs/backfill_personal_stats_job.rb +++ b/app/jobs/backfill_personal_stats_job.rb @@ -6,7 +6,7 @@ class BackfillPersonalStatsJob < ApplicationJob def perform(faction_id, start_date, end_date) faction = Faction.find(faction_id) - users = faction.users.to_a + users = faction.users.active.to_a dates = (start_date.to_date..end_date.to_date).to_a Rails.logger.info("Scheduling backfill for faction #{faction.name}: #{users.count} users, #{dates.size} days") diff --git a/app/jobs/sync_faction_members_job.rb b/app/jobs/sync_faction_members_job.rb index e16b47a..da93e2c 100644 --- a/app/jobs/sync_faction_members_job.rb +++ b/app/jobs/sync_faction_members_job.rb @@ -12,13 +12,13 @@ def perform(faction_id) .where.not(torn_id: member_torn_ids) .update_all(faction_id: nil) - # Create/update users for current members members.each do |member| user = User.find_or_initialize_by(torn_id: member.id) user.assign_attributes( name: member.name, level: member.level, - faction_id: faction.id + faction_id: faction.id, + fallen: member.status_state == "Fallen" ) user.save! end diff --git a/app/models/user.rb b/app/models/user.rb index 6e90d95..58380b8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -16,8 +16,11 @@ class User < ApplicationRecord scope :hof_stats_users, -> { where(hof_stats_user: true) } scope :active_subscribers, -> { where("subscription_expires_at > ?", Time.current) } + scope :active, -> { where(fallen: false) } + scope :fallen, -> { where(fallen: true) } scope :tracked_for_stats, -> { left_joins(:faction) + .where(fallen: false) .where("hof_stats_user = ? OR factions.track_stats = ?", true, true) .distinct } diff --git a/app/views/admin/factions/_faction.html.erb b/app/views/admin/factions/_faction.html.erb index 8d35d66..389b469 100644 --- a/app/views/admin/factions/_faction.html.erb +++ b/app/views/admin/factions/_faction.html.erb @@ -4,9 +4,9 @@
    @@ -61,7 +74,7 @@ + @@ -210,6 +211,19 @@ <%= row[:activity_time_daily] %> min/day + <% end %> From c2beadf18a0229d8f220821c540ceb58de23e0ba Mon Sep 17 00:00:00 2001 From: Bram Date: Wed, 18 Feb 2026 22:15:19 +0100 Subject: [PATCH 056/266] individual backfill --- .../admin/snapshot_management_controller.rb | 23 +++- .../controllers/backfill_row_controller.js | 38 +++++++ app/models/user.rb | 9 ++ .../admin/snapshot_management/index.html.erb | 102 +++++++++--------- ...219100001_add_backfill_ends_at_to_users.rb | 5 + db/schema.rb | 3 +- 6 files changed, 124 insertions(+), 56 deletions(-) create mode 100644 app/javascript/controllers/backfill_row_controller.js create mode 100644 db/migrate/20260219100001_add_backfill_ends_at_to_users.rb diff --git a/app/controllers/admin/snapshot_management_controller.rb b/app/controllers/admin/snapshot_management_controller.rb index bae91b3..e453798 100644 --- a/app/controllers/admin/snapshot_management_controller.rb +++ b/app/controllers/admin/snapshot_management_controller.rb @@ -2,6 +2,9 @@ module Admin class SnapshotManagementController < ApplicationController before_action :require_admin + # Seconds per API call (polling_interval of torn_api queue) + SECONDS_PER_API_CALL = 1.1 + def index @users_with_gaps = users_with_missing_snapshots @summary = calculate_summary @@ -11,12 +14,21 @@ def backfill_user user = User.find(params[:id]) missing_dates = missing_dates_for_user(user) + # Count jobs already in the torn_api queue (not yet finished) + existing_queued_jobs = SolidQueue::Job.where(queue_name: "torn_api", finished_at: nil).count + missing_dates.each_with_index do |date, index| BackfillSingleStatJob.set(wait: index.seconds).perform_later(user.id, date.to_s) end - redirect_to admin_snapshot_management_index_path, - notice: "Scheduled #{missing_dates.size} backfill jobs for #{user.name}" + # Each BackfillSingleStatJob triggers 2 API calls (batch 1 + batch 2) + # Total API calls = existing jobs + (new jobs * 2) + total_api_calls = existing_queued_jobs + (missing_dates.size * 2) + estimated_seconds = (total_api_calls * SECONDS_PER_API_CALL).ceil + + user.update!(backfill_ends_at: Time.current + estimated_seconds.seconds) + + render json: { success: true, message: "Scheduled #{missing_dates.size * 2} API calls for #{user.name} (~#{estimated_seconds}s)" } end private @@ -33,7 +45,10 @@ def users_with_missing_snapshots expected_dates = expected_date_range.to_a - users_data = User.tracked_for_stats.includes(:faction).map do |user| + users_data = User.tracked_for_stats.includes(:faction).filter_map do |user| + # Skip users with backfill in progress + next if user.backfill_in_progress? + user_snapshots = existing_snapshots[user.id] || Set.new missing = expected_dates - user_snapshots.to_a @@ -46,7 +61,7 @@ def users_with_missing_snapshots latest_snapshot: user_snapshots.max, oldest_missing: missing.min } - end.compact + end users_data.sort_by { |u| -u[:missing_count] } end diff --git a/app/javascript/controllers/backfill_row_controller.js b/app/javascript/controllers/backfill_row_controller.js new file mode 100644 index 0000000..6c3cee0 --- /dev/null +++ b/app/javascript/controllers/backfill_row_controller.js @@ -0,0 +1,38 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + async remove(event) { + event.preventDefault() + + const form = event.target.closest("form") + if (!form) return + + try { + const response = await fetch(form.action, { + method: "POST", + headers: { + "Accept": "application/json", + "X-CSRF-Token": document.querySelector("[name='csrf-token']").content + } + }) + + const data = await response.json() + + if (data.success) { + this.element.remove() + this.showFlash("notice", data.message) + } else { + this.showFlash("alert", data.message || "Something went wrong") + } + } catch (error) { + console.error("Backfill error:", error) + this.showFlash("alert", "Failed to schedule backfill jobs") + } + } + + showFlash(type, message) { + window.dispatchEvent(new CustomEvent("flash:show", { + detail: { type, message } + })) + } +} diff --git a/app/models/user.rb b/app/models/user.rb index 58380b8..6760e8b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -43,4 +43,13 @@ def admin? def has_limited_access? LIMITED_ACCESS_TYPES.include?(api_access_type) end + + def backfill_in_progress? + backfill_ends_at.present? && backfill_ends_at > Time.current + end + + def backfill_seconds_remaining + return 0 unless backfill_in_progress? + (backfill_ends_at - Time.current).to_i + end end diff --git a/app/views/admin/snapshot_management/index.html.erb b/app/views/admin/snapshot_management/index.html.erb index 4457097..c3eb7de 100644 --- a/app/views/admin/snapshot_management/index.html.erb +++ b/app/views/admin/snapshot_management/index.html.erb @@ -53,60 +53,60 @@ - <% @users_with_gaps.each do |data| %> - - - - - - - - - - + + + + + + + + + + - + + + <% end %> -
    TIMESTAMP ENDPOINTSELECTIONS STATUS RESPONSE TIME ERROR
    <%= log.created_at.strftime("%Y-%m-%d %H:%M:%S") %> <%= log.endpoint %> + <% if selections.any? %> + <% selections.each do |key, value| %> + + <%= key %>: + <% if key == "timestamp" && value.is_a?(Integer) %> + <%= Time.at(value).utc.strftime("%Y-%m-%d") %> + <% else %> + <%= value.to_s %> + <% end %> + + <% end %> + <% else %> + - + <% end %> + <%= log.status.upcase %> @@ -101,6 +129,12 @@ color: var(--text-primary); } + .stat-sublabel { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 0.25rem; + } + .error-text { color: #e74c3c; } @@ -135,4 +169,25 @@ font-family: monospace; font-size: 0.875rem; } + + .selections-cell { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + } + + .selection-tag { + display: inline-block; + background: rgba(52, 152, 219, 0.15); + border: 1px solid rgba(52, 152, 219, 0.3); + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + white-space: normal; + word-break: break-word; + } + + .selection-tag strong { + color: var(--text-muted); + } From d1e46bf73afc77f0a0bcce90c8a639d8c1f65c39 Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 17 Feb 2026 13:46:25 +0100 Subject: [PATCH 040/266] hof backfill --- app/jobs/backfill_hof_stats_job.rb | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 app/jobs/backfill_hof_stats_job.rb diff --git a/app/jobs/backfill_hof_stats_job.rb b/app/jobs/backfill_hof_stats_job.rb new file mode 100644 index 0000000..8ead3b1 --- /dev/null +++ b/app/jobs/backfill_hof_stats_job.rb @@ -0,0 +1,36 @@ +class BackfillHofStatsJob < ApplicationJob + queue_as :default + + # Seconds per API call (polling_interval of torn_api queue) + SECONDS_PER_API_CALL = 1.1 + + def perform(start_date, end_date) + users = User.hof_stats_users.to_a + dates = (start_date.to_date..end_date.to_date).to_a + + Rails.logger.info("Scheduling HOF backfill: #{users.count} users, #{dates.size} days") + + jobs_scheduled = 0 + + users.each do |user| + existing_snapshots = user.personal_stat_snapshots + .where(timestamp: dates.first.to_time.to_i..dates.last.end_of_day.to_i) + .pluck(:timestamp) + .map { |ts| Time.at(ts).utc.to_date } + .to_set + + dates.each do |date| + next if existing_snapshots.include?(date) + + BackfillSingleStatJob.perform_later(user.id, date.to_s) + jobs_scheduled += 1 + end + end + + existing_queued_jobs = SolidQueue::Job.where(queue_name: "torn_api", finished_at: nil).count + total_api_calls = existing_queued_jobs + (jobs_scheduled * 2) + estimated_seconds = [ total_api_calls * SECONDS_PER_API_CALL, 1 ].max.to_i + + Rails.logger.info("Scheduled #{jobs_scheduled} HOF stat fetch jobs (#{existing_queued_jobs} jobs already queued), completing in ~#{estimated_seconds}s") + end +end From 0cc9e56546fd716a981790d8147b4473cc9ca18a Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 17 Feb 2026 18:36:43 +0100 Subject: [PATCH 041/266] bugfix --- app/controllers/admin/factions_controller.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/controllers/admin/factions_controller.rb b/app/controllers/admin/factions_controller.rb index a562229..14b3bf5 100644 --- a/app/controllers/admin/factions_controller.rb +++ b/app/controllers/admin/factions_controller.rb @@ -70,8 +70,8 @@ def toggle_tracking @faction.update!(track_stats: !@faction.track_stats) if @faction.track_stats - # Sync members immediately when tracking is enabled - Daily::FactionMembershipSyncJob.new.sync_faction_members(@faction) + SyncFactionMembersJob.perform_later(@faction.id) + @faction.reload render json: { success: true, track_stats: @faction.track_stats, member_count: @faction.users.count } else render json: { success: true, track_stats: @faction.track_stats, member_count: @faction.users.count } @@ -81,7 +81,8 @@ def toggle_tracking end def sync_members - Daily::FactionMembershipSyncJob.new.sync_faction_members(@faction) + SyncFactionMembersJob.perform_now(@faction.id) + @faction.reload redirect_to admin_factions_path, notice: "Synced #{@faction.users.count} members for '#{@faction.name}'." rescue => e redirect_to admin_factions_path, alert: "Failed to sync members: #{e.message}" From d7aac5dd9e0207f135a8437403669b128d41359a Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 17 Feb 2026 18:39:56 +0100 Subject: [PATCH 042/266] bugfix --- app/controllers/admin/factions_controller.rb | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/controllers/admin/factions_controller.rb b/app/controllers/admin/factions_controller.rb index 14b3bf5..105b557 100644 --- a/app/controllers/admin/factions_controller.rb +++ b/app/controllers/admin/factions_controller.rb @@ -70,12 +70,11 @@ def toggle_tracking @faction.update!(track_stats: !@faction.track_stats) if @faction.track_stats - SyncFactionMembersJob.perform_later(@faction.id) + SyncFactionMembersJob.perform_now(@faction.id) @faction.reload - render json: { success: true, track_stats: @faction.track_stats, member_count: @faction.users.count } - else - render json: { success: true, track_stats: @faction.track_stats, member_count: @faction.users.count } end + + render json: { success: true, track_stats: @faction.track_stats, member_count: @faction.users.count } rescue => e render json: { success: false, error: e.message }, status: :unprocessable_entity end From fbdde5b0bde292f0a91fbf5920e72973633bdbe5 Mon Sep 17 00:00:00 2001 From: Bram Date: Wed, 18 Feb 2026 10:02:03 +0100 Subject: [PATCH 043/266] changes --- app/jobs/backfill_personal_stats_job.rb | 11 +- app/jobs/backfill_single_stat_job.rb | 52 ++---- app/jobs/daily/personal_stats_job.rb | 6 - app/jobs/fetch_personal_stats_job.rb | 65 ++++--- app/models/personal_stat_snapshot.rb | 31 +--- app/models/torn_api/user/personal_stats.rb | 20 +- ...8085504_cleanup_personal_stat_snapshots.rb | 95 ++++++++++ db/schema.rb | 175 +----------------- test/fixtures/personal_stat_snapshots.yml | 1 + 9 files changed, 159 insertions(+), 297 deletions(-) create mode 100644 db/migrate/20260218085504_cleanup_personal_stat_snapshots.rb diff --git a/app/jobs/backfill_personal_stats_job.rb b/app/jobs/backfill_personal_stats_job.rb index 5ab0419..edd2e31 100644 --- a/app/jobs/backfill_personal_stats_job.rb +++ b/app/jobs/backfill_personal_stats_job.rb @@ -16,15 +16,14 @@ def perform(faction_id, start_date, end_date) # Enqueue all jobs - torn_api queue handles rate limiting (~1 req/sec) users.each do |user| # Get existing snapshots for this user - existing_snapshots = user.personal_stat_snapshots - .where(timestamp: dates.first.to_time.to_i..dates.last.end_of_day.to_i) - .pluck(:timestamp) - .map { |ts| Time.at(ts).utc.to_date } - .to_set + existing_dates = user.personal_stat_snapshots + .where(date: dates.first..dates.last) + .pluck(:date) + .to_set dates.each do |date| # Skip if we already have a snapshot for this date - next if existing_snapshots.include?(date) + next if existing_dates.include?(date) BackfillSingleStatJob.perform_later(user.id, date.to_s) jobs_scheduled += 1 diff --git a/app/jobs/backfill_single_stat_job.rb b/app/jobs/backfill_single_stat_job.rb index c0b49a3..637ebcb 100644 --- a/app/jobs/backfill_single_stat_job.rb +++ b/app/jobs/backfill_single_stat_job.rb @@ -1,7 +1,4 @@ class BackfillSingleStatJob < TornApiJob - # @param user_id [Integer] User ID - # @param date_str [String] Date string (YYYY-MM-DD) - # @param batch [Integer] Which batch to fetch (1 or 2), defaults to 1 which schedules batch 2 def perform(user_id, date_str, batch: 1) user = User.find(user_id) date = Date.parse(date_str) @@ -9,45 +6,32 @@ def perform(user_id, date_str, batch: 1) return Rails.logger.error("No API key found") if api_key.blank? - request_timestamp = date.to_time.to_i - stat_batch = batch == 1 ? PersonalStatSnapshot::TRACKED_STATS_BATCH_1 : PersonalStatSnapshot::TRACKED_STATS_BATCH_2 - - stats = fetch_stats(user.torn_id, request_timestamp, api_key, stat_batch) - + stats = fetch_stats(user, date, api_key, batch) return if stats.nil? - # Use the original request date for snapshot lookup, not API response timestamp - # (API may return different timestamps between batch 1 and batch 2) - response_timestamp = stats[:timestamp] - stats_data = stats.except(:timestamp) - - # Find existing snapshot for the target date, or initialize new one - day_start = date.beginning_of_day.to_i - day_end = date.end_of_day.to_i - snapshot = user.personal_stat_snapshots.find_by(timestamp: day_start..day_end) - snapshot ||= user.personal_stat_snapshots.new(timestamp: response_timestamp) - - snapshot.assign_attributes(stats_data) - - if snapshot.save - Rails.logger.debug("Upserted snapshot for user #{user.torn_id} on #{snapshot.date} (batch #{batch})") - else - Rails.logger.error("Failed to save snapshot for user #{user.torn_id} on #{snapshot.date}: #{snapshot.errors.full_messages.join(', ')}") - end - - # Enqueue batch 2 if this was batch 1 (queue handles rate limiting) - if batch == 1 - BackfillSingleStatJob.perform_later(user_id, date_str, batch: 2) - end + save_snapshot(user, stats, date) + + BackfillSingleStatJob.perform_later(user_id, date_str, batch: 2) if batch == 1 end private - def fetch_stats(torn_id, timestamp, api_key, stat_batch) - api = TornApi::User::PersonalStats.new(api_key, torn_id, timestamp: timestamp, stat_batch: stat_batch) - api.fetch + def fetch_stats(user, date, api_key, batch) + stat_batch = batch == 1 ? PersonalStatSnapshot::TRACKED_STATS_BATCH_1 : PersonalStatSnapshot::TRACKED_STATS_BATCH_2 + + TornApi::User::PersonalStats.new( + api_key, + user.torn_id, + timestamp: date.end_of_day.to_i, + stat_batch: stat_batch + ).fetch rescue TornApi::ApiError => e Rails.logger.error("API error fetching stats: #{e.message}") nil end + + def save_snapshot(user, stats, date) + snapshot = user.personal_stat_snapshots.find_or_initialize_by(date: date) + snapshot.update!(stats.except(:date)) + end end diff --git a/app/jobs/daily/personal_stats_job.rb b/app/jobs/daily/personal_stats_job.rb index 4ba0865..bbde4e4 100644 --- a/app/jobs/daily/personal_stats_job.rb +++ b/app/jobs/daily/personal_stats_job.rb @@ -3,15 +3,9 @@ class PersonalStatsJob < ApplicationJob queue_as :default def perform - total_users = 0 - User.tracked_for_stats.find_each do |user| - # Jobs go to torn_api queue which handles rate limiting (~1 req/sec) FetchPersonalStatsJob.perform_later(user) - total_users += 1 end - - ::Appsignal.set_gauge("jobs.personal_stats_scheduled", total_users) if defined?(::Appsignal) end end end diff --git a/app/jobs/fetch_personal_stats_job.rb b/app/jobs/fetch_personal_stats_job.rb index 0901b42..284682b 100644 --- a/app/jobs/fetch_personal_stats_job.rb +++ b/app/jobs/fetch_personal_stats_job.rb @@ -1,40 +1,39 @@ class FetchPersonalStatsJob < TornApiJob MIN_STAT_ENHANCER = 200 - # @param user [User] User to fetch stats for - # @param batch [Integer] Which batch to fetch (1 or 2), defaults to 1 which schedules batch 2 - # @param target_date [Date, nil] The date to save stats for (batch 2 uses this to find batch 1's snapshot) - def perform(user, batch: 1, target_date: nil) - stat_batch = batch == 1 ? PersonalStatSnapshot::TRACKED_STATS_BATCH_1 : PersonalStatSnapshot::TRACKED_STATS_BATCH_2 + def perform(user, batch: 1, stats_date: Date.current.yesterday) + stats = fetch_stats(user, batch, stats_date) + + check_hof_eligibility(user, stats) if batch == 1 + save_snapshot(user, stats, stats_date) - stats = TornApi::User::PersonalStats.new(OwnerCredentials.api_key, user.torn_id, stat_batch: stat_batch).fetch - - # Check HoF eligibility only on batch 1 (which has stat_enhancers) - if batch == 1 && stats[:items_used_stat_enhancers].to_i > MIN_STAT_ENHANCER - user.update!(hof_stats_user: true) - end - - # Determine the date for this snapshot - # - Batch 1: use the API response timestamp - # - Batch 2: use the target_date passed from batch 1 (API may return different timestamp) - response_timestamp = stats[:timestamp] - snapshot_date = target_date || Time.at(response_timestamp).utc.to_date - day_start = snapshot_date.beginning_of_day.to_i - day_end = snapshot_date.end_of_day.to_i - - snapshot = user.personal_stat_snapshots.find_by(timestamp: day_start..day_end) - snapshot ||= user.personal_stat_snapshots.new(timestamp: response_timestamp) - snapshot.assign_attributes(stats.except(:timestamp)) - snapshot.save! - - # Enqueue batch 2 if this was batch 1 (queue handles rate limiting) - if batch == 1 - FetchPersonalStatsJob.perform_later(user, batch: 2, target_date: snapshot_date) - end - - ::Appsignal.increment_counter("jobs.personal_stats_fetched", 1) if defined?(::Appsignal) - rescue => e - ::Appsignal.increment_counter("jobs.personal_stats_failed", 1) if defined?(::Appsignal) + FetchPersonalStatsJob.perform_later(user, batch: 2, stats_date: stats_date) if batch == 1 + rescue + Appsignal.increment_counter("jobs.personal_stats_failed", 1) if defined?(Appsignal) raise end + + private + + def fetch_stats(user, batch, stats_date) + stat_batch = batch == 1 ? PersonalStatSnapshot::TRACKED_STATS_BATCH_1 : PersonalStatSnapshot::TRACKED_STATS_BATCH_2 + + TornApi::User::PersonalStats.new( + OwnerCredentials.api_key, + user.torn_id, + timestamp: stats_date.end_of_day.to_i, + stat_batch: stat_batch + ).fetch + end + + def check_hof_eligibility(user, stats) + return unless stats[:items_used_stat_enhancers].to_i > MIN_STAT_ENHANCER + + user.update!(hof_stats_user: true) + end + + def save_snapshot(user, stats, stats_date) + snapshot = user.personal_stat_snapshots.find_or_initialize_by(date: stats_date) + snapshot.update!(stats.except(:date)) + end end diff --git a/app/models/personal_stat_snapshot.rb b/app/models/personal_stat_snapshot.rb index b98f95d..d3ae8f6 100644 --- a/app/models/personal_stat_snapshot.rb +++ b/app/models/personal_stat_snapshot.rb @@ -1,8 +1,7 @@ class PersonalStatSnapshot < ApplicationRecord belongs_to :user - validates :timestamp, presence: true - validate :one_snapshot_per_day_per_user, on: :create + validates :date, presence: true, uniqueness: { scope: :user_id } # Central definition of stats we track - maps API stat name to DB column # Split into batches of max 10 due to Torn API v2 limit @@ -29,14 +28,6 @@ def self.api_stat_names TRACKED_STATS.keys end - def self.api_stat_names_batch_1 - TRACKED_STATS_BATCH_1.keys - end - - def self.api_stat_names_batch_2 - TRACKED_STATS_BATCH_2.keys - end - def self.db_columns TRACKED_STATS.values end @@ -44,24 +35,4 @@ def self.db_columns def self.stat_batches [ TRACKED_STATS_BATCH_1, TRACKED_STATS_BATCH_2 ] end - - def date - Time.at(timestamp).utc.to_date if timestamp - end - - private - - def one_snapshot_per_day_per_user - return unless timestamp && user_id - - day_start = date.beginning_of_day.to_i - day_end = date.end_of_day.to_i - - existing = self.class.where(user_id: user_id, timestamp: day_start..day_end) - existing = existing.where.not(id: id) if persisted? - - if existing.exists? - errors.add(:timestamp, "already has a snapshot for #{date}") - end - end end diff --git a/app/models/torn_api/user/personal_stats.rb b/app/models/torn_api/user/personal_stats.rb index 28b9207..d4c2a56 100644 --- a/app/models/torn_api/user/personal_stats.rb +++ b/app/models/torn_api/user/personal_stats.rb @@ -3,21 +3,6 @@ module User class PersonalStats < Base attr_reader :torn_id, :timestamp, :stat_batch - PersonalStatSnapshot = Data.define( - :timestamp, - :drugs_xanax, - :drugs_cannabis, - :other_refills_energy, - :other_refills_nerve, - :items_used_boosters, - :items_used_stat_enhancers, - :missions_contracts_total, - :crimes_offenses_total, - :other_activity_time, - :networth_total, - :attacking_networth_money_mugged - ) - # @param api_key [String] API key # @param torn_id [Integer] User's Torn ID # @param timestamp [Integer, nil] Unix timestamp for historical data @@ -56,11 +41,12 @@ def parse_personalstats(stats, requested_stats) hash[stat["name"]] = stat["value"] end - # Get timestamp from the first stat (they should all have the same timestamp) + # Get date from the first stat's timestamp (they should all have the same timestamp) response_timestamp = stats.first&.dig("timestamp") + response_date = response_timestamp ? Time.at(response_timestamp).utc.to_date : nil # Build result with only the requested stats, defaulting missing to 0 - result = { timestamp: response_timestamp } + result = { date: response_date, timestamp: response_timestamp } requested_stats.each do |api_name, db_column| # Default to 0 if stat not in response (API omits stats with value 0) diff --git a/db/migrate/20260218085504_cleanup_personal_stat_snapshots.rb b/db/migrate/20260218085504_cleanup_personal_stat_snapshots.rb new file mode 100644 index 0000000..d274bac --- /dev/null +++ b/db/migrate/20260218085504_cleanup_personal_stat_snapshots.rb @@ -0,0 +1,95 @@ +class CleanupPersonalStatSnapshots < ActiveRecord::Migration[8.1] + def change + # Remove all unused columns - keeping only: + # - user_id, created_at, updated_at, timestamp + # - drugs_xanax, drugs_cannabis + # - other_refills_energy, other_refills_nerve + # - items_used_boosters, items_used_stat_enhancers + # - missions_contracts_total, crimes_offenses_total + # - other_activity_time, networth_total + # - attacking_networth_money_mugged + + columns_to_remove = %w[ + attacking_ammunition_hollow_point attacking_ammunition_incendiary + attacking_ammunition_piercing attacking_ammunition_special + attacking_ammunition_total attacking_ammunition_tracer + attacking_attacks_assist attacking_attacks_lost attacking_attacks_stalemate + attacking_attacks_stealth attacking_attacks_won attacking_damage_best + attacking_damage_total attacking_defends_lost attacking_defends_stalemate + attacking_defends_total attacking_defends_won attacking_elo + attacking_escapes_foes attacking_escapes_player attacking_faction_raid_hits + attacking_faction_ranked_war_hits attacking_faction_respect + attacking_faction_retaliations attacking_faction_territory_wall_clears + attacking_faction_territory_wall_joins attacking_faction_territory_wall_time + attacking_highest_level_beaten attacking_hits_critical attacking_hits_miss + attacking_hits_one_hit_kills attacking_hits_success attacking_killstreak_best + attacking_networth_items_looted attacking_networth_largest_mug + attacking_unarmored_wins bounties_collected_amount bounties_collected_value + bounties_placed_amount bounties_placed_value bounties_received_amount + bounties_received_value communication_classified_ads + communication_mails_sent_colleagues communication_mails_sent_faction + communication_mails_sent_friends communication_mails_sent_spouse + communication_mails_sent_total communication_personals + crimes_offenses_counterfeiting crimes_offenses_cybercrime + crimes_offenses_extortion crimes_offenses_fraud + crimes_offenses_illegal_production crimes_offenses_illicit_services + crimes_offenses_organized_crimes crimes_offenses_theft crimes_offenses_vandalism + crimes_skills_arson crimes_skills_bootlegging crimes_skills_burglary + crimes_skills_card_skimming crimes_skills_cracking crimes_skills_disposal + crimes_skills_forgery crimes_skills_graffiti crimes_skills_hustling + crimes_skills_pickpocketing crimes_skills_scamming crimes_skills_search_for_cash + crimes_skills_shoplifting crimes_total crimes_version + drugs_ecstasy drugs_ketamine drugs_lsd drugs_opium drugs_overdoses drugs_pcp + drugs_rehabilitations_amount drugs_rehabilitations_fees drugs_shrooms + drugs_speed drugs_total drugs_vicodin + finishing_hits_clubbing finishing_hits_hand_to_hand finishing_hits_heavy_artillery + finishing_hits_machine_guns finishing_hits_mechanical finishing_hits_piercing + finishing_hits_pistols finishing_hits_rifles finishing_hits_shotguns + finishing_hits_slashing finishing_hits_sub_machine_guns finishing_hits_temporary + hospital_blood_withdrawn hospital_medical_items_used hospital_reviving_revives + hospital_reviving_revives_received hospital_reviving_skill hospital_times_hospitalized + items_found_city items_found_dump items_found_easter_eggs items_trashed + items_used_alcohol items_used_books items_used_candy items_used_consumables + items_used_easter_eggs items_used_energy items_used_energy_drinks items_viruses_coded + jail_bails_amount jail_bails_fees jail_busts_fails jail_busts_success jail_times_jailed + jobs_job_points_used jobs_trains_received missions_contracts_duke missions_credits + missions_missions other_activity_streak_best other_activity_streak_current + other_awards other_donator_days other_merits_bought other_ranked_war_wins + other_refills_token racing_points racing_races_entered racing_races_won racing_skill + trading_bazaar_customers trading_bazaar_profit trading_bazaar_sales + trading_item_market_customers trading_item_market_fees trading_item_market_revenue + trading_item_market_sales trading_items_auctions_sold trading_items_auctions_won + trading_items_bought_market trading_items_bought_shops trading_items_sent + trading_points_bought trading_points_sold trading_trades + travel_argentina travel_attacks_won travel_canada travel_cayman_islands + travel_china travel_defends_lost travel_hawaii travel_hunting_skill + travel_items_bought travel_japan travel_mexico travel_south_africa + travel_switzerland travel_time_spent travel_total travel_united_arab_emirates + travel_united_kingdom + ] + + columns_to_remove.each do |column| + remove_column :personal_stat_snapshots, column, if_exists: true + end + + # Add date column populated from existing timestamp data + add_column :personal_stat_snapshots, :date, :date + + reversible do |dir| + dir.up do + execute <<-SQL + UPDATE personal_stat_snapshots + SET date = date(timestamp, 'unixepoch') + SQL + end + end + + change_column_null :personal_stat_snapshots, :date, false + + # Replace old unique index with new one on date + # Keep timestamp column but make it nullable (for audit/logging) + remove_index :personal_stat_snapshots, [ :user_id, :timestamp ] + change_column_null :personal_stat_snapshots, :timestamp, true + add_index :personal_stat_snapshots, [ :user_id, :date ], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index d90dabe..7f8b8ae 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_02_17_095236) do +ActiveRecord::Schema[8.1].define(version: 2026_02_18_085746) do create_table "api_calls", force: :cascade do |t| t.string "api_key", null: false t.datetime "created_at", null: false @@ -57,190 +57,23 @@ end create_table "personal_stat_snapshots", force: :cascade do |t| - t.integer "attacking_ammunition_hollow_point" - t.integer "attacking_ammunition_incendiary" - t.integer "attacking_ammunition_piercing" - t.integer "attacking_ammunition_special" - t.integer "attacking_ammunition_total" - t.integer "attacking_ammunition_tracer" - t.integer "attacking_attacks_assist" - t.integer "attacking_attacks_lost" - t.integer "attacking_attacks_stalemate" - t.integer "attacking_attacks_stealth" - t.integer "attacking_attacks_won" - t.integer "attacking_damage_best" - t.integer "attacking_damage_total" - t.integer "attacking_defends_lost" - t.integer "attacking_defends_stalemate" - t.integer "attacking_defends_total" - t.integer "attacking_defends_won" - t.integer "attacking_elo" - t.integer "attacking_escapes_foes" - t.integer "attacking_escapes_player" - t.integer "attacking_faction_raid_hits" - t.integer "attacking_faction_ranked_war_hits" - t.integer "attacking_faction_respect" - t.integer "attacking_faction_retaliations" - t.integer "attacking_faction_territory_wall_clears" - t.integer "attacking_faction_territory_wall_joins" - t.integer "attacking_faction_territory_wall_time" - t.integer "attacking_highest_level_beaten" - t.integer "attacking_hits_critical" - t.integer "attacking_hits_miss" - t.integer "attacking_hits_one_hit_kills" - t.integer "attacking_hits_success" - t.integer "attacking_killstreak_best" - t.integer "attacking_networth_items_looted" - t.integer "attacking_networth_largest_mug" t.integer "attacking_networth_money_mugged" - t.integer "attacking_unarmored_wins" - t.integer "bounties_collected_amount" - t.integer "bounties_collected_value" - t.integer "bounties_placed_amount" - t.integer "bounties_placed_value" - t.integer "bounties_received_amount" - t.integer "bounties_received_value" - t.integer "communication_classified_ads" - t.integer "communication_mails_sent_colleagues" - t.integer "communication_mails_sent_faction" - t.integer "communication_mails_sent_friends" - t.integer "communication_mails_sent_spouse" - t.integer "communication_mails_sent_total" - t.integer "communication_personals" t.datetime "created_at", null: false - t.integer "crimes_offenses_counterfeiting" - t.integer "crimes_offenses_cybercrime" - t.integer "crimes_offenses_extortion" - t.integer "crimes_offenses_fraud" - t.integer "crimes_offenses_illegal_production" - t.integer "crimes_offenses_illicit_services" - t.integer "crimes_offenses_organized_crimes" - t.integer "crimes_offenses_theft" t.integer "crimes_offenses_total" - t.integer "crimes_offenses_vandalism" - t.integer "crimes_skills_arson" - t.integer "crimes_skills_bootlegging" - t.integer "crimes_skills_burglary" - t.integer "crimes_skills_card_skimming" - t.integer "crimes_skills_cracking" - t.integer "crimes_skills_disposal" - t.integer "crimes_skills_forgery" - t.integer "crimes_skills_graffiti" - t.integer "crimes_skills_hustling" - t.integer "crimes_skills_pickpocketing" - t.integer "crimes_skills_scamming" - t.integer "crimes_skills_search_for_cash" - t.integer "crimes_skills_shoplifting" - t.integer "crimes_total" - t.string "crimes_version" + t.date "date", null: false t.integer "drugs_cannabis" - t.integer "drugs_ecstasy" - t.integer "drugs_ketamine" - t.integer "drugs_lsd" - t.integer "drugs_opium" - t.integer "drugs_overdoses" - t.integer "drugs_pcp" - t.integer "drugs_rehabilitations_amount" - t.integer "drugs_rehabilitations_fees" - t.integer "drugs_shrooms" - t.integer "drugs_speed" - t.integer "drugs_total" - t.integer "drugs_vicodin" t.integer "drugs_xanax" - t.integer "finishing_hits_clubbing" - t.integer "finishing_hits_hand_to_hand" - t.integer "finishing_hits_heavy_artillery" - t.integer "finishing_hits_machine_guns" - t.integer "finishing_hits_mechanical" - t.integer "finishing_hits_piercing" - t.integer "finishing_hits_pistols" - t.integer "finishing_hits_rifles" - t.integer "finishing_hits_shotguns" - t.integer "finishing_hits_slashing" - t.integer "finishing_hits_sub_machine_guns" - t.integer "finishing_hits_temporary" - t.integer "hospital_blood_withdrawn" - t.integer "hospital_medical_items_used" - t.integer "hospital_reviving_revives" - t.integer "hospital_reviving_revives_received" - t.integer "hospital_reviving_skill" - t.integer "hospital_times_hospitalized" - t.integer "items_found_city" - t.integer "items_found_dump" - t.integer "items_found_easter_eggs" - t.integer "items_trashed" - t.integer "items_used_alcohol" - t.integer "items_used_books" t.integer "items_used_boosters" - t.integer "items_used_candy" - t.integer "items_used_consumables" - t.integer "items_used_easter_eggs" - t.integer "items_used_energy" - t.integer "items_used_energy_drinks" t.integer "items_used_stat_enhancers" - t.integer "items_viruses_coded" - t.integer "jail_bails_amount" - t.bigint "jail_bails_fees" - t.integer "jail_busts_fails" - t.integer "jail_busts_success" - t.integer "jail_times_jailed" - t.integer "jobs_job_points_used" - t.integer "jobs_trains_received" - t.integer "missions_contracts_duke" t.integer "missions_contracts_total" - t.integer "missions_credits" - t.integer "missions_missions" t.bigint "networth_total" - t.integer "other_activity_streak_best" - t.integer "other_activity_streak_current" t.integer "other_activity_time" - t.integer "other_awards" - t.integer "other_donator_days" - t.integer "other_merits_bought" - t.integer "other_ranked_war_wins" t.integer "other_refills_energy" t.integer "other_refills_nerve" - t.integer "other_refills_token" - t.integer "racing_points" - t.integer "racing_races_entered" - t.integer "racing_races_won" - t.integer "racing_skill" - t.integer "timestamp", null: false - t.integer "trading_bazaar_customers" - t.bigint "trading_bazaar_profit" - t.integer "trading_bazaar_sales" - t.integer "trading_item_market_customers" - t.bigint "trading_item_market_fees" - t.bigint "trading_item_market_revenue" - t.integer "trading_item_market_sales" - t.integer "trading_items_auctions_sold" - t.integer "trading_items_auctions_won" - t.integer "trading_items_bought_market" - t.integer "trading_items_bought_shops" - t.integer "trading_items_sent" - t.integer "trading_points_bought" - t.integer "trading_points_sold" - t.integer "trading_trades" - t.integer "travel_argentina" - t.integer "travel_attacks_won" - t.integer "travel_canada" - t.integer "travel_cayman_islands" - t.integer "travel_china" - t.integer "travel_defends_lost" - t.integer "travel_hawaii" - t.integer "travel_hunting_skill" - t.integer "travel_items_bought" - t.integer "travel_japan" - t.integer "travel_mexico" - t.integer "travel_south_africa" - t.integer "travel_switzerland" - t.integer "travel_time_spent" - t.integer "travel_total" - t.integer "travel_united_arab_emirates" - t.integer "travel_united_kingdom" + t.integer "timestamp" t.datetime "updated_at", null: false t.integer "user_id", null: false - t.index ["user_id", "timestamp"], name: "index_personal_stat_snapshots_on_user_id_and_timestamp", unique: true + t.index ["user_id", "date"], name: "index_personal_stat_snapshots_on_user_id_and_date", unique: true t.index ["user_id"], name: "index_personal_stat_snapshots_on_user_id" end diff --git a/test/fixtures/personal_stat_snapshots.yml b/test/fixtures/personal_stat_snapshots.yml index 90d76c6..cd353df 100644 --- a/test/fixtures/personal_stat_snapshots.yml +++ b/test/fixtures/personal_stat_snapshots.yml @@ -1,3 +1,4 @@ one: user: one + date: 2025-02-17 timestamp: 1739836800 From acdbab04931bda9333e91cda380a3b3240821cec Mon Sep 17 00:00:00 2001 From: Bram Date: Wed, 18 Feb 2026 10:13:29 +0100 Subject: [PATCH 044/266] update UI and stats --- app/controllers/admin/stats_controller.rb | 52 ++++++++++++++++++-- app/models/personal_stat_snapshot.rb | 6 +++ app/views/admin/api_logs/index.html.erb | 4 +- app/views/admin/factions/_faction.html.erb | 8 +-- app/views/admin/stats/index.html.erb | 34 ++++++++++++- app/views/admin/subscriptions/index.html.erb | 6 +-- app/views/faction/index.html.erb | 2 +- app/views/key_log/show.html.erb | 2 +- app/views/pages/privacy_policy.html.erb | 2 +- app/views/pages/terms_of_service.html.erb | 2 +- app/views/settings/index.html.erb | 2 +- app/views/user_api_calls/index.html.erb | 6 +-- 12 files changed, 103 insertions(+), 23 deletions(-) diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb index 1b7a6d5..f930c17 100644 --- a/app/controllers/admin/stats_controller.rb +++ b/app/controllers/admin/stats_controller.rb @@ -29,15 +29,16 @@ def index @api_calls_today = ApiCall.where("created_at >= ?", Time.current.beginning_of_day).count @api_calls_this_week = ApiCall.where("created_at >= ?", 7.days.ago.beginning_of_day).count - # Data health - users missing recent snapshots - yesterday_start = 1.day.ago.beginning_of_day.to_i - yesterday_end = 1.day.ago.end_of_day.to_i + # Data health - users missing yesterday's snapshot users_with_yesterday_snapshot = PersonalStatSnapshot - .where(timestamp: yesterday_start..yesterday_end) + .where(date: Date.yesterday) .distinct .pluck(:user_id) @users_missing_yesterday = User.tracked_for_stats.where.not(id: users_with_yesterday_snapshot).count + # Data health - snapshot gaps for tracked users + calculate_snapshot_gaps + # Snapshot completeness (users with all 11 stats filled) @complete_snapshots = PersonalStatSnapshot .where.not( @@ -64,5 +65,48 @@ def index .sort_by { |date, _| date } .last(7) end + + private + + def calculate_snapshot_gaps + tracked_user_ids = User.tracked_for_stats.pluck(:id) + return set_empty_gap_stats if tracked_user_ids.empty? + + # Get all existing snapshot dates per user + existing_snapshots = PersonalStatSnapshot + .where(user_id: tracked_user_ids) + .pluck(:user_id, :date) + .group_by(&:first) + .transform_values { |pairs| pairs.map(&:last).to_set } + + start_date = PersonalStatSnapshot::TRACKING_START_DATE + end_date = PersonalStatSnapshot.tracking_end_date + expected_dates = (start_date..end_date).to_a + + users_with_gaps = 0 + total_missing = 0 + missing_by_date = Hash.new(0) + + tracked_user_ids.each do |user_id| + user_snapshots = existing_snapshots[user_id] || Set.new + missing_dates = expected_dates - user_snapshots.to_a + + if missing_dates.any? + users_with_gaps += 1 + total_missing += missing_dates.size + missing_dates.each { |d| missing_by_date[d] += 1 } + end + end + + @users_with_gaps = users_with_gaps + @total_missing_snapshot_days = total_missing + @missing_dates_summary = missing_by_date.sort_by { |date, _| date }.last(10).reverse + end + + def set_empty_gap_stats + @users_with_gaps = 0 + @total_missing_snapshot_days = 0 + @missing_dates_summary = [] + end end end diff --git a/app/models/personal_stat_snapshot.rb b/app/models/personal_stat_snapshot.rb index d3ae8f6..79a76bd 100644 --- a/app/models/personal_stat_snapshot.rb +++ b/app/models/personal_stat_snapshot.rb @@ -3,6 +3,12 @@ class PersonalStatSnapshot < ApplicationRecord validates :date, presence: true, uniqueness: { scope: :user_id } + TRACKING_START_DATE = Date.new(2026, 1, 1).freeze + + def self.tracking_end_date + Date.current.yesterday + end + # Central definition of stats we track - maps API stat name to DB column # Split into batches of max 10 due to Torn API v2 limit TRACKED_STATS_BATCH_1 = { diff --git a/app/views/admin/api_logs/index.html.erb b/app/views/admin/api_logs/index.html.erb index 4fb12de..d0980b9 100644 --- a/app/views/admin/api_logs/index.html.erb +++ b/app/views/admin/api_logs/index.html.erb @@ -67,7 +67,7 @@ <% @api_logs.each do |log| %> <% selections = log.selections.present? ? JSON.parse(log.selections) : {} %>
    <%= log.created_at.strftime("%Y-%m-%d %H:%M:%S") %><%= log.created_at.strftime("%d-%m-%Y %H:%M:%S") %> <%= log.endpoint %> <% if selections.any? %> @@ -75,7 +75,7 @@ <%= key %>: <% if key == "timestamp" && value.is_a?(Integer) %> - <%= Time.at(value).utc.strftime("%Y-%m-%d") %> + <%= Time.at(value).utc.strftime("%d-%m-%Y") %> <% else %> <%= value.to_s %> <% end %> diff --git a/app/views/admin/factions/_faction.html.erb b/app/views/admin/factions/_faction.html.erb index b580a2b..d0b8472 100644 --- a/app/views/admin/factions/_faction.html.erb +++ b/app/views/admin/factions/_faction.html.erb @@ -66,12 +66,12 @@ <%= form_with url: backfill_stats_admin_faction_path(faction), method: :post, data: { turbo: false } do |f| %>
    - <%= f.date_field :start_date, id: "start_date_#{faction.id}", value: "2026-01-01", min: "2026-01-01", max: Date.today.to_s, style: "width:100%; padding:12px 16px; background:#0a0a0a; border:1px solid #262626; border-radius:8px; color:#e5e5e5; font-family:'Geist Mono',monospace; font-size:0.875rem; color-scheme:dark;" %> + <%= f.date_field :start_date, id: "start_date_#{faction.id}", value: "2026-01-01", min: "2026-01-01", max: Date.yesterday.to_s, style: "width:100%; padding:12px 16px; background:#0a0a0a; border:1px solid #262626; border-radius:8px; color:#e5e5e5; font-family:'Geist Mono',monospace; font-size:0.875rem; color-scheme:dark;" %>
    - <%= f.date_field :end_date, id: "end_date_#{faction.id}", value: Date.today.to_s, min: "2026-01-01", max: Date.today.to_s, style: "width:100%; padding:12px 16px; background:#0a0a0a; border:1px solid #262626; border-radius:8px; color:#e5e5e5; font-family:'Geist Mono',monospace; font-size:0.875rem; color-scheme:dark;" %> + <%= f.date_field :end_date, id: "end_date_#{faction.id}", value: Date.yesterday.to_s, min: "2026-01-01", max: Date.yesterday.to_s, style: "width:100%; padding:12px 16px; background:#0a0a0a; border:1px solid #262626; border-radius:8px; color:#e5e5e5; font-family:'Geist Mono',monospace; font-size:0.875rem; color-scheme:dark;" %>
    @@ -96,12 +96,12 @@ <%= form_with url: backfill_user_stats_admin_faction_path(faction, user_id: user.id), method: :post, data: { turbo: false } do |f| %>
    - <%= f.date_field :start_date, id: "user_start_date_#{user.id}", value: "2026-01-01", min: "2026-01-01", max: Date.today.to_s, style: "width:100%; padding:12px 16px; background:#0a0a0a; border:1px solid #262626; border-radius:8px; color:#e5e5e5; font-family:'Geist Mono',monospace; font-size:0.875rem; color-scheme:dark;" %> + <%= f.date_field :start_date, id: "user_start_date_#{user.id}", value: "2026-01-01", min: "2026-01-01", max: Date.yesterday.to_s, style: "width:100%; padding:12px 16px; background:#0a0a0a; border:1px solid #262626; border-radius:8px; color:#e5e5e5; font-family:'Geist Mono',monospace; font-size:0.875rem; color-scheme:dark;" %>
    - <%= f.date_field :end_date, id: "user_end_date_#{user.id}", value: Date.today.to_s, min: "2026-01-01", max: Date.today.to_s, style: "width:100%; padding:12px 16px; background:#0a0a0a; border:1px solid #262626; border-radius:8px; color:#e5e5e5; font-family:'Geist Mono',monospace; font-size:0.875rem; color-scheme:dark;" %> + <%= f.date_field :end_date, id: "user_end_date_#{user.id}", value: Date.yesterday.to_s, min: "2026-01-01", max: Date.yesterday.to_s, style: "width:100%; padding:12px 16px; background:#0a0a0a; border:1px solid #262626; border-radius:8px; color:#e5e5e5; font-family:'Geist Mono',monospace; font-size:0.875rem; color-scheme:dark;" %>
    diff --git a/app/views/admin/stats/index.html.erb b/app/views/admin/stats/index.html.erb index 4b4f604..2931e8c 100644 --- a/app/views/admin/stats/index.html.erb +++ b/app/views/admin/stats/index.html.erb @@ -81,11 +81,11 @@
    Days with Data
    -
    <%= @earliest_snapshot ? Time.at(@earliest_snapshot).utc.strftime("%Y-%m-%d") : "N/A" %>
    +
    <%= @earliest_snapshot ? Time.at(@earliest_snapshot).utc.strftime("%d-%m-%Y") : "N/A" %>
    Earliest Snapshot
    -
    <%= @latest_snapshot ? Time.at(@latest_snapshot).utc.strftime("%Y-%m-%d") : "N/A" %>
    +
    <%= @latest_snapshot ? Time.at(@latest_snapshot).utc.strftime("%d-%m-%Y") : "N/A" %>
    Latest Snapshot
    @@ -144,6 +144,14 @@
    <%= @users_missing_yesterday %>
    Users Missing Yesterday's Snapshot
    +
    +
    <%= @users_with_gaps %>
    +
    Users With Data Gaps
    +
    +
    +
    <%= number_with_delimiter(@total_missing_snapshot_days) %>
    +
    Total Missing Snapshot-Days
    +
    <%= number_with_delimiter(@complete_snapshots) %>
    Complete Snapshots
    @@ -157,6 +165,28 @@
    Completeness Rate
    + + <% if @missing_dates_summary.any? %> +
    +

    Recent Dates With Missing Snapshots

    + + + + + + + + + <% @missing_dates_summary.each do |date, count| %> + + + + + <% end %> + +
    DateUsers Missing
    <%= date.strftime("%d-%m-%Y") %><%= count %>
    +
    + <% end %> diff --git a/app/views/hall_of_famers/index.html.erb b/app/views/hall_of_famers/index.html.erb index a94b611..1849929 100644 --- a/app/views/hall_of_famers/index.html.erb +++ b/app/views/hall_of_famers/index.html.erb @@ -59,7 +59,7 @@
    <%= sortable_header(sort_link("name", "PLAYER")) %> <%= sortable_header(sort_link("xanax_gained", "XANAX GAINED")) %><%= sortable_header(sort_link("energy_drinks_gained", "ENERGY DRINKS")) %><%= sortable_header(sort_link("energy_drinks_gained", "ENERGY DRINKS GAINED")) %> <%= sortable_header(sort_link("networth_gained", "NETWORTH GAINED")) %> <%= sortable_header(sort_link("total_se", "TOTAL SE")) %> <%= sortable_header(sort_link("se_gained", "SE GAINED")) %><%= faction.torn_id %> - <%= faction.users.count %> + <%= faction.users.active.count %><% if faction.users.fallen.any? %> (+<%= faction.users.fallen.count %> fallen)<% end %> <% if faction.users.any? %> - + <% end %> @@ -39,9 +39,9 @@
    <%= sortable_header(sort_link("missions_daily", "CONTRACTS")) %> <%= sortable_header(sort_link("crimes_daily", "CRIMES")) %> <%= sortable_header(sort_link("activity_time_daily", "ACTIVITY")) %>
    min/day
    + +
    Actions
    - - <%= data[:user].name %> - - [<%= data[:user].torn_id %>] - - <% if data[:user].faction %> - <%= data[:user].faction.name %> - <% else %> - - - <% end %> - - - <%= data[:missing_count] %> days - - <%= data[:oldest_missing]&.strftime("%d-%m-%Y") || "-" %><%= data[:latest_snapshot]&.strftime("%d-%m-%Y") || "Never" %> - <%= button_to "Backfill", - backfill_user_admin_snapshot_management_path(data[:user]), - method: :post, - class: "btn-small btn-primary", - data: { confirm: "Schedule #{data[:missing_count]} backfill jobs for #{data[:user].name}?" } %> - - -
    + + <%= data[:user].name %> + + [<%= data[:user].torn_id %>] + + <% if data[:user].faction %> + <%= data[:user].faction.name %> + <% else %> + - + <% end %> + + + <%= data[:missing_count] %> days + + <%= data[:oldest_missing]&.strftime("%d-%m-%Y") || "-" %><%= data[:latest_snapshot]&.strftime("%d-%m-%Y") || "Never" %> + <%= button_to "Backfill", + backfill_user_admin_snapshot_management_path(data[:user]), + method: :post, + class: "btn-small btn-primary", + data: { action: "click->backfill-row#remove" } %> + + +
    <% else %> diff --git a/db/migrate/20260219100001_add_backfill_ends_at_to_users.rb b/db/migrate/20260219100001_add_backfill_ends_at_to_users.rb new file mode 100644 index 0000000..54a0421 --- /dev/null +++ b/db/migrate/20260219100001_add_backfill_ends_at_to_users.rb @@ -0,0 +1,5 @@ +class AddBackfillEndsAtToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :backfill_ends_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 1a43bd5..65be393 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_02_19_100000) do +ActiveRecord::Schema[8.1].define(version: 2026_02_19_100001) do create_table "api_calls", force: :cascade do |t| t.string "api_key", null: false t.datetime "created_at", null: false @@ -123,6 +123,7 @@ create_table "users", force: :cascade do |t| t.string "api_access_type" t.string "api_key" + t.datetime "backfill_ends_at" t.datetime "created_at", null: false t.integer "faction_id" t.boolean "fallen", default: false, null: false From 0d5888910a2f5340fa6a8c5d6df2c56179b0c0ef Mon Sep 17 00:00:00 2001 From: Bram Date: Wed, 18 Feb 2026 23:53:32 +0100 Subject: [PATCH 057/266] ranked wars initial attempt --- app/assets/stylesheets/components/tables.css | 48 ++ app/assets/stylesheets/pages/admin.css | 38 + app/assets/stylesheets/pages/ranked_war.css | 733 ++++++++++++++++++ .../factions/ranked_wars_controller.rb | 182 +++++ .../training_controller.rb} | 79 +- app/controllers/factions_controller.rb | 32 + app/controllers/ranked_war_controller.rb | 4 - app/helpers/faction_helper.rb | 2 +- .../refresh_countdown_controller.js | 9 +- .../controllers/skeleton_loader_controller.js | 12 + app/jobs/backfill_user_stats_job.rb | 13 +- app/models/faction.rb | 7 + app/models/ranked_war.rb | 101 +++ .../torn_api/faction/ranked_war_report.rb | 21 + app/models/torn_api/faction/ranked_wars.rb | 45 ++ .../_member_performance_skeleton.html.erb | 37 + .../_member_performance_table.html.erb | 45 ++ .../_war_history_skeleton.html.erb | 68 ++ .../ranked_wars/_war_history_table.html.erb | 120 +++ app/views/factions/ranked_wars/index.html.erb | 89 +++ app/views/factions/ranked_wars/show.html.erb | 266 +++++++ app/views/factions/show.html.erb | 43 + .../training/show.html.erb} | 75 +- app/views/layouts/_navbar.html.erb | 2 +- app/views/ranked_war/index.html.erb | 3 - config/routes.rb | 12 +- .../20260219100002_create_ranked_wars.rb | 32 + ...100003_add_ranked_wars_sync_to_factions.rb | 5 + ...4_remove_ranked_wars_sync_from_factions.rb | 5 + db/schema.rb | 32 +- 30 files changed, 2045 insertions(+), 115 deletions(-) create mode 100644 app/assets/stylesheets/pages/ranked_war.css create mode 100644 app/controllers/factions/ranked_wars_controller.rb rename app/controllers/{faction_controller.rb => factions/training_controller.rb} (76%) create mode 100644 app/controllers/factions_controller.rb delete mode 100644 app/controllers/ranked_war_controller.rb create mode 100644 app/javascript/controllers/skeleton_loader_controller.js create mode 100644 app/models/ranked_war.rb create mode 100644 app/models/torn_api/faction/ranked_war_report.rb create mode 100644 app/models/torn_api/faction/ranked_wars.rb create mode 100644 app/views/factions/ranked_wars/_member_performance_skeleton.html.erb create mode 100644 app/views/factions/ranked_wars/_member_performance_table.html.erb create mode 100644 app/views/factions/ranked_wars/_war_history_skeleton.html.erb create mode 100644 app/views/factions/ranked_wars/_war_history_table.html.erb create mode 100644 app/views/factions/ranked_wars/index.html.erb create mode 100644 app/views/factions/ranked_wars/show.html.erb create mode 100644 app/views/factions/show.html.erb rename app/views/{faction/index.html.erb => factions/training/show.html.erb} (74%) delete mode 100644 app/views/ranked_war/index.html.erb create mode 100644 db/migrate/20260219100002_create_ranked_wars.rb create mode 100644 db/migrate/20260219100003_add_ranked_wars_sync_to_factions.rb create mode 100644 db/migrate/20260219100004_remove_ranked_wars_sync_from_factions.rb diff --git a/app/assets/stylesheets/components/tables.css b/app/assets/stylesheets/components/tables.css index acb6fd1..a708bbe 100644 --- a/app/assets/stylesheets/components/tables.css +++ b/app/assets/stylesheets/components/tables.css @@ -303,3 +303,51 @@ tbody tr:hover { font-family: "Geist Mono", monospace; color: var(--gray-500); } + +/* Skeleton Loading */ +@keyframes skeleton-pulse { + 0%, 100% { + opacity: 0.4; + } + 50% { + opacity: 0.7; + } +} + +.skeleton { + background: var(--gray-800); + border-radius: var(--radius-sm); + animation: skeleton-pulse 1.5s ease-in-out infinite; +} + +.skeleton-text { + height: 1rem; + width: 100%; +} + +.skeleton-text-sm { + height: 0.75rem; + width: 60%; +} + +.skeleton-badge { + height: 1.25rem; + width: 3rem; + border-radius: var(--radius-sm); +} + +.skeleton-row td { + padding: var(--space-3); +} + +.skeleton-cell { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.skeleton-cell-inline { + display: flex; + align-items: center; + gap: var(--space-2); +} diff --git a/app/assets/stylesheets/pages/admin.css b/app/assets/stylesheets/pages/admin.css index a55370d..8aae986 100644 --- a/app/assets/stylesheets/pages/admin.css +++ b/app/assets/stylesheets/pages/admin.css @@ -96,6 +96,44 @@ color: var(--gray-300); } +/* Disabled Admin Card */ +.admin-card-disabled { + cursor: not-allowed; + opacity: 0.5; + position: relative; +} + +.admin-card-disabled:hover { + border-color: var(--gray-800); + background: var(--gray-900); + transform: none; + box-shadow: none; +} + +.admin-card-disabled:hover .admin-card-icon { + background: rgba(255, 255, 255, 0.05); + color: var(--gray-400); +} + +.admin-card-disabled:hover p { + color: var(--gray-400); +} + +.coming-soon-badge { + position: absolute; + top: var(--space-4); + right: var(--space-4); + background: var(--gray-800); + color: var(--gray-400); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + border: 1px solid var(--gray-700); +} + /* Section Header */ .section-header { display: flex; diff --git a/app/assets/stylesheets/pages/ranked_war.css b/app/assets/stylesheets/pages/ranked_war.css new file mode 100644 index 0000000..19c4bdf --- /dev/null +++ b/app/assets/stylesheets/pages/ranked_war.css @@ -0,0 +1,733 @@ +/* + * Ranked Wars page specific styles + */ + +/* Page Header with Actions */ +.page-header-content { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); +} + +.page-header-actions { + display: flex; + gap: var(--space-2); +} + +/* Ongoing War Banner */ +.ongoing-war-banner { + background: linear-gradient(135deg, rgba(239, 68, 68, 0.15) 0%, rgba(220, 38, 38, 0.15) 100%); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: var(--radius-lg); + padding: var(--space-5); + margin-bottom: var(--space-6); + animation: pulse-border 2s infinite; +} + +@keyframes pulse-border { + 0%, 100% { border-color: rgba(239, 68, 68, 0.3); } + 50% { border-color: rgba(239, 68, 68, 0.6); } +} + +.ongoing-war-content { + display: flex; + align-items: center; + gap: var(--space-4); +} + +.ongoing-war-icon { + flex-shrink: 0; + color: rgb(239, 68, 68); + animation: pulse-icon 1.5s infinite; +} + +@keyframes pulse-icon { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.ongoing-war-text { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--space-1); + color: var(--gray-300); + font-size: 0.875rem; +} + +.ongoing-war-text strong { + color: var(--gray-100); + font-size: 1rem; +} + +.ongoing-war-action { + flex-shrink: 0; +} + +/* Scheduled War Banner */ +.scheduled-war-banner { + background: linear-gradient(135deg, rgba(234, 179, 8, 0.15) 0%, rgba(202, 138, 4, 0.15) 100%); + border: 1px solid rgba(234, 179, 8, 0.3); + border-radius: var(--radius-lg); + padding: var(--space-5); + margin-bottom: var(--space-6); +} + +.scheduled-war-content { + display: flex; + align-items: center; + gap: var(--space-4); +} + +.scheduled-war-icon { + flex-shrink: 0; + color: rgb(234, 179, 8); +} + +.scheduled-war-text { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--space-1); + color: var(--gray-300); + font-size: 0.875rem; +} + +.scheduled-war-text strong { + color: var(--gray-100); + font-size: 1rem; +} + +.scheduled-war-action { + flex-shrink: 0; +} + +.score-winning { + color: rgb(34, 197, 94); +} + +.score-losing { + color: rgb(239, 68, 68); +} + +/* War Summary Stats */ +.war-summary { + display: flex; + justify-content: center; + gap: var(--space-4); + margin-bottom: var(--space-6); +} + +.war-summary .stat-box { + min-width: 200px; +} + +.war-summary .wins-count { + color: rgb(34, 197, 94); +} + +.war-summary .losses-count { + color: rgb(239, 68, 68); +} + +@media (max-width: 768px) { + .war-summary { + flex-direction: column; + align-items: center; + } +} + +/* War Status Badges */ +.war-status { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.war-status.ongoing { + background: rgba(239, 68, 68, 0.2); + color: rgb(239, 68, 68); + border: 1px solid rgba(239, 68, 68, 0.4); + animation: pulse-live 1.5s infinite; +} + +@keyframes pulse-live { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +.war-status.scheduled { + background: rgba(234, 179, 8, 0.2); + color: rgb(234, 179, 8); + border: 1px solid rgba(234, 179, 8, 0.4); +} + +.war-status.won { + background: rgba(34, 197, 94, 0.2); + color: rgb(34, 197, 94); + border: 1px solid rgba(34, 197, 94, 0.4); +} + +.war-status.lost { + background: rgba(239, 68, 68, 0.2); + color: rgb(239, 68, 68); + border: 1px solid rgba(239, 68, 68, 0.4); +} + +/* Countdown cell */ +.countdown-cell { + color: rgb(234, 179, 8); + font-size: 0.8125rem; +} + +/* War Table Cells */ +.status-cell { + width: 60px; +} + +.opponent-cell { + display: flex; + flex-direction: column; + gap: 2px; +} + +.opponent-name { + font-weight: 500; + color: var(--gray-100); +} + +.opponent-id { + font-size: 0.75rem; +} + +.score-cell { + display: flex; + align-items: baseline; + gap: var(--space-2); +} + +.score-value { + font-weight: 600; + color: var(--gray-100); + font-family: "Geist Mono", monospace; +} + +.score-target { + font-size: 0.75rem; +} + +.attacks-cell { + display: flex; + align-items: center; + gap: var(--space-2); + font-family: "Geist Mono", monospace; +} + +.avg-respect-cell { + display: flex; + align-items: center; + gap: var(--space-2); + font-family: "Geist Mono", monospace; +} + +.avg-better { + color: rgb(34, 197, 94); + font-weight: 500; +} + +.avg-worse { + color: rgb(239, 68, 68); + font-weight: 500; +} + +.rank-cell { + display: flex; + align-items: center; + gap: var(--space-2); + font-family: "Geist Mono", monospace; +} + +.rank-arrow { + font-weight: 600; +} + +.rank-arrow.rank-up { + color: rgb(34, 197, 94); +} + +.rank-arrow.rank-down { + color: rgb(239, 68, 68); +} + +.rewards-cell { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 0.75rem; +} + +.reward-item { + color: rgb(34, 197, 94); +} + +.date-cell { + display: flex; + flex-direction: column; + gap: 2px; +} + +.view-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + color: var(--gray-500); + border: 1px solid var(--gray-800); + border-radius: var(--radius-sm); + transition: all 0.15s ease; +} + +.view-link:hover { + color: var(--gray-200); + border-color: var(--gray-600); + background: var(--gray-800); +} + +/* Row states */ +tr.row-ongoing { + background: rgba(239, 68, 68, 0.05); +} + +tr.row-ongoing:hover { + background: rgba(239, 68, 68, 0.08); +} + +tr.row-inactive { + opacity: 0.5; +} + +/* Scrollable table wrapper */ +.table-scroll-wrapper { + max-height: 600px; + overflow-y: auto; +} + +.table-scroll-wrapper table { + border-collapse: separate; + border-spacing: 0; +} + +.table-scroll-wrapper thead { + position: sticky; + top: 0; + z-index: 1; + background: var(--gray-900); +} + +.table-scroll-wrapper thead th { + background: var(--gray-900); + border-bottom: 1px solid var(--gray-800); +} + +/* Rank position column */ +.rank-position { + width: 40px; + text-align: center; + color: var(--gray-500); + font-weight: 500; + font-family: "Geist Mono", monospace; +} + +/* Score highlight */ +.score-highlight { + color: var(--gray-100); + font-weight: 600; + font-family: "Geist Mono", monospace; +} + +/* War Detail Page - Overview */ +.war-overview { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + padding: var(--space-8); + margin-bottom: var(--space-6); +} + +.war-overview-main { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: var(--space-8); + align-items: center; +} + +.war-faction { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.our-faction { + text-align: left; +} + +.their-faction { + text-align: right; +} + +.faction-label { + font-size: 0.75rem; + color: var(--gray-500); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.faction-name { + font-size: 1.25rem; + font-weight: 600; + color: var(--gray-100); +} + +.faction-score { + font-size: 3rem; + font-weight: 700; + font-family: "Geist Mono", monospace; +} + +.faction-score.winning { + color: rgb(34, 197, 94); +} + +.faction-score.losing { + color: rgb(239, 68, 68); +} + +.war-vs { + text-align: center; +} + +.target-score { + font-size: 1rem; + color: var(--gray-400); + margin-bottom: var(--space-2); +} + +.war-duration { + font-size: 0.875rem; + color: var(--gray-500); +} + +/* War Stats Grid */ +.war-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--space-4); + margin-bottom: var(--space-6); +} + +/* War Stats Comparison */ +.war-stats-comparison { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + padding: var(--space-6); + margin-bottom: var(--space-6); +} + +.stats-comparison-table { + max-width: 400px; + margin: 0 auto var(--space-6); +} + +.stats-row { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: var(--space-4); + padding: var(--space-3) 0; + border-bottom: 1px solid var(--gray-800); +} + +.stats-row:last-child { + border-bottom: none; +} + +.stats-row.stats-header { + border-bottom: 1px solid var(--gray-700); + padding-bottom: var(--space-2); + margin-bottom: var(--space-2); +} + +.stats-cell { + font-family: "Geist Mono", monospace; + font-size: 1rem; +} + +.stats-cell.ours { + text-align: right; + color: var(--gray-300); +} + +.stats-cell.theirs { + text-align: left; + color: var(--gray-300); +} + +.stats-cell.label { + text-align: center; + color: var(--gray-500); + font-size: 0.8125rem; + font-family: inherit; + min-width: 100px; +} + +.stats-cell.better { + color: rgb(34, 197, 94); + font-weight: 600; +} + +.stats-header .stats-cell { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--gray-500); +} + +.stats-header .stats-cell.ours { + color: var(--gray-400); +} + +.stats-header .stats-cell.theirs { + color: var(--gray-400); +} + +.rank-display { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); +} + +.rank-value { + color: var(--gray-300); +} + +/* War Rewards Inline */ +.war-rewards-inline { + display: flex; + justify-content: center; + gap: var(--space-4); + padding-top: var(--space-4); + border-top: 1px solid var(--gray-800); +} + +.reward-badge { + display: flex; + align-items: baseline; + gap: var(--space-2); + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.3); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-4); +} + +.reward-badge .reward-value { + color: rgb(34, 197, 94); + font-weight: 600; + font-size: 1.125rem; + font-family: "Geist Mono", monospace; +} + +.reward-badge .reward-label { + color: rgb(34, 197, 94); + font-size: 0.8125rem; + opacity: 0.8; +} + +/* War Members Grid */ +.war-members-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-6); + margin-bottom: var(--space-6); +} + +@media (max-width: 1024px) { + .war-members-grid { + grid-template-columns: 1fr; + } +} + +/* Non-participants section */ +.non-participants { + padding: var(--space-4) var(--space-6); + border-top: 1px solid var(--gray-800); +} + +.non-participants summary { + color: var(--gray-500); + font-size: 0.8125rem; + cursor: pointer; + user-select: none; +} + +.non-participants summary:hover { + color: var(--gray-300); +} + +.non-participants-list { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + padding-top: var(--space-3); +} + +.non-participant { + font-size: 0.8125rem; +} + +.non-participant::after { + content: ","; + color: var(--gray-600); +} + +.non-participant:last-child::after { + content: ""; +} + +/* War Rewards Section */ +.war-rewards-section { + margin-bottom: var(--space-6); +} + +.war-rewards-section h2 { + font-size: 1.25rem; + color: var(--gray-100); + margin-bottom: var(--space-4); +} + +.rewards-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-4); +} + +@media (max-width: 768px) { + .rewards-grid { + grid-template-columns: 1fr; + } +} + +.rewards-card { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + padding: var(--space-6); +} + +.rewards-card h3 { + font-size: 0.875rem; + color: var(--gray-400); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--space-4); +} + +.rewards-list { + list-style: none; + padding: 0; + margin: 0; +} + +.rewards-list li { + display: flex; + justify-content: space-between; + padding: var(--space-2) 0; + border-bottom: 1px solid var(--gray-800); +} + +.rewards-list li:last-child { + border-bottom: none; +} + +.reward-key { + color: var(--gray-400); +} + +.reward-value { + color: var(--gray-100); + font-weight: 500; + font-family: "Geist Mono", monospace; +} + +/* War Not Ended State */ +.war-not-ended { + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; + padding: var(--space-8); +} + +.war-not-ended-content { + text-align: center; + max-width: 500px; +} + +.war-not-ended-icon { + color: var(--gray-500); + margin-bottom: var(--space-6); +} + +.war-not-ended h2 { + color: var(--gray-100); + font-size: 1.5rem; + margin-bottom: var(--space-4); +} + +.war-not-ended p { + color: var(--gray-400); + font-size: 1rem; + line-height: 1.6; + margin-bottom: var(--space-2); +} + +.war-score-preview { + margin-top: var(--space-4); + padding: var(--space-4); + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + font-size: 1.125rem; +} + +/* Responsive */ +@media (max-width: 768px) { + .page-header-content { + flex-direction: column; + } + + .war-overview-main { + grid-template-columns: 1fr; + text-align: center; + } + + .our-faction, + .their-faction { + text-align: center; + } + + .war-vs { + padding: var(--space-4) 0; + border-top: 1px solid var(--gray-800); + border-bottom: 1px solid var(--gray-800); + } +} diff --git a/app/controllers/factions/ranked_wars_controller.rb b/app/controllers/factions/ranked_wars_controller.rb new file mode 100644 index 0000000..85faee0 --- /dev/null +++ b/app/controllers/factions/ranked_wars_controller.rb @@ -0,0 +1,182 @@ +class Factions::RankedWarsController < ApplicationController + SYNC_COOLDOWN = 1.minute + + before_action :set_faction + + def index + return if @tracking_disabled + + # Sync cooldown + @can_sync = can_sync? + @seconds_until_sync = seconds_until_sync + + # Fetch ranked wars + @wars = @faction.ranked_wars.recent.includes(:faction) + + # Summary stats (only completed wars) + completed_wars = @wars.completed + @wins = completed_wars.won.count + @losses = completed_wars.lost.count + + # Ongoing war (in progress) + @ongoing_war = @wars.ongoing.select(&:in_progress?).first + + # Calculate member performance across all wars + @member_performance = calculate_member_performance(completed_wars) + end + + def show + @war = @faction.ranked_wars.find_by(torn_war_id: params[:id]) + + unless @war + redirect_to faction_ranked_wars_path(@faction), alert: "War not found." + nil + end + end + + def sync + unless can_sync? + redirect_to faction_ranked_wars_path(@faction), alert: "Please wait #{seconds_until_sync} seconds before syncing again." + return + end + + api_key = OwnerCredentials.api_key + if api_key.blank? + redirect_to faction_ranked_wars_path(@faction), alert: "No API key configured." + return + end + + # Fetch the last 10 ranked wars synchronously + wars = TornApi::Faction::RankedWars.new(api_key, @faction.torn_id).fetch(limit: 10) + wars_needing_reports = [] + + wars.each do |war_data| + our_faction_data = war_data["factions"].find { |f| f["id"] == @faction.torn_id } + their_faction_data = war_data["factions"].find { |f| f["id"] != @faction.torn_id } + + next unless our_faction_data && their_faction_data + + ranked_war = @faction.ranked_wars.find_or_initialize_by(torn_war_id: war_data["id"]) + + ranked_war.assign_attributes( + opponent_faction_id: their_faction_data["id"], + opponent_faction_name: their_faction_data["name"], + started_at: Time.at(war_data["start"]), + ended_at: war_data["end"].to_i > 0 ? Time.at(war_data["end"]) : nil, + target_score: war_data["target"], + our_score: our_faction_data["score"], + their_score: their_faction_data["score"], + winner_faction_id: war_data["winner"] + ) + + # Track wars that need detailed reports + if ranked_war.completed? && ranked_war.our_members.empty? + wars_needing_reports << war_data["id"] + end + + ranked_war.save! + end + + # Fetch detailed reports synchronously with 1 second delay between requests + wars_needing_reports.each do |torn_war_id| + sleep 1 + fetch_war_report(api_key, torn_war_id) + end + + # Record sync time for cooldown + session[:last_ranked_wars_sync_at] = Time.current.to_i + + redirect_to faction_ranked_wars_path(@faction), notice: "Synced #{wars.size} ranked wars." + end + + private + + def set_faction + @faction = Faction.find_by!(torn_id: params[:faction_torn_id]) + + # Authorization: users can only view their own faction, admins can view any + unless Current.user.admin? || Current.user.faction == @faction + redirect_to root_path, alert: "You don't have access to this faction." + return + end + + unless @faction.track_stats + @tracking_disabled = true + end + end + + def can_sync? + return true unless session[:last_ranked_wars_sync_at] + Time.current - Time.at(session[:last_ranked_wars_sync_at]) >= SYNC_COOLDOWN + end + + def seconds_until_sync + return 0 unless session[:last_ranked_wars_sync_at] + remaining = SYNC_COOLDOWN - (Time.current - Time.at(session[:last_ranked_wars_sync_at])) + [ remaining.to_i, 0 ].max + end + + def fetch_war_report(api_key, torn_war_id) + ranked_war = @faction.ranked_wars.find_by(torn_war_id: torn_war_id) + return unless ranked_war + + report = TornApi::Faction::RankedWarReport.new(api_key, torn_war_id).fetch + return unless report + + our_faction_data = report["factions"].find { |f| f["id"] == @faction.torn_id } + their_faction_data = report["factions"].find { |f| f["id"] != @faction.torn_id } + return unless our_faction_data && their_faction_data + + ranked_war.update!( + forfeit: report["forfeit"] || false, + our_attacks: our_faction_data["attacks"] || 0, + their_attacks: their_faction_data["attacks"] || 0, + rank_before: our_faction_data.dig("rank", "before"), + rank_after: our_faction_data.dig("rank", "after"), + respect_gained: our_faction_data.dig("rewards", "respect") || 0, + points_gained: our_faction_data.dig("rewards", "points") || 0, + our_members: our_faction_data["members"] || [], + their_members: their_faction_data["members"] || [], + our_rewards: our_faction_data["rewards"] || {}, + their_rewards: their_faction_data["rewards"] || {} + ) + end + + def calculate_member_performance(wars) + return {} if wars.empty? + + performance = {} + + wars.each do |war| + next unless war.our_members.present? + + war.our_members.each do |member| + torn_id = member["id"].to_s + name = member["name"] + + performance[torn_id] ||= { + name: name, + torn_id: torn_id, + wars_participated: 0, + total_attacks: 0, + total_score: 0.0 + } + + attacks = member["attacks"].to_i + if attacks > 0 + performance[torn_id][:wars_participated] += 1 + performance[torn_id][:total_attacks] += attacks + performance[torn_id][:total_score] += member["score"].to_f + end + end + end + + # Calculate averages and sort by total score + performance.values.map do |p| + p[:avg_attacks] = p[:wars_participated] > 0 ? (p[:total_attacks].to_f / p[:wars_participated]).round(1) : 0 + p[:avg_score] = p[:wars_participated] > 0 ? (p[:total_score] / p[:wars_participated]).round(1) : 0 + p[:avg_respect_per_hit] = p[:total_attacks] > 0 ? (p[:total_score] / p[:total_attacks]).round(2) : 0 + p + end.sort_by { |p| -p[:total_score] } + end +end diff --git a/app/controllers/faction_controller.rb b/app/controllers/factions/training_controller.rb similarity index 76% rename from app/controllers/faction_controller.rb rename to app/controllers/factions/training_controller.rb index 77e4a81..8127a5b 100644 --- a/app/controllers/faction_controller.rb +++ b/app/controllers/factions/training_controller.rb @@ -1,39 +1,12 @@ -class FactionController < ApplicationController +class Factions::TrainingController < ApplicationController include FactionHelper - SORTABLE_COLUMNS = %w[name xanax_daily energy_refills_daily nerve_refills_daily missions_daily crimes_daily activity_time_daily compliance_score].freeze + before_action :set_faction - def index - # Allow admins to view any faction via faction_id parameter - if Current.user.admin? - if params[:faction_id].present? - @faction = Faction.find_by(id: params[:faction_id]) - unless @faction - redirect_to admin_dashboard_path, alert: "Faction not found." - return - end - else - # Admin with no faction_id - pick first tracked faction - @faction = Faction.where(track_stats: true).order(:name).first - unless @faction - redirect_to admin_dashboard_path, alert: "No factions with tracking enabled." - return - end - end - else - # Regular users view their own faction - unless Current.user.faction - @no_faction = true - return - end - @faction = Current.user.faction - end + SORTABLE_COLUMNS = %w[name xanax_daily energy_refills_daily nerve_refills_daily missions_daily crimes_daily activity_time_daily compliance_score].freeze - # Check if faction has tracking enabled - unless @faction.track_stats - @tracking_disabled = true - return - end + def show + return if @tracking_disabled # Use tracking constants for date bounds @earliest_date = PersonalStatSnapshot.tracking_start_date @@ -47,15 +20,11 @@ def index @total_days_tracked = (@end_date - @start_date).to_i + 1 # Build member stats rows - # Convert date range to timestamp range for querying - start_timestamp = @start_date.beginning_of_day.to_i - end_timestamp = @end_date.end_of_day.to_i - @member_rows = @faction.users.active.includes(:personal_stat_snapshots).filter_map do |user| # Get all snapshots in date range all_snapshots = user.personal_stat_snapshots - .where(timestamp: start_timestamp..end_timestamp) - .order(:timestamp) + .where(date: @start_date..@end_date) + .order(:date) # Helper to calculate stat for a specific field calculate_stat = lambda do |field| @@ -65,9 +34,13 @@ def index first = snapshots.first last = snapshots.last gained = (last[field] || 0) - (first[field] || 0) - daily = @total_days_tracked > 0 ? (gained.to_f / @total_days_tracked).round(2) : 0.0 - { gained: gained, daily: daily, days: @total_days_tracked } + # Calculate actual days between first and last snapshot for this stat + actual_days = (last.date - first.date).to_i + 1 + + daily = actual_days > 0 ? (gained.to_f / actual_days).round(2) : 0.0 + + { gained: gained, daily: daily, days: actual_days } end # Calculate each stat independently @@ -81,6 +54,12 @@ def index # Skip if no valid stats for any critical field next if xanax_stats[:days].zero? && energy_stats[:days].zero? && nerve_stats[:days].zero? + # Calculate overall days tracked for this user (max across all stats) + days_tracked = [ + xanax_stats[:days], energy_stats[:days], nerve_stats[:days], + missions_stats[:days], crimes_stats[:days], activity_stats[:days] + ].max + xanax_daily = xanax_stats[:daily] energy_refills_daily = energy_stats[:daily] nerve_refills_daily = nerve_stats[:daily] @@ -122,7 +101,9 @@ def index crimes_daily: crimes_daily, activity_time_gained: activity_stats[:gained], - activity_time_daily: activity_time_daily + activity_time_daily: activity_time_daily, + + days_tracked: days_tracked } end @@ -141,13 +122,27 @@ def index def member # Placeholder for Phase 3 - individual member detail page - redirect_to faction_index_path, alert: "Member detail page coming soon!" + redirect_to faction_training_path(@faction), alert: "Member detail page coming soon!" end helper_method :sort_link private + def set_faction + @faction = Faction.find_by!(torn_id: params[:faction_torn_id]) + + # Authorization: users can only view their own faction, admins can view any + unless Current.user.admin? || Current.user.faction == @faction + redirect_to root_path, alert: "You don't have access to this faction." + return + end + + unless @faction.track_stats + @tracking_disabled = true + end + end + def sort_link(column, label) direction = (@sort_column == column && @sort_direction == "asc") ? "desc" : "asc" { column: column, label: label, direction: direction, current: @sort_column == column, current_direction: @sort_direction } diff --git a/app/controllers/factions_controller.rb b/app/controllers/factions_controller.rb new file mode 100644 index 0000000..8960d1f --- /dev/null +++ b/app/controllers/factions_controller.rb @@ -0,0 +1,32 @@ +class FactionsController < ApplicationController + before_action :set_faction, only: [ :show ] + + def index + # Redirect to current user's faction + if Current.user.faction.present? + redirect_to faction_path(Current.user.faction) + else + redirect_to root_path, alert: "You are not a member of any faction." + end + end + + def show + # Faction dashboard with cards linking to sub-pages + end + + private + + def set_faction + @faction = Faction.find_by!(torn_id: params[:torn_id]) + + # Authorization: users can only view their own faction, admins can view any + unless Current.user.admin? || Current.user.faction == @faction + redirect_to root_path, alert: "You don't have access to this faction." + return + end + + unless @faction.track_stats + @tracking_disabled = true + end + end +end diff --git a/app/controllers/ranked_war_controller.rb b/app/controllers/ranked_war_controller.rb deleted file mode 100644 index 51143a9..0000000 --- a/app/controllers/ranked_war_controller.rb +++ /dev/null @@ -1,4 +0,0 @@ -class RankedWarController < ApplicationController - def index - end -end diff --git a/app/helpers/faction_helper.rb b/app/helpers/faction_helper.rb index f7a9b93..b223766 100644 --- a/app/helpers/faction_helper.rb +++ b/app/helpers/faction_helper.rb @@ -67,7 +67,7 @@ def row_compliance_class(level) # Generate clipboard text for member stats def member_stats_clipboard_text(row, days, faction) lines = [] - lines << "Over the past #{days} days you have:" + lines << "Over the past #{days} days #{row[:name]} has:" lines << "- Used #{number_with_delimiter(row[:xanax_gained])} xanax (#{row[:xanax_daily]}/day, target: #{faction.xanax_target}/day)" lines << "- Used #{number_with_delimiter(row[:energy_refills_gained])} energy refills (#{row[:energy_refills_daily]}/day, target: #{faction.energy_refill_target}/day)" lines << "- Used #{number_with_delimiter(row[:nerve_refills_gained])} nerve refills (#{row[:nerve_refills_daily]}/day, target: #{faction.nerve_refill_target}/day)" diff --git a/app/javascript/controllers/refresh_countdown_controller.js b/app/javascript/controllers/refresh_countdown_controller.js index b1f4be8..445afb0 100644 --- a/app/javascript/controllers/refresh_countdown_controller.js +++ b/app/javascript/controllers/refresh_countdown_controller.js @@ -1,7 +1,10 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { - static values = { secondsRemaining: Number } + static values = { + secondsRemaining: Number, + buttonText: { type: String, default: "Check for New Payments" } + } connect() { if (this.hasSecondsRemainingValue && this.secondsRemainingValue > 0) { @@ -35,11 +38,11 @@ export default class extends Controller { } updateDisplay() { - this.element.value = `Available in ${this.secondsRemainingValue} seconds` + this.element.value = `Available in ${this.secondsRemainingValue}s` } enableButton() { this.element.disabled = false - this.element.value = "Check for New Payments" + this.element.value = this.buttonTextValue } } diff --git a/app/javascript/controllers/skeleton_loader_controller.js b/app/javascript/controllers/skeleton_loader_controller.js new file mode 100644 index 0000000..dd31fe6 --- /dev/null +++ b/app/javascript/controllers/skeleton_loader_controller.js @@ -0,0 +1,12 @@ +import { Controller } from "@hotwired/stimulus" + +// Shows skeleton loading state on form submission +export default class extends Controller { + static targets = ["content", "skeleton"] + + submit() { + // Hide real content, show skeletons + this.contentTargets.forEach(el => el.style.display = "none") + this.skeletonTargets.forEach(el => el.style.display = "block") + } +} diff --git a/app/jobs/backfill_user_stats_job.rb b/app/jobs/backfill_user_stats_job.rb index ec9fb77..bfcc258 100644 --- a/app/jobs/backfill_user_stats_job.rb +++ b/app/jobs/backfill_user_stats_job.rb @@ -10,16 +10,15 @@ def perform(user_id, start_date, end_date) jobs_scheduled = 0 - # Get existing snapshots and index by date (derived from timestamp) - existing_snapshots = user.personal_stat_snapshots - .where(timestamp: dates.first.to_time.to_i..dates.last.end_of_day.to_i) - .pluck(:timestamp) - .map { |ts| Time.at(ts).utc.to_date } - .to_set + # Get existing snapshots by date + existing_dates = user.personal_stat_snapshots + .where(date: dates.first..dates.last) + .pluck(:date) + .to_set dates.each do |date| # Skip if we already have a snapshot for this date - next if existing_snapshots.include?(date) + next if existing_dates.include?(date) # Jobs go to torn_api queue which handles rate limiting (~1 req/sec) BackfillSingleStatJob.perform_later(user.id, date.to_s) diff --git a/app/models/faction.rb b/app/models/faction.rb index c474b17..1eb374d 100644 --- a/app/models/faction.rb +++ b/app/models/faction.rb @@ -1,6 +1,7 @@ class Faction < ApplicationRecord has_many :users, dependent: :nullify has_many :faction_subscription_grants, dependent: :nullify + has_many :ranked_wars, dependent: :destroy validates :torn_id, presence: true, uniqueness: true validates :name, presence: true @@ -10,10 +11,16 @@ class Faction < ApplicationRecord scope :tracked, -> { where(track_stats: true) } + # Use torn_id in URLs instead of database id + def to_param + torn_id.to_s + end + def member_count users.count end + # Backfill status def backfill_in_progress? backfill_ends_at.present? && backfill_ends_at > Time.current end diff --git a/app/models/ranked_war.rb b/app/models/ranked_war.rb new file mode 100644 index 0000000..33dd09b --- /dev/null +++ b/app/models/ranked_war.rb @@ -0,0 +1,101 @@ +class RankedWar < ApplicationRecord + belongs_to :faction + + scope :completed, -> { where.not(ended_at: nil) } + scope :ongoing, -> { where(ended_at: nil) } + scope :won, -> { completed.joins(:faction).where("winner_faction_id = factions.torn_id") } + scope :lost, -> { completed.joins(:faction).where("winner_faction_id != factions.torn_id AND winner_faction_id IS NOT NULL") } + scope :recent, -> { order(started_at: :desc) } + + # Use torn_war_id in URLs instead of database id + def to_param + torn_war_id.to_s + end + + def ongoing? + ended_at.nil? + end + + def completed? + ended_at.present? + end + + # War hasn't started yet (scheduled for future) + def scheduled? + ongoing? && started_at > Time.current + end + + # War is actively in progress + def in_progress? + ongoing? && started_at <= Time.current + end + + def won? + completed? && winner_faction_id == faction.torn_id + end + + def lost? + completed? && winner_faction_id.present? && winner_faction_id != faction.torn_id + end + + # Seconds until war starts (for scheduled wars) + def starts_in_seconds + return 0 unless scheduled? + (started_at - Time.current).to_i + end + + def duration + return nil if ongoing? + ended_at - started_at + end + + def duration_formatted + return "Ongoing" if ongoing? + + seconds = duration.to_i + hours = seconds / 3600 + minutes = (seconds % 3600) / 60 + + if hours > 0 + "#{hours}h #{minutes}m" + else + "#{minutes}m" + end + end + + def rank_change + return nil unless rank_before.present? && rank_after.present? + "→" + end + + # Member stats helpers + def our_participating_members + our_members.select { |m| m["attacks"].to_i > 0 } + end + + def their_participating_members + their_members.select { |m| m["attacks"].to_i > 0 } + end + + def our_top_performers(limit = 10) + our_members.sort_by { |m| -m["score"].to_f }.first(limit) + end + + def their_top_performers(limit = 10) + their_members.sort_by { |m| -m["score"].to_f }.first(limit) + end + + def our_non_participants + our_members.select { |m| m["attacks"].to_i == 0 } + end + + def score_per_attack + return 0 if our_attacks.to_i.zero? + (our_score.to_f / our_attacks).round(2) + end + + def their_score_per_attack + return 0 if their_attacks.to_i.zero? + (their_score.to_f / their_attacks).round(2) + end +end diff --git a/app/models/torn_api/faction/ranked_war_report.rb b/app/models/torn_api/faction/ranked_war_report.rb new file mode 100644 index 0000000..978c875 --- /dev/null +++ b/app/models/torn_api/faction/ranked_war_report.rb @@ -0,0 +1,21 @@ +module TornApi + module Faction + class RankedWarReport < Base + attr_reader :war_id + + def initialize(api_key, war_id) + super(api_key) + @war_id = war_id + end + + def endpoint + "v2/faction/#{war_id}/rankedwarreport" + end + + def fetch + response = get(endpoint) + response["rankedwarreport"] + end + end + end +end diff --git a/app/models/torn_api/faction/ranked_wars.rb b/app/models/torn_api/faction/ranked_wars.rb new file mode 100644 index 0000000..436e507 --- /dev/null +++ b/app/models/torn_api/faction/ranked_wars.rb @@ -0,0 +1,45 @@ +module TornApi + module Faction + class RankedWars < Base + attr_reader :faction_id + + def initialize(api_key, faction_id = nil) + super(api_key) + @faction_id = faction_id + end + + def endpoint + if faction_id + "v2/faction/#{faction_id}/rankedwars" + else + "v2/faction/rankedwars" + end + end + + def fetch(limit: 20, offset: 0, sort: "DESC") + response = get(endpoint, { limit: limit, offset: offset, sort: sort }) + response["rankedwars"] || [] + end + + # Fetch all wars (paginated) + def fetch_all + all_wars = [] + offset = 0 + limit = 100 + + loop do + wars = fetch(limit: limit, offset: offset) + break if wars.empty? + + all_wars.concat(wars) + offset += limit + + # Safety limit - fetch up to 1000 wars max + break if offset > 1000 + end + + all_wars + end + end + end +end diff --git a/app/views/factions/ranked_wars/_member_performance_skeleton.html.erb b/app/views/factions/ranked_wars/_member_performance_skeleton.html.erb new file mode 100644 index 0000000..5812028 --- /dev/null +++ b/app/views/factions/ranked_wars/_member_performance_skeleton.html.erb @@ -0,0 +1,37 @@ +
    +
    + Member Performance (All Wars) + Syncing... +
    + +
    + + + + + + + + + + + + + + + <% 8.times do |i| %> + + + + + + + + + + + <% end %> + +
    #MemberWars ParticipatedTotal AttacksAvg AttacksTotal ScoreAvg ScoreAvg Respect/Hit
    +
    +
    diff --git a/app/views/factions/ranked_wars/_member_performance_table.html.erb b/app/views/factions/ranked_wars/_member_performance_table.html.erb new file mode 100644 index 0000000..3bba34c --- /dev/null +++ b/app/views/factions/ranked_wars/_member_performance_table.html.erb @@ -0,0 +1,45 @@ +
    +
    + Member Performance (All Wars) +
    + + <% if member_performance.any? %> +
    + + + + + + + + + + + + + + + <% member_performance.each_with_index do |member, index| %> + + + + + + + + + + + <% end %> + +
    #MemberWars ParticipatedTotal AttacksAvg AttacksTotal ScoreAvg ScoreAvg Respect/Hit
    <%= index + 1 %> + <%= link_to member[:name], "https://www.torn.com/profiles.php?XID=#{member[:torn_id]}", target: "_blank", class: "player-link" %> + <%= member[:wars_participated] %><%= number_with_delimiter(member[:total_attacks]) %><%= member[:avg_attacks] %><%= number_with_delimiter(member[:total_score].round(0)) %><%= member[:avg_score] %><%= member[:avg_respect_per_hit] %>
    +
    + <% else %> +
    +
    👤
    +

    No member performance data yet. Sync wars to see member statistics.

    +
    + <% end %> +
    diff --git a/app/views/factions/ranked_wars/_war_history_skeleton.html.erb b/app/views/factions/ranked_wars/_war_history_skeleton.html.erb new file mode 100644 index 0000000..8d01f30 --- /dev/null +++ b/app/views/factions/ranked_wars/_war_history_skeleton.html.erb @@ -0,0 +1,68 @@ +
    +
    + War History + Syncing... +
    + + + + + + + + + + + + + + + + + + <% 5.times do %> + + + + + + + + + + + + + <% end %> + +
    StatusOpponentScoreDurationAttacksAvg RespectRankRewardsDate
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    diff --git a/app/views/factions/ranked_wars/_war_history_table.html.erb b/app/views/factions/ranked_wars/_war_history_table.html.erb new file mode 100644 index 0000000..f28f350 --- /dev/null +++ b/app/views/factions/ranked_wars/_war_history_table.html.erb @@ -0,0 +1,120 @@ +
    +
    + War History +
    + + <% if wars.empty? %> +
    +
    +

    No ranked wars found. Click "Sync Wars" to fetch your faction's war history.

    +
    + <% else %> + + + + + + + + + + + + + + + + + <% wars.each do |war| %> + + + + + + + + + + + + + <% end %> + +
    StatusOpponentScoreDurationAttacksAvg RespectRankRewardsDate
    + <% if war.scheduled? %> + SOON + <% elsif war.in_progress? %> + LIVE + <% elsif war.won? %> + WIN + <% else %> + LOSS + <% end %> + +
    + <%= war.opponent_faction_name %> + [<%= war.opponent_faction_id %>] +
    +
    +
    + <%= war.our_score %> - <%= war.their_score %> + / <%= war.target_score %> +
    +
    + <% if war.scheduled? %> + Starts in <%= distance_of_time_in_words(war.starts_in_seconds) %> + <% else %> + <%= war.duration_formatted %> + <% end %> + +
    + <%= war.our_attacks %> + vs + <%= war.their_attacks %> +
    +
    +
    + <% if war.our_attacks.to_i > 0 && war.their_attacks.to_i > 0 %> + <%= war.score_per_attack %> + vs + <%= war.their_score_per_attack %> + <% else %> + - + <% end %> +
    +
    + <% if war.rank_before && war.rank_after %> +
    + <%= war.rank_before %> + <%= war.rank_change %> + <%= war.rank_after %> +
    + <% else %> + - + <% end %> +
    +
    + <% if war.respect_gained && war.respect_gained > 0 %> + +<%= number_with_delimiter(war.respect_gained) %> respect + <% end %> + <% if war.points_gained && war.points_gained > 0 %> + +<%= war.points_gained %> points + <% end %> + <% if (war.respect_gained.nil? || war.respect_gained == 0) && (war.points_gained.nil? || war.points_gained == 0) %> + - + <% end %> +
    +
    +
    + <%= war.started_at.strftime("%d %b %Y") %> + <%= war.started_at.strftime("%H:%M") %> +
    +
    + <%= link_to faction_ranked_war_path(faction, war), class: "view-link" do %> + + + + <% end %> +
    + <% end %> +
    diff --git a/app/views/factions/ranked_wars/index.html.erb b/app/views/factions/ranked_wars/index.html.erb new file mode 100644 index 0000000..0e0dac8 --- /dev/null +++ b/app/views/factions/ranked_wars/index.html.erb @@ -0,0 +1,89 @@ +<% if @tracking_disabled %> +
    +
    +

    Stats Tracking Disabled

    +

    This faction (<%= @faction.name %>) does not have stats tracking enabled.

    +

    Contact an administrator to enable stats tracking for your faction.

    +
    +
    +<% else %> +
    + + + + <% if @ongoing_war %> +
    +
    +
    + + + + +
    +
    + War in Progress vs <%= @ongoing_war.opponent_faction_name %> + + Score: <%= @ongoing_war.our_score %> + - + <%= @ongoing_war.their_score %> + (Target: <%= @ongoing_war.target_score %>) + +
    +
    + <%= link_to "View Details", faction_ranked_war_path(@faction, @ongoing_war), class: "btn btn-primary btn-sm" %> +
    +
    +
    + <% end %> + + +
    +
    + <%= @wins %> / <%= @losses %> + Wins / Losses +
    +
    + + +
    + <%= render "war_history_table", wars: @wars, faction: @faction %> +
    +
    + <%= render "war_history_skeleton" %> +
    + + +
    + <%= render "member_performance_table", member_performance: @member_performance %> +
    +
    + <%= render "member_performance_skeleton" %> +
    +
    +<% end %> diff --git a/app/views/factions/ranked_wars/show.html.erb b/app/views/factions/ranked_wars/show.html.erb new file mode 100644 index 0000000..285a5f5 --- /dev/null +++ b/app/views/factions/ranked_wars/show.html.erb @@ -0,0 +1,266 @@ + + +<% if @war.ongoing? %> + +
    +
    +
    + + + + +
    +

    War Report Not Available

    + <% if @war.scheduled? %> +

    This war has not started yet. It will begin in <%= distance_of_time_in_words(@war.starts_in_seconds) %>.

    + <% else %> +

    The detailed war report will be available once the war has ended.

    +

    + Current Score: + <%= @war.our_score %> + - + <%= @war.their_score %> + (Target: <%= @war.target_score %>) +

    + <% end %> +
    +
    +<% else %> + +
    +
    +
    + Our Faction + <%= @faction.name %> + <%= @war.our_score %> +
    + +
    +
    Target: <%= @war.target_score %>
    +
    <%= @war.duration_formatted %>
    +
    + +
    + Opponent + <%= @war.opponent_faction_name %> + <%= @war.their_score %> +
    +
    +
    + + +
    +
    +
    +
    Us
    +
    +
    Them
    +
    +
    +
    <%= @war.our_attacks %>
    +
    Attacks
    +
    <%= @war.their_attacks %>
    +
    +
    +
    <%= @war.score_per_attack %>
    +
    Avg Score/Hit
    +
    <%= @war.their_score_per_attack %>
    +
    + <% if @war.rank_before && @war.rank_after %> +
    +
    + + <%= @war.rank_before %> + <%= @war.rank_change %> + <%= @war.rank_after %> + +
    +
    Rank
    +
    +
    + <% end %> +
    + + <% if (@war.respect_gained && @war.respect_gained > 0) || (@war.points_gained && @war.points_gained > 0) %> +
    + <% if @war.respect_gained && @war.respect_gained > 0 %> +
    + +<%= number_with_delimiter(@war.respect_gained) %> + respect +
    + <% end %> + <% if @war.points_gained && @war.points_gained > 0 %> +
    + +<%= @war.points_gained %> + points +
    + <% end %> +
    + <% end %> +
    + + +
    + +
    +
    + Our Team (<%= @war.our_participating_members.count %> participants) +
    + + <% if @war.our_members.blank? %> +
    +

    No member data available. Sync war reports to see detailed stats.

    +
    + <% else %> +
    + + + + + + + + + + + + <% @war.our_top_performers(50).each_with_index do |member, index| %> + + + + + + + + <% end %> + +
    #MemberAttacksScoreAvg/Hit
    <%= index + 1 %> + <%= link_to member["name"], "https://www.torn.com/profiles.php?XID=#{member['id']}", target: "_blank", class: "player-link" %> + <%= member["attacks"] || 0 %><%= (member["score"] || 0).round(0) %> + <% attacks = member["attacks"].to_i %> + <% score = member["score"].to_f %> + <%= attacks > 0 ? (score / attacks).round(2) : "-" %> +
    +
    + + <% if @war.our_non_participants.any? %> +
    +
    + Non-participants (<%= @war.our_non_participants.count %>) +
    + <% @war.our_non_participants.each do |member| %> + + <%= link_to member["name"], "https://www.torn.com/profiles.php?XID=#{member['id']}", target: "_blank", class: "player-link muted" %> + + <% end %> +
    +
    +
    + <% end %> + <% end %> +
    + + +
    +
    + Opponent Team (<%= @war.their_participating_members.count %> participants) +
    + + <% if @war.their_members.blank? %> +
    +

    No opponent data available.

    +
    + <% else %> +
    + + + + + + + + + + + + <% @war.their_top_performers(50).each_with_index do |member, index| %> + + + + + + + + <% end %> + +
    #MemberAttacksScoreAvg/Hit
    <%= index + 1 %> + <%= link_to member["name"], "https://www.torn.com/profiles.php?XID=#{member['id']}", target: "_blank", class: "player-link" %> + <%= member["attacks"] || 0 %><%= (member["score"] || 0).round(0) %> + <% attacks = member["attacks"].to_i %> + <% score = member["score"].to_f %> + <%= attacks > 0 ? (score / attacks).round(2) : "-" %> +
    +
    + <% end %> +
    +
    + + + <% if @war.our_rewards.present? || @war.their_rewards.present? %> +
    +

    War Rewards

    +
    + <% if @war.our_rewards.present? %> +
    +

    Our Rewards

    +
      + <% @war.our_rewards.each do |key, value| %> + <% next if value.nil? || value == 0 %> +
    • + <%= key.to_s.humanize %> + <%= value.is_a?(Numeric) ? number_with_delimiter(value) : value %> +
    • + <% end %> +
    +
    + <% end %> + + <% if @war.their_rewards.present? %> +
    +

    Their Rewards

    +
      + <% @war.their_rewards.each do |key, value| %> + <% next if value.nil? || value == 0 %> +
    • + <%= key.to_s.humanize %> + <%= value.is_a?(Numeric) ? number_with_delimiter(value) : value %> +
    • + <% end %> +
    +
    + <% end %> +
    +
    + <% end %> +<% end %> diff --git a/app/views/factions/show.html.erb b/app/views/factions/show.html.erb new file mode 100644 index 0000000..5f3ec23 --- /dev/null +++ b/app/views/factions/show.html.erb @@ -0,0 +1,43 @@ +<% if @tracking_disabled %> +
    +
    +

    Stats Tracking Disabled

    +

    This faction (<%= @faction.name %>) does not have stats tracking enabled.

    +

    Contact an administrator to enable stats tracking for your faction.

    +
    +
    +<% else %> + + +
    + <%= link_to faction_training_path(@faction), class: "admin-card" do %> +
    + + + + + + + +
    +

    Training Compliance

    +

    Track member xanax, energy refills, nerve refills, and daily activity compliance.

    + <% end %> + +
    + Coming Soon +
    + + + + + +
    +

    Ranked Wars

    +

    View war history, track member performance, and monitor ongoing wars.

    +
    +
    +<% end %> diff --git a/app/views/faction/index.html.erb b/app/views/factions/training/show.html.erb similarity index 74% rename from app/views/faction/index.html.erb rename to app/views/factions/training/show.html.erb index d15086b..dffa633 100644 --- a/app/views/faction/index.html.erb +++ b/app/views/factions/training/show.html.erb @@ -1,41 +1,39 @@ -<% if @no_faction %> -
    -
    -

    No Faction

    -

    You need to be in a faction to view faction stats.

    -

    Join a faction in <%= link_to "Torn", "https://www.torn.com", target: "_blank" %> to access this page.

    -
    -
    -<% elsif @tracking_disabled %> +<% if @tracking_disabled %>

    Stats Tracking Disabled

    -

    Your faction (<%= @faction.name %>) does not have stats tracking enabled.

    +

    This faction (<%= @faction.name %>) does not have stats tracking enabled.

    Contact an administrator to enable stats tracking for your faction.

    <% else %> <% backfill_in_progress = @faction.backfill_in_progress? %> <% - def sortable_header(sort_info) + def sortable_header(sort_info, faction) if sort_info[:current] arrow = sort_info[:current_direction] == "asc" ? "▲" : "▼" arrow_html = "#{arrow}" else arrow_html = "" end - # Preserve date filter params and faction_id when sorting + # Preserve date filter params when sorting url_params = { sort: sort_info[:column], direction: sort_info[:direction] } url_params[:start_date] = params[:start_date] if params[:start_date].present? url_params[:end_date] = params[:end_date] if params[:end_date].present? - url_params[:faction_id] = params[:faction_id] if params[:faction_id].present? - link_to "#{sort_info[:label]} #{arrow_html}".html_safe, faction_index_path(url_params), class: "sort-link" + link_to "#{sort_info[:label]} #{arrow_html}".html_safe, faction_training_path(faction, url_params), class: "sort-link" end %> <% if backfill_in_progress %> @@ -57,26 +55,8 @@ <% end %>
    - <% if Current.user.admin? %> -
    - <%= form_with url: faction_index_path, method: :get, class: "table-filter-form" do |form| %> -
    - Switch Faction: - <%= form.select :faction_id, - options_from_collection_for_select(Faction.where(track_stats: true).order(:name), :id, :name, @faction.id), - {}, - { class: "date-input-inline", style: "width: 250px;", onchange: "this.form.submit()" } %> - <%= hidden_field_tag :start_date, params[:start_date] if params[:start_date].present? %> - <%= hidden_field_tag :end_date, params[:end_date] if params[:end_date].present? %> - <%= hidden_field_tag :sort, params[:sort] if params[:sort].present? %> - <%= hidden_field_tag :direction, params[:direction] if params[:direction].present? %> -
    - <% end %> -
    - <% end %> -
    - <%= form_with url: faction_index_path, method: :get, class: "table-filter-form" do |form| %> + <%= form_with url: faction_training_path(@faction), method: :get, class: "table-filter-form" do |form| %>
    Tracking Period: @@ -87,7 +67,7 @@ max: PersonalStatSnapshot.tracking_end_date, class: "date-input-inline" %> - + -> <%= form.date_field :end_date, value: params[:end_date] || @end_date, @@ -100,13 +80,10 @@ <%= hidden_field_tag :sort, params[:sort] %> <%= hidden_field_tag :direction, params[:direction] %> - <% if Current.user.admin? %> - <%= hidden_field_tag :faction_id, params[:faction_id] %> - <% end %> <% if params[:start_date].present? || params[:end_date].present? %> - <%= link_to "Reset", faction_index_path(faction_id: params[:faction_id]), class: "filter-reset-inline" %> + <%= link_to "Reset", faction_training_path(@faction), class: "filter-reset-inline" %> <% end %>
    <% end %> @@ -148,13 +125,13 @@ Status - <%= sortable_header(sort_link("name", "MEMBER")) %> - <%= sortable_header(sort_link("xanax_daily", "XANAX")) %>
    Target: <%= @faction.xanax_target %>/day - <%= sortable_header(sort_link("energy_refills_daily", "ENERGY REFILLS")) %>
    Target: <%= @faction.energy_refill_target %>/day - <%= sortable_header(sort_link("nerve_refills_daily", "NERVE REFILLS")) %>
    Target: <%= @faction.nerve_refill_target %>/day - <%= sortable_header(sort_link("missions_daily", "CONTRACTS")) %> - <%= sortable_header(sort_link("crimes_daily", "CRIMES")) %> - <%= sortable_header(sort_link("activity_time_daily", "ACTIVITY")) %>
    min/day + <%= sortable_header(sort_link("name", "MEMBER"), @faction) %> + <%= sortable_header(sort_link("xanax_daily", "XANAX"), @faction) %>
    Target: <%= @faction.xanax_target %>/day + <%= sortable_header(sort_link("energy_refills_daily", "ENERGY REFILLS"), @faction) %>
    Target: <%= @faction.energy_refill_target %>/day + <%= sortable_header(sort_link("nerve_refills_daily", "NERVE REFILLS"), @faction) %>
    Target: <%= @faction.nerve_refill_target %>/day + <%= sortable_header(sort_link("missions_daily", "CONTRACTS"), @faction) %> + <%= sortable_header(sort_link("crimes_daily", "CRIMES"), @faction) %> + <%= sortable_header(sort_link("activity_time_daily", "ACTIVITY"), @faction) %>
    min/day @@ -167,7 +144,7 @@ - <%= link_to row[:name], member_faction_index_path(torn_id: row[:torn_id]), class: "player-link" %> + <%= link_to row[:name], member_faction_training_path(@faction, torn_id: row[:torn_id]), class: "player-link" %>
    @@ -215,7 +192,7 @@ + + + <% end %> + + + <% end %> <% end %>
    <% end %> diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index 9db5f3f..ccd9c97 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -3,6 +3,7 @@ + +
    + + + +
    @@ -46,5 +83,22 @@ + + + <% end %> From 706f7ba29a38b91b3b6ea24f51d1a68d1b712ea6 Mon Sep 17 00:00:00 2001 From: Bram Date: Thu, 19 Feb 2026 10:43:34 +0100 Subject: [PATCH 060/266] update clipboard --- app/helpers/faction_helper.rb | 12 ++++++++++-- app/views/factions/training/show.html.erb | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/helpers/faction_helper.rb b/app/helpers/faction_helper.rb index b223766..94b1b76 100644 --- a/app/helpers/faction_helper.rb +++ b/app/helpers/faction_helper.rb @@ -65,9 +65,17 @@ def row_compliance_class(level) end # Generate clipboard text for member stats - def member_stats_clipboard_text(row, days, faction) + def member_stats_clipboard_text(row, days, faction, start_date: nil, end_date: nil) lines = [] - lines << "Over the past #{days} days #{row[:name]} has:" + + if start_date.present? && end_date.present? + formatted_start = start_date.is_a?(String) ? Date.parse(start_date).strftime("%d %b %Y") : start_date.strftime("%d %b %Y") + formatted_end = end_date.is_a?(String) ? Date.parse(end_date).strftime("%d %b %Y") : end_date.strftime("%d %b %Y") + lines << "From #{formatted_start} to #{formatted_end} (#{days} days) #{row[:name]} has:" + else + lines << "Over the past #{days} days #{row[:name]} has:" + end + lines << "- Used #{number_with_delimiter(row[:xanax_gained])} xanax (#{row[:xanax_daily]}/day, target: #{faction.xanax_target}/day)" lines << "- Used #{number_with_delimiter(row[:energy_refills_gained])} energy refills (#{row[:energy_refills_daily]}/day, target: #{faction.energy_refill_target}/day)" lines << "- Used #{number_with_delimiter(row[:nerve_refills_gained])} nerve refills (#{row[:nerve_refills_daily]}/day, target: #{faction.nerve_refill_target}/day)" diff --git a/app/views/factions/training/show.html.erb b/app/views/factions/training/show.html.erb index c1b9fb2..fdcf812 100644 --- a/app/views/factions/training/show.html.erb +++ b/app/views/factions/training/show.html.erb @@ -195,7 +195,7 @@
    @@ -270,7 +282,15 @@ v2/faction/{id}/members - Verify faction membership for grants + Verify faction membership for grants; fetch member lists for training compliance + + + v2/faction/{id}/rankedwars + Fetch ranked war history for tracked factions + + + v2/faction/{war_id}/rankedwarreport + Fetch detailed war reports with member performance data v2/torn/stocks diff --git a/app/views/pages/terms_of_service.html.erb b/app/views/pages/terms_of_service.html.erb index 989c59d..c5b3453 100644 --- a/app/views/pages/terms_of_service.html.erb +++ b/app/views/pages/terms_of_service.html.erb @@ -23,6 +23,8 @@
  • Monitor stat enhancer usage and gains
  • Analyze dividend stock investments and break-even calculations
  • View Hall of Fame statistics for top players
  • +
  • Monitor faction member training compliance (for tracked factions)
  • +
  • View faction ranked war history and member performance
  • TornManager is a read-only service that displays and analyzes data from Torn. I do not modify your From 9e2266d08156ed31e46383468324950784274a8b Mon Sep 17 00:00:00 2001 From: Bram Date: Thu, 19 Feb 2026 19:24:22 +0100 Subject: [PATCH 063/266] public api endpoint --- app/controllers/api/sessions_controller.rb | 44 ++++++++++++++++++++++ config/routes.rb | 4 ++ 2 files changed, 48 insertions(+) create mode 100644 app/controllers/api/sessions_controller.rb diff --git a/app/controllers/api/sessions_controller.rb b/app/controllers/api/sessions_controller.rb new file mode 100644 index 0000000..661f743 --- /dev/null +++ b/app/controllers/api/sessions_controller.rb @@ -0,0 +1,44 @@ +module Api + class SessionsController < ActionController::API + def create + api_key = params[:api_key].to_s.strip + + if api_key.blank? + return render json: { error: "API key is required" }, status: :bad_request + end + + key_info = TornApi::Key::Info.new(api_key).fetch + profile = TornApi::User::Profile.new(api_key).fetch + + user = User.find_by(torn_id: profile.id) || User.new + + user.assign_attributes( + torn_id: profile.id, + api_key: api_key, + api_access_type: key_info.access.type, + name: profile.name, + level: profile.level, + profile_image: profile.image + ) + user.save! + + render json: { + user: { + torn_id: user.torn_id, + name: user.name, + level: user.level, + profile_image: user.profile_image + } + }, status: :ok + + rescue TornApi::InvalidKeyError + render json: { error: "Invalid Torn API key" }, status: :bad_request + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error("API user upsert failed: #{e.record.errors.full_messages}") + render json: { error: "Could not create user profile" }, status: :bad_request + rescue => e + Rails.logger.error("Unexpected API login error: #{e.class} - #{e.message}") + render json: { error: "Unexpected error. Please try again." }, status: :bad_request + end + end +end diff --git a/config/routes.rb b/config/routes.rb index f23678a..7df9c17 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,6 +2,10 @@ mount MissionControl::Jobs::Engine, at: "/jobs" mount ActionCable.server => "/cable" + namespace :api do + resource :session, only: [ :create ] + end + resource :session resources :progress, only: [ :index ] resources :factions, only: [ :index, :show ], param: :torn_id do From 7a9f16f9662f88f108015525acc3f31355932b48 Mon Sep 17 00:00:00 2001 From: Bram Date: Thu, 19 Feb 2026 20:20:37 +0100 Subject: [PATCH 064/266] public api endpoints --- .../api/subscriptions_controller.rb | 61 +++++++++++++++++++ app/views/pages/privacy_policy.html.erb | 5 ++ app/views/pages/terms_of_service.html.erb | 4 +- config/routes.rb | 1 + package-lock.json | 6 ++ 5 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 app/controllers/api/subscriptions_controller.rb create mode 100644 package-lock.json diff --git a/app/controllers/api/subscriptions_controller.rb b/app/controllers/api/subscriptions_controller.rb new file mode 100644 index 0000000..518da73 --- /dev/null +++ b/app/controllers/api/subscriptions_controller.rb @@ -0,0 +1,61 @@ +module Api + class SubscriptionsController < ActionController::API + PAYMENT_CHECK_COOLDOWN = 5.minutes + CACHE_KEY = "api:payment_check:last_run" + + def show + api_key = params[:api_key].to_s.strip + + if api_key.blank? + return render json: { error: "API key is required" }, status: :bad_request + end + + user = User.find_by(api_key: api_key) + + unless user + return render json: { error: "Unknown API key. Please sign in first." }, status: :not_found + end + + unless check_payments! + seconds = seconds_until_available + return render json: { + error: "Payment check was run recently. Try again in #{seconds} seconds." + }, status: :too_many_requests + end + + user.reload + + render json: { + subscription: { + active: user.subscribed?, + expires_at: user.subscription_expires_at&.iso8601 + } + }, status: :ok + rescue => e + Rails.logger.error("API subscription check failed: #{e.class} - #{e.message}") + render json: { error: "Could not check subscription status. Please try again later." }, status: :internal_server_error + end + + private + + def check_payments! + return false if rate_limited? + + Rails.cache.write(CACHE_KEY, Time.current, expires_in: PAYMENT_CHECK_COOLDOWN) + Daily::XanaxPaymentsJob.perform_now + true + end + + def rate_limited? + Rails.cache.exist?(CACHE_KEY) + end + + def seconds_until_available + last_run = Rails.cache.read(CACHE_KEY) + return 0 unless last_run + + remaining = PAYMENT_CHECK_COOLDOWN - (Time.current - last_run) + [ remaining.to_i, 0 ].max + end + end +end diff --git a/app/views/pages/privacy_policy.html.erb b/app/views/pages/privacy_policy.html.erb index 8d217c0..6fd53fc 100644 --- a/app/views/pages/privacy_policy.html.erb +++ b/app/views/pages/privacy_policy.html.erb @@ -10,6 +10,11 @@ Hi, I'm Bram, the creator of TornManager. I respect your privacy and am committed to protecting your personal information. This Privacy Policy explains how I collect, use, and safeguard your data when you use my service.

    +

    + TornManager is available as a web application at tornmanager.com and as a browser userscript that runs on Torn.com. + Both use the same backend and this policy applies equally to both. + The userscript stores your user profile locally in your browser's localStorage and communicates with tornmanager.com to authenticate your API key. +

    diff --git a/app/views/pages/terms_of_service.html.erb b/app/views/pages/terms_of_service.html.erb index c5b3453..772b0e7 100644 --- a/app/views/pages/terms_of_service.html.erb +++ b/app/views/pages/terms_of_service.html.erb @@ -15,8 +15,8 @@

    Description of Service

    - TornManager is a web-based tool I created for players of Torn.com. My service uses the official Torn API - to help you: + TornManager is a tool I created for players of Torn.com, available as a web application at tornmanager.com + and as a browser userscript that runs directly on Torn.com. My service uses the official Torn API to help you:

    • Track your personal statistics and progress over time
    • diff --git a/config/routes.rb b/config/routes.rb index 7df9c17..b39120b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,6 +4,7 @@ namespace :api do resource :session, only: [ :create ] + post :subscription, to: "subscriptions#show" end resource :session diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e69d4da --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "tornmanager", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} From 9b55178f7316fde87c2f2cada5616f68bbf95180 Mon Sep 17 00:00:00 2001 From: Bram Date: Thu, 19 Feb 2026 21:30:36 +0100 Subject: [PATCH 065/266] nice --- app/controllers/api/subscriptions_controller.rb | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/controllers/api/subscriptions_controller.rb b/app/controllers/api/subscriptions_controller.rb index 518da73..b7f1747 100644 --- a/app/controllers/api/subscriptions_controller.rb +++ b/app/controllers/api/subscriptions_controller.rb @@ -16,15 +16,17 @@ def show return render json: { error: "Unknown API key. Please sign in first." }, status: :not_found end - unless check_payments! - seconds = seconds_until_available - return render json: { - error: "Payment check was run recently. Try again in #{seconds} seconds." - }, status: :too_many_requests + if params[:refresh].present? + unless check_payments! + seconds = seconds_until_available + return render json: { + error: "Payment check was run recently. Try again in #{seconds} seconds." + }, status: :too_many_requests + end + + user.reload end - user.reload - render json: { subscription: { active: user.subscribed?, From 7751046624786783b6159667ca59dca1195c2922 Mon Sep 17 00:00:00 2001 From: Bram Date: Thu, 19 Feb 2026 22:58:36 +0100 Subject: [PATCH 066/266] api calls fix --- app/models/api_call.rb | 39 +++++++++++++-------------------------- 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/app/models/api_call.rb b/app/models/api_call.rb index 76f7c57..9e7299f 100644 --- a/app/models/api_call.rb +++ b/app/models/api_call.rb @@ -13,38 +13,25 @@ class ApiCall < ApplicationRecord after_create :broadcast_api_call def self.peak_rate_for(user, scope: :all) - calls = case scope + base = case scope when :today - user.api_calls.today.order(:created_at) + user.api_calls.today else - user.api_calls.order(:created_at) + user.api_calls end - return { rate: 0, minute_start: nil } if calls.empty? + result = base + .group(Arel.sql("strftime('%Y-%m-%d %H:%M', created_at)")) + .order(Arel.sql("COUNT(*) DESC")) + .limit(1) + .pluck(Arel.sql("strftime('%Y-%m-%d %H:%M', created_at), COUNT(*)")) + .first - max_rate = 0 - max_minute = nil - - calls.each do |call| - minute_start = call.created_at.beginning_of_minute - minute_end = minute_start + 1.minute - - scope_calls = case scope - when :today - user.api_calls.today - else - user.api_calls - end - - rate = scope_calls.where(created_at: minute_start..minute_end).count - - if rate > max_rate - max_rate = rate - max_minute = minute_start - end + if result + { rate: result[1], minute_start: Time.zone.parse(result[0]) } + else + { rate: 0, minute_start: nil } end - - { rate: max_rate, minute_start: max_minute } end private From 584b64342dec629252d98d0b8172c3c0dc6f19f6 Mon Sep 17 00:00:00 2001 From: Bram Date: Fri, 20 Feb 2026 08:43:02 +0100 Subject: [PATCH 067/266] roadmap --- app/assets/stylesheets/application.css | 1 + app/assets/stylesheets/pages/roadmap.css | 289 ++++++++++++++++++ app/controllers/roadmap_controller.rb | 48 +++ .../controllers/roadmap_form_controller.js | 54 ++++ app/models/roadmap_item.rb | 32 ++ app/views/layouts/_navbar.html.erb | 4 + app/views/roadmap/index.html.erb | 120 ++++++++ config/routes.rb | 5 + .../20260220071935_create_roadmap_items.rb | 15 + db/schema.rb | 13 +- 10 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 app/assets/stylesheets/pages/roadmap.css create mode 100644 app/controllers/roadmap_controller.rb create mode 100644 app/javascript/controllers/roadmap_form_controller.js create mode 100644 app/models/roadmap_item.rb create mode 100644 app/views/roadmap/index.html.erb create mode 100644 db/migrate/20260220071935_create_roadmap_items.rb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 66ab6b2..9a1c6c9 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -23,4 +23,5 @@ * - pages/key_log.css : Key log specific styles * - pages/login.css : Login form styles * - pages/settings.css : Settings page styles + * - pages/roadmap.css : Roadmap board styles */ diff --git a/app/assets/stylesheets/pages/roadmap.css b/app/assets/stylesheets/pages/roadmap.css new file mode 100644 index 0000000..3dfc534 --- /dev/null +++ b/app/assets/stylesheets/pages/roadmap.css @@ -0,0 +1,289 @@ +/* + * Roadmap Board + */ + +/* Board Layout */ +.roadmap-board { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-4); + margin-top: var(--space-8); + align-items: start; +} + +@media (max-width: 900px) { + .roadmap-board { + grid-template-columns: 1fr; + } +} + +/* Swim Lane */ +.roadmap-lane { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + min-height: 200px; + display: flex; + flex-direction: column; +} + +.roadmap-lane-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--gray-800); +} + +.roadmap-lane-title { + font-size: 0.8125rem; + font-weight: 600; + color: var(--gray-300); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.roadmap-lane-count { + font-size: 0.75rem; + font-weight: 600; + color: var(--gray-500); + background: var(--gray-800); + padding: 2px 8px; + border-radius: 999px; +} + +.roadmap-lane-body { + padding: var(--space-3); + display: flex; + flex-direction: column; + gap: var(--space-3); + flex: 1; +} + +.roadmap-lane-empty { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-8) var(--space-4); + color: var(--gray-600); + font-size: 0.8125rem; + font-style: italic; + flex: 1; +} + +/* Card */ +.roadmap-card { + background: var(--gray-1000); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + padding: var(--space-4); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.roadmap-card:hover { + border-color: var(--gray-700); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.roadmap-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-3); +} + +.roadmap-card-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--gray-100); + margin: 0; + line-height: 1.4; +} + +.roadmap-card-description { + font-size: 0.8125rem; + color: var(--gray-400); + margin: var(--space-2) 0 0 0; + line-height: 1.5; +} + +.roadmap-card-edit { + font-size: 0.75rem; + color: var(--gray-500); + text-decoration: none; + padding: 2px 8px; + border-radius: var(--radius-sm); + transition: all 0.15s ease; +} + +.roadmap-card-edit:hover { + color: var(--gray-200); + background: var(--gray-800); +} + +/* Category Badges */ +.roadmap-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.025em; +} + +.roadmap-badge-factions { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; + border: 1px solid rgba(59, 130, 246, 0.25); +} + +.roadmap-badge-stats { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; + border: 1px solid rgba(34, 197, 94, 0.25); +} + +.roadmap-badge-api { + background: rgba(249, 115, 22, 0.15); + color: #f97316; + border: 1px solid rgba(249, 115, 22, 0.25); +} + +.roadmap-badge-admin { + background: rgba(168, 85, 247, 0.15); + color: #a855f7; + border: 1px solid rgba(168, 85, 247, 0.25); +} + +.roadmap-badge-ui_ux { + background: rgba(236, 72, 153, 0.15); + color: #ec4899; + border: 1px solid rgba(236, 72, 153, 0.25); +} + +.roadmap-badge-torn_script { + background: rgba(220, 38, 38, 0.15); + color: #ef4444; + border: 1px solid rgba(220, 38, 38, 0.25); +} + +.roadmap-badge-discord { + background: rgba(88, 101, 242, 0.15); + color: #7c8af4; + border: 1px solid rgba(88, 101, 242, 0.25); +} + +.roadmap-badge-infrastructure { + background: rgba(156, 163, 175, 0.15); + color: #9ca3af; + border: 1px solid rgba(156, 163, 175, 0.25); +} + +/* Slide-in Form Panel */ +.roadmap-form-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 999; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease, visibility 0.3s ease; + backdrop-filter: blur(2px); +} + +.roadmap-form-overlay--visible { + opacity: 1; + visibility: visible; +} + +.roadmap-form-panel { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 480px; + max-width: 100vw; + background: var(--gray-900); + border-left: 1px solid var(--gray-800); + z-index: 1000; + transform: translateX(100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.roadmap-form-panel--open { + transform: translateX(0); +} + +.roadmap-form-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-5) var(--space-6); + border-bottom: 1px solid var(--gray-800); + flex-shrink: 0; +} + +.roadmap-form-panel-header h2 { + font-size: 1rem; + font-weight: 600; + color: var(--gray-100); + margin: 0; +} + +.roadmap-form-close { + background: none; + border: none; + color: var(--gray-400); + cursor: pointer; + padding: var(--space-1); + border-radius: var(--radius-sm); + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.roadmap-form-close:hover { + color: var(--gray-100); + background: var(--gray-800); +} + +.roadmap-form-panel-body { + padding: var(--space-6); + flex: 1; +} + +.roadmap-form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-4); +} + +.roadmap-textarea { + resize: vertical; + min-height: 100px; +} + +/* Form within panel - ensure full width */ +.roadmap-form .form-input, +.roadmap-form .form-select { + width: 100%; + box-sizing: border-box; +} + +.roadmap-form .form-actions { + margin-top: var(--space-8); + display: flex; + gap: var(--space-3); + flex-wrap: wrap; +} + +@media (max-width: 600px) { + .roadmap-form-row { + grid-template-columns: 1fr; + } +} diff --git a/app/controllers/roadmap_controller.rb b/app/controllers/roadmap_controller.rb new file mode 100644 index 0000000..5ea180c --- /dev/null +++ b/app/controllers/roadmap_controller.rb @@ -0,0 +1,48 @@ +class RoadmapController < ApplicationController + allow_unauthenticated_access only: [ :index ] + before_action :require_admin, except: [ :index ] + before_action :set_roadmap_item, only: [ :update, :destroy ] + + def index + resume_session # Attempt to load user session without requiring it + @items_by_status = RoadmapItem::STATUSES.index_with do |status| + RoadmapItem.by_status(status) + end + @editing_item = RoadmapItem.find_by(id: params[:edit]) if params[:edit].present? + @is_admin = Current.user&.admin? + end + + def create + @item = RoadmapItem.new(roadmap_item_params) + @item.position ||= RoadmapItem.where(status: @item.status).maximum(:position).to_i + 1 + + if @item.save + redirect_to roadmap_path, notice: "Roadmap item created." + else + redirect_to roadmap_path, alert: @item.errors.full_messages.to_sentence + end + end + + def update + if @item.update(roadmap_item_params) + redirect_to roadmap_path, notice: "Roadmap item updated." + else + redirect_to roadmap_path(edit: @item.id), alert: @item.errors.full_messages.to_sentence + end + end + + def destroy + @item.destroy + redirect_to roadmap_path, notice: "Roadmap item deleted." + end + + private + + def set_roadmap_item + @item = RoadmapItem.find(params[:id]) + end + + def roadmap_item_params + params.require(:roadmap_item).permit(:title, :description, :status, :category, :position) + end +end diff --git a/app/javascript/controllers/roadmap_form_controller.js b/app/javascript/controllers/roadmap_form_controller.js new file mode 100644 index 0000000..36368f3 --- /dev/null +++ b/app/javascript/controllers/roadmap_form_controller.js @@ -0,0 +1,54 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["panel", "overlay"] + static values = { editing: Boolean } + + connect() { + // Auto-open the panel if we're in editing mode (from ?edit=ID param) + if (this.editingValue && this.hasPanelTarget) { + // Small delay so the CSS transition is visible + requestAnimationFrame(() => { + this.open() + }) + } + } + + show(event) { + event.preventDefault() + this.open() + } + + hide(event) { + if (event) event.preventDefault() + this.panelTarget.classList.remove("roadmap-form-panel--open") + this.overlayTarget.classList.remove("roadmap-form-overlay--visible") + document.body.style.overflow = "" + + // If we were in edit mode, clean the URL + if (this.editingValue) { + const url = new URL(window.location) + url.searchParams.delete("edit") + history.replaceState(null, "", url) + } + } + + hideOnEscape(event) { + if (event.key === "Escape") { + this.hide() + } + } + + // Private + + open() { + this.panelTarget.classList.add("roadmap-form-panel--open") + this.overlayTarget.classList.add("roadmap-form-overlay--visible") + document.body.style.overflow = "hidden" + + setTimeout(() => { + const firstInput = this.panelTarget.querySelector("input[type='text']") + if (firstInput) firstInput.focus() + }, 300) + } +} diff --git a/app/models/roadmap_item.rb b/app/models/roadmap_item.rb new file mode 100644 index 0000000..dac3613 --- /dev/null +++ b/app/models/roadmap_item.rb @@ -0,0 +1,32 @@ +class RoadmapItem < ApplicationRecord + STATUSES = %w[planned in_progress done].freeze + CATEGORIES = %w[factions stats api admin ui_ux torn_script discord infrastructure].freeze + + validates :title, presence: true + validates :status, presence: true, inclusion: { in: STATUSES } + validates :category, presence: true, inclusion: { in: CATEGORIES } + + scope :ordered, -> { order(position: :asc, created_at: :desc) } + scope :by_status, ->(status) { where(status: status).ordered } + + def self.status_label(status) + { + "planned" => "Planned", + "in_progress" => "In Progress", + "done" => "Done" + }[status] + end + + def self.category_label(category) + { + "factions" => "Factions", + "stats" => "Stats", + "api" => "API", + "admin" => "Admin", + "ui_ux" => "UI/UX", + "torn_script" => "Torn Script", + "discord" => "Discord", + "infrastructure" => "Infrastructure" + }[category] + end +end diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index ccd9c97..a62ac6c 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -29,6 +29,7 @@

      <%= item.title %>

      <% if item.description.present? %> -

      <%= truncate(item.description, length: 120) %>

      + <% if item.description.length > 120 %> +
      +

      <%= truncate(item.description, length: 120) %>

      + +
      + <% else %> +

      <%= item.description %>

      + <% end %> <% end %>
    <% end %> From 6eec68f2d5f1f3f229ad27dd7995b544483c29af Mon Sep 17 00:00:00 2001 From: Bram Date: Fri, 20 Feb 2026 12:53:00 +0100 Subject: [PATCH 069/266] spystats --- app/assets/stylesheets/application.css | 1 + app/assets/stylesheets/components/cards.css | 68 +++++++++ app/assets/stylesheets/pages/spy_stats.css | 68 +++++++++ app/controllers/api/wars_controller.rb | 115 +++++++++++++++ app/controllers/concerns/faction_access.rb | 52 +++++++ .../factions/ranked_wars_controller.rb | 15 +- .../factions/settings_controller.rb | 133 ++++++++++++++++++ .../factions/spy_stats_controller.rb | 10 ++ .../factions/training_controller.rb | 14 +- app/controllers/factions_controller.rb | 19 +-- app/models/faction.rb | 2 + app/models/faction_setting.rb | 17 +++ app/models/spy_report.rb | 17 +++ app/models/torn_stats_api.rb | 69 +++++++++ app/models/torn_stats_api/spy_faction.rb | 63 +++++++++ app/views/factions/settings/show.html.erb | 97 +++++++++++++ app/views/factions/show.html.erb | 86 +++++++++-- app/views/factions/spy_stats/show.html.erb | 56 ++++++++ config/routes.rb | 5 + .../20260220100001_create_faction_settings.rb | 11 ++ .../20260220100002_create_spy_reports.rb | 19 +++ ...orn_api_access_type_to_faction_settings.rb | 5 + db/schema.rb | 32 ++++- 23 files changed, 928 insertions(+), 46 deletions(-) create mode 100644 app/assets/stylesheets/pages/spy_stats.css create mode 100644 app/controllers/api/wars_controller.rb create mode 100644 app/controllers/concerns/faction_access.rb create mode 100644 app/controllers/factions/settings_controller.rb create mode 100644 app/controllers/factions/spy_stats_controller.rb create mode 100644 app/models/faction_setting.rb create mode 100644 app/models/spy_report.rb create mode 100644 app/models/torn_stats_api.rb create mode 100644 app/models/torn_stats_api/spy_faction.rb create mode 100644 app/views/factions/settings/show.html.erb create mode 100644 app/views/factions/spy_stats/show.html.erb create mode 100644 db/migrate/20260220100001_create_faction_settings.rb create mode 100644 db/migrate/20260220100002_create_spy_reports.rb create mode 100644 db/migrate/20260220100003_add_torn_api_access_type_to_faction_settings.rb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 9a1c6c9..67df312 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -23,5 +23,6 @@ * - pages/key_log.css : Key log specific styles * - pages/login.css : Login form styles * - pages/settings.css : Settings page styles + * - pages/spy_stats.css : Spy stats table + filters * - pages/roadmap.css : Roadmap board styles */ diff --git a/app/assets/stylesheets/components/cards.css b/app/assets/stylesheets/components/cards.css index cbe5fd2..d280762 100644 --- a/app/assets/stylesheets/components/cards.css +++ b/app/assets/stylesheets/components/cards.css @@ -479,6 +479,74 @@ text-decoration: underline; } +/* API Keys Grid (side-by-side cards) */ +.api-keys-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-4); +} + +@media (max-width: 768px) { + .api-keys-grid { + grid-template-columns: 1fr; + } +} + +.api-keys-actions { + margin-top: var(--space-4); +} + +/* API Key Field */ +.api-key-field-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.api-key-field-label { + font-size: 0.875rem; + font-weight: 600; + color: var(--gray-200); +} + +.api-key-field-current { + margin-bottom: 0.75rem; +} + +.api-key-form-input-full { + width: 100%; + box-sizing: border-box; + margin-bottom: 0; +} + +/* Import Spy Stats */ +.import-spy-header { + margin-bottom: 1.25rem; +} + +.import-spy-description { + color: var(--gray-400); + font-size: 0.875rem; + margin: 0 0 0.5rem 0; + line-height: 1.5; +} + +.import-spy-count { + color: var(--gray-500); + font-size: 0.8125rem; + margin: 0; +} + +.import-spy-link { + color: var(--blue-500); + text-decoration: none; +} + +.import-spy-link:hover { + text-decoration: underline; +} + .api-key-feedback { margin-top: 0.75rem; padding: 0.75rem 1rem; diff --git a/app/assets/stylesheets/pages/spy_stats.css b/app/assets/stylesheets/pages/spy_stats.css new file mode 100644 index 0000000..28c4b11 --- /dev/null +++ b/app/assets/stylesheets/pages/spy_stats.css @@ -0,0 +1,68 @@ +/* + * Spy Stats Page Styles + */ + +/* Table columns */ +.spy-stats-col-rank { + width: 48px; + text-align: center; +} + +.spy-stats-col-id { + width: 100px; +} + +.spy-stats-col-stat { + text-align: right; +} + +.spy-stats-col-total { + text-align: right; +} + +.spy-stats-col-age { + text-align: right; + width: 120px; +} + +/* Table cells */ +.spy-stats-rank { + text-align: center; + color: var(--gray-500); + font-size: 0.75rem; + font-weight: 600; +} + +.spy-stats-id-link { + color: var(--blue-500); + text-decoration: none; + font-family: "Geist Mono", monospace; + font-weight: 500; +} + +.spy-stats-id-link:hover { + text-decoration: underline; +} + +.spy-stats-number { + text-align: right; + font-family: "Geist Mono", monospace; + font-variant-numeric: tabular-nums; + color: var(--gray-300); +} + +.spy-stats-total { + color: var(--gray-100); + font-weight: 600; +} + +.spy-stats-age { + text-align: right; + color: var(--gray-500); + font-size: 0.8125rem; +} + +/* Empty state */ +.table-empty-cell { + padding: 0 !important; +} diff --git a/app/controllers/api/wars_controller.rb b/app/controllers/api/wars_controller.rb new file mode 100644 index 0000000..9472771 --- /dev/null +++ b/app/controllers/api/wars_controller.rb @@ -0,0 +1,115 @@ +module Api + class WarsController < ActionController::API + STATUS_CACHE_TTL = 30.seconds + + def show + api_key = params[:api_key].to_s.strip + torn_ids = Array(params[:torn_ids]).map(&:to_i).reject(&:zero?) + enemy_faction_id = params[:enemy_faction_id].to_s.strip.presence + refresh = params[:refresh].present? + + if api_key.blank? + return render json: { error: "API key is required" }, status: :bad_request + end + + if torn_ids.empty? + return render json: { error: "torn_ids is required" }, status: :bad_request + end + + user = User.find_by(api_key: api_key) + unless user + return render json: { error: "Unknown API key. Please sign in first." }, status: :not_found + end + + faction = user.faction + unless faction + return render json: { error: "You are not a member of any faction." }, status: :unprocessable_entity + end + + setting = faction.faction_setting + unless setting&.keys_configured? + return render json: { error: "Faction API keys not configured. Ask your faction leader to set them up on tornmanager.com." }, status: :unprocessable_entity + end + + # Look up cached spy reports + spy_reports = faction.spy_reports.for_targets(torn_ids).index_by(&:torn_id) + + # Fetch live status from Torn API (cached briefly) + members_status = {} + if enemy_faction_id.present? + members_status = fetch_enemy_status(setting.torn_api_key, enemy_faction_id, refresh) + end + + # Build response + members = {} + torn_ids.each do |torn_id| + spy = spy_reports[torn_id] + status = members_status[torn_id] + + if spy || status + members[torn_id.to_s] = build_member_data(torn_id, spy, status) + else + members[torn_id.to_s] = nil + end + end + + render json: { members: members }, status: :ok + rescue => e + Rails.logger.error("API war endpoint failed: #{e.class} - #{e.message}") + render json: { error: "Could not fetch war data. Please try again later." }, status: :internal_server_error + end + + private + + def fetch_enemy_status(torn_api_key, enemy_faction_id, refresh) + cache_key = "war:enemy_status:#{enemy_faction_id}" + + if refresh + Rails.cache.delete(cache_key) + end + + Rails.cache.fetch(cache_key, expires_in: STATUS_CACHE_TTL) do + begin + members = TornApi::Faction::Members.new(torn_api_key, enemy_faction_id).fetch + members.each_with_object({}) do |member, hash| + hash[member.id] = member + end + rescue TornApi::ApiError => e + Rails.logger.error("Failed to fetch enemy faction status: #{e.class} - #{e.message}") + {} + end + end + end + + def build_member_data(torn_id, spy, status) + data = { torn_id: torn_id } + + if status + data[:name] = status.name + data[:level] = status.level + + state = status.status_state + if state && state != "Okay" + status_data = { state: state } + status_data[:description] = status.status_description if status.status_description.present? + + # status_until is a Unix timestamp from Torn API; 0 means no timer + if status.status_until.present? && status.status_until.to_i > 0 + status_data[:until] = Time.at(status.status_until.to_i).iso8601 + end + + data[:status] = status_data + else + data[:status] = { state: "Okay" } + end + end + + if spy + data[:stats] = spy.stats_hash + data[:stats_timestamp] = spy.spied_at&.iso8601 + end + + data + end + end +end diff --git a/app/controllers/concerns/faction_access.rb b/app/controllers/concerns/faction_access.rb new file mode 100644 index 0000000..823e36b --- /dev/null +++ b/app/controllers/concerns/faction_access.rb @@ -0,0 +1,52 @@ +module FactionAccess + extend ActiveSupport::Concern + + private + + # Finds @faction from params and verifies the current user is a member (or admin). + # Use as: before_action :require_faction_member + def require_faction_member + find_faction + return if performed? # redirect already issued by find_faction + + unless Current.user.admin? || Current.user.faction == @faction + redirect_to root_path, alert: "You don't have access to this faction." + end + end + + # Finds @faction from params and verifies the current user is a faction leader/co-leader (or admin). + # Use as: before_action :require_faction_leader + # This is a superset of require_faction_member — no need to call both. + def require_faction_leader + find_faction + return if performed? + + return if Current.user.admin? + + unless Current.user.faction == @faction + redirect_to root_path, alert: "You don't have access to this faction." + return + end + + verify_leader_role + end + + def find_faction + torn_id = params[:faction_torn_id] || params[:torn_id] + @faction = Faction.find_by!(torn_id: torn_id) + rescue ActiveRecord::RecordNotFound + redirect_to root_path, alert: "Faction not found." + end + + def verify_leader_role + members = TornApi::Faction::Members.new(Current.user.api_key, @faction.torn_id).fetch + member = members.find { |m| m.id == Current.user.torn_id } + + unless member && %w[Leader Co-leader].include?(member.position) + redirect_to faction_path(@faction), alert: "Only faction leaders can access this page." + end + rescue TornApi::ApiError => e + Rails.logger.error("Faction leader check failed: #{e.class} - #{e.message}") + redirect_to faction_path(@faction), alert: "Could not verify faction role. Please try again." + end +end diff --git a/app/controllers/factions/ranked_wars_controller.rb b/app/controllers/factions/ranked_wars_controller.rb index 85faee0..c2ee8dd 100644 --- a/app/controllers/factions/ranked_wars_controller.rb +++ b/app/controllers/factions/ranked_wars_controller.rb @@ -1,7 +1,10 @@ class Factions::RankedWarsController < ApplicationController + include FactionAccess + SYNC_COOLDOWN = 1.minute - before_action :set_faction + before_action :require_faction_member + before_action :check_tracking_enabled def index return if @tracking_disabled @@ -91,14 +94,8 @@ def sync private - def set_faction - @faction = Faction.find_by!(torn_id: params[:faction_torn_id]) - - # Authorization: users can only view their own faction, admins can view any - unless Current.user.admin? || Current.user.faction == @faction - redirect_to root_path, alert: "You don't have access to this faction." - return - end + def check_tracking_enabled + return if performed? unless @faction.track_stats @tracking_disabled = true diff --git a/app/controllers/factions/settings_controller.rb b/app/controllers/factions/settings_controller.rb new file mode 100644 index 0000000..da589ec --- /dev/null +++ b/app/controllers/factions/settings_controller.rb @@ -0,0 +1,133 @@ +class Factions::SettingsController < ApplicationController + include FactionAccess + + IMPORT_COOLDOWN = 1.minute + + before_action :require_faction_leader + + def show + @faction_setting = @faction.faction_setting || @faction.build_faction_setting + @torn_api_key_masked = mask_key(@faction_setting.torn_api_key) + @tornstats_api_key_masked = mask_key(@faction_setting.tornstats_api_key) + @spy_report_count = @faction.spy_reports.count + @last_import_at = Rails.cache.read(import_cache_key) + @can_import = @last_import_at.nil? + @seconds_until_import = seconds_until_import + end + + def update + @faction_setting = @faction.faction_setting || @faction.build_faction_setting + + # Only update keys that were actually provided (non-blank) + permitted = faction_setting_params + new_torn_key = permitted.delete(:torn_api_key).presence + new_tornstats_key = permitted.delete(:tornstats_api_key).presence + + changes_made = false + + # Validate and store Torn API key + if new_torn_key + begin + key_info = TornApi::Key::Info.new(new_torn_key).fetch + @faction_setting.torn_api_key = new_torn_key + @faction_setting.torn_api_access_type = key_info.access.type + changes_made = true + rescue TornApi::InvalidKeyError + return redirect_to faction_settings_path(@faction), alert: "Invalid Torn API key." + rescue TornApi::ApiError => e + return redirect_to faction_settings_path(@faction), alert: "Could not validate Torn API key: #{e.message}" + end + end + + # Store TornStats API key (no validation endpoint available) + if new_tornstats_key + @faction_setting.tornstats_api_key = new_tornstats_key + changes_made = true + end + + if !changes_made + redirect_to faction_settings_path(@faction), notice: "No changes made." + elsif @faction_setting.save + redirect_to faction_settings_path(@faction), notice: "API keys saved successfully." + else + redirect_to faction_settings_path(@faction), alert: "Failed to save settings." + end + end + + def import_spies + target_faction_id = params[:target_faction_id].to_s.strip + + if target_faction_id.blank? + return redirect_to faction_settings_path(@faction), alert: "Please enter a faction ID to import spy data for." + end + + @faction_setting = @faction.faction_setting + unless @faction_setting&.tornstats_api_key? + return redirect_to faction_settings_path(@faction), alert: "TornStats API key must be configured before importing spy data." + end + + if rate_limited? + return redirect_to faction_settings_path(@faction), alert: "Import was run recently. Try again in #{seconds_until_import} seconds." + end + + Rails.cache.write(import_cache_key, Time.current, expires_in: IMPORT_COOLDOWN) + + begin + spy_data = TornStatsApi::SpyFaction.new( + @faction_setting.tornstats_api_key, + faction_id: target_faction_id + ).fetch + + imported = 0 + spy_data.each do |spy| + report = @faction.spy_reports.find_or_initialize_by(torn_id: spy.torn_id) + report.assign_attributes( + strength: spy.strength, + defense: spy.defense, + speed: spy.speed, + dexterity: spy.dexterity, + total: spy.total, + spied_at: spy.spied_at + ) + report.save! + imported += 1 + end + + redirect_to faction_settings_path(@faction), notice: "Successfully imported #{imported} spy reports." + rescue TornStatsApi::NotFoundError => e + redirect_to faction_settings_path(@faction), alert: "No spy data found: #{e.message}" + rescue TornStatsApi::InvalidKeyError => e + redirect_to faction_settings_path(@faction), alert: "Invalid TornStats API key: #{e.message}" + rescue TornStatsApi::ApiError => e + Rails.logger.error("TornStats import failed: #{e.class} - #{e.message}") + redirect_to faction_settings_path(@faction), alert: "Import failed: #{e.message}" + end + end + + private + + def faction_setting_params + params.require(:faction_setting).permit(:torn_api_key, :tornstats_api_key) + end + + def import_cache_key + "faction:#{@faction.id}:spy_import:last_run" + end + + def rate_limited? + Rails.cache.exist?(import_cache_key) + end + + def seconds_until_import + last_run = Rails.cache.read(import_cache_key) + return 0 unless last_run + + remaining = IMPORT_COOLDOWN - (Time.current - last_run) + [ remaining.to_i, 0 ].max + end + + def mask_key(key) + return nil if key.blank? + "#{key[0..3]}********#{key[-4..]}" + end +end diff --git a/app/controllers/factions/spy_stats_controller.rb b/app/controllers/factions/spy_stats_controller.rb new file mode 100644 index 0000000..8cef358 --- /dev/null +++ b/app/controllers/factions/spy_stats_controller.rb @@ -0,0 +1,10 @@ +class Factions::SpyStatsController < ApplicationController + include FactionAccess + + before_action :require_faction_member + + def show + @spy_reports = @faction.spy_reports.order(total: :desc) + @total_count = @spy_reports.count + end +end diff --git a/app/controllers/factions/training_controller.rb b/app/controllers/factions/training_controller.rb index d25f3e1..dba6298 100644 --- a/app/controllers/factions/training_controller.rb +++ b/app/controllers/factions/training_controller.rb @@ -1,7 +1,9 @@ class Factions::TrainingController < ApplicationController + include FactionAccess include FactionHelper - before_action :set_faction + before_action :require_faction_member + before_action :check_tracking_enabled SORTABLE_COLUMNS = %w[name xanax_daily energy_refills_daily nerve_refills_daily missions_daily crimes_daily activity_time_daily compliance_score].freeze @@ -137,14 +139,8 @@ def member private - def set_faction - @faction = Faction.find_by!(torn_id: params[:faction_torn_id]) - - # Authorization: users can only view their own faction, admins can view any - unless Current.user.admin? || Current.user.faction == @faction - redirect_to root_path, alert: "You don't have access to this faction." - return - end + def check_tracking_enabled + return if performed? unless @faction.track_stats @tracking_disabled = true diff --git a/app/controllers/factions_controller.rb b/app/controllers/factions_controller.rb index 8960d1f..1b5b7ab 100644 --- a/app/controllers/factions_controller.rb +++ b/app/controllers/factions_controller.rb @@ -1,8 +1,9 @@ class FactionsController < ApplicationController - before_action :set_faction, only: [ :show ] + include FactionAccess + + before_action :require_faction_member, only: [ :show ] def index - # Redirect to current user's faction if Current.user.faction.present? redirect_to faction_path(Current.user.faction) else @@ -11,20 +12,6 @@ def index end def show - # Faction dashboard with cards linking to sub-pages - end - - private - - def set_faction - @faction = Faction.find_by!(torn_id: params[:torn_id]) - - # Authorization: users can only view their own faction, admins can view any - unless Current.user.admin? || Current.user.faction == @faction - redirect_to root_path, alert: "You don't have access to this faction." - return - end - unless @faction.track_stats @tracking_disabled = true end diff --git a/app/models/faction.rb b/app/models/faction.rb index 1eb374d..6f7ae97 100644 --- a/app/models/faction.rb +++ b/app/models/faction.rb @@ -2,6 +2,8 @@ class Faction < ApplicationRecord has_many :users, dependent: :nullify has_many :faction_subscription_grants, dependent: :nullify has_many :ranked_wars, dependent: :destroy + has_one :faction_setting, dependent: :destroy + has_many :spy_reports, dependent: :destroy validates :torn_id, presence: true, uniqueness: true validates :name, presence: true diff --git a/app/models/faction_setting.rb b/app/models/faction_setting.rb new file mode 100644 index 0000000..915771b --- /dev/null +++ b/app/models/faction_setting.rb @@ -0,0 +1,17 @@ +class FactionSetting < ApplicationRecord + belongs_to :faction + + validates :faction_id, uniqueness: true + + def torn_api_key? + torn_api_key.present? + end + + def tornstats_api_key? + tornstats_api_key.present? + end + + def keys_configured? + torn_api_key? && tornstats_api_key? + end +end diff --git a/app/models/spy_report.rb b/app/models/spy_report.rb new file mode 100644 index 0000000..630c6ce --- /dev/null +++ b/app/models/spy_report.rb @@ -0,0 +1,17 @@ +class SpyReport < ApplicationRecord + belongs_to :faction + + validates :torn_id, presence: true, uniqueness: { scope: :faction_id } + + scope :for_targets, ->(torn_ids) { where(torn_id: torn_ids) } + + def stats_hash + { + strength: strength, + defense: defense, + speed: speed, + dexterity: dexterity, + total: total + } + end +end diff --git a/app/models/torn_stats_api.rb b/app/models/torn_stats_api.rb new file mode 100644 index 0000000..35a1bff --- /dev/null +++ b/app/models/torn_stats_api.rb @@ -0,0 +1,69 @@ +require "net/http" +require "json" + +module TornStatsApi + class ApiError < StandardError; end + class RateLimitError < ApiError; end + class InvalidKeyError < ApiError; end + class NotFoundError < ApiError; end + + class Base + BASE_URL = "https://www.tornstats.com" + DEFAULT_READ_TIMEOUT = 15 + DEFAULT_OPEN_TIMEOUT = 5 + + attr_reader :api_key + + def initialize(api_key) + raise InvalidKeyError, "No TornStats API key provided" if api_key.blank? + @api_key = api_key + end + + def get(path) + uri = URI("#{BASE_URL}/#{path}") + + Rails.logger.info("TornStatsAPI request: #{uri.path.gsub(api_key, '[REDACTED]')}") + + response = perform_request(uri) + body = parse_response(response) + + Rails.logger.debug("TornStatsAPI success: #{uri.path.gsub(api_key, '[REDACTED]')}") + + body + rescue Net::ReadTimeout, Net::OpenTimeout => e + Rails.logger.error("TornStatsAPI timeout: #{e.message}") + raise ApiError, "TornStats API request timed out" + rescue JSON::ParserError => e + Rails.logger.error("TornStatsAPI JSON parse error: #{e.message}") + raise ApiError, "Invalid JSON response from TornStats API" + rescue Net::HTTPError, SocketError => e + Rails.logger.error("TornStatsAPI network error: #{e.message}") + raise ApiError, "Network error contacting TornStats: #{e.message}" + end + + private + + def perform_request(uri) + Net::HTTP.start( + uri.host, + uri.port, + use_ssl: true, + read_timeout: DEFAULT_READ_TIMEOUT, + open_timeout: DEFAULT_OPEN_TIMEOUT + ) do |http| + req = Net::HTTP::Get.new(uri) + req["accept"] = "application/json" + http.request(req) + end + end + + def parse_response(response) + unless response.code.to_i == 200 + Rails.logger.error("HTTP #{response.code} from TornStats API: #{response.body[0..500]}") + raise ApiError, "TornStats API request failed (HTTP #{response.code})" + end + + JSON.parse(response.body) + end + end +end diff --git a/app/models/torn_stats_api/spy_faction.rb b/app/models/torn_stats_api/spy_faction.rb new file mode 100644 index 0000000..6eb4054 --- /dev/null +++ b/app/models/torn_stats_api/spy_faction.rb @@ -0,0 +1,63 @@ +module TornStatsApi + class SpyFaction < Base + SpyData = Data.define( + :torn_id, + :name, + :level, + :strength, + :defense, + :speed, + :dexterity, + :total, + :spied_at + ) + + attr_reader :faction_id + + def initialize(api_key, faction_id:) + super(api_key) + @faction_id = faction_id + end + + def fetch + response = get("api/v2/#{api_key}/spy/faction/#{faction_id}") + + unless response["status"] + message = response["message"] || "Unknown error" + raise NotFoundError, "TornStats spy data not available: #{message}" + end + + faction_data = response["faction"] + unless faction_data && faction_data["members"] + raise NotFoundError, "No faction member data in TornStats response" + end + + parse_members(faction_data["members"]) + end + + private + + def parse_members(members_hash) + members_hash.filter_map do |torn_id, member_data| + spy = member_data["spy"] + next unless spy && spy["total"] + + # Spy timestamp is on the spy object itself + spy_timestamp = spy["timestamp"] + spied_at = spy_timestamp ? Time.at(spy_timestamp.to_i) : nil + + SpyData.new( + torn_id: torn_id.to_i, + name: member_data["name"], + level: member_data["level"], + strength: spy["strength"]&.to_i, + defense: spy["defense"]&.to_i, + speed: spy["speed"]&.to_i, + dexterity: spy["dexterity"]&.to_i, + total: spy["total"]&.to_i, + spied_at: spied_at + ) + end + end + end +end diff --git a/app/views/factions/settings/show.html.erb b/app/views/factions/settings/show.html.erb new file mode 100644 index 0000000..8538808 --- /dev/null +++ b/app/views/factions/settings/show.html.erb @@ -0,0 +1,97 @@ + + +<%= link_to "← Back to faction", faction_path(@faction), class: "back-link mb-4" %> + + +
    +

    API Keys

    + + <%= form_with model: @faction_setting, url: faction_settings_path(@faction), method: :patch do |form| %> +
    + +
    +
    + Torn API Key + <% if @faction_setting.torn_api_key? %> + <% access_type = @faction_setting.torn_api_access_type %> + <% badge_class = access_type&.downcase&.include?("full") ? "api-key-access-good" : "api-key-access-limited" %> + <%= access_type || "Unknown" %> + <% end %> +
    +
    + <%= @torn_api_key_masked || "Not set" %> +
    + <%= form.text_field :torn_api_key, + class: "api-key-form-input api-key-form-input-full", + placeholder: "Enter new Torn API key", + autocomplete: "off", + value: "" %> +

    Faction access required. Validated on save.

    +
    + + +
    +
    + TornStats API Key +
    +
    + <%= @tornstats_api_key_masked || "Not set" %> +
    + <%= form.text_field :tornstats_api_key, + class: "api-key-form-input api-key-form-input-full", + placeholder: "Enter new TornStats API key", + autocomplete: "off", + value: "" %> +

    Used to import spy data for enemy factions.

    +
    +
    + +
    + <%= form.submit "Save Keys", class: "btn-primary" %> +
    + <% end %> +
    + + +
    +

    Import Spy Stats

    + +
    +
    +

    Import spy data from TornStats for an enemy faction. Updates existing reports and adds new ones.

    +

    <%= @spy_report_count %> reports stored + <% if @spy_report_count > 0 %> + · <%= link_to "View all", faction_spy_stats_path(@faction), class: "import-spy-link" %> + <% end %> +

    +
    + + <% unless @faction_setting.tornstats_api_key? %> +

    Configure your TornStats API key above before importing.

    + <% else %> + <%= form_with url: import_spies_faction_settings_path(@faction), method: :post do |form| %> +
    + <%= form.text_field :target_faction_id, + class: "api-key-form-input", + placeholder: "Enter enemy faction ID", + autocomplete: "off", + inputmode: "numeric", + pattern: "[0-9]*" %> + <% if @can_import %> + <%= form.submit "Import", class: "btn-primary" %> + <% else %> + + + + <% end %> +
    + <% end %> + <% end %> +
    +
    diff --git a/app/views/factions/show.html.erb b/app/views/factions/show.html.erb index 5f3ec23..d1ee587 100644 --- a/app/views/factions/show.html.erb +++ b/app/views/factions/show.html.erb @@ -27,17 +27,81 @@

    Track member xanax, energy refills, nerve refills, and daily activity compliance.

    <% end %> -
    - Coming Soon -
    - - - - - + <% if Current.user.admin? %> + <%= link_to faction_ranked_wars_path(@faction), class: "admin-card" do %> +
    + + + + + +
    +

    Ranked Wars

    +

    View war history, track member performance, and monitor ongoing wars.

    + <% end %> + <% else %> +
    + Coming Soon +
    + + + + + +
    +

    Ranked Wars

    +

    View war history, track member performance, and monitor ongoing wars.

    -

    Ranked Wars

    -

    View war history, track member performance, and monitor ongoing wars.

    -
    + <% end %> + + <% if Current.user.admin? %> + <%= link_to faction_spy_stats_path(@faction), class: "admin-card" do %> +
    + + + + +
    +

    Spy Stats

    +

    Browse imported spy reports, filter by stats, and view enemy battle stats.

    + <% end %> + <% else %> +
    + Coming Soon +
    + + + + +
    +

    Spy Stats

    +

    Browse imported spy reports, filter by stats, and view enemy battle stats.

    +
    + <% end %> + + <% if Current.user.admin? %> + <%= link_to faction_settings_path(@faction), class: "admin-card" do %> +
    + + + + +
    +

    Faction Settings

    +

    Configure API keys, import spy data, and manage faction-specific settings.

    + <% end %> + <% else %> +
    + Coming Soon +
    + + + + +
    +

    Faction Settings

    +

    Configure API keys, import spy data, and manage faction-specific settings.

    +
    + <% end %>
    <% end %> diff --git a/app/views/factions/spy_stats/show.html.erb b/app/views/factions/spy_stats/show.html.erb new file mode 100644 index 0000000..a392647 --- /dev/null +++ b/app/views/factions/spy_stats/show.html.erb @@ -0,0 +1,56 @@ + + +<%= link_to "← Back to faction", faction_path(@faction), class: "back-link mb-4" %> + +
    +
    + + + + + + + + + + + + + + + <% if @spy_reports.empty? %> + + + + <% else %> + <% @spy_reports.each_with_index do |report, index| %> + + + + + + + + + + + <% end %> + <% end %> + +
    #TORN IDSTRENGTHDEFENSESPEEDDEXTERITYTOTALDATA AGE
    +
    +

    No spy reports imported yet.

    +

    Import spy data from the <%= link_to "Faction Settings", faction_settings_path(@faction) %> page.

    +
    +
    <%= index + 1 %> + + <%= report.torn_id %> + + <%= number_with_delimiter(report.strength) %><%= number_with_delimiter(report.defense) %><%= number_with_delimiter(report.speed) %><%= number_with_delimiter(report.dexterity) %><%= number_with_delimiter(report.total) %><%= report.spied_at ? time_ago_in_words(report.spied_at) + " ago" : "Unknown" %>
    +
    +
    diff --git a/config/routes.rb b/config/routes.rb index b7efa84..b31d11a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,6 +5,7 @@ namespace :api do resource :session, only: [ :create ] post :subscription, to: "subscriptions#show" + post :war, to: "wars#show" end resource :session @@ -18,6 +19,10 @@ post :sync end end + resource :settings, only: [ :show, :update ], controller: "factions/settings" do + post :import_spies + end + resource :spy_stats, only: [ :show ], controller: "factions/spy_stats" end resources :welcome, only: [ :index ] diff --git a/db/migrate/20260220100001_create_faction_settings.rb b/db/migrate/20260220100001_create_faction_settings.rb new file mode 100644 index 0000000..112ba75 --- /dev/null +++ b/db/migrate/20260220100001_create_faction_settings.rb @@ -0,0 +1,11 @@ +class CreateFactionSettings < ActiveRecord::Migration[8.1] + def change + create_table :faction_settings do |t| + t.references :faction, null: false, foreign_key: true, index: { unique: true } + t.string :torn_api_key + t.string :tornstats_api_key + + t.timestamps + end + end +end diff --git a/db/migrate/20260220100002_create_spy_reports.rb b/db/migrate/20260220100002_create_spy_reports.rb new file mode 100644 index 0000000..0f38e53 --- /dev/null +++ b/db/migrate/20260220100002_create_spy_reports.rb @@ -0,0 +1,19 @@ +class CreateSpyReports < ActiveRecord::Migration[8.1] + def change + create_table :spy_reports do |t| + t.references :faction, null: false, foreign_key: true + t.integer :torn_id, null: false + t.bigint :strength + t.bigint :defense + t.bigint :speed + t.bigint :dexterity + t.bigint :total + t.datetime :spied_at + + t.timestamps + end + + add_index :spy_reports, [ :faction_id, :torn_id ], unique: true + add_index :spy_reports, :torn_id + end +end diff --git a/db/migrate/20260220100003_add_torn_api_access_type_to_faction_settings.rb b/db/migrate/20260220100003_add_torn_api_access_type_to_faction_settings.rb new file mode 100644 index 0000000..b176793 --- /dev/null +++ b/db/migrate/20260220100003_add_torn_api_access_type_to_faction_settings.rb @@ -0,0 +1,5 @@ +class AddTornApiAccessTypeToFactionSettings < ActiveRecord::Migration[8.1] + def change + add_column :faction_settings, :torn_api_access_type, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 8afca46..65bde95 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_02_20_071935) do +ActiveRecord::Schema[8.1].define(version: 2026_02_20_100003) do create_table "api_calls", force: :cascade do |t| t.string "api_key", null: false t.datetime "created_at", null: false @@ -27,6 +27,16 @@ t.index ["user_id"], name: "index_api_calls_on_user_id" end + create_table "faction_settings", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "faction_id", null: false + t.string "torn_api_access_type" + t.string "torn_api_key" + t.string "tornstats_api_key" + t.datetime "updated_at", null: false + t.index ["faction_id"], name: "index_faction_settings_on_faction_id", unique: true + end + create_table "faction_subscription_grants", force: :cascade do |t| t.datetime "created_at", null: false t.integer "faction_id" @@ -126,6 +136,24 @@ t.index ["user_id"], name: "index_sessions_on_user_id" end + create_table "spy_reports", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "defense" + t.bigint "dexterity" + t.integer "faction_id", null: false + t.decimal "fair_fight" + t.bigint "speed" + t.datetime "spied_at" + t.string "spy_type" + t.bigint "strength" + t.integer "torn_id", null: false + t.bigint "total" + t.datetime "updated_at", null: false + t.index ["faction_id", "torn_id"], name: "index_spy_reports_on_faction_id_and_torn_id", unique: true + t.index ["faction_id"], name: "index_spy_reports_on_faction_id" + t.index ["torn_id"], name: "index_spy_reports_on_torn_id" + end + create_table "subscription_grants", force: :cascade do |t| t.datetime "created_at", null: false t.integer "faction_subscription_grant_id", null: false @@ -196,12 +224,14 @@ end add_foreign_key "api_calls", "users" + add_foreign_key "faction_settings", "factions" add_foreign_key "faction_subscription_grants", "factions" add_foreign_key "faction_subscription_grants", "users", column: "granted_by_id" add_foreign_key "personal_stat_snapshots", "users" add_foreign_key "personal_stat_snapshots", "users" add_foreign_key "ranked_wars", "factions" add_foreign_key "sessions", "users" + add_foreign_key "spy_reports", "factions" add_foreign_key "subscription_grants", "faction_subscription_grants" add_foreign_key "subscription_grants", "users" add_foreign_key "users", "factions" From 9ba5f8abafeb2bcc7560e7f94ceec4e8ad3ce9fa Mon Sep 17 00:00:00 2001 From: Bram Date: Sat, 21 Feb 2026 11:52:47 +0100 Subject: [PATCH 070/266] update --- app/controllers/api/wars_controller.rb | 33 ++++++------------- .../factions/settings_controller.rb | 6 ++++ app/controllers/progress_controller.rb | 2 +- app/controllers/settings_controller.rb | 4 +-- app/models/torn_api.rb | 32 +++++++++++------- 5 files changed, 39 insertions(+), 38 deletions(-) diff --git a/app/controllers/api/wars_controller.rb b/app/controllers/api/wars_controller.rb index 9472771..4648a6b 100644 --- a/app/controllers/api/wars_controller.rb +++ b/app/controllers/api/wars_controller.rb @@ -1,12 +1,9 @@ module Api class WarsController < ActionController::API - STATUS_CACHE_TTL = 30.seconds - def show api_key = params[:api_key].to_s.strip torn_ids = Array(params[:torn_ids]).map(&:to_i).reject(&:zero?) enemy_faction_id = params[:enemy_faction_id].to_s.strip.presence - refresh = params[:refresh].present? if api_key.blank? return render json: { error: "API key is required" }, status: :bad_request @@ -31,13 +28,13 @@ def show return render json: { error: "Faction API keys not configured. Ask your faction leader to set them up on tornmanager.com." }, status: :unprocessable_entity end - # Look up cached spy reports + # Look up spy reports spy_reports = faction.spy_reports.for_targets(torn_ids).index_by(&:torn_id) - # Fetch live status from Torn API (cached briefly) + # Live fetch enemy status from Torn API members_status = {} if enemy_faction_id.present? - members_status = fetch_enemy_status(setting.torn_api_key, enemy_faction_id, refresh) + members_status = fetch_enemy_status(setting.torn_api_key, enemy_faction_id) end # Build response @@ -61,24 +58,14 @@ def show private - def fetch_enemy_status(torn_api_key, enemy_faction_id, refresh) - cache_key = "war:enemy_status:#{enemy_faction_id}" - - if refresh - Rails.cache.delete(cache_key) - end - - Rails.cache.fetch(cache_key, expires_in: STATUS_CACHE_TTL) do - begin - members = TornApi::Faction::Members.new(torn_api_key, enemy_faction_id).fetch - members.each_with_object({}) do |member, hash| - hash[member.id] = member - end - rescue TornApi::ApiError => e - Rails.logger.error("Failed to fetch enemy faction status: #{e.class} - #{e.message}") - {} - end + def fetch_enemy_status(torn_api_key, enemy_faction_id) + members = TornApi::Faction::Members.new(torn_api_key, enemy_faction_id).fetch + members.each_with_object({}) do |member, hash| + hash[member.id] = member end + rescue TornApi::ApiError => e + Rails.logger.error("War live fetch failed for faction #{enemy_faction_id}: #{e.class} - #{e.message}") + {} end def build_member_data(torn_id, spy, status) diff --git a/app/controllers/factions/settings_controller.rb b/app/controllers/factions/settings_controller.rb index da589ec..5718799 100644 --- a/app/controllers/factions/settings_controller.rb +++ b/app/controllers/factions/settings_controller.rb @@ -29,6 +29,12 @@ def update if new_torn_key begin key_info = TornApi::Key::Info.new(new_torn_key).fetch + + # Verify the key belongs to the current user + unless Current.user.admin? || key_info.user.id == Current.user.torn_id + return redirect_to faction_settings_path(@faction), alert: "This API key does not belong to you." + end + @faction_setting.torn_api_key = new_torn_key @faction_setting.torn_api_access_type = key_info.access.type changes_made = true diff --git a/app/controllers/progress_controller.rb b/app/controllers/progress_controller.rb index 67e9016..6049f6e 100644 --- a/app/controllers/progress_controller.rb +++ b/app/controllers/progress_controller.rb @@ -5,7 +5,7 @@ def index @has_limited_access = Current.user.has_limited_access? if @has_limited_access - owned_stocks = TornApi::User::Stocks.new(Current.user.api_key, user: Current.user).fetch + owned_stocks = TornApi::User::Stocks.new(Current.user.api_key).fetch @table_rows = Torn::Stock.money_rows(owned_stocks).sort_by { |row| row[:days_to_break_even].infinite? ? Float::INFINITY : row[:days_to_break_even] } else # Show all stocks without ownership info for users without Limited Access diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 1849181..c234c65 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -38,7 +38,7 @@ def update_api_key begin # Validate the new API key with Torn API (track the request) - key_info = TornApi::Key::Info.new(new_api_key, user: Current.user).fetch + key_info = TornApi::Key::Info.new(new_api_key).fetch # Don't allow Full Access keys if key_info.access.type == "Full Access" @@ -47,7 +47,7 @@ def update_api_key end # Verify the key belongs to this user (track the request) - profile = TornApi::User::Profile.new(new_api_key, user: Current.user).fetch + profile = TornApi::User::Profile.new(new_api_key).fetch if profile.id != Current.user.torn_id render json: { success: false, message: "This API key belongs to a different user." } diff --git a/app/models/torn_api.rb b/app/models/torn_api.rb index 0fb0db4..097a3fa 100644 --- a/app/models/torn_api.rb +++ b/app/models/torn_api.rb @@ -15,12 +15,11 @@ class Base DEFAULT_OPEN_TIMEOUT = 5 MAX_RETRIES = 2 - attr_reader :api_key, :user + attr_reader :api_key - def initialize(api_key, user: nil) + def initialize(api_key) raise InvalidKeyError, "No API key provided" if api_key.blank? @api_key = api_key - @user = user end def get(path, params = {}, retries: 0) @@ -175,14 +174,12 @@ def log_success(uri) end def log_api_call(endpoint, params, status, response_time, metadata, error_message = nil) - is_owner_key = api_key == OwnerCredentials.api_key + target_user = resolve_api_key_owner - Rails.logger.debug("log_api_call called: endpoint=#{endpoint}, user=#{user&.id}, is_owner_key=#{is_owner_key}") - - return unless user || is_owner_key - - target_user = user || ::User.find_by(torn_id: 2728237) - Rails.logger.debug("Creating API log for user_id=#{target_user&.id}, api_key=#{api_key[0..5]}...") + unless target_user + Rails.logger.debug("log_api_call skipped: no user found for api_key=#{api_key[0..5]}...") + return + end ApiCall.create!( user: target_user, @@ -194,11 +191,22 @@ def log_api_call(endpoint, params, status, response_time, metadata, error_messag error_message: error_message, torn_api_timestamp: metadata&.dig("timestamp") ) - - Rails.logger.debug("API call logged successfully") rescue => e Rails.logger.error("Failed to log API call: #{e.message}") Rails.logger.error(e.backtrace.first(5).join("\n")) end + + def resolve_api_key_owner + @resolved_user ||= begin + # Try to find user by API key + found = ::User.find_by(api_key: api_key) + return found if found + + # Fall back to owner if this is the owner key + if api_key == OwnerCredentials.api_key + ::User.find_by(torn_id: 2728237) + end + end + end end end From 089f3e0384ee387b84ad6ef815ffe7ddc888af6f Mon Sep 17 00:00:00 2001 From: Bram Date: Sat, 21 Feb 2026 12:52:07 +0100 Subject: [PATCH 071/266] api cal model tested --- test/controllers/sessions_controller_test.rb | 6 +- test/controllers/settings_controller_test.rb | 2 +- .../user_api_calls_controller_test.rb | 2 +- test/fixtures/api_calls.yml | 34 +++--- test/fixtures/faction_subscription_grants.yml | 4 +- test/fixtures/personal_stat_snapshots.yml | 2 +- test/fixtures/users.yml | 10 +- test/fixtures/xanax_payments.yml | 8 +- test/models/api_call_test.rb | 108 +++++++++++++++++- 9 files changed, 138 insertions(+), 38 deletions(-) diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index c91c5bb..b456714 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -4,7 +4,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest setup do @valid_api_key = "test_api_key_valid" @invalid_api_key = "test_api_key_invalid" - @existing_user = users(:one) + @existing_user = users(:bram) end test "new shows login page" do @@ -292,7 +292,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest end test "destroy terminates session and redirects to root" do - user = users(:one) + user = users(:bram) sign_in_as(user) session_count_before = user.sessions.count @@ -305,7 +305,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest end test "destroy uses see_other status" do - sign_in_as(users(:one)) + sign_in_as(users(:bram)) delete session_path diff --git a/test/controllers/settings_controller_test.rb b/test/controllers/settings_controller_test.rb index 80eee1d..607732a 100644 --- a/test/controllers/settings_controller_test.rb +++ b/test/controllers/settings_controller_test.rb @@ -2,7 +2,7 @@ class SettingsControllerTest < ActionDispatch::IntegrationTest setup do - @user = users(:one) + @user = users(:bram) sign_in_as(@user) end diff --git a/test/controllers/user_api_calls_controller_test.rb b/test/controllers/user_api_calls_controller_test.rb index aaa0f44..4a36c30 100644 --- a/test/controllers/user_api_calls_controller_test.rb +++ b/test/controllers/user_api_calls_controller_test.rb @@ -2,7 +2,7 @@ class UserApiCallsControllerTest < ActionDispatch::IntegrationTest setup do - @user = users(:one) + @user = users(:bram) sign_in_as(@user) end diff --git a/test/fixtures/api_calls.yml b/test/fixtures/api_calls.yml index af8ed73..ab80793 100644 --- a/test/fixtures/api_calls.yml +++ b/test/fixtures/api_calls.yml @@ -1,21 +1,19 @@ -# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html - one: - user: one - api_key: test_api_key_one - endpoint: MyString - selections: MyString - response_time: 1 - status: MyString - error_message: MyText - torn_api_timestamp: 1 + user: bram + api_key: ABCDEF + endpoint: /user + selections: '{"selections":"profile"}' + response_time: 150 + status: success + error_message: + torn_api_timestamp: 1708000000 two: - user: two - api_key: test_api_key_two - endpoint: MyString - selections: MyString - response_time: 1 - status: MyString - error_message: MyText - torn_api_timestamp: 1 + user: bert + api_key: GHIJKL + endpoint: /faction + selections: '{"selections":"basic"}' + response_time: 200 + status: error + error_message: "Incorrect ID" + torn_api_timestamp: 1708000100 diff --git a/test/fixtures/faction_subscription_grants.yml b/test/fixtures/faction_subscription_grants.yml index 77199c8..e61f552 100644 --- a/test/fixtures/faction_subscription_grants.yml +++ b/test/fixtures/faction_subscription_grants.yml @@ -4,12 +4,12 @@ one: torn_faction_id: 12345 faction_name: Test Faction One weeks_granted: 4 - granted_by: one + granted_by: bram granted_at: 2026-02-15 18:03:20 two: torn_faction_id: 67890 faction_name: Test Faction Two weeks_granted: 2 - granted_by: two + granted_by: bert granted_at: 2026-02-15 18:03:20 diff --git a/test/fixtures/personal_stat_snapshots.yml b/test/fixtures/personal_stat_snapshots.yml index cd353df..74c3cd8 100644 --- a/test/fixtures/personal_stat_snapshots.yml +++ b/test/fixtures/personal_stat_snapshots.yml @@ -1,4 +1,4 @@ one: - user: one + user: bram date: 2025-02-17 timestamp: 1739836800 diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 9eeda21..a27747c 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -1,13 +1,13 @@ -one: +bram: name: Bram torn_id: 2728237 level: 69 hof_stats_user: false api_key: ABCDEF -two: - name: TestUser +bert: + name: Bert torn_id: 1234567 level: 50 - hof_stats_user: true - api_key: nil + hof_stats_user: false + api_key: GHIJKL diff --git a/test/fixtures/xanax_payments.yml b/test/fixtures/xanax_payments.yml index 6f1a8db..643eb80 100644 --- a/test/fixtures/xanax_payments.yml +++ b/test/fixtures/xanax_payments.yml @@ -1,16 +1,16 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html one: - recipient: one - sender: one + recipient: bram + sender: bram log_id: log_one xanax_amount: 1 weeks_granted: 1 processed_at: 2026-02-15 18:02:55 two: - recipient: two - sender: two + recipient: bert + sender: bert log_id: log_two xanax_amount: 1 weeks_granted: 1 diff --git a/test/models/api_call_test.rb b/test/models/api_call_test.rb index 6c01ece..0a7c2f7 100644 --- a/test/models/api_call_test.rb +++ b/test/models/api_call_test.rb @@ -1,7 +1,109 @@ require "test_helper" class ApiCallTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test "requires an endpoint" do + call = users(:bram).api_calls.build(status: "success", api_key: "k") + assert_not call.valid? + end + + test "requires a status" do + call = users(:bram).api_calls.build(endpoint: "/user", api_key: "k") + assert_not call.valid? + end + + test "recent returns newest first" do + old = users(:bram).api_calls.create!(endpoint: "/old", status: "success", api_key: "k", created_at: 2.hours.ago) + fresh = users(:bram).api_calls.create!(endpoint: "/new", status: "success", api_key: "k", created_at: 1.second.from_now) + + assert_equal fresh, users(:bram).api_calls.recent.first + end + + test "today excludes yesterday" do + users(:bram).api_calls.create!(endpoint: "/y", status: "success", api_key: "k", created_at: 1.day.ago) + today = users(:bram).api_calls.create!(endpoint: "/t", status: "success", api_key: "k") + + assert_includes users(:bram).api_calls.today, today + assert_equal 1, users(:bram).api_calls.today.where(endpoint: ["/y", "/t"]).count + end + + test "last 24 hours excludes older calls" do + users(:bram).api_calls.create!(endpoint: "/old", status: "success", api_key: "k", created_at: 25.hours.ago) + recent = users(:bram).api_calls.create!(endpoint: "/new", status: "success", api_key: "k", created_at: 1.hour.ago) + + results = users(:bram).api_calls.last_24_hours + assert_includes results, recent + assert_not results.where(endpoint: "/old").exists? + end + + test "successful scope filters by success" do + users(:bram).api_calls.create!(endpoint: "/ok", status: "success", api_key: "k") + users(:bram).api_calls.create!(endpoint: "/fail", status: "error", api_key: "k") + + assert users(:bram).api_calls.successful.where(endpoint: "/ok").exists? + assert_not users(:bram).api_calls.successful.where(endpoint: "/fail").exists? + end + + test "failed scope filters by error" do + users(:bram).api_calls.create!(endpoint: "/ok", status: "success", api_key: "k") + users(:bram).api_calls.create!(endpoint: "/fail", status: "error", api_key: "k") + + assert users(:bram).api_calls.failed.where(endpoint: "/fail").exists? + assert_not users(:bram).api_calls.failed.where(endpoint: "/ok").exists? + end + + test "peak rate finds the busiest minute" do + burst_time = 2.hours.ago.change(sec: 0) + 5.times { |i| users(:bram).api_calls.create!(endpoint: "/burst", status: "success", api_key: "k", created_at: burst_time + i.seconds) } + + quiet_time = 1.hour.ago.change(sec: 0) + 2.times { |i| users(:bram).api_calls.create!(endpoint: "/quiet", status: "success", api_key: "k", created_at: quiet_time + i.seconds) } + + result = ApiCall.peak_rate_for(users(:bram)) + assert_equal 5, result[:rate] + assert_not_nil result[:minute_start] + end + + test "peak rate today ignores yesterday" do + yesterday = 1.day.ago.change(sec: 0) + 10.times { |i| users(:bram).api_calls.create!(endpoint: "/y", status: "success", api_key: "k", created_at: yesterday + i.seconds) } + + today = Time.current.change(sec: 0) - 30.minutes + 3.times { |i| users(:bram).api_calls.create!(endpoint: "/t", status: "success", api_key: "k", created_at: today + i.seconds) } + + assert_equal 3, ApiCall.peak_rate_for(users(:bram), scope: :today)[:rate] + end + + test "peak rate is zero with no calls" do + users(:bert).api_calls.destroy_all + + result = ApiCall.peak_rate_for(users(:bert)) + assert_equal 0, result[:rate] + assert_nil result[:minute_start] + end + + test "peak rate is scoped to the user" do + burst_time = 1.hour.ago.change(sec: 0) + 3.times { |i| users(:bert).api_calls.create!(endpoint: "/u2", status: "success", api_key: "k", created_at: burst_time + i.seconds) } + 7.times { |i| users(:bram).api_calls.create!(endpoint: "/u1", status: "success", api_key: "k", created_at: burst_time + i.seconds) } + + assert_equal 3, ApiCall.peak_rate_for(users(:bert))[:rate] + end + + test "broadcasts to ActionCable after create" do + ApiRateMonitorChannel.expects(:broadcast_to).with( + users(:bram), + has_entries(endpoint: "/test", status: "success") + ) + + users(:bram).api_calls.create!(endpoint: "/test", status: "success", api_key: "k") + end + + test "broadcast payload includes id, created_at, and response_time" do + ApiRateMonitorChannel.expects(:broadcast_to).with( + users(:bram), + has_key(:id) & has_key(:created_at) & has_entries(response_time: 150) + ) + + users(:bram).api_calls.create!(endpoint: "/user", status: "success", api_key: "k", response_time: 150) + end end From 6a9ea616a4c8668653f7c5630101d68a8f90087c Mon Sep 17 00:00:00 2001 From: Bram Date: Sat, 21 Feb 2026 12:59:14 +0100 Subject: [PATCH 072/266] remove appsignal --- .../admin/subscriptions_controller.rb | 10 - app/controllers/sessions_controller.rb | 8 - app/controllers/settings_controller.rb | 11 - app/jobs/daily/data_retention_cleanup_job.rb | 2 - app/jobs/daily/xanax_payments_job.rb | 6 - app/jobs/fetch_personal_stats_job.rb | 3 - app/jobs/sync_faction_members_job.rb | 2 - app/models/api_call.rb | 26 +- app/models/torn_api.rb | 23 -- appsignal_dashboard.json | 236 ------------------ config/appsignal.rb | 20 +- lib/tasks/appsignal.rake | 51 ---- 12 files changed, 10 insertions(+), 388 deletions(-) delete mode 100644 appsignal_dashboard.json delete mode 100644 lib/tasks/appsignal.rake diff --git a/app/controllers/admin/subscriptions_controller.rb b/app/controllers/admin/subscriptions_controller.rb index 8979e96..edc72c7 100644 --- a/app/controllers/admin/subscriptions_controller.rb +++ b/app/controllers/admin/subscriptions_controller.rb @@ -20,17 +20,13 @@ def create_faction_grant grant_faction_subscription rescue TornApi::InvalidKeyError => e Rails.logger.error("Invalid API key error: #{e.message}") - ::Appsignal.increment_counter("subscription.faction_grant_failed", 1, { reason: "invalid_key" }) if defined?(::Appsignal) redirect_with_error("Invalid API key.") rescue TornApi::ApiError => e Rails.logger.error("Torn API error: #{e.class} - #{e.message}") - ::Appsignal.increment_counter("subscription.faction_grant_failed", 1, { reason: "api_error" }) if defined?(::Appsignal) redirect_with_error("Torn API error: #{e.message}") rescue => e Rails.logger.error("Faction grant error: #{e.class} - #{e.message}") Rails.logger.error(e.backtrace.join("\n")) - ::Appsignal.increment_counter("subscription.faction_grant_failed", 1, { reason: "unexpected_error" }) if defined?(::Appsignal) - ::Appsignal.send_error(e) if defined?(::Appsignal) redirect_with_error("An error occurred: #{e.message}") end @@ -40,7 +36,6 @@ def update_days if days >= 0 user.update!(subscription_expires_at: days.days.from_now) - ::Appsignal.increment_counter("subscription.days_updated", 1) if defined?(::Appsignal) render json: { success: true, new_expires_at: user.subscription_expires_at.strftime("%Y-%m-%d %H:%M"), days: days } else render json: { success: false, error: "Days must be a positive number" }, status: :unprocessable_entity @@ -64,11 +59,6 @@ def grant_faction_subscription members.each { |member| grant_to_member(grant, member) } end - # Track faction grant - ::Appsignal.increment_counter("subscription.faction_grant", 1) if defined?(::Appsignal) - ::Appsignal.increment_counter("subscription.weeks_granted", weeks * members.count, { type: "faction_grant" }) if defined?(::Appsignal) - ::Appsignal.increment_counter("subscription.users_granted", members.count) if defined?(::Appsignal) - redirect_to admin_subscriptions_path, notice: "Successfully granted #{weeks} week(s) to #{members.count} members of #{faction_info['name']}." end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index bddc14b..aed79f0 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -13,7 +13,6 @@ def create profile = TornApi::User::Profile.new(api_key).fetch if profile.nil? - ::Appsignal.increment_counter("auth.login_failed", 1, { reason: "profile_fetch_failed" }) if defined?(::Appsignal) return redirect_to new_session_path, alert: "Could not fetch profile from Torn API." end @@ -31,26 +30,19 @@ def create start_new_session_for user - ::Appsignal.increment_counter("auth.login_success", 1) if defined?(::Appsignal) - redirect_to after_authentication_url rescue TornApi::InvalidKeyError - ::Appsignal.increment_counter("auth.login_failed", 1, { reason: "invalid_key" }) if defined?(::Appsignal) redirect_to new_session_path, alert: "Invalid Torn API key." rescue ActiveRecord::RecordInvalid => e Rails.logger.error("User upsert failed: #{e.record.errors.full_messages}") - ::Appsignal.increment_counter("auth.login_failed", 1, { reason: "user_save_failed" }) if defined?(::Appsignal) redirect_to new_session_path, alert: "Could not create user profile." rescue => e Rails.logger.error("Unexpected login error: #{e.class} - #{e.message}") - ::Appsignal.increment_counter("auth.login_failed", 1, { reason: "unexpected_error" }) if defined?(::Appsignal) - ::Appsignal.send_error(e) if defined?(::Appsignal) redirect_to new_session_path, alert: "Unexpected error. Please try again." end end def destroy - ::Appsignal.increment_counter("auth.logout", 1) if defined?(::Appsignal) terminate_session redirect_to root_path, status: :see_other end diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index c234c65..f4e910a 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -60,8 +60,6 @@ def update_api_key api_access_type: key_info.access.type ) - ::Appsignal.increment_counter("user.api_key_updated", 1) if defined?(::Appsignal) - render json: { success: true, message: "API key updated! Access level: #{key_info.access.type}", access_type: key_info.access.type } rescue TornApi::InvalidKeyError => e Rails.logger.error "Invalid API key for user #{Current.user.torn_id}: #{e.message}" @@ -71,7 +69,6 @@ def update_api_key render json: { success: false, message: "Torn API error: #{e.message}" } rescue => e Rails.logger.error "Failed to update API key for user #{Current.user.torn_id}: #{e.class} - #{e.message}" - ::Appsignal.send_error(e) if defined?(::Appsignal) render json: { success: false, message: "Failed to update API key. Please try again later." } end end @@ -83,8 +80,6 @@ def purge_data Current.user.api_calls.destroy_all Current.user.update!(api_key: nil) - ::Appsignal.increment_counter("user.data_purged", 1) if defined?(::Appsignal) - Rails.logger.info "Data purge completed for user #{Current.user.torn_id}" terminate_session @@ -125,8 +120,6 @@ def export_data end } - ::Appsignal.increment_counter("user.data_exported", 1) if defined?(::Appsignal) - send_data export.to_json, filename: "tornmanager-data-#{user.torn_id}-#{Date.current}.json", type: "application/json", @@ -142,13 +135,9 @@ def refresh_subscription session[:last_subscription_refresh_at] = Time.current.to_i Daily::XanaxPaymentsJob.perform_now - ::Appsignal.increment_counter("subscription.manual_refresh", 1) if defined?(::Appsignal) - redirect_to settings_path, notice: "Subscription status refreshed! Check your subscription details above." rescue => e Rails.logger.error "Failed to refresh subscription for user #{Current.user.torn_id}: #{e.message}" - ::Appsignal.increment_counter("subscription.refresh_failed", 1) if defined?(::Appsignal) - ::Appsignal.send_error(e) if defined?(::Appsignal) redirect_to settings_path, alert: "Failed to refresh subscription status. Please try again later." end diff --git a/app/jobs/daily/data_retention_cleanup_job.rb b/app/jobs/daily/data_retention_cleanup_job.rb index 28b1bda..2ab8416 100644 --- a/app/jobs/daily/data_retention_cleanup_job.rb +++ b/app/jobs/daily/data_retention_cleanup_job.rb @@ -18,7 +18,6 @@ def cleanup_old_sessions deleted_count = Session.where("created_at < ?", cutoff).delete_all Rails.logger.info "DataRetentionCleanupJob: Deleted #{deleted_count} sessions older than #{SESSION_RETENTION_DAYS} days" - ::Appsignal.set_gauge("data_retention.sessions_deleted", deleted_count) if defined?(::Appsignal) end def cleanup_old_api_calls @@ -26,7 +25,6 @@ def cleanup_old_api_calls deleted_count = ApiCall.where("created_at < ?", cutoff).delete_all Rails.logger.info "DataRetentionCleanupJob: Deleted #{deleted_count} API calls older than #{API_CALL_RETENTION_DAYS} days" - ::Appsignal.set_gauge("data_retention.api_calls_deleted", deleted_count) if defined?(::Appsignal) end end end diff --git a/app/jobs/daily/xanax_payments_job.rb b/app/jobs/daily/xanax_payments_job.rb index 62cb9c5..11569ea 100644 --- a/app/jobs/daily/xanax_payments_job.rb +++ b/app/jobs/daily/xanax_payments_job.rb @@ -34,12 +34,6 @@ def process_payment(entry) sender.extend_subscription!(entry.xanax_quantity) - # Track subscription payment - if defined?(::Appsignal) - ::Appsignal.increment_counter("subscription.xanax_payment", 1) - ::Appsignal.increment_counter("subscription.weeks_granted", entry.xanax_quantity, { type: "xanax" }) - end - Rails.logger.info "Xanax payment processed: #{entry.xanax_quantity} from #{sender.name || sender.torn_id}" end diff --git a/app/jobs/fetch_personal_stats_job.rb b/app/jobs/fetch_personal_stats_job.rb index 284682b..7394ca3 100644 --- a/app/jobs/fetch_personal_stats_job.rb +++ b/app/jobs/fetch_personal_stats_job.rb @@ -8,9 +8,6 @@ def perform(user, batch: 1, stats_date: Date.current.yesterday) save_snapshot(user, stats, stats_date) FetchPersonalStatsJob.perform_later(user, batch: 2, stats_date: stats_date) if batch == 1 - rescue - Appsignal.increment_counter("jobs.personal_stats_failed", 1) if defined?(Appsignal) - raise end private diff --git a/app/jobs/sync_faction_members_job.rb b/app/jobs/sync_faction_members_job.rb index da93e2c..27360e1 100644 --- a/app/jobs/sync_faction_members_job.rb +++ b/app/jobs/sync_faction_members_job.rb @@ -24,9 +24,7 @@ def perform(faction_id) end Rails.logger.info "SyncFactionMembersJob: Synced #{members.size} members for faction #{faction.name} [#{faction.torn_id}]" - ::Appsignal.set_gauge("faction_sync.members_synced", members.size, faction_id: faction.torn_id) if defined?(::Appsignal) rescue TornApi::ApiError => e Rails.logger.error "SyncFactionMembersJob: Failed to sync faction #{faction.torn_id}: #{e.message}" - ::Appsignal.send_error(e) if defined?(::Appsignal) end end diff --git a/app/models/api_call.rb b/app/models/api_call.rb index 9e7299f..7bcd593 100644 --- a/app/models/api_call.rb +++ b/app/models/api_call.rb @@ -1,4 +1,6 @@ class ApiCall < ApplicationRecord + MINUTE_BUCKET = Arel.sql("strftime('%Y-%m-%d %H:%M', created_at)") + belongs_to :user validates :endpoint, presence: true @@ -13,25 +15,15 @@ class ApiCall < ApplicationRecord after_create :broadcast_api_call def self.peak_rate_for(user, scope: :all) - base = case scope - when :today - user.api_calls.today - else - user.api_calls - end - - result = base - .group(Arel.sql("strftime('%Y-%m-%d %H:%M', created_at)")) + calls = scope == :today ? user.api_calls.today : user.api_calls + + minute, count = calls + .group(MINUTE_BUCKET) .order(Arel.sql("COUNT(*) DESC")) .limit(1) - .pluck(Arel.sql("strftime('%Y-%m-%d %H:%M', created_at), COUNT(*)")) - .first - - if result - { rate: result[1], minute_start: Time.zone.parse(result[0]) } - else - { rate: 0, minute_start: nil } - end + .pick(MINUTE_BUCKET, Arel.sql("COUNT(*)")) + + { rate: count || 0, minute_start: minute ? Time.zone.parse(minute) : nil } end private diff --git a/app/models/torn_api.rb b/app/models/torn_api.rb index 097a3fa..81aa91c 100644 --- a/app/models/torn_api.rb +++ b/app/models/torn_api.rb @@ -40,22 +40,10 @@ def get(path, params = {}, retries: 0) log_api_call(path, merged_params, "success", response_time, body["_metadata"]) log_success(uri) - # Track successful API call metrics - if defined?(::Appsignal) - ::Appsignal.add_distribution_value("torn_api.response_time", response_time) - ::Appsignal.increment_counter("torn_api.requests", 1, { status: "success", endpoint: path.split("/").first }) - end - body rescue InvalidKeyError, ApiError => e response_time = ((Time.current - start_time) * 1000).to_i log_api_call(path, merged_params, "error", response_time, nil, e.message) - - # Track API errors - if defined?(::Appsignal) - ::Appsignal.increment_counter("torn_api.requests", 1, { status: "error", endpoint: path.split("/").first, error_type: e.class.name }) - end - raise rescue Net::ReadTimeout, Net::OpenTimeout => e handle_timeout(uri, api_params, e, retries, start_time) @@ -63,9 +51,6 @@ def get(path, params = {}, retries: 0) response_time = ((Time.current - start_time) * 1000).to_i log_api_call(path, merged_params, "error", response_time, nil, "JSON parse error: #{e.message}") Rails.logger.error("JSON parse error for #{uri}: #{e.message}") - - ::Appsignal.increment_counter("torn_api.requests", 1, { status: "error", endpoint: path.split("/").first, error_type: "JSONParseError" }) if defined?(::Appsignal) - raise ApiError, "Invalid JSON response from Torn API" rescue Net::HTTPError, SocketError => e handle_network_error(uri, api_params, e, retries, start_time) @@ -108,10 +93,8 @@ def handle_api_error(error) case error_code when 2 - ::Appsignal.increment_counter("torn_api.invalid_key", 1) if defined?(::Appsignal) raise InvalidKeyError, "Invalid API key" when 5 - ::Appsignal.increment_counter("torn_api.rate_limit", 1) if defined?(::Appsignal) raise RateLimitError, "Too many requests: #{error_msg}" when 6 raise NotFoundError, "Incorrect ID: #{error_msg}" @@ -120,10 +103,8 @@ def handle_api_error(error) when 9 raise ApiError, "API disabled: #{error_msg}" when 10 - ::Appsignal.increment_counter("torn_api.invalid_key", 1) if defined?(::Appsignal) raise InvalidKeyError, "Key owner is in federal jail" when 13 - ::Appsignal.increment_counter("torn_api.rate_limit", 1) if defined?(::Appsignal) raise RateLimitError, "Key rate limit reached: #{error_msg}" else raise ApiError, "API error #{error_code}: #{error_msg}" @@ -133,14 +114,12 @@ def handle_api_error(error) def handle_timeout(uri, api_params, error, retries, start_time) if retries < MAX_RETRIES Rails.logger.warn("Timeout for #{uri}, retrying (#{retries + 1}/#{MAX_RETRIES}): #{error.message}") - ::Appsignal.increment_counter("torn_api.retries", 1, { reason: "timeout" }) if defined?(::Appsignal) sleep(1 * (retries + 1)) get(uri.path.sub(/^\//, ""), api_params, retries: retries + 1) else response_time = ((Time.current - start_time) * 1000).to_i log_api_call(uri.path.sub(/^\//, ""), api_params, "error", response_time, nil, "Timeout after #{MAX_RETRIES} retries") Rails.logger.error("Timeout for #{uri} after #{MAX_RETRIES} retries: #{error.message}") - ::Appsignal.increment_counter("torn_api.requests", 1, { status: "error", error_type: "TimeoutError" }) if defined?(::Appsignal) raise TimeoutError, "Torn API request timed out after #{MAX_RETRIES} retries" end end @@ -148,14 +127,12 @@ def handle_timeout(uri, api_params, error, retries, start_time) def handle_network_error(uri, api_params, error, retries, start_time) if retries < MAX_RETRIES Rails.logger.warn("Network error for #{uri}, retrying (#{retries + 1}/#{MAX_RETRIES}): #{error.message}") - ::Appsignal.increment_counter("torn_api.retries", 1, { reason: "network_error" }) if defined?(::Appsignal) sleep(1 * (retries + 1)) get(uri.path.sub(/^\//, ""), api_params, retries: retries + 1) else response_time = ((Time.current - start_time) * 1000).to_i log_api_call(uri.path.sub(/^\//, ""), api_params, "error", response_time, nil, "Network error: #{error.message}") Rails.logger.error("Network error for #{uri} after #{MAX_RETRIES} retries: #{error.message}") - ::Appsignal.increment_counter("torn_api.requests", 1, { status: "error", error_type: "NetworkError" }) if defined?(::Appsignal) raise ApiError, "Network error: #{error.message}" end end diff --git a/appsignal_dashboard.json b/appsignal_dashboard.json deleted file mode 100644 index 240e808..0000000 --- a/appsignal_dashboard.json +++ /dev/null @@ -1,236 +0,0 @@ -{ - "title": "TornManager Overview", - "description": "", - "visuals": [ - { - "type": "timeseries", - "title": "API Response Time", - "line_label": "%name% %field%", - "display": "LINE", - "format": "duration", - "draw_null_as_zero": true, - "metrics": [ - { - "name": "torn_api.response_time", - "fields": [ - { "field": "MEAN" }, - { "field": "P90" }, - { "field": "P95" } - ], - "tags": [] - } - ] - }, - { - "type": "timeseries", - "title": "API Requests", - "line_label": "%name% %tag_value%", - "display": "LINE", - "format": "number", - "draw_null_as_zero": true, - "metrics": [ - { - "name": "torn_api.requests", - "fields": [ - { "field": "COUNTER" } - ], - "tags": [ - { "key": "status", "value": "*" } - ] - } - ] - }, - { - "type": "timeseries", - "title": "Rate Limits & Invalid Keys", - "line_label": "%name%", - "display": "LINE", - "format": "number", - "draw_null_as_zero": true, - "metrics": [ - { - "name": "torn_api.rate_limit", - "fields": [ - { "field": "COUNTER" } - ], - "tags": [] - }, - { - "name": "torn_api.invalid_key", - "fields": [ - { "field": "COUNTER" } - ], - "tags": [] - } - ] - }, - { - "type": "timeseries", - "title": "Logins", - "line_label": "%name%", - "display": "LINE", - "format": "number", - "draw_null_as_zero": true, - "metrics": [ - { - "name": "auth.login_success", - "fields": [ - { "field": "COUNTER" } - ], - "tags": [] - }, - { - "name": "auth.login_failed", - "fields": [ - { "field": "COUNTER" } - ], - "tags": [] - } - ] - }, - { - "type": "timeseries", - "title": "Login Failures by Reason", - "line_label": "%name% %tag_value%", - "display": "LINE", - "format": "number", - "draw_null_as_zero": true, - "metrics": [ - { - "name": "auth.login_failed", - "fields": [ - { "field": "COUNTER" } - ], - "tags": [ - { "key": "reason", "value": "*" } - ] - } - ] - }, - { - "type": "timeseries", - "title": "Xanax Payments", - "line_label": "%name%", - "display": "LINE", - "format": "number", - "draw_null_as_zero": true, - "metrics": [ - { - "name": "subscription.xanax_payment", - "fields": [ - { "field": "COUNTER" } - ], - "tags": [] - } - ] - }, - { - "type": "timeseries", - "title": "Weeks Granted", - "line_label": "%name% %tag_value%", - "display": "LINE", - "format": "number", - "draw_null_as_zero": true, - "metrics": [ - { - "name": "subscription.weeks_granted", - "fields": [ - { "field": "COUNTER" } - ], - "tags": [ - { "key": "type", "value": "*" } - ] - } - ] - }, - { - "type": "timeseries", - "title": "Faction Grants", - "line_label": "%name%", - "display": "LINE", - "format": "number", - "draw_null_as_zero": true, - "metrics": [ - { - "name": "subscription.faction_grant", - "fields": [ - { "field": "COUNTER" } - ], - "tags": [] - }, - { - "name": "subscription.users_granted", - "fields": [ - { "field": "COUNTER" } - ], - "tags": [] - } - ] - }, - { - "type": "timeseries", - "title": "Manual Refreshes", - "line_label": "%name%", - "display": "LINE", - "format": "number", - "draw_null_as_zero": true, - "metrics": [ - { - "name": "subscription.manual_refresh", - "fields": [ - { "field": "COUNTER" } - ], - "tags": [] - }, - { - "name": "subscription.refresh_failed", - "fields": [ - { "field": "COUNTER" } - ], - "tags": [] - } - ] - }, - { - "type": "timeseries", - "title": "Personal Stats Jobs", - "line_label": "%name%", - "display": "LINE", - "format": "number", - "draw_null_as_zero": true, - "metrics": [ - { - "name": "jobs.personal_stats_fetched", - "fields": [ - { "field": "COUNTER" } - ], - "tags": [] - }, - { - "name": "jobs.personal_stats_failed", - "fields": [ - { "field": "COUNTER" } - ], - "tags": [] - } - ] - }, - { - "type": "timeseries", - "title": "Data Purges", - "line_label": "%name%", - "display": "LINE", - "format": "number", - "draw_null_as_zero": true, - "metrics": [ - { - "name": "user.data_purged", - "fields": [ - { "field": "COUNTER" } - ], - "tags": [] - } - ] - } - ] -} diff --git a/config/appsignal.rb b/config/appsignal.rb index ae8485a..841103d 100644 --- a/config/appsignal.rb +++ b/config/appsignal.rb @@ -1,26 +1,8 @@ -# AppSignal Ruby gem configuration -# Visit our documentation for a list of all available configuration options. -# https://docs.appsignal.com/ruby/configuration/options.html Appsignal.configure do |config| - config.activate_if_environment("production", "development") + config.activate_if_environment("production") config.name = "TornManager" - # Only load API key in non-test environments to avoid credential issues in tests unless Rails.env.test? - # The application's Push API key - # We recommend removing this line and setting this option with the - # APPSIGNAL_PUSH_API_KEY environment variable instead. - # https://docs.appsignal.com/ruby/configuration/options.html#option-push_api_key config.push_api_key = Rails.application.credentials.dig(:appsignal, :api_key) end - - # Configure actions that should not be monitored by AppSignal. - # For more information see our docs: - # https://docs.appsignal.com/ruby/configuration/ignore-actions.html - # config.ignore_actions << "ApplicationController#isup" - - # Configure errors that should not be recorded by AppSignal. - # For more information see our docs: - # https://docs.appsignal.com/ruby/configuration/ignore-errors.html - # config.ignore_errors << "MyCustomError" end diff --git a/lib/tasks/appsignal.rake b/lib/tasks/appsignal.rake deleted file mode 100644 index 92d755f..0000000 --- a/lib/tasks/appsignal.rake +++ /dev/null @@ -1,51 +0,0 @@ -namespace :appsignal do - desc "Generate fake metrics for testing AppSignal dashboard" - task test_metrics: :environment do - puts "Generating fake AppSignal metrics..." - - # Torn API metrics - 10.times do - response_time = rand(50..500) - Appsignal.add_distribution_value("torn_api.response_time", response_time) - Appsignal.increment_counter("torn_api.requests", 1, { status: "success", endpoint: "user" }) - end - 2.times do - Appsignal.increment_counter("torn_api.requests", 1, { status: "error", endpoint: "user", error_type: "TimeoutError" }) - end - Appsignal.increment_counter("torn_api.rate_limit", 1) - Appsignal.increment_counter("torn_api.invalid_key", 1) - Appsignal.increment_counter("torn_api.retries", 1, { reason: "timeout" }) - puts " - Torn API metrics sent" - - # Auth metrics - 5.times { Appsignal.increment_counter("auth.login_success", 1) } - Appsignal.increment_counter("auth.login_failed", 1, { reason: "invalid_key" }) - Appsignal.increment_counter("auth.login_failed", 1, { reason: "wrong_key_type" }) - Appsignal.increment_counter("auth.login_failed", 1, { reason: "profile_fetch_failed" }) - Appsignal.increment_counter("auth.logout", 1) - puts " - Auth metrics sent" - - # Subscription metrics - 3.times { Appsignal.increment_counter("subscription.xanax_payment", 1) } - Appsignal.increment_counter("subscription.weeks_granted", 5, { type: "xanax" }) - Appsignal.increment_counter("subscription.weeks_granted", 20, { type: "faction_grant" }) - Appsignal.increment_counter("subscription.faction_grant", 1) - Appsignal.increment_counter("subscription.users_granted", 15) - Appsignal.increment_counter("subscription.manual_refresh", 2) - Appsignal.increment_counter("subscription.refresh_failed", 1) - Appsignal.increment_counter("subscription.days_updated", 1) - puts " - Subscription metrics sent" - - # Job metrics - 20.times { Appsignal.increment_counter("jobs.personal_stats_fetched", 1) } - 2.times { Appsignal.increment_counter("jobs.personal_stats_failed", 1) } - Appsignal.set_gauge("jobs.personal_stats_scheduled", 150) - puts " - Job metrics sent" - - # User metrics - Appsignal.increment_counter("user.data_purged", 1) - puts " - User metrics sent" - - puts "\nDone! Check AppSignal in a few minutes." - end -end From 6751606800e8e3dca389ac8b048e9121eacbd37d Mon Sep 17 00:00:00 2001 From: Bram Date: Sat, 21 Feb 2026 13:21:31 +0100 Subject: [PATCH 073/266] settings page revamp --- app/assets/stylesheets/components/badges.css | 45 +++++ app/assets/stylesheets/components/cards.css | 18 +- app/assets/stylesheets/pages/settings.css | 84 +++++++++ app/controllers/settings_controller.rb | 11 -- app/views/settings/index.html.erb | 127 ++++++------- test/controllers/settings_controller_test.rb | 187 +++++++++++++++++-- 6 files changed, 363 insertions(+), 109 deletions(-) diff --git a/app/assets/stylesheets/components/badges.css b/app/assets/stylesheets/components/badges.css index e3bd4ff..d9d3dd2 100644 --- a/app/assets/stylesheets/components/badges.css +++ b/app/assets/stylesheets/components/badges.css @@ -141,3 +141,48 @@ box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.1); } } + +/* Ping Dot — reusable notification indicator + * Usage: + */ +.ping-dot { + position: relative; + display: inline-block; + width: 6px; + height: 6px; + margin-left: 4px; + vertical-align: super; +} + +.ping-dot::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 6px; + height: 6px; + background: #10b981; + border-radius: 50%; +} + +.ping-ring { + position: absolute; + top: 0; + left: 0; + width: 6px; + height: 6px; + background: #10b981; + border-radius: 50%; + animation: ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite; +} + +@keyframes ping { + 0% { + transform: scale(1); + opacity: 0.75; + } + 75%, 100% { + transform: scale(2.5); + opacity: 0; + } +} diff --git a/app/assets/stylesheets/components/cards.css b/app/assets/stylesheets/components/cards.css index d280762..ab39f81 100644 --- a/app/assets/stylesheets/components/cards.css +++ b/app/assets/stylesheets/components/cards.css @@ -328,6 +328,7 @@ border-radius: var(--radius-md); padding: 2rem; margin-bottom: 1.5rem; + min-height: 360px; } .subscription-active, @@ -349,22 +350,6 @@ margin-bottom: 1rem; } -/* Progress Bar */ -.progress-bar-container { - width: 100%; - height: 8px; - background: var(--gray-800); - border-radius: var(--radius-sm); - overflow: hidden; - margin-bottom: 1rem; -} - -.progress-bar-fill { - height: 100%; - background: linear-gradient(90deg, #10b981 0%, #059669 100%); - transition: width 0.3s ease; -} - .renewal-method { margin-bottom: 1rem; } @@ -379,6 +364,7 @@ border: 1px solid var(--gray-800); border-radius: var(--radius-md); padding: 2rem; + min-height: 360px; } .api-key-info { diff --git a/app/assets/stylesheets/pages/settings.css b/app/assets/stylesheets/pages/settings.css index c7de1c7..c284090 100644 --- a/app/assets/stylesheets/pages/settings.css +++ b/app/assets/stylesheets/pages/settings.css @@ -13,6 +13,90 @@ color: var(--gray-100); } +/* Top Grid: API Key + Subscription side by side */ +.settings-top-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + align-items: start; +} + +@media (max-width: 768px) { + .settings-top-grid { + grid-template-columns: 1fr; + } +} + +/* Collapsible "How to subscribe" */ +.subscribe-details { + margin-top: 1.5rem; + border-top: 1px solid var(--gray-800); + padding-top: 1rem; +} + +.subscribe-details summary { + cursor: pointer; + color: var(--gray-400); + font-size: 0.875rem; + font-weight: 500; + user-select: none; + list-style: none; +} + +.subscribe-details summary::-webkit-details-marker { + display: none; +} + +.subscribe-details summary::before { + content: "▸ "; + color: var(--gray-500); +} + +.subscribe-details[open] summary::before { + content: "▾ "; +} + +.subscribe-details-content { + margin-top: 1rem; +} + +.subscribe-method { + margin-bottom: 1rem; +} + +.subscribe-method:last-child { + margin-bottom: 0; +} + +.subscribe-method h4 { + font-size: 0.875rem; + font-weight: 600; + color: var(--gray-200); + margin-bottom: 0.5rem; +} + +.subscribe-method p { + color: var(--gray-400); + font-size: 0.8125rem; + line-height: 1.6; + margin-bottom: 0.25rem; +} + +.subscribe-method p:last-child { + margin-bottom: 0; +} + +.subscribe-method a { + color: var(--blue-500); + text-decoration: none; +} + +.subscribe-method a:hover { + text-decoration: underline; +} + + + /* Settings Page Stat Cards */ .settings-section .stat-card .stat-label { color: #ffffff; diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index f4e910a..4fc852c 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -9,7 +9,6 @@ def index @subscribed = Current.user.subscribed? @days_remaining = calculate_days_remaining if @subscribed - @subscription_progress = calculate_subscription_progress if @subscribed @last_refresh_at = session[:last_subscription_refresh_at] @can_refresh = can_refresh? @@ -147,16 +146,6 @@ def calculate_days_remaining (Current.user.subscription_expires_at.to_date - Date.current).to_i end - def calculate_subscription_progress - return 0 unless Current.user.subscribed? - - days_remaining = calculate_days_remaining - total_days = 365 - - percentage_remaining = [ (days_remaining.to_f / total_days * 100).round(1), 100 ].min - 100 - percentage_remaining - end - def can_refresh? return true unless session[:last_subscription_refresh_at] Time.current - Time.at(session[:last_subscription_refresh_at]) >= REFRESH_COOLDOWN diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb index a3b29b4..fd1154f 100644 --- a/app/views/settings/index.html.erb +++ b/app/views/settings/index.html.erb @@ -3,76 +3,73 @@

    Manage your account and subscription

    - +
    -

    API Key

    - - <%= render "api_key_card" %> -
    +
    + +
    +

    API Key

    + <%= render "api_key_card" %> +
    - -
    -

    Subscription Status

    - -
    - <% if @subscribed %> - -
    -
    ✓ Active Subscriber
    -
    <%= @days_remaining %> days remaining
    -
    Expires: <%= Current.user.subscription_expires_at.strftime("%d %B %Y") %>
    - - -
    -
    -
    -
    - <% else %> - -
    -
    Not Subscribed
    -

    You don't have an active subscription.

    + +
    +

    Subscription Status

    +
    + <% if @subscribed %> +
    +
    ✓ Active Subscriber
    +
    <%= @days_remaining %> days remaining
    +
    Expires: <%= Current.user.subscription_expires_at.strftime("%d %B %Y") %>
    +
    + <% else %> +
    +
    Not Subscribed
    +

    You don't have an active subscription.

    +
    + <% end %> + + + <%= form_with url: settings_refresh_subscription_path, method: :post do |form| %> + <% if @can_refresh %> + <%= form.submit "Check for New Payments", + class: "btn-secondary", + data: { turbo_submits_with: "Checking..." } %> + <% else %> + <%= form.submit "Available in #{@seconds_until_refresh} seconds", + class: "btn-secondary", + disabled: true, + data: { + controller: "refresh-countdown", + refresh_countdown_seconds_remaining_value: @seconds_until_refresh + } %> + <% end %> + <% end %> + + +
    - <% end %> - - - <%= form_with url: settings_refresh_subscription_path, method: :post do |form| %> - <% if @can_refresh %> - <%= form.submit "Check for New Payments", - class: "btn-secondary", - data: { turbo_submits_with: "Checking..." } %> - <% else %> - <%= form.submit "Available in #{@seconds_until_refresh} seconds", - class: "btn-secondary", - disabled: true, - data: { - controller: "refresh-countdown", - refresh_countdown_seconds_remaining_value: @seconds_until_refresh - } %> - <% end %> - <% end %> -
    - - -

    How to Subscribe or Extend Your Subscription

    - -
    -
    -

    💊 Xanax Payment (Automated)

    -

    Send Xanax to <%= link_to "Bram [2728237]", "https://www.torn.com/profiles.php?XID=2728237", target: "_blank" %>.

    -

    Each Xanax sent = 1 week of subscription.

    -

    Payments are automatically processed daily at 12 PM TCT. Use the "Check for New Payments" button above to refresh immediately.

    -
    - -
    -

    🏢 Faction Grant

    -

    If you're part of a faction, your faction leader can request bulk subscriptions for all members.

    -

    Contact <%= link_to "Bram [2728237]", "https://www.torn.com/profiles.php?XID=2728237", target: "_blank" %> to arrange a faction grant.

    - +

    Your Data

    @@ -108,7 +105,7 @@
    - +

    Data Management

    @@ -119,7 +116,7 @@
    -

    ⚠️ Delete Your Data

    +

    Delete Your Data

    This will permanently delete:

    TornManager is a read-only service that displays and analyzes data from Torn. I do not modify your @@ -153,7 +156,7 @@

    • Xanax Payment: Send Xanax to <%= link_to "Bram [2728237]", "https://www.torn.com/profiles.php?XID=2728237", target: "_blank" %>. Each Xanax sent equals 1 week of subscription. Payments are automatically processed daily at 12 PM TCT.
    • -
    • Faction Grant: Faction leaders can arrange bulk subscriptions for their members by contacting <%= link_to "Bram [2728237]", "https://www.torn.com/profiles.php?XID=2728237", target: "_blank" %>.
    • +
    • Subscription Sharing: Faction leaders with active subscriptions can share subscription weeks with their faction members through the faction settings. This allows leaders to extend access to their members without individual payments.

    Subscription status is tied to your Torn ID, not your stored data. If you delete your collected data, @@ -161,6 +164,35 @@

    +
    +

    Faction Features

    +

    + TornManager offers enhanced features for faction leaders and members of tracked factions: +

    + +

    Faction API Keys

    +

    + Faction leaders may optionally provide a separate faction API key to enable enhanced faction features. + By providing a faction API key, you confirm that you are authorized to use that key for faction management + purposes and accept responsibility for its use within TornManager. +

    + +

    Faction Whitelists

    +

    + Faction leaders can configure whitelists to allow members of other factions to view their faction's training + compliance data. By whitelisting another faction, you grant their members read-only access to your faction's + aggregate training statistics. You can revoke whitelist access at any time. +

    + +

    Live War Features

    +

    + During ranked wars, TornManager provides live war dashboards displaying enemy faction member status, + hospital timers, and spy data sourced from public APIs (TornStats). This information is intended to + assist with tactical decision-making during wars. The accuracy of spy data depends on third-party sources + and may not reflect current battle stats. +

    +
    +

    No Monetization

    From e8da6b595ba7d5eb5d1d47292c4794bee2c97e55 Mon Sep 17 00:00:00 2001 From: Bram Date: Mon, 23 Feb 2026 20:55:14 +0100 Subject: [PATCH 098/266] keep navbar faction links contextual to the faction being viewed --- app/helpers/application_helper.rb | 16 ++++++++++++++++ app/views/layouts/_navbar.html.erb | 30 +++++++++++++++--------------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be79..d58c4fa 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,18 @@ module ApplicationHelper + # Returns the faction currently being viewed (from URL), or falls back to the user's own faction. + # Used by the navbar to keep faction links contextual to the page being viewed. + def navbar_faction + @_navbar_faction ||= begin + torn_id = params[:faction_torn_id] || params[:torn_id] + if torn_id.present? + Faction.find_by(torn_id: torn_id) + end + end || Current.user&.faction + end + + def viewing_other_faction? + navbar_faction.present? && + Current.user&.faction.present? && + navbar_faction != Current.user.faction + end end diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index e000ba0..5d88bbc 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -1,24 +1,24 @@

    - - diff --git a/app/views/factions/leadership/spy_reports/show.html.erb b/app/views/factions/leadership/spy_reports/show.html.erb index 39388eb..989d05e 100644 --- a/app/views/factions/leadership/spy_reports/show.html.erb +++ b/app/views/factions/leadership/spy_reports/show.html.erb @@ -104,123 +104,3 @@
    - - diff --git a/app/views/factions/leadership/war_history/show.html.erb b/app/views/factions/leadership/war_history/show.html.erb index 67605d0..2946983 100644 --- a/app/views/factions/leadership/war_history/show.html.erb +++ b/app/views/factions/leadership/war_history/show.html.erb @@ -24,26 +24,3 @@ <% end %> - - From e6ff5c8faba8ab45f7bdf465364f053652e39b89 Mon Sep 17 00:00:00 2001 From: Bram Date: Thu, 2 Apr 2026 23:32:48 +0200 Subject: [PATCH 206/266] fix --- app/views/factions/ranked_wars/_war_history_table.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/factions/ranked_wars/_war_history_table.html.erb b/app/views/factions/ranked_wars/_war_history_table.html.erb index f69b75c..2efe078 100644 --- a/app/views/factions/ranked_wars/_war_history_table.html.erb +++ b/app/views/factions/ranked_wars/_war_history_table.html.erb @@ -107,7 +107,7 @@ - <%= link_to faction_ranked_war_path(faction, war), class: "view-link", title: "View details" do %> + <%= link_to faction_ranked_war_path(faction, war), class: "view-link", title: "View details", data: { turbo_frame: "_top" } do %> From f52701dc7c4e728435cfbffb6448cf5fadfc4ac7 Mon Sep 17 00:00:00 2001 From: Bram Date: Fri, 3 Apr 2026 23:46:22 +0200 Subject: [PATCH 207/266] init --- app/assets/stylesheets/pages/leadership.css | 410 ++++++++++++++++++ .../leadership/war_reports_controller.rb | 97 +++++ .../controllers/export_csv_controller.js | 54 +++ .../payout_calculator_controller.js | 54 +++ .../controllers/sortable_table_controller.js | 62 +++ app/jobs/fetch_war_attacks_job.rb | 112 +++++ app/models/ranked_war.rb | 1 + app/models/ranked_war_attack.rb | 19 + app/models/torn_api/faction/attacks.rb | 115 +++++ .../leadership/_hero_section.html.erb | 10 + .../leadership/war_reports/show.html.erb | 260 +++++++++++ config/routes.rb | 3 + ...0260403152750_create_ranked_war_attacks.rb | 56 +++ db/schema.rb | 42 +- .../leadership/war_reports_controller_test.rb | 150 +++++++ test/jobs/fetch_war_attacks_job_test.rb | 158 +++++++ .../recon/collect_training_sample_job_test.rb | 3 +- test/models/ranked_war_attack_test.rb | 94 ++++ test/models/spy_report_test.rb | 37 ++ test/models/torn_api/faction/attacks_test.rb | 210 +++++++++ 20 files changed, 1945 insertions(+), 2 deletions(-) create mode 100644 app/controllers/factions/leadership/war_reports_controller.rb create mode 100644 app/javascript/controllers/export_csv_controller.js create mode 100644 app/javascript/controllers/payout_calculator_controller.js create mode 100644 app/javascript/controllers/sortable_table_controller.js create mode 100644 app/jobs/fetch_war_attacks_job.rb create mode 100644 app/models/ranked_war_attack.rb create mode 100644 app/models/torn_api/faction/attacks.rb create mode 100644 app/views/factions/leadership/war_reports/show.html.erb create mode 100644 db/migrate/20260403152750_create_ranked_war_attacks.rb create mode 100644 test/controllers/factions/leadership/war_reports_controller_test.rb create mode 100644 test/jobs/fetch_war_attacks_job_test.rb create mode 100644 test/models/ranked_war_attack_test.rb create mode 100644 test/models/spy_report_test.rb create mode 100644 test/models/torn_api/faction/attacks_test.rb diff --git a/app/assets/stylesheets/pages/leadership.css b/app/assets/stylesheets/pages/leadership.css index 77b1a14..ceec706 100644 --- a/app/assets/stylesheets/pages/leadership.css +++ b/app/assets/stylesheets/pages/leadership.css @@ -1010,4 +1010,414 @@ .stat-compliant { color: #22c55e; } .stat-warning { color: #eab308; } + +/* War Reports */ +.war-reports-top { + display: grid; + grid-template-columns: 280px 1fr; + gap: var(--space-6); + margin-bottom: var(--space-6); +} + +@media (max-width: 768px) { + .war-reports-top { + grid-template-columns: 1fr; + } +} + +.war-reports-header h2 { + margin: 0 0 var(--space-3); +} + +.war-reports-sidebar { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + padding: var(--space-4); +} + +.war-reports-sidebar h3 { + margin: 0 0 var(--space-3); + font-size: 0.875rem; + color: var(--gray-300); +} + +.war-reports-list { + display: flex; + flex-direction: column; + gap: var(--space-1); + max-height: 300px; + overflow-y: auto; + scrollbar-color: var(--gray-700) transparent; +} + +.war-reports-list::-webkit-scrollbar { + width: 4px; +} + +.war-reports-list::-webkit-scrollbar-thumb { + background: var(--gray-800); + border-radius: 2px; +} + +.war-reports-item { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + text-decoration: none; + color: var(--gray-300); + font-size: 0.8125rem; + transition: background 0.15s; +} + +.war-reports-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.war-reports-item-selected { + background: rgba(255, 255, 255, 0.08); + border: 1px solid var(--gray-700); +} + +.war-reports-item-status { + font-size: 0.6875rem; + font-weight: 700; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + flex-shrink: 0; +} + +.war-reports-item-status.won { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; +} + +.war-reports-item-status.lost { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; +} + +.war-reports-item-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.war-reports-item-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.war-reports-item-date { + font-size: 0.6875rem; + color: var(--gray-500); +} + +.war-reports-item-score { + font-family: "Geist Mono", monospace; + font-size: 0.75rem; + color: var(--gray-500); +} + +.war-reports-detail { + min-width: 0; +} + +.war-reports-summary { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-6); + font-size: 0.875rem; +} + +.war-reports-badge { + padding: 2px 8px; + border-radius: var(--radius-sm); + font-size: 0.6875rem; + font-weight: 700; + text-transform: uppercase; +} + +.war-reports-badge.won { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; +} + +.war-reports-badge.lost { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; +} + +.war-reports-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.war-reports-title-row h2 { + margin: 0; +} + +.war-reports-fetch-btn { + display: flex; + align-items: center; + gap: var(--space-2); + white-space: nowrap; +} + +.pulse-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.pulse-dot-green { + background: #22c55e; + box-shadow: 0 0 4px #22c55e; +} + +.pulse-dot-yellow { + background: #eab308; + box-shadow: 0 0 4px #eab308; + animation: pulse-yellow 2s ease-in-out infinite; +} + +@keyframes pulse-yellow { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.war-reports-warning { + background: rgba(234, 179, 8, 0.1); + border: 1px solid rgba(234, 179, 8, 0.3); + border-radius: var(--radius-md); + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-4); + font-size: 0.8125rem; + color: #eab308; + display: flex; + align-items: center; + gap: var(--space-3); +} + +.war-reports-fetch { + padding: var(--space-8); + text-align: center; + color: var(--gray-400); +} + +.war-reports-stats-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: var(--space-3); + margin-bottom: var(--space-6); +} + +.war-reports-stat { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + padding: var(--space-3); + text-align: center; +} + +.war-reports-stat-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--gray-100); + font-family: "Geist Mono", monospace; + display: block; +} + +.war-reports-stat-label { + font-size: 0.6875rem; + color: var(--gray-500); + text-transform: uppercase; +} + +.war-reports-table-container { + display: flex; + flex-direction: column; + max-height: 500px; +} + +.war-reports-table-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.war-reports-table-header h2 { + margin: 0; +} + +.war-reports-table-wrapper { + flex: 1; + overflow: auto; + min-height: 0; + scrollbar-color: var(--gray-700) transparent; +} + +.war-reports-table-wrapper::-webkit-scrollbar { + width: 6px; +} + +.war-reports-table-wrapper::-webkit-scrollbar-track { + background: transparent; +} + +.war-reports-table-wrapper::-webkit-scrollbar-thumb { + background: var(--gray-800); + border-radius: 3px; +} + +.war-reports-table-wrapper::-webkit-scrollbar-thumb:hover { + background: var(--gray-700); +} + +.war-reports-table-wrapper thead { + position: sticky; + top: 0; + z-index: 1; + background: var(--gray-900); +} + +.war-reports-table-wrapper td { + font-family: "Geist Mono", monospace; + font-size: 0.8125rem; +} + +.war-reports-member-row { + cursor: pointer; +} + +.war-reports-member-row:hover { + background: rgba(255, 255, 255, 0.05); +} + +.war-reports-detail-row td { + background: var(--gray-1000); + padding: var(--space-3) var(--space-4); +} + +.war-reports-attack-log { + max-height: 300px; + overflow-y: auto; + scrollbar-color: var(--gray-700) transparent; +} + +.war-reports-attack-log::-webkit-scrollbar { + width: 4px; +} + +.war-reports-attack-log::-webkit-scrollbar-thumb { + background: var(--gray-800); + border-radius: 2px; +} + +.war-reports-subtable { + font-size: 0.75rem; +} + +.war-reports-subtable th { + font-size: 0.625rem; + background: var(--gray-900); + position: sticky; + top: 0; +} + +.war-reports-subtable td { + font-size: 0.75rem; + padding: var(--space-1) var(--space-2); +} + +/* Payout Calculator */ +.war-reports-payout-form { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + padding: var(--space-4); + margin-bottom: var(--space-4); +} + +.payout-inputs { + display: flex; + gap: var(--space-4); + margin-bottom: var(--space-3); +} + +.payout-field { + flex: 1; +} + +.payout-field label { + display: block; + font-size: 0.6875rem; + color: var(--gray-400); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--space-1); +} + +.payout-field input { + width: 100%; + background: var(--gray-1000); + border: 1px solid var(--gray-700); + border-radius: var(--radius-sm); + color: var(--gray-100); + font-family: "Geist Mono", monospace; + font-size: 0.875rem; + padding: var(--space-2) var(--space-3); +} + +.payout-field input:focus { + outline: none; + border-color: var(--gray-500); +} + +.payout-summary { + display: flex; + gap: var(--space-6); + font-size: 0.8125rem; + color: var(--gray-400); +} + +.payout-summary strong { + color: #22c55e; + font-family: "Geist Mono", monospace; +} + +.payout-cell { + color: #22c55e; + font-weight: 600; +} + +/* Sort indicators */ +th.sortable.sorted-asc::after { + content: " ▲"; + font-size: 0.625rem; +} + +th.sortable.sorted-desc::after { + content: " ▼"; + font-size: 0.625rem; +} + +@media (max-width: 600px) { + .payout-inputs { + flex-direction: column; + } +} .stat-danger { color: #ef4444; } diff --git a/app/controllers/factions/leadership/war_reports_controller.rb b/app/controllers/factions/leadership/war_reports_controller.rb new file mode 100644 index 0000000..1de7361 --- /dev/null +++ b/app/controllers/factions/leadership/war_reports_controller.rb @@ -0,0 +1,97 @@ +class Factions::Leadership::WarReportsController < Factions::Leadership::BaseController + def show + @wars = @faction.ranked_wars.completed.recent + @selected_war = if params[:war].present? + @wars.find_by(torn_war_id: params[:war]) + else + @wars.first + end + + load_war_report if @selected_war + end + + def fetch_attacks + war = @faction.ranked_wars.find_by!(torn_war_id: params[:war]) + + unless @faction.torn_api_key&.faction_access? + return redirect_to faction_leadership_war_reports_path(@faction, war: war.torn_war_id), + alert: "API key needs faction API access to fetch attack logs." + end + + FetchWarAttacksJob.perform_now(war.id) + + redirect_to faction_leadership_war_reports_path(@faction, war: war.torn_war_id), + notice: "Fetching attack logs for war vs #{war.opponent_faction_name}..." + end + + private + + def load_war_report + @attacks = @selected_war.ranked_war_attacks + @has_attacks = @attacks.exists? + + faction_id = @faction.torn_id + @outgoing = @attacks.outgoing(faction_id).order(started: :asc) + @incoming = @attacks.incoming(faction_id).order(started: :asc) + + expected_outgoing = @selected_war.our_attacks || 0 + actual_outgoing = @outgoing.count + @integrity_ok = actual_outgoing >= expected_outgoing + @integrity_message = "#{actual_outgoing} / #{expected_outgoing} outgoing attacks collected" unless @integrity_ok + + @attacks_by_member = @outgoing.group_by(&:attacker_id) + @member_stats = @has_attacks ? calculate_member_stats(@outgoing, faction_id) : [] + end + + def calculate_member_stats(outgoing_attacks, faction_id) + stats = {} + + outgoing_attacks.each do |attack| + id = attack.attacker_id + stats[id] ||= { + name: attack.attacker_name, + torn_id: id, + hits: 0, + respect: 0.0, + assists: 0, + ff_low: 0, # FF < 1.25 + ff_mid: 0, # 1.25 <= FF < 1.75 + ff_high: 0, # FF >= 1.75 + warlord_hits: 0, + overseas_hits: 0, + total_ff: 0.0 + } + + ff = attack.fair_fight || 0 + + if attack.result == "Assist" + stats[id][:assists] += 1 + else + stats[id][:hits] += 1 + end + + stats[id][:respect] += attack.respect_gain || 0 + stats[id][:warlord_hits] += 1 if attack.used_warlord? + stats[id][:overseas_hits] += 1 if attack.overseas? + stats[id][:total_ff] += ff + + if ff < 1.25 + stats[id][:ff_low] += 1 + elsif ff < 1.75 + stats[id][:ff_mid] += 1 + else + stats[id][:ff_high] += 1 + end + end + + total_actions = ->(s) { s[:hits] + s[:assists] } + + stats.values.each do |s| + actions = total_actions.call(s) + s[:avg_respect] = actions > 0 ? (s[:respect] / actions).round(2) : 0 + s[:avg_ff] = actions > 0 ? (s[:total_ff] / actions).round(2) : 0 + end + + stats.values.sort_by { |s| -s[:respect] } + end +end diff --git a/app/javascript/controllers/export_csv_controller.js b/app/javascript/controllers/export_csv_controller.js new file mode 100644 index 0000000..95011a2 --- /dev/null +++ b/app/javascript/controllers/export_csv_controller.js @@ -0,0 +1,54 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["table", "totalPot", "factionCut", "assistValue"] + + export() { + const totalPot = this.totalPotTarget.value || "0" + const factionCut = this.factionCutTarget.value || "0" + const assistValue = this.assistValueTarget.value || "0.75" + + const rows = [] + + rows.push(["War Report Export"]) + rows.push(["Total Pot", totalPot]) + rows.push(["Faction Cut %", factionCut]) + rows.push(["Assist Value", assistValue]) + rows.push([]) + + const table = this.tableTarget + + // Headers from the main thead only + const mainThead = table.querySelector(":scope > thead") + const headers = Array.from(mainThead.querySelectorAll("th")).map(th => th.textContent.trim()) + rows.push(headers) + + // Only the summary rows (first tr of each direct tbody child) + const tbodies = table.querySelectorAll(":scope > tbody") + tbodies.forEach(tbody => { + const summaryRow = tbody.querySelector(":scope > tr:first-child") + if (!summaryRow || summaryRow.cells.length !== headers.length) return + + const cells = Array.from(summaryRow.cells).map(cell => cell.textContent.trim().replace(/\s+/g, " ")) + rows.push(cells) + }) + + const csv = rows.map(row => + row.map(cell => { + const str = String(cell) + if (str.includes(",") || str.includes('"') || str.includes("\n")) { + return `"${str.replace(/"/g, '""')}"` + } + return str + }).join(",") + ).join("\n") + + const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;" }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `war_report_${new Date().toISOString().split("T")[0]}.csv` + link.click() + URL.revokeObjectURL(url) + } +} diff --git a/app/javascript/controllers/payout_calculator_controller.js b/app/javascript/controllers/payout_calculator_controller.js new file mode 100644 index 0000000..c07f201 --- /dev/null +++ b/app/javascript/controllers/payout_calculator_controller.js @@ -0,0 +1,54 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["totalPot", "factionCut", "assistValue", "factionCutAmount", "payoutPot", "payoutCell"] + + connect() { + this.calculate() + } + + calculate() { + const totalPot = this.parseNumber(this.totalPotTarget.value) + const factionCutPct = parseFloat(this.factionCutTarget.value) || 0 + const assistValue = parseFloat(this.assistValueTarget.value) || 0.75 + + const factionCutAmount = Math.round(totalPot * (factionCutPct / 100)) + const payoutPot = totalPot - factionCutAmount + + this.factionCutAmountTarget.textContent = this.formatNumber(factionCutAmount) + this.payoutPotTarget.textContent = this.formatNumber(payoutPot) + + // Calculate weighted score for each member + let totalWeightedScore = 0 + const memberScores = [] + + this.payoutCellTargets.forEach(cell => { + const hits = parseFloat(cell.dataset.hits) || 0 + const assists = parseFloat(cell.dataset.assists) || 0 + const respect = parseFloat(cell.dataset.respect) || 0 + + // Weighted: full hits by respect, assists at assist value + const weightedScore = respect + (assists * assistValue) + totalWeightedScore += weightedScore + memberScores.push({ cell, weightedScore }) + }) + + memberScores.forEach(({ cell, weightedScore }) => { + if (totalWeightedScore > 0 && payoutPot > 0) { + const share = weightedScore / totalWeightedScore + const payout = Math.round(payoutPot * share) + cell.textContent = this.formatNumber(payout) + } else { + cell.textContent = "—" + } + }) + } + + parseNumber(str) { + return parseInt((str || "0").replace(/,/g, "")) || 0 + } + + formatNumber(num) { + return num.toLocaleString() + } +} diff --git a/app/javascript/controllers/sortable_table_controller.js b/app/javascript/controllers/sortable_table_controller.js new file mode 100644 index 0000000..2846f39 --- /dev/null +++ b/app/javascript/controllers/sortable_table_controller.js @@ -0,0 +1,62 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["header", "body"] + + sort(event) { + const th = event.currentTarget + const column = parseInt(th.dataset.column) + const type = th.dataset.sortType || "number" + const currentDir = th.dataset.sortDir || "none" + const newDir = currentDir === "asc" ? "desc" : "asc" + + this.headerTargets.forEach(h => { + h.dataset.sortDir = "none" + h.classList.remove("sorted-asc", "sorted-desc") + }) + + th.dataset.sortDir = newDir + th.classList.add(`sorted-${newDir}`) + + const table = this.element.querySelector("table") + const bodies = this.bodyTargets + + if (bodies.length > 1) { + // Multiple tbodies: sort entire tbodies by their first row + bodies.sort((a, b) => { + const aVal = this.getCellValue(a.rows[0], column, type) + const bVal = this.getCellValue(b.rows[0], column, type) + + if (aVal < bVal) return newDir === "asc" ? -1 : 1 + if (aVal > bVal) return newDir === "asc" ? 1 : -1 + return 0 + }) + + bodies.forEach(tbody => table.appendChild(tbody)) + } else if (bodies.length === 1) { + // Single tbody: sort rows + const rows = Array.from(bodies[0].querySelectorAll("tr")) + + rows.sort((a, b) => { + const aVal = this.getCellValue(a, column, type) + const bVal = this.getCellValue(b, column, type) + + if (aVal < bVal) return newDir === "asc" ? -1 : 1 + if (aVal > bVal) return newDir === "asc" ? 1 : -1 + return 0 + }) + + rows.forEach(row => bodies[0].appendChild(row)) + } + } + + getCellValue(row, column, type) { + const cell = row && row.cells ? row.cells[column] : null + if (!cell) return 0 + + const text = cell.textContent.trim().replace(/,/g, "") + + if (type === "string") return text.toLowerCase() + return parseFloat(text) || 0 + } +} diff --git a/app/jobs/fetch_war_attacks_job.rb b/app/jobs/fetch_war_attacks_job.rb new file mode 100644 index 0000000..94cd9a1 --- /dev/null +++ b/app/jobs/fetch_war_attacks_job.rb @@ -0,0 +1,112 @@ +class FetchWarAttacksJob < ApplicationJob + queue_as :default + + MAX_PAGES = 100 + + def perform(ranked_war_id) + war = RankedWar.find_by(id: ranked_war_id) + return unless war + + faction = war.faction + api_key = faction.torn_api_key + return unless api_key&.faction_access? + + existing_ids = war.ranked_war_attacks.pluck(:torn_attack_id).to_set + stored = 0 + + # Resume from where we left off — use the earliest stored attack's timestamp + # so we paginate backwards from war end into uncollected territory + earliest_stored = war.ranked_war_attacks.minimum(:started) + current_to = earliest_stored ? earliest_stored : war.ended_at&.to_i + pages = 0 + + loop do + break if pages >= MAX_PAGES + + result = TornApi::Faction::Attacks.new( + api_key.key, + from: war.started_at.to_i, + to: current_to, + filters: "outgoing" + ).fetch + + result.attacks.each do |attack| + next unless attack.is_ranked_war + next if existing_ids.include?(attack.id) + + store_attack(war, attack) + existing_ids.add(attack.id) + stored += 1 + end + + pages += 1 + + break unless result.prev_url + + new_to = extract_to_param(result.prev_url) + break unless new_to + current_to = new_to + end + + integrity_check(war, faction) + + Rails.logger.info("[FetchWarAttacksJob] War #{war.torn_war_id}: stored #{stored} attacks in #{pages} pages") + end + + private + + def store_attack(war, attack) + war.ranked_war_attacks.create!( + torn_attack_id: attack.id, + code: attack.code, + attacker_id: attack.attacker_id, + attacker_name: attack.attacker_name, + attacker_level: attack.attacker_level, + attacker_faction_id: attack.attacker_faction_id, + attacker_faction_name: attack.attacker_faction_name, + defender_id: attack.defender_id, + defender_name: attack.defender_name, + defender_level: attack.defender_level, + defender_faction_id: attack.defender_faction_id, + defender_faction_name: attack.defender_faction_name, + started: attack.started, + ended: attack.ended, + result: attack.result, + respect_gain: attack.respect_gain, + respect_loss: attack.respect_loss, + chain: attack.chain, + is_stealthed: attack.is_stealthed, + is_interrupted: attack.is_interrupted, + is_raid: attack.is_raid, + fair_fight: attack.fair_fight, + war: attack.war, + retaliation: attack.retaliation, + group_modifier: attack.group, + overseas: attack.overseas, + chain_modifier: attack.chain_modifier, + warlord: attack.warlord, + finishing_hit_effects: attack.finishing_hit_effects + ) + end + + def extract_to_param(url) + uri = URI.parse(url) + params = URI.decode_www_form(uri.query || "").to_h + params["to"]&.to_i + end + + def integrity_check(war, faction) + our_count = war.ranked_war_attacks.outgoing(faction.torn_id).count + expected_ours = war.our_attacks || 0 + + if our_count < expected_ours + Rails.logger.warn( + "[FetchWarAttacksJob] Integrity check: war #{war.torn_war_id} outgoing #{our_count}/#{expected_ours}" + ) + else + Rails.logger.info( + "[FetchWarAttacksJob] Integrity check passed for war #{war.torn_war_id}: #{our_count} outgoing attacks" + ) + end + end +end diff --git a/app/models/ranked_war.rb b/app/models/ranked_war.rb index 4bea213..63f232f 100644 --- a/app/models/ranked_war.rb +++ b/app/models/ranked_war.rb @@ -1,5 +1,6 @@ class RankedWar < ApplicationRecord belongs_to :faction + has_many :ranked_war_attacks, dependent: :destroy scope :completed, -> { where.not(ended_at: nil) } scope :ongoing, -> { where(ended_at: nil) } diff --git a/app/models/ranked_war_attack.rb b/app/models/ranked_war_attack.rb new file mode 100644 index 0000000..2c7e7ed --- /dev/null +++ b/app/models/ranked_war_attack.rb @@ -0,0 +1,19 @@ +class RankedWarAttack < ApplicationRecord + belongs_to :ranked_war + + validates :torn_attack_id, presence: true, uniqueness: { scope: :ranked_war_id } + validates :attacker_id, presence: true + validates :defender_id, presence: true + validates :result, presence: true + + scope :outgoing, ->(faction_torn_id) { where(attacker_faction_id: faction_torn_id) } + scope :incoming, ->(faction_torn_id) { where(defender_faction_id: faction_torn_id) } + + def used_warlord? + warlord.present? && warlord > 1 + end + + def overseas? + self.overseas.present? && self.overseas > 1 + end +end diff --git a/app/models/torn_api/faction/attacks.rb b/app/models/torn_api/faction/attacks.rb new file mode 100644 index 0000000..10a9a7a --- /dev/null +++ b/app/models/torn_api/faction/attacks.rb @@ -0,0 +1,115 @@ +module TornApi + module Faction + class Attacks < Base + Attack = Data.define( + :id, :code, :started, :ended, + :attacker_id, :attacker_name, :attacker_level, :attacker_faction_id, :attacker_faction_name, + :defender_id, :defender_name, :defender_level, :defender_faction_id, :defender_faction_name, + :result, :respect_gain, :respect_loss, :chain, + :is_ranked_war, :is_stealthed, :is_interrupted, :is_raid, + :fair_fight, :war, :retaliation, :group, :overseas, :chain_modifier, :warlord, + :finishing_hit_effects + ) + + Result = Data.define(:attacks, :prev_url) + + def initialize(api_key, from: nil, to: nil, filters: nil, limit: 100) + super(api_key) + @from = from + @to = to + @filters = filters + @limit = limit + end + + MAX_PAGES = 50 + + def fetch + response = get("v2/faction/attacks", build_params) + + attacks = (response["attacks"] || []).map { |a| parse_attack(a) } + prev_url = response.dig("_metadata", "links", "prev") + + Result.new(attacks: attacks, prev_url: prev_url) + end + + def fetch_all(max_pages: MAX_PAGES) + all_attacks = [] + current_to = @to + pages = 0 + + loop do + break if pages >= max_pages + + params = build_params(to_override: current_to) + response = get("v2/faction/attacks", params) + + attacks = (response["attacks"] || []).map { |a| parse_attack(a) } + all_attacks.concat(attacks) + pages += 1 + + prev_url = response.dig("_metadata", "links", "prev") + break unless prev_url + + current_to = extract_to_param(prev_url) + break unless current_to + end + + all_attacks + end + + private + + def build_params(to_override: nil) + params = { limit: @limit, sort: "DESC" } + params[:from] = @from if @from + to_val = to_override || @to + params[:to] = to_val if to_val + params[:filters] = @filters if @filters + params + end + + def extract_to_param(url) + uri = URI.parse(url) + params = URI.decode_www_form(uri.query || "").to_h + params["to"]&.to_i + end + + def parse_attack(data) + modifiers = data["modifiers"] || {} + + Attack.new( + id: data["id"], + code: data["code"], + started: data["started"], + ended: data["ended"], + attacker_id: data.dig("attacker", "id"), + attacker_name: data.dig("attacker", "name"), + attacker_level: data.dig("attacker", "level"), + attacker_faction_id: data.dig("attacker", "faction", "id"), + attacker_faction_name: data.dig("attacker", "faction", "name"), + defender_id: data.dig("defender", "id"), + defender_name: data.dig("defender", "name"), + defender_level: data.dig("defender", "level"), + defender_faction_id: data.dig("defender", "faction", "id"), + defender_faction_name: data.dig("defender", "faction", "name"), + result: data["result"], + respect_gain: data["respect_gain"], + respect_loss: data["respect_loss"], + chain: data["chain"], + is_ranked_war: data["is_ranked_war"], + is_stealthed: data["is_stealthed"], + is_interrupted: data["is_interrupted"], + is_raid: data["is_raid"], + fair_fight: modifiers["fair_fight"], + war: modifiers["war"], + retaliation: modifiers["retaliation"], + group: modifiers["group"], + overseas: modifiers["overseas"], + chain_modifier: modifiers["chain"], + warlord: modifiers["warlord"], + finishing_hit_effects: data["finishing_hit_effects"] || [] + ) + end + end + end +end diff --git a/app/views/factions/leadership/_hero_section.html.erb b/app/views/factions/leadership/_hero_section.html.erb index 55bbab2..ed11e48 100644 --- a/app/views/factions/leadership/_hero_section.html.erb +++ b/app/views/factions/leadership/_hero_section.html.erb @@ -45,6 +45,16 @@ Settings <% end %> + <%= link_to faction_leadership_war_reports_path(@faction), class: "dashboard-stat-card dashboard-stat-card-link" do %> + + + + + + <%= @wars.size %> + War Reports + <% end %> + <% if @war_polling_active %> <%= button_to stop_faction_leadership_war_polling_path(@faction), method: :delete, class: "dashboard-stat-card dashboard-stat-card-link dashboard-stat-card-toggle", data: { turbo_confirm: "Stop war polling?" } do %> diff --git a/app/views/factions/leadership/war_reports/show.html.erb b/app/views/factions/leadership/war_reports/show.html.erb new file mode 100644 index 0000000..92b7e32 --- /dev/null +++ b/app/views/factions/leadership/war_reports/show.html.erb @@ -0,0 +1,260 @@ +
    + <%= link_to "← Back to Leadership", faction_leadership_path(@faction), class: "back-link" %> + +
    + + + <% if @selected_war %> +
    +
    +

    vs <%= @selected_war.opponent_faction_name %>

    + <%= button_to fetch_attacks_faction_leadership_war_reports_path(@faction, war: @selected_war.torn_war_id), + method: :post, class: "btn btn-sm btn-secondary war-reports-fetch-btn", + data: { turbo_submits_with: ' Fetching...' } do %> + Fetch + <% end %> +
    +
    + + <%= @selected_war.won? ? 'VICTORY' : 'DEFEAT' %> + + <%= @selected_war.our_score %> – <%= @selected_war.their_score %> / <%= @selected_war.target_score %> + <%= @selected_war.duration_formatted %> + <%= @selected_war.started_at.strftime("%d %b %Y %H:%M") %> +
    + + <% attacks_count = @has_attacks ? @outgoing.count : 0 %> + <% total_respect = @has_attacks ? @outgoing.sum(:respect_gain) : 0 %> + <% avg_respect = attacks_count > 0 ? (total_respect / attacks_count).round(2) : 0 %> + <% avg_ff = attacks_count > 0 ? (@outgoing.average(:fair_fight)&.round(2) || 0) : 0 %> + <% total_assists = @has_attacks ? @member_stats.sum { |m| m[:assists] } : 0 %> + <% total_overseas = @has_attacks ? @member_stats.sum { |m| m[:overseas_hits] } : 0 %> + <% total_warlord = @has_attacks ? @member_stats.sum { |m| m[:warlord_hits] } : 0 %> + +
    +
    + <%= attacks_count %> + Attacks +
    +
    + <%= number_with_delimiter(total_respect.round(0)) %> + Respect +
    +
    + <%= avg_respect %> + Avg Respect +
    +
    + <%= avg_ff %> + Avg FF +
    +
    + <%= @has_attacks ? @member_stats.size : 0 %> + Participants +
    +
    + <%= total_warlord %> + Warlord +
    +
    + <%= total_overseas %> + Overseas +
    +
    + <%= total_assists %> + Assists +
    +
    +
    + <% else %> +
    +
    +

    No completed wars found.

    +
    +
    + <% end %> +
    + + <% if @selected_war %> + <% if !@integrity_ok %> +
    + Incomplete: <%= @integrity_message || "No attack data fetched yet" %> +
    + <% end %> + +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + Faction Cut: 0 + Payout Pot: 0 +
    +
    + +
    +
    +

    Member Breakdown (<%= @has_attacks ? @member_stats.size : 0 %> participants)

    + +
    +
    + + + + + + + + + + + + + + + + + <% if @has_attacks %> + <% @member_stats.each do |member| %> + + + + + + + + + + + + + + + + + + + <% end %> + <% else %> + + <% end %> +
    MemberRW HitsFF<1.25FF<1.75FF≥1.75OverseasAssistsWarlordRespectAvgPayout
    + + <%= member[:name] %> + + <%= member[:hits] %> + <% if member[:ff_low] > 0 %> + <%= member[:ff_low] %> + <% else %> + 0 + <% end %> + + <% if member[:ff_mid] > 0 %> + <%= member[:ff_mid] %> + <% else %> + 0 + <% end %> + <%= member[:ff_high] %> + <% if member[:overseas_hits] > 0 %> + <%= member[:overseas_hits] %> + <% else %> + 0 + <% end %> + + <% if member[:assists] > 0 %> + <%= member[:assists] %> + <% else %> + 0 + <% end %> + + <% if member[:warlord_hits] > 0 %> + <%= member[:warlord_hits] %> + <% else %> + 0 + <% end %> + <%= number_with_delimiter(member[:respect].round(0)) %><%= member[:avg_respect] %>
    +
    +
    +
    + <% end %> +
    diff --git a/config/routes.rb b/config/routes.rb index 68e74b8..0a97f89 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -42,6 +42,9 @@ delete "/:id", to: "factions/leadership/spy_reports#destroy", as: :destroy_report post :fetch_enemy end + resource :war_reports, only: [ :show ], controller: "factions/leadership/war_reports" do + post :fetch_attacks + end resource :settings, only: [ :show ], controller: "factions/leadership/settings" resource :api_logs, only: [ :show ], controller: "factions/leadership/api_logs" resource :data_coverage, only: [ :show ], controller: "factions/leadership/data_coverage" do diff --git a/db/migrate/20260403152750_create_ranked_war_attacks.rb b/db/migrate/20260403152750_create_ranked_war_attacks.rb new file mode 100644 index 0000000..36c0271 --- /dev/null +++ b/db/migrate/20260403152750_create_ranked_war_attacks.rb @@ -0,0 +1,56 @@ +class CreateRankedWarAttacks < ActiveRecord::Migration[8.1] + def change + create_table :ranked_war_attacks do |t| + t.references :ranked_war, null: false, foreign_key: true + t.integer :torn_attack_id, null: false + t.string :code + + # Attacker + t.integer :attacker_id, null: false + t.string :attacker_name + t.integer :attacker_level + t.integer :attacker_faction_id + t.string :attacker_faction_name + + # Defender + t.integer :defender_id, null: false + t.string :defender_name + t.integer :defender_level + t.integer :defender_faction_id + t.string :defender_faction_name + + # Timing + t.integer :started, null: false + t.integer :ended, null: false + + # Result + t.string :result, null: false + t.float :respect_gain, default: 0 + t.float :respect_loss, default: 0 + t.integer :chain, default: 0 + + # Flags + t.boolean :is_stealthed, default: false + t.boolean :is_interrupted, default: false + t.boolean :is_raid, default: false + + # Modifiers + t.float :fair_fight + t.float :war + t.float :retaliation + t.float :group_modifier + t.float :overseas + t.float :chain_modifier + t.float :warlord + + # Effects + t.json :finishing_hit_effects, default: [] + + t.timestamps + end + + add_index :ranked_war_attacks, [ :ranked_war_id, :torn_attack_id ], unique: true + add_index :ranked_war_attacks, :attacker_id + add_index :ranked_war_attacks, :defender_id + end +end diff --git a/db/schema.rb b/db/schema.rb index 0143324..caaefb1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_03_30_194240) do +ActiveRecord::Schema[8.1].define(version: 2026_04_03_152750) do create_table "api_calls", force: :cascade do |t| t.string "api_key", null: false t.datetime "created_at", null: false @@ -112,6 +112,45 @@ t.index ["slug"], name: "index_public_war_lobbies_on_slug", unique: true end + create_table "ranked_war_attacks", force: :cascade do |t| + t.integer "attacker_faction_id" + t.string "attacker_faction_name" + t.integer "attacker_id", null: false + t.integer "attacker_level" + t.string "attacker_name" + t.integer "chain", default: 0 + t.float "chain_modifier" + t.string "code" + t.datetime "created_at", null: false + t.integer "defender_faction_id" + t.string "defender_faction_name" + t.integer "defender_id", null: false + t.integer "defender_level" + t.string "defender_name" + t.integer "ended", null: false + t.float "fair_fight" + t.json "finishing_hit_effects", default: [] + t.float "group_modifier" + t.boolean "is_interrupted", default: false + t.boolean "is_raid", default: false + t.boolean "is_stealthed", default: false + t.float "overseas" + t.integer "ranked_war_id", null: false + t.float "respect_gain", default: 0.0 + t.float "respect_loss", default: 0.0 + t.string "result", null: false + t.float "retaliation" + t.integer "started", null: false + t.integer "torn_attack_id", null: false + t.datetime "updated_at", null: false + t.float "war" + t.float "warlord" + t.index ["attacker_id"], name: "index_ranked_war_attacks_on_attacker_id" + t.index ["defender_id"], name: "index_ranked_war_attacks_on_defender_id" + t.index ["ranked_war_id", "torn_attack_id"], name: "index_ranked_war_attacks_on_ranked_war_id_and_torn_attack_id", unique: true + t.index ["ranked_war_id"], name: "index_ranked_war_attacks_on_ranked_war_id" + end + create_table "ranked_wars", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "ended_at" @@ -300,6 +339,7 @@ add_foreign_key "faction_subscription_grants", "users", column: "granted_by_id" add_foreign_key "personal_stat_snapshots", "users" add_foreign_key "personal_stat_snapshots", "users" + add_foreign_key "ranked_war_attacks", "ranked_wars" add_foreign_key "ranked_wars", "factions" add_foreign_key "sessions", "users" add_foreign_key "spy_reports", "factions" diff --git a/test/controllers/factions/leadership/war_reports_controller_test.rb b/test/controllers/factions/leadership/war_reports_controller_test.rb new file mode 100644 index 0000000..a3dc187 --- /dev/null +++ b/test/controllers/factions/leadership/war_reports_controller_test.rb @@ -0,0 +1,150 @@ +require "test_helper" + +class Factions::Leadership::WarReportsControllerTest < ActionDispatch::IntegrationTest + setup do + @faction = Faction.create!( + torn_id: 99999, name: "Test Faction", xanax_target: 2.5, + energy_refill_target: 1.0, nerve_refill_target: 1.0, setup_completed: true + ) + @faction.create_faction_setting! + ApiKey::Torn.create!(faction: @faction, key: "FACTION_KEY", access_type: "Limited Access", faction_access: true) + + @bram = users(:bram) + @bert = users(:bert) + @bram.update!(faction: @faction, subscription_expires_at: 1.month.from_now, leadership_access: true) + @bert.update!(faction: @faction, subscription_expires_at: 1.month.from_now) + + @war = @faction.ranked_wars.create!( + torn_war_id: 1001, opponent_faction_id: 88888, opponent_faction_name: "Enemy", + started_at: 1.week.ago, ended_at: 3.days.ago, target_score: 100, + our_score: 100, their_score: 50, winner_faction_id: @faction.torn_id, + our_attacks: 5, their_attacks: 3 + ) + end + + # -- Access control -- + + test "requires authentication" do + get faction_leadership_war_reports_path(@faction) + assert_redirected_to new_session_path + end + + test "requires leadership access" do + sign_in_as(@bert) + get faction_leadership_war_reports_path(@faction) + assert_redirected_to faction_path(@faction) + end + + test "redirects to setup when no api keys" do + @faction.torn_api_key&.destroy! + sign_in_as(@bram) + get faction_leadership_war_reports_path(@faction) + assert_redirected_to faction_leadership_setup_path(@faction) + end + + # -- Show -- + + test "shows page with war sidebar" do + sign_in_as(@bram) + get faction_leadership_war_reports_path(@faction) + assert_response :success + assert_select ".war-reports-sidebar" + assert_select "a.back-link" + end + + test "selects first war by default" do + sign_in_as(@bram) + get faction_leadership_war_reports_path(@faction) + assert_response :success + assert_select ".war-reports-header h2", /Enemy/ + end + + test "selects war via query param" do + other_war = @faction.ranked_wars.create!( + torn_war_id: 1002, opponent_faction_id: 77777, opponent_faction_name: "Other Enemy", + started_at: 2.weeks.ago, ended_at: 1.week.ago, target_score: 100, + our_score: 50, their_score: 100, winner_faction_id: 77777 + ) + + sign_in_as(@bram) + get faction_leadership_war_reports_path(@faction, war: 1002) + assert_response :success + assert_select ".war-reports-header h2", /Other Enemy/ + end + + test "always shows table and payout form" do + sign_in_as(@bram) + get faction_leadership_war_reports_path(@faction) + assert_response :success + assert_select "table" + assert_select ".war-reports-payout-form" + end + + test "shows fetch button with yellow dot when incomplete" do + sign_in_as(@bram) + get faction_leadership_war_reports_path(@faction) + assert_response :success + assert_select ".pulse-dot-yellow" + assert_select "button", text: /Fetch/ + end + + test "shows fetch button with green dot when complete" do + @war.update!(our_attacks: 0) + + sign_in_as(@bram) + get faction_leadership_war_reports_path(@faction) + assert_response :success + assert_select ".pulse-dot-green" + end + + test "shows empty state when no completed wars" do + @war.destroy! + sign_in_as(@bram) + get faction_leadership_war_reports_path(@faction) + assert_response :success + assert_select ".war-reports-header", /No completed wars/ + end + + # -- Fetch attacks -- + + test "fetch_attacks runs job inline and redirects" do + FetchWarAttacksJob.any_instance.stubs(:perform) + + sign_in_as(@bram) + post fetch_attacks_faction_leadership_war_reports_path(@faction, war: @war.torn_war_id) + + assert_redirected_to faction_leadership_war_reports_path(@faction, war: @war.torn_war_id) + assert_match /Fetching/, flash[:notice] + end + + test "fetch_attacks requires leadership access" do + sign_in_as(@bert) + post fetch_attacks_faction_leadership_war_reports_path(@faction, war: @war.torn_war_id) + assert_redirected_to faction_path(@faction) + end + + test "fetch_attacks requires faction_access on api key" do + @faction.torn_api_key.update!(faction_access: false) + + sign_in_as(@bram) + post fetch_attacks_faction_leadership_war_reports_path(@faction, war: @war.torn_war_id) + + assert_redirected_to faction_leadership_war_reports_path(@faction, war: @war.torn_war_id) + assert_match /faction API access/, flash[:alert] + end + + private + + def create_attack(attrs = {}) + @war.ranked_war_attacks.create!({ + torn_attack_id: attrs[:torn_attack_id] || 1, + attacker_id: 111, attacker_name: "Attacker", + attacker_faction_id: attrs[:attacker_faction_id] || @faction.torn_id, + defender_id: 222, defender_name: "Defender", + defender_faction_id: 88888, + started: 1.week.ago.to_i, ended: 1.week.ago.to_i + 10, + result: "Attacked", respect_gain: 5.0, + fair_fight: 3, war: 2, warlord: 1, overseas: 1 + }.merge(attrs.except(:attacker_faction_id, :torn_attack_id))) + end +end diff --git a/test/jobs/fetch_war_attacks_job_test.rb b/test/jobs/fetch_war_attacks_job_test.rb new file mode 100644 index 0000000..1e00138 --- /dev/null +++ b/test/jobs/fetch_war_attacks_job_test.rb @@ -0,0 +1,158 @@ +require "test_helper" + +class FetchWarAttacksJobTest < ActiveJob::TestCase + setup do + @faction = Faction.create!(torn_id: 9055, name: "Test Faction", xanax_target: 2.5) + @faction.create_faction_setting! + ApiKey::Torn.create!(faction: @faction, key: "FACTION_KEY", access_type: "Limited Access", faction_access: true) + + @war = @faction.ranked_wars.create!( + torn_war_id: 1001, opponent_faction_id: 88888, opponent_faction_name: "Enemy", + started_at: 1.week.ago, ended_at: 3.days.ago, target_score: 100, + our_score: 100, their_score: 50, winner_faction_id: @faction.torn_id, + our_attacks: 3, their_attacks: 2 + ) + end + + test "fetches and stores all ranked war attacks" do + attacks_page = { + "attacks" => [ + build_api_attack(id: 1, attacker_faction_id: 9055, is_ranked_war: true, result: "Attacked"), + build_api_attack(id: 2, attacker_faction_id: 9055, is_ranked_war: true, result: "Attacked"), + build_api_attack(id: 3, attacker_faction_id: 9055, is_ranked_war: true, result: "Attacked"), + build_api_attack(id: 4, attacker_faction_id: 88888, defender_faction_id: 9055, is_ranked_war: true, result: "Attacked"), + build_api_attack(id: 5, attacker_faction_id: 88888, defender_faction_id: 9055, is_ranked_war: true, result: "Attacked"), + build_api_attack(id: 6, attacker_faction_id: 9055, is_ranked_war: false, result: "Attacked") + ], + "_metadata" => { "links" => { "prev" => nil, "next" => nil } } + } + + TornApi::Faction::Attacks.any_instance.stubs(:get).returns(attacks_page) + + assert_difference "RankedWarAttack.count", 5 do + FetchWarAttacksJob.perform_now(@war.id) + end + end + + test "skips non-ranked-war attacks" do + attacks_page = { + "attacks" => [ + build_api_attack(id: 1, is_ranked_war: false), + build_api_attack(id: 2, is_ranked_war: true, result: "Attacked") + ], + "_metadata" => { "links" => { "prev" => nil, "next" => nil } } + } + + TornApi::Faction::Attacks.any_instance.stubs(:get).returns(attacks_page) + + assert_difference "RankedWarAttack.count", 1 do + FetchWarAttacksJob.perform_now(@war.id) + end + end + + test "skips duplicate attacks on re-run" do + @war.ranked_war_attacks.create!( + torn_attack_id: 1, attacker_id: 111, defender_id: 222, + started: 1.week.ago.to_i, ended: 1.week.ago.to_i + 10, result: "Attacked" + ) + + attacks_page = { + "attacks" => [ + build_api_attack(id: 1, is_ranked_war: true, result: "Attacked"), + build_api_attack(id: 2, is_ranked_war: true, result: "Attacked") + ], + "_metadata" => { "links" => { "prev" => nil, "next" => nil } } + } + + TornApi::Faction::Attacks.any_instance.stubs(:get).returns(attacks_page) + + assert_difference "RankedWarAttack.count", 1 do + FetchWarAttacksJob.perform_now(@war.id) + end + end + + test "logs integrity warning when outgoing count mismatch" do + @war.update!(our_attacks: 10) + + attacks_page = { + "attacks" => [ + build_api_attack(id: 1, attacker_faction_id: 9055, is_ranked_war: true, result: "Attacked") + ], + "_metadata" => { "links" => { "prev" => nil, "next" => nil } } + } + + TornApi::Faction::Attacks.any_instance.stubs(:get).returns(attacks_page) + + Rails.logger.expects(:warn).with(regexp_matches(/outgoing 1\/10/)) + + FetchWarAttacksJob.perform_now(@war.id) + end + + test "passes integrity check when outgoing count matches" do + @war.update!(our_attacks: 1) + + attacks_page = { + "attacks" => [ + build_api_attack(id: 1, attacker_faction_id: 9055, is_ranked_war: true, result: "Attacked") + ], + "_metadata" => { "links" => { "prev" => nil, "next" => nil } } + } + + TornApi::Faction::Attacks.any_instance.stubs(:get).returns(attacks_page) + + Rails.logger.expects(:warn).never + Rails.logger.stubs(:info) + + FetchWarAttacksJob.perform_now(@war.id) + end + + test "requires faction api key with faction access" do + @faction.torn_api_key.update!(faction_access: false) + + assert_no_difference "RankedWarAttack.count" do + FetchWarAttacksJob.perform_now(@war.id) + end + end + + test "skips if war not found" do + assert_nothing_raised do + FetchWarAttacksJob.perform_now(999999) + end + end + + private + + def build_api_attack(id:, attacker_faction_id: 9055, defender_faction_id: 88888, is_ranked_war: true, result: "Attacked") + { + "id" => id, + "code" => "abc#{id}", + "started" => 1.week.ago.to_i, + "ended" => 1.week.ago.to_i + 10, + "attacker" => { + "id" => 111 + id, + "name" => "Attacker#{id}", + "level" => 50, + "faction" => { "id" => attacker_faction_id, "name" => "Faction" } + }, + "defender" => { + "id" => 222 + id, + "name" => "Defender#{id}", + "level" => 30, + "faction" => { "id" => defender_faction_id, "name" => "Enemy" } + }, + "result" => result, + "respect_gain" => 5.0, + "respect_loss" => 1.0, + "chain" => id, + "is_interrupted" => false, + "is_stealthed" => false, + "is_raid" => false, + "is_ranked_war" => is_ranked_war, + "modifiers" => { + "fair_fight" => 3, "war" => 2, "retaliation" => 1, + "group" => 1, "overseas" => 1, "chain" => 1, "warlord" => 1 + }, + "finishing_hit_effects" => [] + } + end +end diff --git a/test/jobs/recon/collect_training_sample_job_test.rb b/test/jobs/recon/collect_training_sample_job_test.rb index bd32a42..ff83897 100644 --- a/test/jobs/recon/collect_training_sample_job_test.rb +++ b/test/jobs/recon/collect_training_sample_job_test.rb @@ -49,8 +49,9 @@ class Recon::CollectTrainingSampleJobTest < ActiveJob::TestCase test "skips if sample does not exist" do stub_api_calls - # Should not raise Recon::CollectTrainingSampleJob.perform_now(player_id: 9999999, spied_at: "2026-03-24") + + assert_nil Recon::TrainingSample.find_by(player_id: 9999999) end test "handles API errors gracefully" do diff --git a/test/models/ranked_war_attack_test.rb b/test/models/ranked_war_attack_test.rb new file mode 100644 index 0000000..2f2e62e --- /dev/null +++ b/test/models/ranked_war_attack_test.rb @@ -0,0 +1,94 @@ +require "test_helper" + +class RankedWarAttackTest < ActiveSupport::TestCase + setup do + @faction = Faction.create!(torn_id: 99999, name: "Test", xanax_target: 2.5) + @war = @faction.ranked_wars.create!( + torn_war_id: 1001, opponent_faction_id: 88888, opponent_faction_name: "Enemy", + started_at: 1.week.ago, ended_at: 3.days.ago, target_score: 100, + our_score: 100, their_score: 50, winner_faction_id: @faction.torn_id + ) + end + + test "belongs to ranked_war" do + attack = RankedWarAttack.new(ranked_war: @war) + assert_equal @war, attack.ranked_war + end + + test "validates required fields" do + attack = RankedWarAttack.new + assert_not attack.valid? + assert_includes attack.errors[:ranked_war], "must exist" + assert_includes attack.errors[:torn_attack_id], "can't be blank" + assert_includes attack.errors[:attacker_id], "can't be blank" + assert_includes attack.errors[:defender_id], "can't be blank" + assert_includes attack.errors[:result], "can't be blank" + end + + test "validates uniqueness of torn_attack_id per war" do + create_attack(torn_attack_id: 123) + duplicate = build_attack(torn_attack_id: 123) + assert_not duplicate.valid? + end + + test "allows same torn_attack_id in different wars" do + other_war = @faction.ranked_wars.create!( + torn_war_id: 1002, opponent_faction_id: 77777, opponent_faction_name: "Other", + started_at: 2.weeks.ago, ended_at: 1.week.ago, target_score: 100, + our_score: 50, their_score: 100, winner_faction_id: 77777 + ) + + create_attack(torn_attack_id: 123) + other = build_attack(torn_attack_id: 123, ranked_war: other_war) + assert other.valid? + end + + test "outgoing scope returns attacks by our faction" do + outgoing = create_attack(attacker_faction_id: @faction.torn_id) + create_attack(torn_attack_id: 2, attacker_faction_id: 88888, defender_faction_id: @faction.torn_id) + + assert_includes RankedWarAttack.outgoing(@faction.torn_id), outgoing + assert_equal 1, RankedWarAttack.outgoing(@faction.torn_id).count + end + + test "incoming scope returns attacks against our faction" do + create_attack(attacker_faction_id: @faction.torn_id, defender_faction_id: 88888) + incoming = create_attack(torn_attack_id: 2, attacker_faction_id: 88888, defender_faction_id: @faction.torn_id) + + assert_includes RankedWarAttack.incoming(@faction.torn_id), incoming + assert_equal 1, RankedWarAttack.incoming(@faction.torn_id).count + end + + test "used_warlord? returns true when warlord modifier > 1" do + attack = build_attack(warlord: 1.25) + assert attack.used_warlord? + end + + test "used_warlord? returns false when warlord is 1" do + attack = build_attack(warlord: 1) + assert_not attack.used_warlord? + end + + test "overseas? returns true when overseas modifier > 1" do + attack = build_attack(overseas: 1.5) + assert attack.overseas? + end + + private + + def build_attack(attrs = {}) + RankedWarAttack.new({ + ranked_war: @war, + torn_attack_id: 1, + attacker_id: 111, attacker_name: "Attacker", attacker_faction_id: @faction.torn_id, + defender_id: 222, defender_name: "Defender", defender_faction_id: 88888, + started: 1.week.ago.to_i, ended: 1.week.ago.to_i + 10, + result: "Attacked", respect_gain: 5.0, + fair_fight: 3, war: 2, warlord: 1, overseas: 1 + }.merge(attrs)) + end + + def create_attack(attrs = {}) + build_attack(attrs).tap(&:save!) + end +end diff --git a/test/models/spy_report_test.rb b/test/models/spy_report_test.rb new file mode 100644 index 0000000..eb6a74d --- /dev/null +++ b/test/models/spy_report_test.rb @@ -0,0 +1,37 @@ +require "test_helper" + +class SpyReportTest < ActiveSupport::TestCase + setup do + @faction = Faction.create!(torn_id: 99999, name: "Test", xanax_target: 2.5) + end + + test "recalculates total on save" do + report = @faction.spy_reports.create!( + torn_id: 111, strength: 100, defense: 200, speed: 300, dexterity: 400, total: 0 + ) + + assert_equal 1000, report.total + end + + test "recalculates total on update" do + report = @faction.spy_reports.create!( + torn_id: 111, strength: 100, defense: 200, speed: 300, dexterity: 400, total: 1000 + ) + + report.update!(strength: 500) + assert_equal 1400, report.total + end + + test "validates torn_id uniqueness per faction" do + @faction.spy_reports.create!(torn_id: 111, strength: 1, defense: 1, speed: 1, dexterity: 1, total: 4) + duplicate = @faction.spy_reports.new(torn_id: 111, strength: 1, defense: 1, speed: 1, dexterity: 1, total: 4) + assert_not duplicate.valid? + end + + test "allows same torn_id in different factions" do + other = Faction.create!(torn_id: 88888, name: "Other", xanax_target: 2.5) + @faction.spy_reports.create!(torn_id: 111, strength: 1, defense: 1, speed: 1, dexterity: 1, total: 4) + report = other.spy_reports.new(torn_id: 111, strength: 1, defense: 1, speed: 1, dexterity: 1, total: 4) + assert report.valid? + end +end diff --git a/test/models/torn_api/faction/attacks_test.rb b/test/models/torn_api/faction/attacks_test.rb new file mode 100644 index 0000000..7b99609 --- /dev/null +++ b/test/models/torn_api/faction/attacks_test.rb @@ -0,0 +1,210 @@ +require "test_helper" + +class TornApi::Faction::AttacksTest < ActiveSupport::TestCase + setup do + @api_key = "test_key" + end + + test "fetches attacks with default params" do + api_response = { + "attacks" => [ + build_attack_response(id: 1, attacker_name: "Bram", defender_name: "Enemy1"), + build_attack_response(id: 2, attacker_name: "Ally", defender_name: "Enemy2") + ], + "_metadata" => { "links" => { "prev" => nil, "next" => nil } } + } + + service = TornApi::Faction::Attacks.new(@api_key) + service.expects(:get).with("v2/faction/attacks", { limit: 100, sort: "DESC" }).returns(api_response) + + result = service.fetch + assert_equal 2, result.attacks.size + assert_nil result.prev_url + end + + test "fetches attacks with time range" do + api_response = { + "attacks" => [ build_attack_response(id: 1) ], + "_metadata" => { "links" => { "prev" => "https://api.torn.com/v2/faction/attacks?to=123", "next" => nil } } + } + + service = TornApi::Faction::Attacks.new(@api_key, from: 1000, to: 2000) + service.expects(:get).with("v2/faction/attacks", { limit: 100, sort: "DESC", from: 1000, to: 2000 }).returns(api_response) + + result = service.fetch + assert_equal 1, result.attacks.size + assert_equal "https://api.torn.com/v2/faction/attacks?to=123", result.prev_url + end + + test "fetches outgoing attacks only" do + api_response = { + "attacks" => [], + "_metadata" => { "links" => { "prev" => nil, "next" => nil } } + } + + service = TornApi::Faction::Attacks.new(@api_key, filters: "outgoing") + service.expects(:get).with("v2/faction/attacks", { limit: 100, sort: "DESC", filters: "outgoing" }).returns(api_response) + + result = service.fetch + assert_empty result.attacks + end + + test "parses attack data into structs" do + api_response = { + "attacks" => [ build_attack_response( + id: 459887212, + attacker_id: 2728237, attacker_name: "Bram", attacker_level: 73, + attacker_faction_id: 9055, attacker_faction_name: "Natural Selection IV", + defender_id: 1600780, defender_name: "Illness", defender_level: 24, + defender_faction_id: nil, defender_faction_name: nil, + result: "Attacked", respect_gain: 5.77, chain: 480, + is_ranked_war: true, is_stealthed: false, + fair_fight: 2.45, war: 2, overseas: 1, warlord: 1.25, + finishing_hit_effects: [ { "name" => "warlord", "value" => 25 } ] + ) ], + "_metadata" => { "links" => { "prev" => nil, "next" => nil } } + } + + service = TornApi::Faction::Attacks.new(@api_key) + service.expects(:get).returns(api_response) + + attack = service.fetch.attacks.first + + assert_equal 459887212, attack.id + assert_equal 2728237, attack.attacker_id + assert_equal "Bram", attack.attacker_name + assert_equal 9055, attack.attacker_faction_id + assert_equal 1600780, attack.defender_id + assert_equal "Illness", attack.defender_name + assert_equal "Attacked", attack.result + assert_in_delta 5.77, attack.respect_gain + assert_equal 480, attack.chain + assert attack.is_ranked_war + assert_not attack.is_stealthed + assert_in_delta 2.45, attack.fair_fight + assert_in_delta 2, attack.war + assert_in_delta 1, attack.overseas + assert_in_delta 1.25, attack.warlord + assert_equal [ { "name" => "warlord", "value" => 25 } ], attack.finishing_hit_effects + end + + test "handles empty attacks array" do + api_response = { + "attacks" => [], + "_metadata" => { "links" => { "prev" => nil, "next" => nil } } + } + + service = TornApi::Faction::Attacks.new(@api_key) + service.expects(:get).returns(api_response) + + result = service.fetch + assert_empty result.attacks + end + + test "fetch_all paginates through all pages" do + page1 = { + "attacks" => [ build_attack_response(id: 3), build_attack_response(id: 2) ], + "_metadata" => { "links" => { "prev" => "https://api.torn.com/v2/faction/attacks?to=1000&limit=100&sort=desc", "next" => nil } } + } + page2 = { + "attacks" => [ build_attack_response(id: 1) ], + "_metadata" => { "links" => { "prev" => nil, "next" => nil } } + } + + service = TornApi::Faction::Attacks.new(@api_key, from: 500) + service.expects(:get).with("v2/faction/attacks", { limit: 100, sort: "DESC", from: 500 }).returns(page1) + service.expects(:get).with("v2/faction/attacks", { limit: 100, sort: "DESC", from: 500, to: 1000 }).returns(page2) + + attacks = service.fetch_all + assert_equal 3, attacks.size + assert_equal [3, 2, 1], attacks.map(&:id) + end + + test "fetch_all stops at max pages to prevent infinite loops" do + response = { + "attacks" => [ build_attack_response(id: 1) ], + "_metadata" => { "links" => { "prev" => "https://api.torn.com/v2/faction/attacks?to=1000", "next" => nil } } + } + + service = TornApi::Faction::Attacks.new(@api_key) + service.stubs(:get).returns(response) + + attacks = service.fetch_all(max_pages: 3) + assert_equal 3, attacks.size + end + + test "fetch_all returns empty array when no attacks" do + api_response = { + "attacks" => [], + "_metadata" => { "links" => { "prev" => nil, "next" => nil } } + } + + service = TornApi::Faction::Attacks.new(@api_key) + service.expects(:get).returns(api_response) + + assert_empty service.fetch_all + end + + test "custom limit" do + api_response = { + "attacks" => [], + "_metadata" => { "links" => { "prev" => nil, "next" => nil } } + } + + service = TornApi::Faction::Attacks.new(@api_key, limit: 50) + service.expects(:get).with("v2/faction/attacks", { limit: 50, sort: "DESC" }).returns(api_response) + + service.fetch + end + + private + + def build_attack_response( + id: 1, started: 1774472479, ended: 1774472489, + attacker_id: 123, attacker_name: "Attacker", attacker_level: 50, + attacker_faction_id: 9055, attacker_faction_name: "Test Faction", + defender_id: 456, defender_name: "Defender", defender_level: 30, + defender_faction_id: 8888, defender_faction_name: "Enemy Faction", + result: "Attacked", respect_gain: 3.36, respect_loss: 0.84, chain: 1, + is_ranked_war: false, is_stealthed: false, is_interrupted: false, is_raid: false, + fair_fight: 3, war: 1, retaliation: 1, group: 1, overseas: 1, warlord: 1, + finishing_hit_effects: [] + ) + { + "id" => id, + "code" => "abc123", + "started" => started, + "ended" => ended, + "attacker" => { + "id" => attacker_id, + "name" => attacker_name, + "level" => attacker_level, + "faction" => attacker_faction_id ? { "id" => attacker_faction_id, "name" => attacker_faction_name } : nil + }, + "defender" => { + "id" => defender_id, + "name" => defender_name, + "level" => defender_level, + "faction" => defender_faction_id ? { "id" => defender_faction_id, "name" => defender_faction_name } : nil + }, + "result" => result, + "respect_gain" => respect_gain, + "respect_loss" => respect_loss, + "chain" => chain, + "is_interrupted" => is_interrupted, + "is_stealthed" => is_stealthed, + "is_raid" => is_raid, + "is_ranked_war" => is_ranked_war, + "modifiers" => { + "fair_fight" => fair_fight, + "war" => war, + "retaliation" => retaliation, + "group" => group, + "overseas" => overseas, + "chain" => 1, + "warlord" => warlord + }, + "finishing_hit_effects" => finishing_hit_effects + } + end +end From a5279b12ee0f66c94f0f37e6d2c8353a9ea65c1c Mon Sep 17 00:00:00 2001 From: Bram Date: Sat, 4 Apr 2026 00:01:46 +0200 Subject: [PATCH 208/266] remove comments --- .../factions/leadership/setup_controller.rb | 1 - app/controllers/factions_controller.rb | 20 +------------------ app/controllers/public_wars_controller.rb | 9 --------- app/controllers/roadmap_controller.rb | 2 +- app/controllers/sessions_controller.rb | 1 - app/helpers/faction_helper.rb | 2 +- app/jobs/backfill_personal_stats_job.rb | 2 -- app/models/recon/spy_data_parser.rb | 1 - test/models/torn_api/faction/attacks_test.rb | 2 +- 9 files changed, 4 insertions(+), 36 deletions(-) diff --git a/app/controllers/factions/leadership/setup_controller.rb b/app/controllers/factions/leadership/setup_controller.rb index bce0bf3..cdcf884 100644 --- a/app/controllers/factions/leadership/setup_controller.rb +++ b/app/controllers/factions/leadership/setup_controller.rb @@ -28,7 +28,6 @@ def update return redirect_to faction_leadership_setup_path(@faction), alert: "This API key does not belong to you." end - # Ensure faction_setting exists @faction.create_faction_setting! unless @faction.faction_setting torn_record = @faction.torn_api_key || @faction.build_torn_api_key diff --git a/app/controllers/factions_controller.rb b/app/controllers/factions_controller.rb index 38d896c..ac63202 100644 --- a/app/controllers/factions_controller.rb +++ b/app/controllers/factions_controller.rb @@ -19,7 +19,6 @@ def index end def show - # Subscription check: must be subscribed or admin unless Current.user.subscribed? || Current.user.admin? return render :subscription_expired end @@ -40,7 +39,6 @@ def setup_unavailable def create api_key = params[:api_key].to_s.strip - # Validate the API key begin key_info = TornApi::Key::Info.new(api_key).fetch rescue TornApi::InvalidKeyError @@ -48,43 +46,35 @@ def create return render :setup, status: :unprocessable_entity end - # Must be Limited Access unless key_info.access.type == "Limited Access" flash.now[:alert] = "This key is #{key_info.access.type}. A Limited Access key is required." return render :setup, status: :unprocessable_entity end - # Must belong to the current user unless key_info.user.id == Current.user.torn_id flash.now[:alert] = "This API key does not belong to you." return render :setup, status: :unprocessable_entity end - # Must be for the correct faction unless key_info.user.faction_id == @faction.torn_id flash.now[:alert] = "This API key is for a different faction." return render :setup, status: :unprocessable_entity end - # Create faction setting and store the API key @faction.create_faction_setting! unless @faction.faction_setting torn_record = @faction.torn_api_key || @faction.build_torn_api_key torn_record.update!(key: api_key, access_type: "Limited Access", faction_access: key_info.access.faction == true) - # Grant leadership access to the setup user Current.user.update!(leadership_access: true) - # Fetch faction members to grant trial subscriptions and leadership access to leaders members = TornApi::Faction::Members.new(api_key, @faction.torn_id).fetch - # Grant 1-month trial subscription to members who haven't received one yet trial_expiry = 1.month.from_now @faction.users.where(trial_granted_at: nil).update_all( subscription_expires_at: trial_expiry, trial_granted_at: Time.current ) - # Persist positions and grant leadership access to Leaders and Co-leaders members.each do |member| user = User.find_by(torn_id: member.id) next unless user @@ -94,12 +84,11 @@ def create user.update!(attrs) end - # Calculate backfill ETA immediately so the banner shows right away start_date = PersonalStatSnapshot.tracking_start_date end_date = Date.yesterday dates_count = (start_date..end_date).count members_count = @faction.users.active.count - total_api_calls = members_count * dates_count * 2 # 2 batches per user per date + total_api_calls = members_count * dates_count * 2 # 2 batches per user per date estimated_seconds = [ total_api_calls * BackfillPersonalStatsJob::SECONDS_PER_API_CALL, 1 ].max.to_i @faction.update!( @@ -107,7 +96,6 @@ def create backfill_target_date: start_date ) - # Queue background jobs BackfillRankedWarsJob.perform_later(@faction.id) BackfillPersonalStatsJob.perform_later( @faction.id, @@ -115,7 +103,6 @@ def create end_date.to_s ) - # Mark setup as completed @faction.update!(setup_completed: true) redirect_to faction_path(@faction), notice: "Your faction has been set up. Welcome to TornManager!" @@ -160,28 +147,23 @@ def find_faction_for_setup return end - # Must be a member of this faction unless Current.user.faction == @faction redirect_to root_path, alert: "You cannot set up this faction." return end - # Must not already be set up if @faction.setup_completed? redirect_to root_path, alert: "This faction is already set up." end end def load_hero_data - # Member count @member_count = @faction.users.active.count - # War record (current year only) current_year_wars = @faction.ranked_wars.completed.where(started_at: Date.current.beginning_of_year..) @war_wins = current_year_wars.won.count @war_losses = current_year_wars.lost.count - # Weekly top performers (last calendar week: Monday to Sunday) last_week_end = Date.current.beginning_of_week(:monday) - 1.day last_week_start = last_week_end.beginning_of_week(:monday) weekly_summary = ComplianceSummary.new(@faction, start_date: last_week_start, end_date: last_week_end) diff --git a/app/controllers/public_wars_controller.rb b/app/controllers/public_wars_controller.rb index 7405cd8..7bae276 100644 --- a/app/controllers/public_wars_controller.rb +++ b/app/controllers/public_wars_controller.rb @@ -32,7 +32,6 @@ def create return render_index_with_error end - # Validate the API key and look up the creator begin key_info = TornApi::Key::Info.new(api_key).fetch creator = TornApi::User::Profile.new(api_key).fetch @@ -44,13 +43,11 @@ def create return render_index_with_error end - # Enforce Public Only key access level unless key_info.access.type == "Public Only" flash.now[:alert] = "Only Public Only API keys are accepted. Your key has #{key_info.access.type} access. Please create a Public Only key in your Torn API settings." return render_index_with_error end - # Fetch faction info begin faction_info = TornApi::Faction::Basic.new(api_key, faction_torn_id).fetch faction_name = faction_info["name"] @@ -62,7 +59,6 @@ def create return render_index_with_error end - # Find active ranked war begin wars = TornApi::Faction::RankedWars.new(api_key, faction_torn_id).fetch(limit: 5) active_war = wars.find { |w| w["end"].to_i == 0 } @@ -76,17 +72,14 @@ def create return render_index_with_error end - # Find opponent faction name from the war data opponent = active_war["factions"]&.find { |f| f["id"] != faction_torn_id } opponent_name = opponent&.dig("name") || "Unknown" - # Check for duplicate lobby if PublicWarLobby.exists?(faction_torn_id: faction_torn_id) flash.now[:alert] = "A lobby already exists for this faction's war." return render_index_with_error end - # Create the lobby lobby = PublicWarLobby.new( faction_torn_id: faction_torn_id, faction_name: faction_name, @@ -101,10 +94,8 @@ def create return render_index_with_error end - # Cache the API key (no expiry — lives until server restart or manual delete) Rails.cache.write(lobby.api_key_cache_key, api_key) - # Start polling PublicWarPollingJob.perform_later(lobby.id) redirect_to public_war_path(lobby), notice: "Lobby created! Live polling has started." diff --git a/app/controllers/roadmap_controller.rb b/app/controllers/roadmap_controller.rb index 5ea180c..5b1d953 100644 --- a/app/controllers/roadmap_controller.rb +++ b/app/controllers/roadmap_controller.rb @@ -4,7 +4,7 @@ class RoadmapController < ApplicationController before_action :set_roadmap_item, only: [ :update, :destroy ] def index - resume_session # Attempt to load user session without requiring it + resume_session @items_by_status = RoadmapItem::STATUSES.index_with do |status| RoadmapItem.by_status(status) end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 50b5366..4164099 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -28,7 +28,6 @@ def create profile_image: profile.image ) - # Assign user to their faction, creating it if needed torn_faction_id = key_info.user.faction_id if torn_faction_id.present? && torn_faction_id > 0 faction = Faction.find_by(torn_id: torn_faction_id) diff --git a/app/helpers/faction_helper.rb b/app/helpers/faction_helper.rb index 211b47b..c4c0612 100644 --- a/app/helpers/faction_helper.rb +++ b/app/helpers/faction_helper.rb @@ -6,7 +6,7 @@ def stat_compliance(actual_daily, target) if ratio >= 1.0 :green - elsif ratio >= 0.6 # 60% of target + elsif ratio >= 0.6 :yellow else :red diff --git a/app/jobs/backfill_personal_stats_job.rb b/app/jobs/backfill_personal_stats_job.rb index dbff4be..1945afb 100644 --- a/app/jobs/backfill_personal_stats_job.rb +++ b/app/jobs/backfill_personal_stats_job.rb @@ -27,8 +27,6 @@ def perform(faction_id, start_date, end_date) end end - # Schedule cleanup job based on the faction's existing backfill ETA - # (backfill_ends_at is already set by the controller at setup time) if faction.backfill_ends_at.present? wait_seconds = [ (faction.backfill_ends_at - Time.current).to_i, 1 ].max ClearBackfillStatusJob.set(wait: wait_seconds.seconds).perform_later(faction.id) diff --git a/app/models/recon/spy_data_parser.rb b/app/models/recon/spy_data_parser.rb index 1375aad..07f336e 100644 --- a/app/models/recon/spy_data_parser.rb +++ b/app/models/recon/spy_data_parser.rb @@ -22,7 +22,6 @@ def self.parse_row(line) cols = line.split("\t").map(&:strip) return nil if cols.size < 8 - # If first column is a pure number, it's a rank — shift it off cols.shift if cols[0].match?(/\A\d+\z/) name, player_id = extract_name_and_id(cols[0]) diff --git a/test/models/torn_api/faction/attacks_test.rb b/test/models/torn_api/faction/attacks_test.rb index 7b99609..08bdc16 100644 --- a/test/models/torn_api/faction/attacks_test.rb +++ b/test/models/torn_api/faction/attacks_test.rb @@ -117,7 +117,7 @@ class TornApi::Faction::AttacksTest < ActiveSupport::TestCase attacks = service.fetch_all assert_equal 3, attacks.size - assert_equal [3, 2, 1], attacks.map(&:id) + assert_equal [ 3, 2, 1 ], attacks.map(&:id) end test "fetch_all stops at max pages to prevent infinite loops" do From 5076339f45ff2052ba97f3d9c45fff48fa72436f Mon Sep 17 00:00:00 2001 From: Bram Date: Sun, 5 Apr 2026 12:26:13 +0200 Subject: [PATCH 209/266] init --- Gemfile | 1 + Gemfile.lock | 33 +++++++ app/controllers/sessions_controller.rb | 15 ++- app/models/discord/bot.rb | 50 ++++++++++ app/models/discord/error_notifier.rb | 22 +++++ app/models/discord/notifier.rb | 30 ++++++ app/models/discord/verifier.rb | 114 ++++++++++++++++++++++ app/models/torn_api/user/discord.rb | 28 ++++++ bin/discord_bot | 13 +++ config/credentials.yml.enc | 2 +- config/deploy.yml | 8 +- config/initializers/discord_bot.rb | 3 + test/models/discord/verifier_test.rb | 52 ++++++++++ test/models/torn_api/user/discord_test.rb | 44 +++++++++ 14 files changed, 408 insertions(+), 7 deletions(-) create mode 100644 app/models/discord/bot.rb create mode 100644 app/models/discord/error_notifier.rb create mode 100644 app/models/discord/notifier.rb create mode 100644 app/models/discord/verifier.rb create mode 100644 app/models/torn_api/user/discord.rb create mode 100755 bin/discord_bot create mode 100644 config/initializers/discord_bot.rb create mode 100644 test/models/discord/verifier_test.rb create mode 100644 test/models/torn_api/user/discord_test.rb diff --git a/Gemfile b/Gemfile index 79e2332..de9e62d 100644 --- a/Gemfile +++ b/Gemfile @@ -43,6 +43,7 @@ gem "thruster", require: false gem "image_processing", "~> 1.2" gem "appsignal" +gem "discordrb", require: false group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem diff --git a/Gemfile.lock b/Gemfile.lock index 9b17b2c..d5ff82a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -112,6 +112,16 @@ GEM debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) + discordrb (3.7.2) + base64 (~> 0.2) + discordrb-webhooks (~> 3.7.2) + ffi (>= 1.9.24) + opus-ruby + rest-client (>= 2.0.0) + websocket-client-simple (>= 0.9.0) + discordrb-webhooks (3.7.2) + rest-client (>= 2.0.0) + domain_name (0.6.20240107) dotenv (3.2.0) drb (2.2.3) ed25519 (1.4.0) @@ -119,6 +129,7 @@ GEM erubi (1.13.1) et-orbi (1.4.0) tzinfo + event_emitter (0.2.6) ffi (1.17.3-aarch64-linux-gnu) ffi (1.17.3-aarch64-linux-musl) ffi (1.17.3-arm-linux-gnu) @@ -132,6 +143,9 @@ GEM raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) + http-accept (1.7.0) + http-cookie (1.1.0) + domain_name (~> 0.5) i18n (1.14.8) concurrent-ruby (~> 1.0) image_processing (1.14.0) @@ -176,6 +190,10 @@ GEM net-smtp marcel (1.1.0) matrix (0.4.3) + mime-types (3.7.0) + logger + mime-types-data (~> 3.2025, >= 3.2025.0507) + mime-types-data (3.2026.0331) mini_magick (5.3.1) logger mini_mime (1.1.5) @@ -195,6 +213,7 @@ GEM mocha (3.1.0) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) + mutex_m (0.3.0) net-imap (0.6.3) date net-protocol @@ -209,6 +228,7 @@ GEM net-smtp (0.5.1) net-protocol net-ssh (7.3.2) + netrc (0.11.0) nio4r (2.7.5) nokogiri (1.19.2-aarch64-linux-gnu) racc (~> 1.4) @@ -226,6 +246,8 @@ GEM racc (~> 1.4) nokogiri (1.19.2-x86_64-linux-musl) racc (~> 1.4) + opus-ruby (1.0.1) + ffi ostruct (0.6.3) parallel (1.27.0) parser (3.3.10.2) @@ -294,6 +316,11 @@ GEM regexp_parser (2.11.3) reline (0.6.3) io-console (~> 0.5) + rest-client (2.1.0) + http-accept (>= 1.7.0, < 2.0) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 4.0) + netrc (~> 0.8) rexml (3.4.4) rubocop (1.84.2) json (~> 2.3) @@ -393,6 +420,11 @@ GEM bindex (>= 0.4.0) railties (>= 8.0.0) websocket (1.2.11) + websocket-client-simple (0.9.0) + base64 + event_emitter + mutex_m + websocket websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) @@ -421,6 +453,7 @@ DEPENDENCIES bundler-audit capybara debug + discordrb image_processing (~> 1.2) importmap-rails jbuilder diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 4164099..477e044 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -49,6 +49,7 @@ def create user.save! start_new_session_for user + notify_sign_in(user) redirect_to after_authentication_url rescue TornApi::InvalidKeyError @@ -69,8 +70,18 @@ def destroy private - # Light member sync for new factions during login. - # Only upserts members — no backfill scheduling. + def notify_sign_in(user) + Discord::Notifier.notify( + webhook_key: :notifications_webhook_url, + embed: { + title: "Sign In", + description: "[#{user.name} [#{user.torn_id}]](https://www.torn.com/profiles.php?XID=#{user.torn_id})", + color: 5_025_616, + footer: { text: "TornManager" }, + timestamp: Time.current.iso8601 + } + ) + end def sync_faction_members(faction) members = TornApi::Faction::Members.new(AdminCredentials.api_key, faction.torn_id).fetch diff --git a/app/models/discord/bot.rb b/app/models/discord/bot.rb new file mode 100644 index 0000000..d0ed33b --- /dev/null +++ b/app/models/discord/bot.rb @@ -0,0 +1,50 @@ +require "discordrb" + +module Discord + class Bot + attr_reader :client + + def initialize + token = Rails.application.credentials.dig(:discord, :bot_token) + raise "Discord bot_token not configured" unless token + + @client = Discordrb::Bot.new( + token: token, + intents: [ :server_members ] + ) + end + + def start + register_commands + register_events + Rails.logger.info("[Discord::Bot] Starting...") + client.run + end + + private + + def register_commands + guild_id = Rails.application.credentials.dig(:discord, :guild_id) + existing = client.get_application_commands(server_id: guild_id) + + unless existing.any? { |cmd| cmd.name == "verify" } + client.register_application_command(:verify, "Verify your Torn account", server_id: guild_id) + Rails.logger.info("[Discord::Bot] Registered /verify command") + end + end + + def register_events + client.ready do |_event| + Rails.logger.info("[Discord::Bot] Online and ready") + end + + client.member_join do |event| + Discord::Verifier.new(event).call + end + + client.application_command(:verify) do |event| + Discord::Verifier.new(event).verify + end + end + end +end diff --git a/app/models/discord/error_notifier.rb b/app/models/discord/error_notifier.rb new file mode 100644 index 0000000..80f5ef3 --- /dev/null +++ b/app/models/discord/error_notifier.rb @@ -0,0 +1,22 @@ +module Discord + class ErrorNotifier + def report(error, handled:, severity:, context:, source: nil) + return if handled + + Discord::Notifier.notify( + webhook_key: :error_webhook_url, + embed: { + title: error.class.to_s, + description: "```#{error.message.truncate(1000)}```", + color: 15_158_332, + fields: [ + { name: "Source", value: source || "unknown", inline: true }, + { name: "Severity", value: severity.to_s, inline: true } + ], + footer: { text: "TornManager Error Reporter" }, + timestamp: Time.current.iso8601 + } + ) + end + end +end diff --git a/app/models/discord/notifier.rb b/app/models/discord/notifier.rb new file mode 100644 index 0000000..955c44a --- /dev/null +++ b/app/models/discord/notifier.rb @@ -0,0 +1,30 @@ +require "net/http" +require "json" + +module Discord + class Notifier + def initialize(webhook_key: :default_webhook_url) + @webhook_url = Rails.application.credentials.dig(:discord, webhook_key) + end + + def send(content: nil, embed: nil) + return if Rails.env.test? + return unless @webhook_url + + payload = {} + payload[:content] = content if content + payload[:embeds] = [ embed ] if embed + + Thread.new do + uri = URI(@webhook_url) + Net::HTTP.post(uri, payload.to_json, "Content-Type" => "application/json") + rescue => e + Rails.logger.error("[Discord::Notifier] Failed: #{e.message}") + end + end + + def self.notify(webhook_key: :default_webhook_url, content: nil, embed: nil) + new(webhook_key: webhook_key).send(content: content, embed: embed) + end + end +end diff --git a/app/models/discord/verifier.rb b/app/models/discord/verifier.rb new file mode 100644 index 0000000..34fac94 --- /dev/null +++ b/app/models/discord/verifier.rb @@ -0,0 +1,114 @@ +module Discord + class Verifier + def initialize(event) + @event = event + @guild_id = Rails.application.credentials.dig(:discord, :guild_id) + @verified_role_id = Rails.application.credentials.dig(:discord, :verified_role_id) + end + + def call + return unless home_server? + + discord_id = @event.user.id.to_s + Rails.logger.info("[Discord::Verifier] New member: #{@event.user.name} (#{discord_id})") + + torn_user = lookup_torn_user(discord_id) + + if torn_user + apply_verification(torn_user) + else + send_link_instructions + end + rescue => e + Rails.logger.error("[Discord::Verifier] #{e.class}: #{e.message}") + end + + def verify + return unless home_server? + + discord_id = @event.user.id.to_s + torn_user = lookup_torn_user(discord_id) + + if torn_user + name = apply_verification(torn_user) + @event.respond( + content: "Verified successfully! Welcome [#{name} [#{torn_user.user_id}]](https://www.torn.com/profiles.php?XID=#{torn_user.user_id})", + ephemeral: true + ) + else + @event.respond( + content: "Could not find your Torn account. Make sure you've linked your Discord to Torn:\n" \ + "\n\n" \ + "Once linked, run `/verify` again.", + ephemeral: true + ) + end + rescue => e + Rails.logger.error("[Discord::Verifier] Slash command error: #{e.class}: #{e.message}") + @event.respond(content: "Something went wrong. Please try again later.", ephemeral: true) + end + + private + + def home_server? + @event.server.id.to_s == @guild_id.to_s + end + + def lookup_torn_user(discord_id) + api_key = AdminCredentials.api_key + return nil unless api_key + + TornApi::User::Discord.new(api_key, discord_id).fetch + rescue TornApi::ApiError => e + Rails.logger.error("[Discord::Verifier] Torn API error: #{e.message}") + nil + end + + def apply_verification(torn_user) + member = @event.server.member(@event.user.id) + name = fetch_torn_name(torn_user.user_id) + display_name = "#{name} [#{torn_user.user_id}]" + + begin + member.nick = display_name + rescue => e + Rails.logger.warn("[Discord::Verifier] Cannot set nickname for #{@event.user.name}: #{e.message}") + end + + begin + member.add_role(@verified_role_id.to_i) + rescue => e + Rails.logger.warn("[Discord::Verifier] Cannot add role for #{@event.user.name}: #{e.message}") + end + + Rails.logger.info("[Discord::Verifier] Verified #{@event.user.name} as #{display_name}") + name + end + + def send_link_instructions + @event.user.pm( + "Welcome to the TornManager Discord!\n\n" \ + "I couldn't verify your Torn account. To get verified:\n" \ + "1. Link your Discord to Torn by following this guide by IBF: " \ + "\n" \ + "2. Once linked, come back and type `/verify` in any channel." + ) + rescue Discordrb::Errors::NoPermission + Rails.logger.warn("[Discord::Verifier] Cannot DM #{@event.user.name}") + end + + def fetch_torn_name(torn_id) + user = ::User.find_by(torn_id: torn_id) + return user.name if user + + api_key = AdminCredentials.api_key + return "Player" unless api_key + + response = TornApi::Base.new(api_key).get("v2/user/#{torn_id}/profile", {}) + response.dig("profile", "name") || "Player" + rescue => e + Rails.logger.warn("[Discord::Verifier] Failed to fetch name for #{torn_id}: #{e.message}") + "Player" + end + end +end diff --git a/app/models/torn_api/user/discord.rb b/app/models/torn_api/user/discord.rb new file mode 100644 index 0000000..f47c1a6 --- /dev/null +++ b/app/models/torn_api/user/discord.rb @@ -0,0 +1,28 @@ +module TornApi + module User + class Discord < Base + DiscordData = Data.define(:discord_id, :user_id) + + attr_reader :lookup_id + + def initialize(api_key, lookup_id) + super(api_key) + @lookup_id = lookup_id + end + + def fetch + response = get("v2/user/#{lookup_id}/discord") + discord = response["discord"] + + return nil unless discord && discord["user_id"] + + DiscordData.new( + discord_id: discord["discord_id"], + user_id: discord["user_id"] + ) + rescue TornApi::NotFoundError + nil + end + end + end +end diff --git a/bin/discord_bot b/bin/discord_bot new file mode 100755 index 0000000..685c39d --- /dev/null +++ b/bin/discord_bot @@ -0,0 +1,13 @@ +#!/usr/bin/env ruby +require_relative "../config/environment" + +puts "Starting TornManager Discord Bot..." + +begin + bot = Discord::Bot.new + bot.start +rescue => e + puts "Discord Bot failed to start: #{e.message}" + Rails.logger.error("[Discord::Bot] Failed to start: #{e.class}: #{e.message}") + exit 1 +end diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index 557d95d..2fa623d 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -Fg1DtnfqffscHHk7HJi55ak3kfOKPrjxvXZxoRRs47pQ1e9UCE3eEpqzKg9HAHW/faY2KREf2K+RePpgg1HWjovqi66EOE4PGqSVYsiQ0bpxtrNzdFrEK9y/0sWVTHEQdhXmPib8GI++/Tc5PwSJ9x3OOFeaXt81UZc23IX5z9WpN3l6RIlOCBFZHco5W+lHfj01HpX7ufexehXocuko6FEw7+MaJYLu7p+HNtIKRN7qI43WJugECsOqW/U2hhOi0Cgr+N25/PuC5aR3kK8J6KGfPx5l2v1YPIuDygXRy1X8vN6PXMJLJS+Xhlb224jmZK32O7DUfqgxi//n7fqWhdzSjorjY4hs3ShgyMjDPijVZU757Z3SloKxX9YJr2xV//fwrTEK0cSeQ/KeJp4Zah7JPtc0yJaOMbCpL4Sl1zDj1TBf/ZjOPR5lvEBRUcf6JygoQr5zzz1C/QCVYgiBk6HSmKcCcleBG/lBCgGq/OH1G+mB8pJJFVDYb15q5RWFMgUXpsvC+OSdZ5zsSp3NtQI/46/ltsijq+QR0tANLB2EMlkuERrvuOK3P9N/ljdkPX8XRSvMVHitnaSV49uHrtCq/+zkMl3fsaHKDd8itBIY/ZB8NNFMNtkW/HfVH31DdwfAYrVv9YALRTO2K6Nv8JGOKh9NR/wJaWvZf6xo7eFUbWHoplwy0/MnDmZ14ECZqgw3Gq6im+A3LrTM27w8IAZ7R2A+VDn7fnmCx3Q19/J3XOZKO3Sr4EXr817seBYcZe4NMkwT/cDAq2xgovp2I0Thk1TzO+Y5XNbDgZWGebsCUZBgfm9W--EdYxqpHytBH4G7FG--SaJGxe7jKPn2njZM3Q1NTg== \ No newline at end of file +JWY4QPOwE0VS4J9ZW+6hmXwmOEgO39gsoBFwIGOejR7EN/Me/IcazHO7yRhXAvaP1X+pYEYDUKTVpQqLCswv0buafwki6Z63x/vksXfXkFNNutVariBeGTys3L+AIw/uD0hHzp/6XlSyNSlwtgMrXqJXJd7hM/thDQ1Hivs9SiWiDRXTQ7n7a/peABzM05kshEgw8zRpL10wcE8Xlb5BEcathJE2W6w0CRfMOKh3m4M38yi2NA1mrgdq2BK/nubCGrcEjWrz0yHW3Tt0tjgBaQLQHnXeg52DyEFMXQ54yFzjaoODK5YZth9qCw8q5SWnh9rS8h3CQ+p7tXbd5qZMQLuIL0+gtnNFTXOhx4nfPr5AFVNKuBCC7G5UoD8AdGDZnkMn/hUs3TtcBApv5wlXF1KBNkFukxPyJlkzUCDYFrRpEN2QAOO+Lkdfs2X7ek0DO2YmArGjVIjkhyOXel6XC770Cy4A7HXlBu+Trtsussa2Crqf/Ya4sc49ocIedaZkE0iF41uxuyaN70PFD59kVFKjNj8V4tS17u8PovFlZTQ84wQ+FeANUWsJUSvQrflGP8nzLzYMiZ+gFn5xY1xRGfmOWZ0F4Oi0OsgqHFG6166YgyKp812iNhdxSbYCgxbFZhQzZDgKx81lDnghhFj3/GbN+C+8AsPsNyVxxnKLz/cPbmwj5YJZmZM6pjchMgtjbX8/xo2hJ9tacFrMDDIQJ/CoyAZiE3iWXl9HFsepP1rl+wN1ygvZ1pi9ZRTQmLsQblDrkHl7zovvUMsWecY1c9LoX5CnBaf4ACljD9INJtrqr+6k/4PX1T81BFgMXjoGUeZwqI0Xnf39As1hWE7HdmPjiroqgw8GAlxbAfw8MdJLOV04G64n82vkBciOSjXY5zB2dsGB9Sc5VYDpEd8srQrJYG1aBltxuI3AcpKqjhnO0OhzLaiCtJGEZKHzr2rtP4X6ru791cKXGaMu8QVBeCcxw4QH9f8UV35Qe5MCJpclM5v8NAQPu5p/BD0iigmUKxmyTh79LVbo4y3/Gxm9YIYoIAAu4PjNvAEnMsLvebApHl9wDjlctrfNJk8c11Bc2vXxJTF9QAhEWuYrakC+CkO4gWemAHjIlOQAQcXpiITSErVneY6BrDMjGRx/tXcAXk7dilu8fgQ/8VU1tvtmLOSm3OBzmY/XHnMVv1OGbbCQbDCNpNDCjZGZJr5PYoHT07erAuYHnj+TED8EW7CcstgA7F7GnjxA8k/j/Sph0rZTo/Jw8vWv0NVenQZPN4Dc49hZ64ILEH7U6SRKxT/fvi2FdjddRX4uyJoeiflBeClhu2bh1T8/xf3JJe1XBNOyF51sCmUE88rWCMWmGXUniePheOHPeUn5VdRMBlBN42pplrTEBuUemgrYRwcmNtDA4mr8ov1BCazwBFyJ6gYJ5WeRrQgB6Dm2KsSSzvxcF15ZaAYe4Tmfzf0Bl1f0wNrujqkX8QhU3Hs5cLw=--oTPKwAEHeaIo538s--8dF7cQU7ue3uQrq/YVpWYA== \ No newline at end of file diff --git a/config/deploy.yml b/config/deploy.yml index a0c0adf..a7dbc1c 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -8,10 +8,10 @@ image: ibramsterdam/tornmanager servers: web: - 95.216.184.222 - # job: - # hosts: - # - 192.168.0.1 - # cmd: bin/jobs + discord: + hosts: + - 95.216.184.222 + cmd: bin/discord_bot # Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. # Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer. diff --git a/config/initializers/discord_bot.rb b/config/initializers/discord_bot.rb new file mode 100644 index 0000000..2fbd736 --- /dev/null +++ b/config/initializers/discord_bot.rb @@ -0,0 +1,3 @@ +Rails.application.config.after_initialize do + Rails.error.subscribe(Discord::ErrorNotifier.new) +end diff --git a/test/models/discord/verifier_test.rb b/test/models/discord/verifier_test.rb new file mode 100644 index 0000000..a1847a2 --- /dev/null +++ b/test/models/discord/verifier_test.rb @@ -0,0 +1,52 @@ +require "test_helper" + +class Discord::VerifierTest < ActiveSupport::TestCase + test "lookup_torn_user returns data when discord is linked" do + AdminCredentials.stubs(:api_key).returns("test_key") + TornApi::User::Discord.any_instance.stubs(:fetch).returns( + TornApi::User::Discord::DiscordData.new(discord_id: "123456", user_id: 2728237) + ) + + verifier = Discord::Verifier.allocate + result = verifier.send(:lookup_torn_user, "123456") + + assert_equal 2728237, result.user_id + end + + test "lookup_torn_user returns nil when not linked" do + AdminCredentials.stubs(:api_key).returns("test_key") + TornApi::User::Discord.any_instance.stubs(:fetch).returns(nil) + + verifier = Discord::Verifier.allocate + result = verifier.send(:lookup_torn_user, "999999") + + assert_nil result + end + + test "lookup_torn_user returns nil when no api key" do + AdminCredentials.stubs(:api_key).returns(nil) + + verifier = Discord::Verifier.allocate + result = verifier.send(:lookup_torn_user, "123456") + + assert_nil result + end + + test "fetch_torn_name returns name from local database" do + user = users(:bram) + + verifier = Discord::Verifier.allocate + result = verifier.send(:fetch_torn_name, user.torn_id) + + assert_equal "Bram", result + end + + test "fetch_torn_name returns Player when not in database and no api key" do + AdminCredentials.stubs(:api_key).returns(nil) + + verifier = Discord::Verifier.allocate + result = verifier.send(:fetch_torn_name, 9999999) + + assert_equal "Player", result + end +end diff --git a/test/models/torn_api/user/discord_test.rb b/test/models/torn_api/user/discord_test.rb new file mode 100644 index 0000000..50c81d6 --- /dev/null +++ b/test/models/torn_api/user/discord_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class TornApi::User::DiscordTest < ActiveSupport::TestCase + setup do + @api_key = "test_key" + end + + test "fetches discord data for a user by discord id" do + response = { + "discord" => { + "discord_id" => "123456789", + "user_id" => 2728237 + } + } + + service = TornApi::User::Discord.new(@api_key, "123456789") + service.expects(:get).with("v2/user/123456789/discord").returns(response) + + result = service.fetch + assert_equal "123456789", result.discord_id + assert_equal 2728237, result.user_id + end + + test "returns nil when user not found" do + service = TornApi::User::Discord.new(@api_key, "999999999") + service.expects(:get).raises(TornApi::NotFoundError, "not found") + + assert_nil service.fetch + end + + test "returns nil when discord not linked" do + response = { + "discord" => { + "discord_id" => nil, + "user_id" => nil + } + } + + service = TornApi::User::Discord.new(@api_key, "123456789") + service.expects(:get).returns(response) + + assert_nil service.fetch + end +end From 56ab2cb060e9f9d941b668960c854deb520c0ae8 Mon Sep 17 00:00:00 2001 From: Bram Date: Sun, 5 Apr 2026 15:01:55 +0200 Subject: [PATCH 210/266] another discord notifier message --- app/models/discord/verifier.rb | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/app/models/discord/verifier.rb b/app/models/discord/verifier.rb index 34fac94..6d2aa0a 100644 --- a/app/models/discord/verifier.rb +++ b/app/models/discord/verifier.rb @@ -72,19 +72,33 @@ def apply_verification(torn_user) begin member.nick = display_name rescue => e - Rails.logger.warn("[Discord::Verifier] Cannot set nickname for #{@event.user.name}: #{e.message}") + notify_warning("Cannot set nickname for #{@event.user.name}: #{e.message}") end begin member.add_role(@verified_role_id.to_i) rescue => e - Rails.logger.warn("[Discord::Verifier] Cannot add role for #{@event.user.name}: #{e.message}") + notify_warning("Cannot add role for #{@event.user.name}: #{e.message}") end Rails.logger.info("[Discord::Verifier] Verified #{@event.user.name} as #{display_name}") name end + def notify_warning(message) + Rails.logger.warn("[Discord::Verifier] #{message}") + Discord::Notifier.notify( + webhook_key: :error_webhook_url, + embed: { + title: "Verification Warning", + description: message, + color: 16_776_960, + footer: { text: "Discord::Verifier" }, + timestamp: Time.current.iso8601 + } + ) + end + def send_link_instructions @event.user.pm( "Welcome to the TornManager Discord!\n\n" \ From 38dc6b74093d401423b3b8505644a3ec76bb498e Mon Sep 17 00:00:00 2001 From: Bram Date: Sun, 5 Apr 2026 16:58:34 +0200 Subject: [PATCH 211/266] announcements --- app/models/discord/notifier.rb | 29 +++++++++++ bin/announce | 88 ++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100755 bin/announce diff --git a/app/models/discord/notifier.rb b/app/models/discord/notifier.rb index 955c44a..6ff4a53 100644 --- a/app/models/discord/notifier.rb +++ b/app/models/discord/notifier.rb @@ -26,5 +26,34 @@ def send(content: nil, embed: nil) def self.notify(webhook_key: :default_webhook_url, content: nil, embed: nil) new(webhook_key: webhook_key).send(content: content, embed: embed) end + + def self.send_to_channel(channel_id, content: nil, embed: nil) + return if Rails.env.test? + + token = Rails.application.credentials.dig(:discord, :bot_token) + return unless token + + payload = {} + payload[:content] = content if content + payload[:embeds] = [ embed ] if embed + + Thread.new do + uri = URI("https://discord.com/api/v10/channels/#{channel_id}/messages") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + + request = Net::HTTP::Post.new(uri) + request["Authorization"] = "Bot #{token}" + request["Content-Type"] = "application/json" + request.body = payload.to_json + + response = http.request(request) + unless response.code.to_i == 200 + Rails.logger.error("[Discord::Notifier] Channel message failed (#{response.code}): #{response.body}") + end + rescue => e + Rails.logger.error("[Discord::Notifier] Failed: #{e.message}") + end + end end end diff --git a/bin/announce b/bin/announce new file mode 100755 index 0000000..6aae321 --- /dev/null +++ b/bin/announce @@ -0,0 +1,88 @@ +#!/usr/bin/env ruby +require_relative "../config/environment" +require "net/http" +require "json" + +ANNOUNCEMENTS_CHANNEL_ID = 1490353824169328690 + +embed = { + title: "What is TornManager?", + color: 0x5865F2, + description: [ + "TornManager is a companion tool for Torn - available as a website and a userscript that runs directly on torn.com.", + "", + "**Personal Features**", + "- Daily stat tracking (xanax, e-refills, nerve refills, contracts, crimes, activity, networth)", + "- Stock portfolio with break-even analysis", + "- API key activity viewer (no login required)", + "- Userscript overlay with live data on torn.com", + "", + "**Faction Features**", + "- Member compliance tracking with customizable daily targets", + "- Compliance scoring per member (xanax, refills, activity)", + "- Worst performers overview", + "- Export member stats to clipboard for faction announcements", + "- Automatic faction roster sync", + "", + "**Ranked War Tools**", + "- Live war status with real-time polling", + "- Member breakdown (hospital, traveling, jail, online/idle)", + "- Score tracking with target display", + "- Full war history with win/loss records", + "- Per-member war performance (attacks, assists, respect, fair fight brackets)", + "- Warlord bonus and overseas combat tracking", + "- War payout calculator with configurable splits", + "", + "**Spy Reports**", + "- TornStats integration for enemy faction spying", + "- Manual spy report import and editing", + "- Per-target stat breakdown (str/def/spd/dex)", + "- Auto-fetch enemy stats during active wars", + "", + "**Public War Lobbies**", + "- Anonymous war rooms for community use", + "- Only requires a Public API key", + "- Optional password protection", + "- Collaborative spy stat tracking", + "- Up to 10 active lobbies", + "", + "**Leadership Dashboard**", + "- Granular access control for faction leaders and co-leaders", + "- Grant leadership access to trusted members", + "- Faction API key management", + "- API usage monitoring with rate limit tracking", + "- Historical data coverage and backfill tools", + "- Complete faction data deletion", + "", + "**Subscription**", + "- Free: userscript, public wars, key log viewer", + "- Paid: all faction features, compliance tracking, war tools, spy reports", + "- Pay with in-game Xanax (1 Xanax = 1 week)", + "- Faction leaders can grant subscriptions to all members", + "- Trial subscription on faction setup", + "", + "**Privacy & GDPR**", + "- Only Limited Access API keys accepted (Full Access keys rejected)", + "- Full data export in JSON format", + "- Complete account and data deletion at any time", + "- [Privacy Policy & Terms of Service](https://tornmanager.com/legal)", + "", + "This is just the beginning. Share your ideas in <#1490344856134881383> and report issues in <#1490354149030891664>." + ].join("\n") +} + +token = Rails.application.credentials.dig(:discord, :bot_token) +abort("Discord bot_token not configured") unless token + +uri = URI("https://discord.com/api/v10/channels/#{ANNOUNCEMENTS_CHANNEL_ID}/messages") +http = Net::HTTP.new(uri.host, uri.port) +http.use_ssl = true + +request = Net::HTTP::Post.new(uri) +request["Authorization"] = "Bot #{token}" +request["Content-Type"] = "application/json" +request.body = { embeds: [embed] }.to_json + +puts "Sending announcement to #announcements..." +response = http.request(request) +puts "Response: #{response.code} #{response.body}" From 9f0aa6af5c300c4ec57cf56c60e87cea5716be39 Mon Sep 17 00:00:00 2001 From: Bram Date: Sun, 5 Apr 2026 17:35:40 +0200 Subject: [PATCH 212/266] remove roadmap --- app/assets/stylesheets/application.css | 1 - app/assets/stylesheets/pages/roadmap.css | 315 ------------------ app/controllers/roadmap_controller.rb | 48 --- .../controllers/roadmap_form_controller.js | 49 --- app/models/roadmap_item.rb | 32 -- .../factions/subscription_expired.html.erb | 3 - app/views/home/index.html.erb | 4 - app/views/layouts/_navbar.html.erb | 4 - app/views/roadmap/index.html.erb | 123 ------- config/routes.rb | 5 - .../20260405153436_drop_roadmap_items.rb | 12 + db/schema.rb | 13 +- 12 files changed, 13 insertions(+), 596 deletions(-) delete mode 100644 app/assets/stylesheets/pages/roadmap.css delete mode 100644 app/controllers/roadmap_controller.rb delete mode 100644 app/javascript/controllers/roadmap_form_controller.js delete mode 100644 app/models/roadmap_item.rb delete mode 100644 app/views/roadmap/index.html.erb create mode 100644 db/migrate/20260405153436_drop_roadmap_items.rb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 67df312..14efffe 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -24,5 +24,4 @@ * - pages/login.css : Login form styles * - pages/settings.css : Settings page styles * - pages/spy_stats.css : Spy stats table + filters - * - pages/roadmap.css : Roadmap board styles */ diff --git a/app/assets/stylesheets/pages/roadmap.css b/app/assets/stylesheets/pages/roadmap.css deleted file mode 100644 index 8345bad..0000000 --- a/app/assets/stylesheets/pages/roadmap.css +++ /dev/null @@ -1,315 +0,0 @@ -/* - * Roadmap Board - */ - -/* Board Layout */ -.roadmap-board { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: var(--space-4); - margin-top: var(--space-8); - align-items: start; -} - -@media (max-width: 900px) { - .roadmap-board { - grid-template-columns: 1fr; - } -} - -/* Swim Lane */ -.roadmap-lane { - background: var(--gray-900); - border: 1px solid var(--gray-800); - border-radius: var(--radius-lg); - min-height: 200px; - display: flex; - flex-direction: column; -} - -.roadmap-lane-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-4) var(--space-5); - border-bottom: 1px solid var(--gray-800); -} - -.roadmap-lane-title { - font-size: 0.8125rem; - font-weight: 600; - color: var(--gray-300); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.roadmap-lane-count { - font-size: 0.75rem; - font-weight: 600; - color: var(--gray-500); - background: var(--gray-800); - padding: 2px 8px; - border-radius: 999px; -} - -.roadmap-lane-body { - padding: var(--space-3); - display: flex; - flex-direction: column; - gap: var(--space-3); - flex: 1; - max-height: 70vh; - overflow-y: auto; -} - -.roadmap-lane-empty { - display: flex; - align-items: center; - justify-content: center; - padding: var(--space-8) var(--space-4); - color: var(--gray-600); - font-size: 0.8125rem; - font-style: italic; - flex: 1; -} - -/* Card */ -.roadmap-card { - background: var(--gray-1000); - border: 1px solid var(--gray-800); - border-radius: var(--radius-md); - padding: var(--space-4); - transition: border-color 0.15s ease, box-shadow 0.15s ease; -} - -.roadmap-card:hover { - border-color: var(--gray-700); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); -} - -.roadmap-card-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: var(--space-3); -} - -.roadmap-card-title { - font-size: 0.875rem; - font-weight: 600; - color: var(--gray-100); - margin: 0; - line-height: 1.4; -} - -.roadmap-card-description { - font-size: 0.8125rem; - color: var(--gray-400); - margin: var(--space-2) 0 0 0; - line-height: 1.5; -} - -.roadmap-card-description p { - margin: 0; -} - -.roadmap-card-description .hidden { - display: none; -} - -.roadmap-read-more { - background: none; - border: none; - color: var(--primary); - cursor: pointer; - font-size: 0.8125rem; - padding: 0; - font-weight: 500; - transition: color 0.15s ease; -} - -.roadmap-read-more:hover { - color: var(--primary-light, var(--gray-200)); - text-decoration: underline; -} - -.roadmap-card-edit { - font-size: 0.75rem; - color: var(--gray-500); - text-decoration: none; - padding: 2px 8px; - border-radius: var(--radius-sm); - transition: all 0.15s ease; -} - -.roadmap-card-edit:hover { - color: var(--gray-200); - background: var(--gray-800); -} - -/* Category Badges */ -.roadmap-badge { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 999px; - font-size: 0.6875rem; - font-weight: 600; - letter-spacing: 0.025em; -} - -.roadmap-badge-factions { - background: rgba(59, 130, 246, 0.15); - color: #3b82f6; - border: 1px solid rgba(59, 130, 246, 0.25); -} - -.roadmap-badge-stats { - background: rgba(34, 197, 94, 0.15); - color: #22c55e; - border: 1px solid rgba(34, 197, 94, 0.25); -} - -.roadmap-badge-api { - background: rgba(249, 115, 22, 0.15); - color: #f97316; - border: 1px solid rgba(249, 115, 22, 0.25); -} - -.roadmap-badge-admin { - background: rgba(168, 85, 247, 0.15); - color: #a855f7; - border: 1px solid rgba(168, 85, 247, 0.25); -} - -.roadmap-badge-ui_ux { - background: rgba(236, 72, 153, 0.15); - color: #ec4899; - border: 1px solid rgba(236, 72, 153, 0.25); -} - -.roadmap-badge-torn_script { - background: rgba(220, 38, 38, 0.15); - color: #ef4444; - border: 1px solid rgba(220, 38, 38, 0.25); -} - -.roadmap-badge-discord { - background: rgba(88, 101, 242, 0.15); - color: #7c8af4; - border: 1px solid rgba(88, 101, 242, 0.25); -} - -.roadmap-badge-infrastructure { - background: rgba(156, 163, 175, 0.15); - color: #9ca3af; - border: 1px solid rgba(156, 163, 175, 0.25); -} - -/* Slide-in Form Panel */ -.roadmap-form-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.6); - z-index: 999; - opacity: 0; - visibility: hidden; - transition: opacity 0.3s ease, visibility 0.3s ease; - backdrop-filter: blur(2px); -} - -.roadmap-form-overlay--visible { - opacity: 1; - visibility: visible; -} - -.roadmap-form-panel { - position: fixed; - top: 0; - right: 0; - bottom: 0; - width: 480px; - max-width: 100vw; - background: var(--gray-900); - border-left: 1px solid var(--gray-800); - z-index: 1000; - transform: translateX(100%); - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); - display: flex; - flex-direction: column; - overflow-y: auto; -} - -.roadmap-form-panel--open { - transform: translateX(0); -} - -.roadmap-form-panel-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-5) var(--space-6); - border-bottom: 1px solid var(--gray-800); - flex-shrink: 0; -} - -.roadmap-form-panel-header h2 { - font-size: 1rem; - font-weight: 600; - color: var(--gray-100); - margin: 0; -} - -.roadmap-form-close { - background: none; - border: none; - color: var(--gray-400); - cursor: pointer; - padding: var(--space-1); - border-radius: var(--radius-sm); - transition: all 0.15s ease; - display: flex; - align-items: center; - justify-content: center; -} - -.roadmap-form-close:hover { - color: var(--gray-100); - background: var(--gray-800); -} - -.roadmap-form-panel-body { - padding: var(--space-6); - flex: 1; -} - -.roadmap-form-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--space-4); -} - -.roadmap-textarea { - resize: vertical; - min-height: 100px; -} - -/* Form within panel - ensure full width */ -.roadmap-form .form-input, -.roadmap-form .form-select { - width: 100%; - box-sizing: border-box; -} - -.roadmap-form .form-actions { - margin-top: var(--space-8); - display: flex; - gap: var(--space-3); - flex-wrap: wrap; -} - -@media (max-width: 600px) { - .roadmap-form-row { - grid-template-columns: 1fr; - } -} diff --git a/app/controllers/roadmap_controller.rb b/app/controllers/roadmap_controller.rb deleted file mode 100644 index 5b1d953..0000000 --- a/app/controllers/roadmap_controller.rb +++ /dev/null @@ -1,48 +0,0 @@ -class RoadmapController < ApplicationController - allow_unauthenticated_access only: [ :index ] - before_action :require_admin, except: [ :index ] - before_action :set_roadmap_item, only: [ :update, :destroy ] - - def index - resume_session - @items_by_status = RoadmapItem::STATUSES.index_with do |status| - RoadmapItem.by_status(status) - end - @editing_item = RoadmapItem.find_by(id: params[:edit]) if params[:edit].present? - @is_admin = Current.user&.admin? - end - - def create - @item = RoadmapItem.new(roadmap_item_params) - @item.position ||= RoadmapItem.where(status: @item.status).maximum(:position).to_i + 1 - - if @item.save - redirect_to roadmap_path, notice: "Roadmap item created." - else - redirect_to roadmap_path, alert: @item.errors.full_messages.to_sentence - end - end - - def update - if @item.update(roadmap_item_params) - redirect_to roadmap_path, notice: "Roadmap item updated." - else - redirect_to roadmap_path(edit: @item.id), alert: @item.errors.full_messages.to_sentence - end - end - - def destroy - @item.destroy - redirect_to roadmap_path, notice: "Roadmap item deleted." - end - - private - - def set_roadmap_item - @item = RoadmapItem.find(params[:id]) - end - - def roadmap_item_params - params.require(:roadmap_item).permit(:title, :description, :status, :category, :position) - end -end diff --git a/app/javascript/controllers/roadmap_form_controller.js b/app/javascript/controllers/roadmap_form_controller.js deleted file mode 100644 index afd4731..0000000 --- a/app/javascript/controllers/roadmap_form_controller.js +++ /dev/null @@ -1,49 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = ["panel", "overlay"] - static values = { editing: Boolean } - - connect() { - if (this.editingValue && this.hasPanelTarget) { - requestAnimationFrame(() => { - this.open() - }) - } - } - - show(event) { - event.preventDefault() - this.open() - } - - hide(event) { - if (event) event.preventDefault() - this.panelTarget.classList.remove("roadmap-form-panel--open") - this.overlayTarget.classList.remove("roadmap-form-overlay--visible") - document.body.style.overflow = "" - - if (this.editingValue) { - const url = new URL(window.location) - url.searchParams.delete("edit") - history.replaceState(null, "", url) - } - } - - hideOnEscape(event) { - if (event.key === "Escape") { - this.hide() - } - } - - open() { - this.panelTarget.classList.add("roadmap-form-panel--open") - this.overlayTarget.classList.add("roadmap-form-overlay--visible") - document.body.style.overflow = "hidden" - - setTimeout(() => { - const firstInput = this.panelTarget.querySelector("input[type='text']") - if (firstInput) firstInput.focus() - }, 300) - } -} diff --git a/app/models/roadmap_item.rb b/app/models/roadmap_item.rb deleted file mode 100644 index dac3613..0000000 --- a/app/models/roadmap_item.rb +++ /dev/null @@ -1,32 +0,0 @@ -class RoadmapItem < ApplicationRecord - STATUSES = %w[planned in_progress done].freeze - CATEGORIES = %w[factions stats api admin ui_ux torn_script discord infrastructure].freeze - - validates :title, presence: true - validates :status, presence: true, inclusion: { in: STATUSES } - validates :category, presence: true, inclusion: { in: CATEGORIES } - - scope :ordered, -> { order(position: :asc, created_at: :desc) } - scope :by_status, ->(status) { where(status: status).ordered } - - def self.status_label(status) - { - "planned" => "Planned", - "in_progress" => "In Progress", - "done" => "Done" - }[status] - end - - def self.category_label(category) - { - "factions" => "Factions", - "stats" => "Stats", - "api" => "API", - "admin" => "Admin", - "ui_ux" => "UI/UX", - "torn_script" => "Torn Script", - "discord" => "Discord", - "infrastructure" => "Infrastructure" - }[category] - end -end diff --git a/app/views/factions/subscription_expired.html.erb b/app/views/factions/subscription_expired.html.erb index fbf04d7..327d881 100644 --- a/app/views/factions/subscription_expired.html.erb +++ b/app/views/factions/subscription_expired.html.erb @@ -12,9 +12,6 @@
  • Ask your faction leadership to share subscription time with you from the Leadership dashboard.
  • -
  • - Check the roadmap for upcoming payment and subscription options. -
  • diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index 00555be..2777528 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -270,9 +270,6 @@ -
    @@ -307,7 +304,6 @@ diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index b8d9750..7b99f4e 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -42,7 +42,6 @@
    diff --git a/bin/announce b/bin/announce index c2d8e66..2367d5d 100755 --- a/bin/announce +++ b/bin/announce @@ -7,42 +7,33 @@ require "json" CHANNEL_ID = 1490451144818819193 # test channel embed = { - title: "New Feature: Member Activity", + title: "New Subscription Model", color: 0x5865F2, description: [ - "Faction leaders can now see when their members are active with the new **Activity** dashboard.", + "The subscription system has been reworked to be simpler and fairer.", "", - "**Heatmap**", - "- 14-day activity heatmap showing when members are online", - "- Color-coded by hour and day so you can spot patterns at a glance", - "- Today's row highlights the current hour, future hours are greyed out", - "- Scroll down to see older days", + "**What changed**", + "- Subscriptions are now **faction-wide** - one subscription covers all members", + "- No more individual subscriptions needed when your faction is subscribed", + "- Cost scales with faction size: 1 personal week per 10 members", "", - "**Chain Coverage**", - "- See average active members per hour across the last 14 days", - "- Danger windows highlight consecutive hours with low coverage", - "- Helps you plan chains and identify when you need more people online", + "**How to pay**", + "- Send Xanax to Bram [2728237] in-game", + "- Each Xanax gives you **2 weeks** of personal balance", + "- From **Leadership > Settings**, transfer your personal weeks to the faction", + "- The whole faction gets access as long as the faction subscription is active", "", - "**Member Timeline**", - "- Per-member 24-hour activity bar showing when each person is typically online", - "- Instantly see who covers overnight, who plays in the evening, and where the gaps are", + "**Pricing example**", + "- 10 members = 1 personal week per faction week", + "- 50 members = 5 personal weeks per faction week", + "- 100 members = 10 personal weeks per faction week", "", - "**How it works**", - "- Your faction's member status is polled every 15 minutes using your faction API key", - "- Members are counted as active if they are Online, Idle, or had activity within the last 15 minutes", - "- Data collection has started for all existing factions", - "- The Activity page will become accessible in **~24 hours** once enough data is collected", - "- For new faction signups, the 24-hour collection period starts from the moment setup is completed", + "**What about existing subscriptions?**", + "- All factions with completed setup have received a **1 month** subscription", + "- New factions get a **14-day trial** on setup", + "- Individual subscriptions still work for solo features", "", - "Once ready, find it under **Leadership > Activity**.", - "", - "**What's next?**", - "- Activity: month overview and ability to browse beyond 14 days", - "- RW Payouts: save settings so you don't redo them every war", - "- RW Payouts: auto-calculate initial reward based on average RW reward prices", - "- RW Payouts: more configuration options", - "", - "Have ideas? Drop them in <#1490344856134881383>." + "Questions? Drop them in <#1490354149030891664>." ].join("\n") } diff --git a/test/controllers/factions/leadership_controller_test.rb b/test/controllers/factions/leadership_controller_test.rb index 9b98604..302159a 100644 --- a/test/controllers/factions/leadership_controller_test.rb +++ b/test/controllers/factions/leadership_controller_test.rb @@ -434,7 +434,7 @@ class Factions::LeadershipControllerTest < ActionDispatch::IntegrationTest post faction_leadership_subscriptions_path(@faction), params: { weeks: 10 } assert_redirected_to faction_leadership_settings_path(@faction) - assert_match /weeks remaining/, flash[:alert] + assert_match /personal weeks/, flash[:alert] end test "extend_faction_subscription creates faction subscription grant record" do diff --git a/test/controllers/factions_controller_test.rb b/test/controllers/factions_controller_test.rb index e28c077..11134e3 100644 --- a/test/controllers/factions_controller_test.rb +++ b/test/controllers/factions_controller_test.rb @@ -529,8 +529,8 @@ class FactionsControllerTest < ActionDispatch::IntegrationTest setup_faction.reload assert setup_faction.subscription.present?, "Faction should have a subscription" - assert_in_delta 1.month.from_now.to_i, setup_faction.subscription.expires_at.to_i, 5, - "Faction subscription should expire in ~1 month" + assert_in_delta 14.days.from_now.to_i, setup_faction.subscription.expires_at.to_i, 5, + "Faction subscription should expire in ~14 days" # All members should be subscribed via the faction subscription assert @bert.reload.subscribed?, "Setup user should be subscribed via faction" From b1d599651378b2b8bf8f2899b1a614c7b7db0144 Mon Sep 17 00:00:00 2001 From: Bram Date: Mon, 6 Apr 2026 14:38:10 +0200 Subject: [PATCH 229/266] more tests --- .../factions/leadership_controller_test.rb | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/controllers/factions/leadership_controller_test.rb b/test/controllers/factions/leadership_controller_test.rb index 302159a..66b0fa2 100644 --- a/test/controllers/factions/leadership_controller_test.rb +++ b/test/controllers/factions/leadership_controller_test.rb @@ -463,6 +463,47 @@ class Factions::LeadershipControllerTest < ActionDispatch::IntegrationTest assert remaining < original_weeks, "Personal subscription should be deducted" end + test "extend_faction_subscription cost scales with member count" do + # Add more members to increase the cost + 20.times do |i| + User.create!(torn_id: 800000 + i, name: "Member#{i}", level: 10, faction: @faction) + end + # 22 members total (bram + bert + 20) -> ceil(22/10) = 3 personal weeks per faction week + + grant_subscription(@bram, expires_at: 52.weeks.from_now) + original_weeks = @bram.subscription_weeks_remaining + + sign_in_as(@bram) + post faction_leadership_subscriptions_path(@faction), params: { weeks: 2 } + + # 2 faction weeks * 3 cost = 6 personal weeks deducted + deducted = original_weeks - @bram.reload.subscription_weeks_remaining + assert_equal 6, deducted, "Should deduct 6 personal weeks (2 faction weeks * 3 cost)" + end + + test "extend_faction_subscription creates new subscription when none exists" do + @faction.subscription&.destroy! + grant_subscription(@bram, expires_at: 52.weeks.from_now) + + sign_in_as(@bram) + post faction_leadership_subscriptions_path(@faction), params: { weeks: 2 } + + assert @faction.reload.subscription.present?, "Faction should have a subscription" + assert @faction.subscription.active?, "Faction subscription should be active" + end + + test "extend_faction_subscription rejects cost exceeding personal balance" do + # 2 members -> cost = 1 per faction week + # Give bram only 3 weeks, request 5 faction weeks = 5 personal weeks + grant_subscription(@bram, expires_at: 3.weeks.from_now) + + sign_in_as(@bram) + post faction_leadership_subscriptions_path(@faction), params: { weeks: 5 } + + assert_redirected_to faction_leadership_settings_path(@faction) + assert_match /personal weeks/, flash[:alert] + end + # -- Import Spies -- test "import_spies imports spy reports from tornstats" do From 88efa3830ad29fa401d64ec70e6cdc95f31e5dee Mon Sep 17 00:00:00 2001 From: Bram Date: Mon, 6 Apr 2026 15:17:27 +0200 Subject: [PATCH 230/266] update --- .../factions/leadership/base_controller.rb | 2 +- .../leadership/subscriptions_controller.rb | 2 +- app/views/home/index.html.erb | 2 +- bin/announce | 25 ++++++++++--------- .../factions/leadership_controller_test.rb | 6 ++--- 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/app/controllers/factions/leadership/base_controller.rb b/app/controllers/factions/leadership/base_controller.rb index d0e7793..93759d1 100644 --- a/app/controllers/factions/leadership/base_controller.rb +++ b/app/controllers/factions/leadership/base_controller.rb @@ -51,7 +51,7 @@ def load_settings_data @faction_subscription_active = faction_sub&.active? || false @faction_subscription_expires_at = faction_sub&.expires_at @faction_subscription_days_remaining = faction_sub&.days_remaining || 0 - @faction_week_cost = (@faction.users.active.count / 10.0).ceil.clamp(1, 100) + @faction_week_cost = (@faction.users.active.count / 4.0).ceil.clamp(1, 100) @max_faction_weeks = @subscription_weeks_remaining > 0 ? (@subscription_weeks_remaining / @faction_week_cost) : 0 @war_polling_active = @faction.war_polling_active? end diff --git a/app/controllers/factions/leadership/subscriptions_controller.rb b/app/controllers/factions/leadership/subscriptions_controller.rb index 90dc224..ecac781 100644 --- a/app/controllers/factions/leadership/subscriptions_controller.rb +++ b/app/controllers/factions/leadership/subscriptions_controller.rb @@ -43,7 +43,7 @@ def create private def faction_week_cost - (@faction.users.active.count / 10.0).ceil.clamp(1, 100) + (@faction.users.active.count / 4.0).ceil.clamp(1, 100) end helper_method :faction_week_cost end diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index 5f86e4b..0f91558 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -243,7 +243,7 @@
  • - Cost scales with faction size (1 personal week per 10 members) + Cost scales with faction size (1 personal week per 4 members)
  • diff --git a/bin/announce b/bin/announce index 2367d5d..1eb656b 100755 --- a/bin/announce +++ b/bin/announce @@ -13,25 +13,26 @@ embed = { "The subscription system has been reworked to be simpler and fairer.", "", "**What changed**", - "- Subscriptions are now **faction-wide** - one subscription covers all members", + "- Subscriptions are now **faction-wide**, one subscription covers all members", "- No more individual subscriptions needed when your faction is subscribed", - "- Cost scales with faction size: 1 personal week per 10 members", + "- Cost scales with faction size", + "- All features included, no tiers", "", "**How to pay**", "- Send Xanax to Bram [2728237] in-game", - "- Each Xanax gives you **2 weeks** of personal balance", - "- From **Leadership > Settings**, transfer your personal weeks to the faction", - "- The whole faction gets access as long as the faction subscription is active", + "- Go to **Leadership > Settings** and transfer to your faction", + "- The whole faction gets access as long as the subscription is active", "", - "**Pricing example**", - "- 10 members = 1 personal week per faction week", - "- 50 members = 5 personal weeks per faction week", - "- 100 members = 10 personal weeks per faction week", + "**Monthly cost**", + "- 10 members: ~6 Xanax/month", + "- 25 members: ~14 Xanax/month", + "- 50 members: ~26 Xanax/month", + "- 75 members: ~38 Xanax/month", + "- 100 members: ~50 Xanax/month", "", - "**What about existing subscriptions?**", - "- All factions with completed setup have received a **1 month** subscription", + "**Existing factions**", + "- All factions with completed setup have received **1 month free**", "- New factions get a **14-day trial** on setup", - "- Individual subscriptions still work for solo features", "", "Questions? Drop them in <#1490354149030891664>." ].join("\n") diff --git a/test/controllers/factions/leadership_controller_test.rb b/test/controllers/factions/leadership_controller_test.rb index 66b0fa2..c90a44d 100644 --- a/test/controllers/factions/leadership_controller_test.rb +++ b/test/controllers/factions/leadership_controller_test.rb @@ -468,7 +468,7 @@ class Factions::LeadershipControllerTest < ActionDispatch::IntegrationTest 20.times do |i| User.create!(torn_id: 800000 + i, name: "Member#{i}", level: 10, faction: @faction) end - # 22 members total (bram + bert + 20) -> ceil(22/10) = 3 personal weeks per faction week + # 22 members total (bram + bert + 20) -> ceil(22/4) = 6 personal weeks per faction week grant_subscription(@bram, expires_at: 52.weeks.from_now) original_weeks = @bram.subscription_weeks_remaining @@ -476,9 +476,9 @@ class Factions::LeadershipControllerTest < ActionDispatch::IntegrationTest sign_in_as(@bram) post faction_leadership_subscriptions_path(@faction), params: { weeks: 2 } - # 2 faction weeks * 3 cost = 6 personal weeks deducted + # 2 faction weeks * 6 cost = 12 personal weeks deducted deducted = original_weeks - @bram.reload.subscription_weeks_remaining - assert_equal 6, deducted, "Should deduct 6 personal weeks (2 faction weeks * 3 cost)" + assert_equal 12, deducted, "Should deduct 12 personal weeks (2 faction weeks * 6 cost)" end test "extend_faction_subscription creates new subscription when none exists" do From a0aa5900c40d94fa5b3c1f13aad05360b6cfd5ce Mon Sep 17 00:00:00 2001 From: Bram Date: Mon, 6 Apr 2026 15:39:24 +0200 Subject: [PATCH 231/266] settings page revamp --- app/assets/stylesheets/components/badges.css | 18 ++-- app/assets/stylesheets/components/cards.css | 66 +++++++++---- app/controllers/factions_controller.rb | 1 - app/controllers/settings_controller.rb | 10 ++ app/views/settings/index.html.erb | 98 ++++++++++++-------- test/controllers/settings_controller_test.rb | 46 ++++----- 6 files changed, 145 insertions(+), 94 deletions(-) diff --git a/app/assets/stylesheets/components/badges.css b/app/assets/stylesheets/components/badges.css index f97ee47..f522f49 100644 --- a/app/assets/stylesheets/components/badges.css +++ b/app/assets/stylesheets/components/badges.css @@ -64,22 +64,22 @@ /* Subscription Badge */ .subscription-badge { - display: inline-block; - padding: 0.5rem 1rem; - background: #10b981; - color: white; + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.75rem; + background: rgba(16, 185, 129, 0.15); + color: #10b981; + border: 1px solid rgba(16, 185, 129, 0.3); border-radius: var(--radius-sm); font-weight: 600; font-size: 0.875rem; - margin-bottom: 1rem; } .subscription-badge-inactive { - background: transparent; + background: rgba(239, 68, 68, 0.1); color: #ef4444; - padding: 0; - font-size: 1.25rem; - animation: pulse-red 2s ease-in-out infinite; + border-color: rgba(239, 68, 68, 0.3); } @keyframes pulse-red { diff --git a/app/assets/stylesheets/components/cards.css b/app/assets/stylesheets/components/cards.css index 4dcc5d6..c4d13a3 100644 --- a/app/assets/stylesheets/components/cards.css +++ b/app/assets/stylesheets/components/cards.css @@ -432,36 +432,66 @@ background: var(--gray-900); border: 1px solid var(--gray-800); border-radius: var(--radius-md); - padding: 2rem; + padding: 1.25rem; margin-bottom: 1.5rem; - min-height: 360px; } -.subscription-active, -.subscription-inactive { - margin-bottom: 1.5rem; +.subscription-status-header { + margin-bottom: 0.75rem; } -.subscription-days { - font-size: 2rem; - font-weight: 700; - color: var(--gray-100); - margin-bottom: 0.5rem; - font-variant-numeric: tabular-nums; +.subscription-sources { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.subscription-source { + padding: 0.5rem 0.75rem; + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--gray-800); + border-radius: 6px; +} + +.subscription-source-header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.8125rem; } -.subscription-expires { +.subscription-source-detail { + font-size: 0.75rem; color: var(--gray-400); - font-size: 0.875rem; - margin-bottom: 1rem; + margin-top: 0.25rem; } -.renewal-method { - margin-bottom: 1rem; +.subscription-refresh-form { + margin-top: 0.25rem; } -.renewal-method:last-child { - margin-bottom: 0; +.subscription-how { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--gray-800); + font-size: 0.6875rem; + color: var(--gray-500); + line-height: 1.5; +} + +.subscription-how strong { + color: var(--gray-400); +} + +.subscription-how a { + color: var(--gray-400); +} + +.subscription-source-infinity { + font-size: 1.25rem; + color: #22c55e; + line-height: 1; } /* API Key Card */ diff --git a/app/controllers/factions_controller.rb b/app/controllers/factions_controller.rb index 849f4a9..68e4cd8 100644 --- a/app/controllers/factions_controller.rb +++ b/app/controllers/factions_controller.rb @@ -81,7 +81,6 @@ def create members = TornApi::Faction::Members.new(api_key, @faction.torn_id).fetch - # Create a faction-level subscription (1 month trial) @faction.create_subscription!(expires_at: 14.days.from_now) unless @faction.subscription members.each do |member| diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 9bf4935..304d775 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -10,6 +10,16 @@ def index @subscribed = Current.user.subscribed? @days_remaining = calculate_days_remaining if @subscribed + @personal_sub = Current.user.subscription + @personal_active = @personal_sub&.active? || false + @personal_days = @personal_sub&.days_remaining || 0 + + @faction = Current.user.faction + @faction_sub = @faction&.subscription + @faction_sub_active = @faction_sub&.active? || false + @faction_sub_days = @faction_sub&.days_remaining || 0 + @faction_setup = @faction&.setup_completed? || false + @last_refresh_at = session[:last_subscription_refresh_at] @can_refresh = can_refresh? @seconds_until_refresh = seconds_until_refresh diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb index d2357b7..2ce6989 100644 --- a/app/views/settings/index.html.erb +++ b/app/views/settings/index.html.erb @@ -11,54 +11,72 @@
    -

    Subscription Status

    +

    Subscription

    - <% if @subscribed %> -
    -
    ✓ Active Subscriber
    -
    <%= @days_remaining %> days remaining
    -
    Expires: <%= Current.user.effective_subscription_expires_at.strftime("%d %B %Y") %>
    -
    - <% else %> -
    -
    Not Subscribed
    -

    You don't have an active subscription.

    -
    - <% end %> - - <%= form_with url: settings_refresh_subscription_path, method: :post do |form| %> - <% if @can_refresh %> - <%= form.submit "Check for New Payments", - class: "btn-secondary", - data: { turbo_submits_with: "Checking..." } %> +
    + <% if @subscribed %> + Active <% else %> - <%= form.submit "Available in #{@seconds_until_refresh} seconds", - class: "btn-secondary", - disabled: true, - data: { - controller: "refresh-countdown", - refresh_countdown_seconds_remaining_value: @seconds_until_refresh - } %> + Inactive <% end %> - <% end %> +
    - +
    + +
    +

    Send Xanax to <%= link_to "Bram [2728237]", "https://www.torn.com/profiles.php?XID=2728237", target: "_blank" %>. Each Xanax = 2 weeks personal balance. Leaders can transfer personal weeks to the faction from Leadership > Settings. Payments processed daily at 04:00 TCT.

    +
    diff --git a/test/controllers/settings_controller_test.rb b/test/controllers/settings_controller_test.rb index 017b9b7..3813688 100644 --- a/test/controllers/settings_controller_test.rb +++ b/test/controllers/settings_controller_test.rb @@ -18,39 +18,36 @@ class SettingsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to new_session_path end - test "index shows subscription status for subscribed users" do + test "index shows active subscription status" do grant_subscription(@user, expires_at: 30.days.from_now) get settings_path assert_response :success - assert_match /days remaining/, response.body - assert_match /Active Subscriber/, response.body + assert_select ".subscription-badge", /Active/ + assert_select ".pulse-dot-green" end - test "index shows not subscribed state for non-subscribed users" do - @user.update!(subscription_expires_at: nil) + test "index shows inactive for non-subscribed users" do get settings_path assert_response :success - assert_select ".subscription-badge-inactive", text: "Not Subscribed" - assert_select ".subscription-inactive", /don't have an active subscription/ - assert_select ".subscription-active", count: 0 - assert_select ".subscription-days", count: 0 + assert_select ".subscription-badge-inactive", /Inactive/ end - test "index shows not subscribed state for expired subscriptions" do - @user.update!(subscription_expires_at: 1.day.ago) + test "index shows personal subscription days" do + grant_subscription(@user, expires_at: 42.days.from_now) get settings_path assert_response :success - assert_select ".subscription-badge-inactive", text: "Not Subscribed" - assert_select ".subscription-inactive", /don't have an active subscription/ - assert_select ".subscription-active", count: 0 - assert_select ".subscription-days", count: 0 + assert_match /42 days/, response.body end - test "index calculates days remaining correctly" do - grant_subscription(@user, expires_at: 42.days.from_now) + test "index shows faction subscription with infinity sign" do + faction = Faction.create!(torn_id: 99999, name: "Test Faction", xanax_target: 2.5, setup_completed: true) + @user.update!(faction: faction) + grant_subscription(faction, expires_at: 30.days.from_now) + get settings_path assert_response :success - assert_match /42 days remaining/, response.body + assert_select ".subscription-source-infinity" + assert_match /Test Faction/, response.body end test "index shows data counts" do @@ -182,17 +179,14 @@ class SettingsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to new_session_path end - test "index displays subscription instructions in collapsible details" do + test "index displays subscription instructions" do get settings_path assert_response :success - assert_select "details.subscribe-details" do - assert_select "summary", text: "How to subscribe" - end + assert_select ".subscription-how" assert_match /Bram \[2728237\]/, response.body assert_match /2 week/, response.body - assert_match /Xanax Payment/, response.body - assert_match /Faction Subscription/, response.body + assert_match /Xanax/, response.body end test "index renders API Key and Subscription in side-by-side grid" do @@ -226,13 +220,13 @@ class SettingsControllerTest < ActionDispatch::IntegrationTest # Check index shows disabled button get settings_path assert_response :success - assert_match /Available in \d+ seconds/, response.body + assert_match /Available in \d+s/, response.body end test "index shows enabled button when no previous refresh" do get settings_path assert_response :success - assert_match /Check for New Payments/, response.body + assert_match /Check for Xanax payments/, response.body end # ── update_api_key ── From 88ce136f0ca2c96e95b5fdd0456c5715aa60f16e Mon Sep 17 00:00:00 2001 From: Bram Date: Mon, 6 Apr 2026 16:35:37 +0200 Subject: [PATCH 232/266] last announcement message --- bin/announce | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/bin/announce b/bin/announce index 1eb656b..f88f342 100755 --- a/bin/announce +++ b/bin/announce @@ -3,38 +3,28 @@ require_relative "../config/environment" require "net/http" require "json" -# CHANNEL_ID = 1490353824169328690 -CHANNEL_ID = 1490451144818819193 # test channel +CHANNEL_ID = 1490353824169328690 +# CHANNEL_ID = 1490451144818819193 # test channel embed = { - title: "New Subscription Model", + title: "Subscription Update", color: 0x5865F2, description: [ - "The subscription system has been reworked to be simpler and fairer.", + "The subscription system has been reworked. This update improves how subscriptions work under the hood and introduces faction-wide subscriptions.", "", "**What changed**", "- Subscriptions are now **faction-wide**, one subscription covers all members", "- No more individual subscriptions needed when your faction is subscribed", - "- Cost scales with faction size", + "- Leadership of a faction can transfer their personal subscription time to the faction in **Leadership > Settings**", "- All features included, no tiers", "", - "**How to pay**", - "- Send Xanax to Bram [2728237] in-game", - "- Go to **Leadership > Settings** and transfer to your faction", - "- The whole faction gets access as long as the subscription is active", + "**What about existing factions?**", + "I have reached out to every faction currently using TornManager and will manually extend your subscription since you are all helping me with testing. You don't need to do anything right now! :-)", "", - "**Monthly cost**", - "- 10 members: ~6 Xanax/month", - "- 25 members: ~14 Xanax/month", - "- 50 members: ~26 Xanax/month", - "- 75 members: ~38 Xanax/month", - "- 100 members: ~50 Xanax/month", + "**Pricing**", + "The pricing model is still being discussed and might change. For now the cost scales with faction size. I will share more details once finalized. Feel free to share your thoughts in <#1490344856134881383>.", "", - "**Existing factions**", - "- All factions with completed setup have received **1 month free**", - "- New factions get a **14-day trial** on setup", - "", - "Questions? Drop them in <#1490354149030891664>." + "New factions get a **14-day free trial** on setup." ].join("\n") } From c441a20bfc4dcb285f38cd13ae8b544fd5607e52 Mon Sep 17 00:00:00 2001 From: Bram Date: Mon, 6 Apr 2026 21:22:07 +0200 Subject: [PATCH 233/266] armoury feature --- app/assets/stylesheets/pages/armory.css | 269 ++++++++++++++++++ app/controllers/admin/factions_controller.rb | 7 +- .../factions/leadership/armory_controller.rb | 75 +++++ .../factions/leadership_controller.rb | 17 ++ app/controllers/factions_controller.rb | 1 + .../controllers/sortable_table_controller.js | 15 +- app/jobs/backfill_armory_news_job.rb | 56 ++++ app/jobs/daily/armory_news_job.rb | 12 + app/jobs/daily/data_retention_cleanup_job.rb | 9 + app/jobs/fetch_armory_news_job.rb | 44 +++ app/models/armory_news_entry.rb | 11 + app/models/faction.rb | 6 + app/models/torn_api.rb | 30 ++ app/models/torn_api/faction/armory.rb | 88 ++++++ app/models/torn_api/faction/armory_news.rb | 97 +++++++ app/models/torn_api/torn/item_details.rb | 37 +++ app/views/admin/factions/_faction.html.erb | 1 + .../leadership/_hero_section.html.erb | 12 + .../factions/leadership/armory/show.html.erb | 226 +++++++++++++++ config/recurring.yml | 4 + config/routes.rb | 5 + .../20260406152903_create_armory_items.rb | 27 ++ .../20260406175921_drop_armory_items.rb | 31 ++ ...260406184323_create_armory_news_entries.rb | 19 ++ db/schema.rb | 19 +- .../leadership/armory_controller_test.rb | 96 +++++++ 26 files changed, 1207 insertions(+), 7 deletions(-) create mode 100644 app/assets/stylesheets/pages/armory.css create mode 100644 app/controllers/factions/leadership/armory_controller.rb create mode 100644 app/jobs/backfill_armory_news_job.rb create mode 100644 app/jobs/daily/armory_news_job.rb create mode 100644 app/jobs/fetch_armory_news_job.rb create mode 100644 app/models/armory_news_entry.rb create mode 100644 app/models/torn_api/faction/armory.rb create mode 100644 app/models/torn_api/faction/armory_news.rb create mode 100644 app/models/torn_api/torn/item_details.rb create mode 100644 app/views/factions/leadership/armory/show.html.erb create mode 100644 db/migrate/20260406152903_create_armory_items.rb create mode 100644 db/migrate/20260406175921_drop_armory_items.rb create mode 100644 db/migrate/20260406184323_create_armory_news_entries.rb create mode 100644 test/controllers/factions/leadership/armory_controller_test.rb diff --git a/app/assets/stylesheets/pages/armory.css b/app/assets/stylesheets/pages/armory.css new file mode 100644 index 0000000..8942210 --- /dev/null +++ b/app/assets/stylesheets/pages/armory.css @@ -0,0 +1,269 @@ +/* Armory Page */ +.armory-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-4); +} + +.armory-header h1 { + font-size: 1.25rem; + font-weight: 700; + margin: 0; +} + +/* Search */ +.armory-search { + width: 100%; + max-width: 300px; + padding: 0.375rem 0.75rem; + font-size: 0.8125rem; + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: 6px; + color: var(--color-text); +} + +.armory-search:focus { + outline: none; + border-color: var(--gray-600); +} + +/* Search highlight */ +.armory-highlight { + background: rgba(234, 179, 8, 0.3); + color: inherit; + border-radius: 2px; + padding: 0 1px; +} + +/* Backfill pulse */ +.pulse-dot-blue { + background: #3b82f6; + box-shadow: 0 0 4px #3b82f6; + animation: pulse-blue 2s ease-in-out infinite; +} + +@keyframes pulse-blue { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* Date range in header */ +.armory-date-range { + font-size: 0.75rem; + font-weight: 400; + color: var(--gray-500); + margin-left: 0.5rem; +} + +/* Armor set filter chips */ +.armory-set-assault { + background: rgba(239, 68, 68, 0.15); + color: rgb(239, 68, 68); + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.armory-set-delta { + background: rgba(99, 102, 241, 0.15); + color: rgb(165, 170, 255); + border: 1px solid rgba(99, 102, 241, 0.3); +} + +.armory-set-riot { + background: rgba(234, 179, 8, 0.15); + color: rgb(234, 179, 8); + border: 1px solid rgba(234, 179, 8, 0.3); +} + +.armory-set-dune { + background: rgba(249, 115, 22, 0.15); + color: rgb(249, 115, 22); + border: 1px solid rgba(249, 115, 22, 0.3); +} + +.armory-set-combat { + background: rgba(34, 197, 94, 0.15); + color: rgb(34, 197, 94); + border: 1px solid rgba(34, 197, 94, 0.3); +} + +.armory-set-other { + background: rgba(148, 163, 184, 0.15); + color: rgb(148, 163, 184); + border: 1px solid rgba(148, 163, 184, 0.3); +} + +/* Slot cells */ +.armory-slot-cell { + vertical-align: top; + font-size: 0.75rem; +} + +.armory-chip { + display: inline-block; + padding: 2px 8px; + margin: 1px; + border-radius: 4px; + font-size: 0.6875rem; + white-space: nowrap; +} + +.armory-chip-assault { + background: rgba(239, 68, 68, 0.12); + color: rgb(252, 129, 129); + border: 1px solid rgba(239, 68, 68, 0.25); +} + +.armory-chip-delta { + background: rgba(99, 102, 241, 0.12); + color: rgb(165, 170, 255); + border: 1px solid rgba(99, 102, 241, 0.25); +} + +.armory-chip-riot { + background: rgba(234, 179, 8, 0.12); + color: rgb(250, 204, 21); + border: 1px solid rgba(234, 179, 8, 0.25); +} + +.armory-chip-dune { + background: rgba(249, 115, 22, 0.12); + color: rgb(251, 146, 60); + border: 1px solid rgba(249, 115, 22, 0.25); +} + +.armory-chip-combat { + background: rgba(34, 197, 94, 0.12); + color: rgb(74, 222, 128); + border: 1px solid rgba(34, 197, 94, 0.25); +} + +.armory-chip-default { + background: var(--gray-800); + color: var(--gray-200); + border: 1px solid var(--gray-700); +} + +/* Total badge */ +.armory-total-cell { + text-align: center; +} + +.armory-total-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + padding: 0 6px; + font-size: 0.75rem; + font-weight: 600; + background: rgba(99, 102, 241, 0.2); + color: rgb(165, 170, 255); + border-radius: 12px; +} + +/* Loan history */ +.armory-history { + padding: 0.75rem 0; +} + +.armory-history-title { + font-size: 0.75rem; + font-weight: 600; + color: var(--gray-400); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.5rem; +} + +.armory-history-table { + width: 100%; + font-size: 0.8125rem; +} + +.armory-history-table th { + font-size: 0.6875rem; + color: var(--gray-500); + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.25rem 0.75rem; + border-bottom: 1px solid var(--gray-800); +} + +.armory-history-table td { + padding: 0.375rem 0.75rem; + border-bottom: 1px solid var(--gray-850, var(--gray-800)); +} + +/* Action badges */ +.armory-action-badge { + display: inline-block; + font-size: 0.6875rem; + padding: 1px 6px; + border-radius: 3px; + font-weight: 500; +} + +.armory-action-loaned { + background: rgba(234, 179, 8, 0.15); + color: rgb(234, 179, 8); +} + +.armory-action-returned { + background: rgba(34, 197, 94, 0.15); + color: rgb(34, 197, 94); +} + +/* Armory News */ +.armory-news-card { + margin-top: var(--space-4); +} + +.armory-news-list { + max-height: 400px; + overflow-y: auto; +} + +.armory-news-entry { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--gray-800); + font-size: 0.8125rem; +} + +.armory-news-entry:last-child { + border-bottom: none; +} + +.armory-news-text { + color: var(--gray-300); +} + +.armory-news-text a { + color: var(--gray-100); + text-decoration: none; +} + +.armory-news-text a:hover { + text-decoration: underline; +} + +.armory-news-time { + color: var(--gray-500); + font-size: 0.75rem; + white-space: nowrap; + flex-shrink: 0; +} + +@media (max-width: 768px) { + .armory-header { + flex-direction: column; + align-items: flex-start; + gap: var(--space-3); + } +} diff --git a/app/controllers/admin/factions_controller.rb b/app/controllers/admin/factions_controller.rb index 2c69832..beb1296 100644 --- a/app/controllers/admin/factions_controller.rb +++ b/app/controllers/admin/factions_controller.rb @@ -1,7 +1,7 @@ module Admin class FactionsController < ApplicationController before_action :require_admin - before_action :set_faction, only: [ :edit, :update, :destroy, :toggle_ssl, :toggle_public_wars ] + before_action :set_faction, only: [ :edit, :update, :destroy, :toggle_ssl, :toggle_public_wars, :backfill_armory_news ] def index @factions = Faction.includes(:users).order(:name) @@ -82,6 +82,11 @@ def toggle_ssl render json: { success: false, error: e.message }, status: :unprocessable_entity end + def backfill_armory_news + BackfillArmoryNewsJob.perform_later(@faction.id) + redirect_to admin_factions_path, notice: "Armory news backfill started for #{@faction.name}." + end + def toggle_public_wars @faction.update!(public_wars: !@faction.public_wars) render json: { success: true, public_wars: @faction.public_wars } diff --git a/app/controllers/factions/leadership/armory_controller.rb b/app/controllers/factions/leadership/armory_controller.rb new file mode 100644 index 0000000..13d6771 --- /dev/null +++ b/app/controllers/factions/leadership/armory_controller.rb @@ -0,0 +1,75 @@ +class Factions::Leadership::ArmoryController < Factions::Leadership::BaseController + def show + api_key = @faction.torn_api_key&.key + raise TornApi::InvalidKeyError, "No API key configured" unless api_key + + loans_by_member = Hash[TornApi::Faction::Armory.new(api_key).fetch_by_member] + + @armory_news = @faction.armory_news_entries + .recent(2.months) + .newest_first + .map { |e| entry_to_hash(e) } + + latest_entry = @faction.armory_news_entries.maximum(:occurred_at) + @backfill_in_progress = latest_entry.nil? || latest_entry < 1.day.ago + + member_names = @faction.users.where(torn_id: loans_by_member.keys).pluck(:torn_id, :name).to_h + + @members = loans_by_member.map do |member_id, slots| + total = slots.values.sum(&:size) + name = member_names[member_id] || "Unknown" + { torn_id: member_id, name: name, slots: slots, total: total } + end.sort_by { |m| -m[:total] } + + @news_by_member = @armory_news + .select { |e| e[:action].in?([ :loaned, :returned ]) } + .group_by { |e| e[:player_id] } + rescue TornApi::InvalidKeyError => e + redirect_to faction_leadership_path(@faction), alert: "API key error: #{e.message}" + rescue TornApi::ApiError => e + redirect_to faction_leadership_path(@faction), alert: "API error: #{e.message}" + end + + def sync + api_key = @faction.torn_api_key&.key + client = TornApi::Faction::ArmoryNews.new(api_key) + + entries = client.fetch_all(since: Time.current.beginning_of_day, max_entries: 1000) + if entries.any? + records = entries.map { |e| build_record(e) } + ArmoryNewsEntry.insert_all(records, unique_by: [ :faction_id, :torn_news_id ]) + end + + redirect_to faction_leadership_armory_path(@faction), notice: "Synced #{entries.size} activity entries." + rescue TornApi::ApiError => e + redirect_to faction_leadership_armory_path(@faction), alert: "Sync failed: #{e.message}" + end + + private + + def build_record(entry) + { + faction_id: @faction.id, + torn_news_id: entry[:id].to_s, + player_id: entry[:player_id], + player_name: entry[:player_name], + action: entry[:action].to_s, + item: entry[:item], + text: entry[:text], + occurred_at: Time.at(entry[:timestamp]), + created_at: Time.current + } + end + + def entry_to_hash(entry) + { + id: entry.torn_news_id, + text: entry.text, + timestamp: entry.occurred_at.to_i, + player_name: entry.player_name, + player_id: entry.player_id, + action: entry.action.to_sym, + item: entry.item + } + end +end diff --git a/app/controllers/factions/leadership_controller.rb b/app/controllers/factions/leadership_controller.rb index 627534d..310e69a 100644 --- a/app/controllers/factions/leadership_controller.rb +++ b/app/controllers/factions/leadership_controller.rb @@ -10,6 +10,23 @@ def show load_data_coverage load_api_peak_rate load_activity_data + load_armory_stats + end + + def load_armory_stats + api_key = @faction.torn_api_key&.key + return unless api_key + + stats = Rails.cache.fetch("armory_stats/#{@faction.id}", expires_in: 10.minutes) do + response = TornApi::Faction::Armory.new(api_key).fetch + items = (response["weapons"] || []) + (response["armor"] || []) + total_loaned = items.sum { |i| i["loaned"] || 0 } + { total_loaned: total_loaned } + end + + @armory_loaned_count = stats[:total_loaned] + rescue TornApi::ApiError, TornApi::InvalidKeyError + @armory_loaned_count = nil end def war_data diff --git a/app/controllers/factions_controller.rb b/app/controllers/factions_controller.rb index 68e4cd8..48958f5 100644 --- a/app/controllers/factions_controller.rb +++ b/app/controllers/factions_controller.rb @@ -104,6 +104,7 @@ def create backfill_target_date: start_date ) + BackfillArmoryNewsJob.perform_later(@faction.id) BackfillRankedWarsJob.perform_later(@faction.id) BackfillPersonalStatsJob.perform_later( @faction.id, diff --git a/app/javascript/controllers/sortable_table_controller.js b/app/javascript/controllers/sortable_table_controller.js index 2846f39..47114e9 100644 --- a/app/javascript/controllers/sortable_table_controller.js +++ b/app/javascript/controllers/sortable_table_controller.js @@ -1,7 +1,7 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { - static targets = ["header", "body"] + static targets = ["header", "body", "arrow"] sort(event) { const th = event.currentTarget @@ -12,17 +12,23 @@ export default class extends Controller { this.headerTargets.forEach(h => { h.dataset.sortDir = "none" - h.classList.remove("sorted-asc", "sorted-desc") + }) + + this.arrowTargets.forEach(arrow => { + arrow.classList.remove("active", "asc") }) th.dataset.sortDir = newDir - th.classList.add(`sorted-${newDir}`) + const arrow = th.querySelector(".sort-arrow") + if (arrow) { + arrow.classList.add("active") + if (newDir === "asc") arrow.classList.add("asc") + } const table = this.element.querySelector("table") const bodies = this.bodyTargets if (bodies.length > 1) { - // Multiple tbodies: sort entire tbodies by their first row bodies.sort((a, b) => { const aVal = this.getCellValue(a.rows[0], column, type) const bVal = this.getCellValue(b.rows[0], column, type) @@ -34,7 +40,6 @@ export default class extends Controller { bodies.forEach(tbody => table.appendChild(tbody)) } else if (bodies.length === 1) { - // Single tbody: sort rows const rows = Array.from(bodies[0].querySelectorAll("tr")) rows.sort((a, b) => { diff --git a/app/jobs/backfill_armory_news_job.rb b/app/jobs/backfill_armory_news_job.rb new file mode 100644 index 0000000..317d026 --- /dev/null +++ b/app/jobs/backfill_armory_news_job.rb @@ -0,0 +1,56 @@ +class BackfillArmoryNewsJob < FactionApiJob + BACKFILL_FLOOR = Time.utc(2026, 1, 1).to_i + PAGE_DELAY = 40.minutes + + limits_concurrency to: 1, key: ->(faction_id, _cursor = nil) { faction_id }, group: "FactionApiCalls" + + def perform(faction_id, cursor = nil) + faction = Faction.find_by(id: faction_id) + return unless faction + + api_key = faction.torn_api_key&.key + return unless api_key + + if cursor.nil? + oldest = faction.armory_news_entries.minimum(:occurred_at) + cursor = oldest ? oldest.to_i - 1 : Time.current.to_i + end + + return if cursor <= BACKFILL_FLOOR + + client = TornApi::Faction::ArmoryNews.new(api_key) + batch = client.fetch(to: cursor, limit: 100) + return if batch.empty? + + records = batch.map { |entry| build_record(faction.id, entry) } + ArmoryNewsEntry.insert_all(records, unique_by: [ :faction_id, :torn_news_id ]) + + oldest_in_batch = batch.map { |e| e[:timestamp] }.min + next_cursor = oldest_in_batch - 1 + + if batch.size == 100 && next_cursor > BACKFILL_FLOOR + BackfillArmoryNewsJob.set(wait: PAGE_DELAY).perform_later(faction_id, next_cursor) + end + rescue TornApi::ApiError => e + Rails.logger.error("BackfillArmoryNewsJob: Failed for faction #{faction_id}: #{e.message}") + if e.message.include?("Daily read limit") || e.message.include?("rate limit") + BackfillArmoryNewsJob.set(wait: 1.hour).perform_later(faction_id, cursor) + end + end + + private + + def build_record(faction_id, entry) + { + faction_id: faction_id, + torn_news_id: entry[:id].to_s, + player_id: entry[:player_id], + player_name: entry[:player_name], + action: entry[:action].to_s, + item: entry[:item], + text: entry[:text], + occurred_at: Time.at(entry[:timestamp]), + created_at: Time.current + } + end +end diff --git a/app/jobs/daily/armory_news_job.rb b/app/jobs/daily/armory_news_job.rb new file mode 100644 index 0000000..c5fe93b --- /dev/null +++ b/app/jobs/daily/armory_news_job.rb @@ -0,0 +1,12 @@ +module Daily + class ArmoryNewsJob < ApplicationJob + queue_as :default + + def perform + Faction.where(setup_completed: true).find_each do |faction| + next unless faction.torn_api_key.present? + FetchArmoryNewsJob.perform_later(faction.id) + end + end + end +end diff --git a/app/jobs/daily/data_retention_cleanup_job.rb b/app/jobs/daily/data_retention_cleanup_job.rb index a666a15..1a17df0 100644 --- a/app/jobs/daily/data_retention_cleanup_job.rb +++ b/app/jobs/daily/data_retention_cleanup_job.rb @@ -4,10 +4,12 @@ class DataRetentionCleanupJob < ApplicationJob SESSION_RETENTION_DAYS = 90 API_CALL_RETENTION_DAYS = 30 + ARMORY_NEWS_RETENTION_DAYS = 365 def perform cleanup_old_sessions cleanup_old_api_calls + cleanup_old_armory_news end private @@ -25,5 +27,12 @@ def cleanup_old_api_calls Rails.logger.info "DataRetentionCleanupJob: Deleted #{deleted_count} API calls older than #{API_CALL_RETENTION_DAYS} days" end + + def cleanup_old_armory_news + cutoff = ARMORY_NEWS_RETENTION_DAYS.days.ago + deleted_count = ArmoryNewsEntry.where("occurred_at < ?", cutoff).delete_all + + Rails.logger.info "DataRetentionCleanupJob: Deleted #{deleted_count} armory news entries older than #{ARMORY_NEWS_RETENTION_DAYS} days" + end end end diff --git a/app/jobs/fetch_armory_news_job.rb b/app/jobs/fetch_armory_news_job.rb new file mode 100644 index 0000000..973dc8f --- /dev/null +++ b/app/jobs/fetch_armory_news_job.rb @@ -0,0 +1,44 @@ +class FetchArmoryNewsJob < FactionApiJob + limits_concurrency to: 1, key: ->(faction_id) { faction_id }, group: "FactionApiCalls" + + def perform(faction_id) + faction = Faction.find_by(id: faction_id) + return unless faction + + api_key = faction.torn_api_key&.key + return unless api_key + + client = TornApi::Faction::ArmoryNews.new(api_key) + latest = faction.armory_news_entries.maximum(:occurred_at) + cursor = latest ? latest.to_i + 1 : 1.day.ago.to_i + + loop do + batch = client.fetch(from: cursor, limit: 100, sort: "ASC") + break if batch.empty? + + records = batch.map { |entry| build_record(faction.id, entry) } + ArmoryNewsEntry.insert_all(records, unique_by: [ :faction_id, :torn_news_id ]) + + break if batch.size < 100 + cursor = batch.map { |e| e[:timestamp] }.max + 1 + end + rescue TornApi::ApiError => e + Rails.logger.error("FetchArmoryNewsJob: Failed for faction #{faction_id}: #{e.message}") + end + + private + + def build_record(faction_id, entry) + { + faction_id: faction_id, + torn_news_id: entry[:id].to_s, + player_id: entry[:player_id], + player_name: entry[:player_name], + action: entry[:action].to_s, + item: entry[:item], + text: entry[:text], + occurred_at: Time.at(entry[:timestamp]), + created_at: Time.current + } + end +end diff --git a/app/models/armory_news_entry.rb b/app/models/armory_news_entry.rb new file mode 100644 index 0000000..ae9298a --- /dev/null +++ b/app/models/armory_news_entry.rb @@ -0,0 +1,11 @@ +class ArmoryNewsEntry < ApplicationRecord + belongs_to :faction + + validates :torn_news_id, presence: true, uniqueness: { scope: :faction_id } + validates :action, :occurred_at, presence: true + + scope :recent, ->(duration = 1.year) { where(occurred_at: duration.ago..) } + scope :loans_and_returns, -> { where(action: %w[loaned returned]) } + scope :by_member, ->(player_id) { where(player_id: player_id) } + scope :newest_first, -> { order(occurred_at: :desc) } +end diff --git a/app/models/faction.rb b/app/models/faction.rb index 8819756..8618dfd 100644 --- a/app/models/faction.rb +++ b/app/models/faction.rb @@ -8,6 +8,8 @@ class Faction < ApplicationRecord has_one :tornstats_api_key, class_name: "ApiKey::Tornstats" has_many :api_calls, dependent: :nullify has_many :spy_reports, dependent: :destroy + + has_many :armory_news_entries, dependent: :destroy has_many :member_activity_snapshots, dependent: :destroy has_many :leadership, -> { where(leadership_access: true) }, class_name: "User" has_one :subscription, as: :subscribable, dependent: :destroy @@ -84,6 +86,8 @@ def delete_all_data! PersonalStatSnapshot.where(user_id: user_ids).delete_all if user_ids.any? spy_reports.delete_all ranked_wars.delete_all + + armory_news_entries.delete_all member_activity_snapshots.delete_all users.update_all(leadership_access: false) api_keys.destroy_all @@ -100,9 +104,11 @@ def cancel_background_jobs(user_ids) SolidQueue::Job .where(finished_at: nil) .where(class_name: %w[ + BackfillArmoryNewsJob BackfillPersonalStatsJob BackfillRankedWarsJob ClearBackfillStatusJob + FetchArmoryNewsJob WarPollingJob ]) .where("arguments LIKE ?", "%\"arguments\":[#{id},%") diff --git a/app/models/torn_api.rb b/app/models/torn_api.rb index 46f1262..1d98c61 100644 --- a/app/models/torn_api.rb +++ b/app/models/torn_api.rb @@ -44,6 +44,7 @@ def get(path, params = {}, retries: 0) rescue InvalidKeyError, ApiError => e response_time = ((Time.current - start_time) * 1000).to_i log_api_call(path, merged_params, "error", response_time, nil, e.message) + notify_discord_error(path, merged_params, e) raise rescue Net::ReadTimeout, Net::OpenTimeout => e handle_timeout(uri, api_params, e, retries, start_time) @@ -174,6 +175,35 @@ def log_api_call(endpoint, params, status, response_time, metadata, error_messag Rails.logger.error(e.backtrace.first(5).join("\n")) end + def notify_discord_error(path, params, error) + selections = params[:selections] + owner = resolve_api_key_owner + owner_label = if owner + faction = owner.faction + faction ? "#{owner.name} [#{faction.name}]" : owner.name + else + "Unknown" + end + + Discord::Notifier.notify( + webhook_key: :error_webhook_url, + embed: { + title: "Torn API Error", + description: "```#{error.message}```", + color: 15_158_332, + fields: [ + { name: "Endpoint", value: path, inline: true }, + { name: "Selections", value: selections || "N/A", inline: true }, + { name: "Owner", value: owner_label, inline: true } + ], + footer: { text: "TornManager API Monitor" }, + timestamp: Time.current.iso8601 + } + ) + rescue => e + Rails.logger.error("[Discord API Error Notify] Failed: #{e.message}") + end + def resolve_api_key_owner @resolved_user ||= begin found = ::User.find_by_api_key(api_key) diff --git a/app/models/torn_api/faction/armory.rb b/app/models/torn_api/faction/armory.rb new file mode 100644 index 0000000..2e652f5 --- /dev/null +++ b/app/models/torn_api/faction/armory.rb @@ -0,0 +1,88 @@ +module TornApi + module Faction + class Armory < Base + SELECTIONS = "weapons,armor" + + ARMOR_SLOT_MAP = { + "Helmet" => :head, + "Body" => :chest, + "Vest" => :chest, + "Armor" => :chest, + "Gloves" => :chest, # fallback, overridden below + "Pants" => :pants, + "Boots" => :boots + }.freeze + + def initialize(api_key, faction_torn_id = nil) + super(api_key) + @faction_torn_id = faction_torn_id + end + + def endpoint + base = "v2/faction" + base = "#{base}/#{@faction_torn_id}" if @faction_torn_id + base + end + + def fetch + get(endpoint, { selections: SELECTIONS, striptags: false }) + end + + def fetch_by_member + response = fetch + members = Hash.new { |h, k| h[k] = empty_slots } + + parse_items(response["armor"] || [], members, :armor) + parse_items(response["weapons"] || [], members, :weapon) + + members + end + + private + + def empty_slots + { head: [], chest: [], gloves: [], pants: [], boots: [], primary: [], secondary: [], melee: [] } + end + + def parse_items(items, members, category) + items.each do |item| + next unless (item["loaned"] || 0) > 0 + + loaned_to = parse_loaned_to(item["loaned_to"]) + name = item["name"] + slot = category == :armor ? armor_slot(name) : weapon_slot(item["type"]) + + loaned_to.each do |member_id| + members[member_id][slot] << name + end + end + end + + def parse_loaned_to(value) + case value + when Integer then [ value ] + when String then value.split(",").map { |id| id.strip.to_i } + when Array then value.map(&:to_i) + else [] + end + end + + def armor_slot(name) + return :head if name.match?(/helmet|gas mask/i) + return :gloves if name.match?(/gloves/i) + return :boots if name.match?(/boots/i) + return :pants if name.match?(/pants/i) + :chest + end + + def weapon_slot(type) + case type + when "Primary" then :primary + when "Secondary" then :secondary + when "Melee" then :melee + else :primary + end + end + end + end +end diff --git a/app/models/torn_api/faction/armory_news.rb b/app/models/torn_api/faction/armory_news.rb new file mode 100644 index 0000000..4fd6f8e --- /dev/null +++ b/app/models/torn_api/faction/armory_news.rb @@ -0,0 +1,97 @@ +module TornApi + module Faction + class ArmoryNews < Base + V1_ENDPOINT = "faction" + + def fetch(from: nil, to: nil, limit: 100, sort: nil) + params = { selections: "armorynews", limit: [ limit, 100 ].min } + params[:from] = from.to_i if from + params[:to] = to.to_i if to + params[:sort] = sort if sort + response = get(V1_ENDPOINT, params) + parse(response["armorynews"]) + end + + def fetch_all(since: 1.year.ago, max_entries: 2000) + all_entries = [] + cursor_to = nil + floor = since.to_i + + loop do + params = { selections: "armorynews", limit: 100, sort: "DESC" } + params[:to] = cursor_to if cursor_to + params[:from] = floor + + response = get(V1_ENDPOINT, params) + batch = parse(response["armorynews"]) + break if batch.empty? + + all_entries.concat(batch) + break if all_entries.size >= max_entries + break if batch.size < 100 + + cursor_to = batch.map { |e| e[:timestamp] }.min - 1 + break if cursor_to < floor + end + + all_entries.first(max_entries) + end + + private + + def parse(entries) + return [] unless entries + + list = if entries.is_a?(Hash) + entries.map { |id, entry| build_entry(id, entry) } + elsif entries.is_a?(Array) + entries.map { |entry| build_entry(entry["id"], entry) } + else + [] + end + + list.compact + end + + def build_entry(id, entry) + return nil unless entry && entry["news"] && entry["timestamp"] + + text = entry["news"] + player_name = text[/>([^<]+)<\/a>/, 1] + player_id = text[/XID=(\d+)/, 1]&.to_i + + plain = text.gsub(/<[^>]+>/, "").strip + action, item = parse_action(plain, player_name) + + { + id: id, + text: ActionController::Base.helpers.sanitize(text, tags: %w[a], attributes: %w[href]), + timestamp: entry["timestamp"], + player_name: player_name, + player_id: player_id, + action: action, + item: item + } + end + + def parse_action(plain, player_name) + after_name = plain.sub(/\A#{Regexp.escape(player_name.to_s)}\s*/, "") + + case after_name + when /\Aloaned (\d+)x (.+?) to/ + [ :loaned, $2 ] + when /\Areturned (\d+)x (.+)/ + [ :returned, $2 ] + when /\Adeposited (\d+)x (.+)/ + [ :deposited, $2 ] + when /\Aused one of the faction's (.+?) items/ + [ :used, $1 ] + when /\Afilled .+ to create a (.+)/ + [ :filled, $1 ] + else + [ :unknown, after_name ] + end + end + end + end +end diff --git a/app/models/torn_api/torn/item_details.rb b/app/models/torn_api/torn/item_details.rb new file mode 100644 index 0000000..a6fb04b --- /dev/null +++ b/app/models/torn_api/torn/item_details.rb @@ -0,0 +1,37 @@ +module TornApi + module Torn + class ItemDetails < Base + def initialize(api_key, uid) + super(api_key) + @uid = uid + end + + def endpoint + "v2/torn/#{@uid}/itemdetails" + end + + def fetch + response = get(endpoint, { striptags: false }) + parse(response["itemdetails"]) + end + + private + + def parse(details) + return nil unless details + + stats = details["stats"] || {} + { + id: details["id"], + name: details["name"], + damage: stats["damage"], + accuracy: stats["accuracy"], + armor: stats["armor"], + quality: stats["quality"], + rarity: details["rarity"], + bonuses: details["bonuses"] || [] + } + end + end + end +end diff --git a/app/views/admin/factions/_faction.html.erb b/app/views/admin/factions/_faction.html.erb index 0de91a9..7b5ee63 100644 --- a/app/views/admin/factions/_faction.html.erb +++ b/app/views/admin/factions/_faction.html.erb @@ -27,6 +27,7 @@ <%= link_to "Edit Targets", edit_admin_faction_path(faction), class: "btn-small" %> + <%= button_to "Backfill Armory", backfill_armory_news_admin_faction_path(faction), method: :post, class: "btn-small" %> <%= button_to "Delete", admin_faction_path(faction), method: :delete, class: "btn-small btn-small-danger", data: { turbo_confirm: "Are you sure you want to remove '#{faction.name}'?" } %> diff --git a/app/views/factions/leadership/_hero_section.html.erb b/app/views/factions/leadership/_hero_section.html.erb index 884efb2..489f1c9 100644 --- a/app/views/factions/leadership/_hero_section.html.erb +++ b/app/views/factions/leadership/_hero_section.html.erb @@ -119,6 +119,18 @@ Member Activity <% end %> + <%= link_to faction_leadership_armory_path(@faction), class: "dashboard-stat-card dashboard-stat-card-link" do %> + + + + + + <% if @armory_loaned_count %> + <%= @armory_loaned_count %> + <% end %> + Items Loaned + <% end %> + <%= link_to faction_leadership_api_logs_path(@faction), class: "dashboard-stat-card dashboard-stat-card-link" do %> diff --git a/app/views/factions/leadership/armory/show.html.erb b/app/views/factions/leadership/armory/show.html.erb new file mode 100644 index 0000000..adf93a1 --- /dev/null +++ b/app/views/factions/leadership/armory/show.html.erb @@ -0,0 +1,226 @@ +
    + <%= link_to "← Back to Leadership".html_safe, faction_leadership_path(@faction), class: "back-link" %> + +
    +
    +

    Armory

    +

    <%= @members.size %> members with loans

    +
    + <%= button_to "Sync Today", sync_faction_leadership_armory_path(@faction), method: :post, class: "btn btn-sm btn-primary" %> +
    + + <% if @backfill_in_progress %> +
    +
    +
    + + + + +
    +
    + Fetching armoury history + <% earliest = @faction.armory_news_entries.minimum(:occurred_at) %> + Doing a data backfill up until 1 January 2026, because of Torn API v1 call constraints this backfill is spread across 10 days. The earliest Armoury Activity date is <%= earliest ? earliest.strftime("%d %b %Y") : "pending" %>. +
    +
    +
    + <% end %> + + <% if @members.any? %> +
    +
    +
    Member Loans
    + +
    + +
    +
    + Armor Set +
    + Assault + Delta + Riot + Dune + Combat + Other +
    +
    +
    + +
    + + + + + + + + + + + + + + + + <% @members.each do |member| %> + <% + all_items = member[:slots].values.flatten.map(&:downcase) + armor_sets = %w[assault delta riot dune combat].select { |s| all_items.any? { |i| i.start_with?(s) } } + armor_sets << "other" if all_items.any? { |i| %w[assault delta riot dune combat].none? { |s| i.start_with?(s) } } + %> + <% search_text = ([member[:name]] + member[:slots].values.flatten).join(" ").downcase %> + + + + <% [:head, :chest, :gloves, :pants, :boots, :primary, :secondary, :melee].each do |slot| %> + + <% end %> + + + + + + + <% end %> +
    + Member + + Head + + Chest + + Gloves + + Pants + + Boots + + Primary + + Secondary + + Melee + + Total +
    + <%= member[:name] %> + + <% if member[:slots][slot].any? %> + <% member[:slots][slot].each do |item| %> + <% set = %w[assault delta riot dune combat].find { |s| item.downcase.start_with?(s) } || "default" %> + <%= item %> + <% end %> + <% else %> + + <% end %> + <%= member[:total] %>
    +
    +
    + + <% if @armory_news.any? %> +
    +
    +
    + Armoury Activity + <% if @armory_news.any? %> + + <%= Time.at(@armory_news.last[:timestamp]).strftime("%d %b %Y") %> – <%= Time.at(@armory_news.first[:timestamp]).strftime("%d %b %Y") %> + + <% end %> +
    + +
    +
    + <% @armory_news.each do |entry| %> +
    "> + <%= entry[:text].html_safe %> + <%= Time.at(entry[:timestamp]).strftime("%H:%M %d/%m") %> +
    + <% end %> +
    +
    + <% end %> + + + <% else %> +

    No members currently have loaned items.

    + <% end %> +
    diff --git a/config/recurring.yml b/config/recurring.yml index 30cd87e..5a1cd8d 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -38,6 +38,10 @@ production: class: Daily::ValidateFactionKeysJob queue: default schedule: at 1am every day + armory_news_sync: + class: Daily::ArmoryNewsJob + queue: default + schedule: at 5am every day member_activity_poll: class: MemberActivityPollJob queue: default diff --git a/config/routes.rb b/config/routes.rb index f81b5f7..4892202 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -53,6 +53,10 @@ end resource :activity, only: [ :show ], controller: "factions/leadership/activity" + resource :armory, only: [ :show ], controller: "factions/leadership/armory" do + post :sync + end + resource :war_polling, only: [], controller: "factions/leadership/war_polling" do post :start delete :stop @@ -101,6 +105,7 @@ member do patch :toggle_ssl patch :toggle_public_wars + post :backfill_armory_news end end resources :api_logs, only: [ :index ] diff --git a/db/migrate/20260406152903_create_armory_items.rb b/db/migrate/20260406152903_create_armory_items.rb new file mode 100644 index 0000000..95d90c7 --- /dev/null +++ b/db/migrate/20260406152903_create_armory_items.rb @@ -0,0 +1,27 @@ +class CreateArmoryItems < ActiveRecord::Migration[8.1] + def change + create_table :armory_items do |t| + t.references :faction, null: false, foreign_key: true + t.integer :torn_item_id, null: false + t.bigint :uid + t.string :name, null: false + t.string :category, null: false + t.string :item_type + t.integer :quantity, default: 1 + t.integer :available, default: 0 + t.integer :loaned, default: 0 + t.text :loaned_to + t.decimal :damage + t.decimal :accuracy + t.decimal :armor_rating + t.decimal :quality + t.string :rarity + t.json :bonuses, default: [] + + t.timestamps + end + + add_index :armory_items, [ :faction_id, :torn_item_id ] + add_index :armory_items, [ :faction_id, :uid ], unique: true + end +end diff --git a/db/migrate/20260406175921_drop_armory_items.rb b/db/migrate/20260406175921_drop_armory_items.rb new file mode 100644 index 0000000..b82df03 --- /dev/null +++ b/db/migrate/20260406175921_drop_armory_items.rb @@ -0,0 +1,31 @@ +class DropArmoryItems < ActiveRecord::Migration[8.1] + def up + drop_table :armory_items + end + + def down + create_table :armory_items do |t| + t.references :faction, null: false, foreign_key: true + t.integer :torn_item_id, null: false + t.bigint :uid + t.string :name, null: false + t.string :category, null: false + t.string :item_type + t.integer :quantity, default: 1 + t.integer :available, default: 0 + t.integer :loaned, default: 0 + t.text :loaned_to + t.decimal :damage + t.decimal :accuracy + t.decimal :armor_rating + t.decimal :quality + t.string :rarity + t.json :bonuses, default: [] + + t.timestamps + end + + add_index :armory_items, [ :faction_id, :torn_item_id ] + add_index :armory_items, [ :faction_id, :uid ], unique: true + end +end diff --git a/db/migrate/20260406184323_create_armory_news_entries.rb b/db/migrate/20260406184323_create_armory_news_entries.rb new file mode 100644 index 0000000..041f26d --- /dev/null +++ b/db/migrate/20260406184323_create_armory_news_entries.rb @@ -0,0 +1,19 @@ +class CreateArmoryNewsEntries < ActiveRecord::Migration[8.1] + def change + create_table :armory_news_entries do |t| + t.references :faction, null: false, foreign_key: true + t.string :torn_news_id, null: false + t.integer :player_id + t.string :player_name + t.string :action, null: false + t.string :item + t.text :text + t.datetime :occurred_at, null: false + t.datetime :created_at, null: false + end + + add_index :armory_news_entries, [ :faction_id, :torn_news_id ], unique: true + add_index :armory_news_entries, [ :faction_id, :occurred_at ] + add_index :armory_news_entries, [ :faction_id, :player_id, :action ] + end +end diff --git a/db/schema.rb b/db/schema.rb index d5b9061..fb67e3d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_06_103205) do +ActiveRecord::Schema[8.1].define(version: 2026_04_06_184323) do create_table "api_calls", force: :cascade do |t| t.string "api_key", null: false t.datetime "created_at", null: false @@ -43,6 +43,22 @@ t.index ["user_id", "type"], name: "index_api_keys_on_user_id_and_type", unique: true, where: "user_id IS NOT NULL" end + create_table "armory_news_entries", force: :cascade do |t| + t.string "action", null: false + t.datetime "created_at", null: false + t.integer "faction_id", null: false + t.string "item" + t.datetime "occurred_at", null: false + t.integer "player_id" + t.string "player_name" + t.text "text" + t.string "torn_news_id", null: false + t.index ["faction_id", "occurred_at"], name: "index_armory_news_entries_on_faction_id_and_occurred_at" + t.index ["faction_id", "player_id", "action"], name: "idx_on_faction_id_player_id_action_86316add96" + t.index ["faction_id", "torn_news_id"], name: "index_armory_news_entries_on_faction_id_and_torn_news_id", unique: true + t.index ["faction_id"], name: "index_armory_news_entries_on_faction_id" + end + create_table "faction_settings", force: :cascade do |t| t.datetime "created_at", null: false t.integer "faction_id", null: false @@ -351,6 +367,7 @@ t.index ["sender_id"], name: "index_xanax_payments_on_sender_id" end + add_foreign_key "armory_news_entries", "factions" add_foreign_key "faction_subscription_grants", "factions" add_foreign_key "faction_subscription_grants", "users", column: "granted_by_id" add_foreign_key "member_activity_snapshots", "factions" diff --git a/test/controllers/factions/leadership/armory_controller_test.rb b/test/controllers/factions/leadership/armory_controller_test.rb new file mode 100644 index 0000000..c84a4cc --- /dev/null +++ b/test/controllers/factions/leadership/armory_controller_test.rb @@ -0,0 +1,96 @@ +require "test_helper" + +class Factions::Leadership::ArmoryControllerTest < ActionDispatch::IntegrationTest + setup do + @faction = Faction.create!( + torn_id: 99999, name: "Test Faction", xanax_target: 2.5, + energy_refill_target: 1.0, nerve_refill_target: 1.0, setup_completed: true + ) + @faction.create_faction_setting! + ApiKey::Torn.create!(faction: @faction, key: "FACTION_KEY_123", access_type: "Limited Access") + + @bram = users(:bram) + @bert = users(:bert) + @bram.update!(faction: @faction, leadership_access: true) + @bert.update!(faction: @faction) + grant_subscription(@faction, expires_at: 1.month.from_now) + end + + test "requires authentication" do + get faction_leadership_armory_path(@faction) + assert_redirected_to new_session_path + end + + test "requires leadership access" do + sign_in_as(@bert) + get faction_leadership_armory_path(@faction) + assert_redirected_to faction_path(@faction) + end + + test "show displays armory page" do + stub_armory_api + sign_in_as(@bram) + get faction_leadership_armory_path(@faction) + assert_response :success + assert_select "h1", "Armory" + end + + test "show displays member loans" do + stub_armory_api(weapons: [ + { "ID" => 26, "name" => "AK-47", "type" => "Primary", "quantity" => 1, "available" => 0, "loaned" => 1, "loaned_to" => @bram.torn_id.to_s } + ]) + sign_in_as(@bram) + get faction_leadership_armory_path(@faction) + assert_response :success + assert_match(/AK-47/, response.body) + end + + test "show displays news from database" do + stub_armory_api(weapons: [ + { "ID" => 26, "name" => "AK-47", "type" => "Primary", "quantity" => 1, "available" => 0, "loaned" => 1, "loaned_to" => @bram.torn_id.to_s } + ]) + ArmoryNewsEntry.create!( + faction: @faction, torn_news_id: "abc123", player_id: @bram.torn_id, + player_name: @bram.name, action: "loaned", item: "AK-47", + text: "#{@bram.name} loaned 1x AK-47", occurred_at: 1.hour.ago + ) + + sign_in_as(@bram) + get faction_leadership_armory_path(@faction) + assert_response :success + assert_match(/AK-47/, response.body) + end + + test "shows backfill banner when no news entries exist" do + stub_armory_api + sign_in_as(@bram) + get faction_leadership_armory_path(@faction) + assert_response :success + assert_match(/Fetching armoury history/, response.body) + end + + test "handles API errors gracefully" do + TornApi::Faction::Armory.any_instance.stubs(:fetch).raises(TornApi::ApiError, "Server error") + + sign_in_as(@bram) + get faction_leadership_armory_path(@faction) + assert_redirected_to faction_leadership_path(@faction) + assert_match(/API error/, flash[:alert]) + end + + test "redirects to setup when no api keys" do + @faction.torn_api_key&.destroy! + sign_in_as(@bram) + get faction_leadership_armory_path(@faction) + assert_redirected_to faction_leadership_setup_path(@faction) + end + + private + + def stub_armory_api(weapons: [], armor: []) + TornApi::Faction::Armory.any_instance.stubs(:fetch).returns({ + "weapons" => weapons, + "armor" => armor + }) + end +end From 697501c719249e5c999a9f7b171bbe83885528f4 Mon Sep 17 00:00:00 2001 From: Bram Date: Mon, 6 Apr 2026 21:55:20 +0200 Subject: [PATCH 234/266] hotfix --- app/controllers/admin/factions_controller.rb | 1 + .../factions/leadership/armory_controller.rb | 3 +-- app/controllers/factions_controller.rb | 1 + app/jobs/backfill_armory_news_job.rb | 15 ++++++++++++--- ...106_add_armory_backfill_pending_to_factions.rb | 5 +++++ db/schema.rb | 3 ++- .../factions/leadership/armory_controller_test.rb | 3 ++- 7 files changed, 24 insertions(+), 7 deletions(-) create mode 100644 db/migrate/20260406195106_add_armory_backfill_pending_to_factions.rb diff --git a/app/controllers/admin/factions_controller.rb b/app/controllers/admin/factions_controller.rb index beb1296..0f762da 100644 --- a/app/controllers/admin/factions_controller.rb +++ b/app/controllers/admin/factions_controller.rb @@ -83,6 +83,7 @@ def toggle_ssl end def backfill_armory_news + @faction.update!(armory_backfill_pending: true) BackfillArmoryNewsJob.perform_later(@faction.id) redirect_to admin_factions_path, notice: "Armory news backfill started for #{@faction.name}." end diff --git a/app/controllers/factions/leadership/armory_controller.rb b/app/controllers/factions/leadership/armory_controller.rb index 13d6771..edda951 100644 --- a/app/controllers/factions/leadership/armory_controller.rb +++ b/app/controllers/factions/leadership/armory_controller.rb @@ -10,8 +10,7 @@ def show .newest_first .map { |e| entry_to_hash(e) } - latest_entry = @faction.armory_news_entries.maximum(:occurred_at) - @backfill_in_progress = latest_entry.nil? || latest_entry < 1.day.ago + @backfill_in_progress = @faction.armory_backfill_pending? member_names = @faction.users.where(torn_id: loans_by_member.keys).pluck(:torn_id, :name).to_h diff --git a/app/controllers/factions_controller.rb b/app/controllers/factions_controller.rb index 48958f5..61d9dc3 100644 --- a/app/controllers/factions_controller.rb +++ b/app/controllers/factions_controller.rb @@ -104,6 +104,7 @@ def create backfill_target_date: start_date ) + @faction.update!(armory_backfill_pending: true) BackfillArmoryNewsJob.perform_later(@faction.id) BackfillRankedWarsJob.perform_later(@faction.id) BackfillPersonalStatsJob.perform_later( diff --git a/app/jobs/backfill_armory_news_job.rb b/app/jobs/backfill_armory_news_job.rb index 317d026..7bcd367 100644 --- a/app/jobs/backfill_armory_news_job.rb +++ b/app/jobs/backfill_armory_news_job.rb @@ -1,6 +1,6 @@ class BackfillArmoryNewsJob < FactionApiJob BACKFILL_FLOOR = Time.utc(2026, 1, 1).to_i - PAGE_DELAY = 40.minutes + PAGE_DELAY = 1.minute limits_concurrency to: 1, key: ->(faction_id, _cursor = nil) { faction_id }, group: "FactionApiCalls" @@ -16,11 +16,18 @@ def perform(faction_id, cursor = nil) cursor = oldest ? oldest.to_i - 1 : Time.current.to_i end - return if cursor <= BACKFILL_FLOOR + if cursor <= BACKFILL_FLOOR + faction.update!(armory_backfill_pending: false) + return + end client = TornApi::Faction::ArmoryNews.new(api_key) batch = client.fetch(to: cursor, limit: 100) - return if batch.empty? + + if batch.empty? + faction.update!(armory_backfill_pending: false) + return + end records = batch.map { |entry| build_record(faction.id, entry) } ArmoryNewsEntry.insert_all(records, unique_by: [ :faction_id, :torn_news_id ]) @@ -30,6 +37,8 @@ def perform(faction_id, cursor = nil) if batch.size == 100 && next_cursor > BACKFILL_FLOOR BackfillArmoryNewsJob.set(wait: PAGE_DELAY).perform_later(faction_id, next_cursor) + else + faction.update!(armory_backfill_pending: false) end rescue TornApi::ApiError => e Rails.logger.error("BackfillArmoryNewsJob: Failed for faction #{faction_id}: #{e.message}") diff --git a/db/migrate/20260406195106_add_armory_backfill_pending_to_factions.rb b/db/migrate/20260406195106_add_armory_backfill_pending_to_factions.rb new file mode 100644 index 0000000..cae91c2 --- /dev/null +++ b/db/migrate/20260406195106_add_armory_backfill_pending_to_factions.rb @@ -0,0 +1,5 @@ +class AddArmoryBackfillPendingToFactions < ActiveRecord::Migration[8.1] + def change + add_column :factions, :armory_backfill_pending, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index fb67e3d..8501263 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_06_184323) do +ActiveRecord::Schema[8.1].define(version: 2026_04_06_195106) do create_table "api_calls", force: :cascade do |t| t.string "api_key", null: false t.datetime "created_at", null: false @@ -83,6 +83,7 @@ end create_table "factions", force: :cascade do |t| + t.boolean "armory_backfill_pending", default: false, null: false t.datetime "backfill_ends_at" t.date "backfill_target_date" t.datetime "created_at", null: false diff --git a/test/controllers/factions/leadership/armory_controller_test.rb b/test/controllers/factions/leadership/armory_controller_test.rb index c84a4cc..9e6c9dc 100644 --- a/test/controllers/factions/leadership/armory_controller_test.rb +++ b/test/controllers/factions/leadership/armory_controller_test.rb @@ -61,8 +61,9 @@ class Factions::Leadership::ArmoryControllerTest < ActionDispatch::IntegrationTe assert_match(/AK-47/, response.body) end - test "shows backfill banner when no news entries exist" do + test "shows backfill banner when backfill is pending" do stub_armory_api + @faction.update!(armory_backfill_pending: true) sign_in_as(@bram) get faction_leadership_armory_path(@faction) assert_response :success From d95f59a2bee12af202d0aeca1c27548bc97debfc Mon Sep 17 00:00:00 2001 From: Bram Date: Mon, 6 Apr 2026 22:09:14 +0200 Subject: [PATCH 235/266] specify env for errors in api --- app/models/torn_api.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/torn_api.rb b/app/models/torn_api.rb index 1d98c61..9b75f34 100644 --- a/app/models/torn_api.rb +++ b/app/models/torn_api.rb @@ -194,7 +194,8 @@ def notify_discord_error(path, params, error) fields: [ { name: "Endpoint", value: path, inline: true }, { name: "Selections", value: selections || "N/A", inline: true }, - { name: "Owner", value: owner_label, inline: true } + { name: "Owner", value: owner_label, inline: true }, + { name: "Environment", value: Rails.env, inline: true } ], footer: { text: "TornManager API Monitor" }, timestamp: Time.current.iso8601 From a5b78e63a5e5cf94203ee9c14d88622b17d9413c Mon Sep 17 00:00:00 2001 From: Bram Date: Mon, 6 Apr 2026 22:26:20 +0200 Subject: [PATCH 236/266] legal page update and landing page --- app/views/home/index.html.erb | 13 +++++++++---- app/views/pages/_privacy_policy.html.erb | 10 ++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index 0f91558..b85bd71 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -160,6 +160,15 @@

    Tampermonkey companion that enhances torn.com with war stat overlays and member details.

    +
    + + + +
    + Armoury Management +

    See who has what gear loaned, color-coded by armor set. Full activity log with loan and return history per member.

    +
    +
    @@ -283,10 +292,6 @@

    Organised Crimes 2.0

    Crime team management with CPR tracking, cost analysis, and optimal team suggestions.

    -
    -

    Armoury Management

    -

    Track loaned equipment, manage faction armoury inventory, and monitor returns.

    -

    Discord Integration

    War notifications, compliance alerts, and member updates pushed directly to your faction's Discord server.

    diff --git a/app/views/pages/_privacy_policy.html.erb b/app/views/pages/_privacy_policy.html.erb index 1d7dc00..634db25 100644 --- a/app/views/pages/_privacy_policy.html.erb +++ b/app/views/pages/_privacy_policy.html.erb @@ -408,6 +408,16 @@ Backfill historical personal statistics for faction members Once per member per day during initial backfill + + v2/faction (selections: weapons, armor) + Fetch current armory inventory with loan status and borrower IDs per item type + On armory page load + + + v1/faction (selections: armorynews) + Fetch armory activity log (loans, returns, deposits, item usage) + On armory page load; during initial backfill +
    From f95b9e8817e24befb1efa4c6c40012ba4a785346 Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 7 Apr 2026 20:53:39 +0200 Subject: [PATCH 237/266] redesign compliance --- .../stylesheets/pages/faction_dashboard.css | 40 ++++++++++++------- app/views/factions/_training_section.html.erb | 14 +++---- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/app/assets/stylesheets/pages/faction_dashboard.css b/app/assets/stylesheets/pages/faction_dashboard.css index 477d54e..433ecf1 100644 --- a/app/assets/stylesheets/pages/faction_dashboard.css +++ b/app/assets/stylesheets/pages/faction_dashboard.css @@ -11,7 +11,7 @@ /* Hero Section */ .dashboard-hero { - min-height: calc(100vh - 60px); /* Account for header height */ + min-height: calc(100vh - 40px); /* Account for header height */ display: flex; align-items: center; justify-content: center; @@ -524,7 +524,7 @@ /* Full Page Sections */ .dashboard-fullpage { - min-height: calc(100vh - 60px); /* Account for header height */ + min-height: calc(100vh - 40px); /* Account for header height */ display: flex; flex-direction: column; padding: var(--space-12) var(--space-6); @@ -909,9 +909,9 @@ /* Training Section - fit in viewport */ #training { - height: calc(100vh - 60px); /* Exact viewport height minus header */ - min-height: calc(100vh - 60px); - max-height: calc(100vh - 60px); + height: calc(100vh - 40px); /* Exact viewport height minus header */ + min-height: calc(100vh - 40px); + max-height: calc(100vh - 40px); overflow: hidden; padding-top: var(--space-4); padding-bottom: var(--space-2); @@ -938,27 +938,37 @@ #training .compliance-summary { flex-shrink: 0; - padding: var(--space-3) var(--space-4); - gap: var(--space-4); + padding: var(--space-2) var(--space-4); + gap: var(--space-3); } -#training .target-info h3 { +#training .target-info-inline { + display: flex; + align-items: center; + gap: 0.5rem; font-size: 0.75rem; - margin-bottom: var(--space-2); + color: var(--gray-400); } -#training .target-info li { - font-size: 0.75rem; - padding: 2px 0; +#training .target-info-inline .target-label { + font-weight: 600; + color: var(--gray-300); + text-transform: uppercase; + font-size: 0.625rem; + letter-spacing: 0.05em; +} + +#training .target-info-inline .target-separator { + color: var(--gray-600); } #training .stat-box { - padding: var(--space-2) var(--space-3); + padding: var(--space-1) var(--space-3); } #training .stat-box .count { - font-size: 1.5rem; - margin-bottom: var(--space-1); + font-size: 1.25rem; + margin-bottom: 0; } #training .stat-box .label { diff --git a/app/views/factions/_training_section.html.erb b/app/views/factions/_training_section.html.erb index 9716a04..882620c 100644 --- a/app/views/factions/_training_section.html.erb +++ b/app/views/factions/_training_section.html.erb @@ -104,13 +104,13 @@ <%= render "factions/training/table_skeleton" %> <% else %>
    -
    -

    Daily Targets

    -
      -
    • Xanax: <%= @xanax_target %>/day
    • -
    • Energy Refills: <%= energy_enabled ? "#{@energy_target}/day" : "Disabled" %>
    • -
    • Nerve Refills: <%= nerve_enabled ? "#{@nerve_target}/day" : "Disabled" %>
    • -
    +
    + Targets: + Xanax <%= @xanax_target %>/day + · + Energy <%= energy_enabled ? "#{@energy_target}/day" : "Off" %> + · + Nerve <%= nerve_enabled ? "#{@nerve_target}/day" : "Off" %>
    From 348641c7c96a36ed0ddb95420b98f04350de17f9 Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 7 Apr 2026 21:32:24 +0200 Subject: [PATCH 238/266] torn api degraded reports --- app/jobs/torn_api_health_check_job.rb | 49 ++++++++++++++ app/models/torn_api.rb | 40 ++++++++++-- bin/announce | 31 +++++---- test/controllers/factions_controller_test.rb | 6 +- test/jobs/torn_api_health_check_job_test.rb | 67 ++++++++++++++++++++ 5 files changed, 171 insertions(+), 22 deletions(-) create mode 100644 app/jobs/torn_api_health_check_job.rb create mode 100644 test/jobs/torn_api_health_check_job_test.rb diff --git a/app/jobs/torn_api_health_check_job.rb b/app/jobs/torn_api_health_check_job.rb new file mode 100644 index 0000000..9a254b0 --- /dev/null +++ b/app/jobs/torn_api_health_check_job.rb @@ -0,0 +1,49 @@ +class TornApiHealthCheckJob < ApplicationJob + queue_as :default + + RECHECK_INTERVAL = 5.minutes + DISCORD_CHANNEL_ID = "1491152993859670167" + + limits_concurrency to: 1, key: ->(endpoint) { "torn_health_#{endpoint}" } + + def perform(endpoint) + return unless Rails.env.production? + + api_key = AdminCredentials.api_key + return unless api_key + + client = TornApi::Base.new(api_key) + client.get(endpoint) + + sanitized = endpoint.gsub(%r{/\d+(?=/|$)}, "/{id}") + Rails.cache.delete("torn_degraded:#{sanitized}") + + Discord::Notifier.send_to_channel( + DISCORD_CHANNEL_ID, + embed: { + title: ":green_circle: Torn API Recovered", + description: "**Endpoint:** `#{sanitized}`\n\nResponding normally again. Services restored.", + color: 0x22c55e, + footer: { text: "TornManager Status Monitor" }, + timestamp: Time.current.iso8601 + } + ) + rescue TornApi::ApiError => e + if e.message.include?("HTTP 5") + sanitized = endpoint.gsub(%r{/\d+(?=/|$)}, "/{id}") + + Discord::Notifier.send_to_channel( + DISCORD_CHANNEL_ID, + embed: { + title: ":yellow_circle: Torn API Still Degraded", + description: "**Endpoint:** `#{sanitized}`\n\nStill returning errors. Next health check .", + color: 0xeab308, + footer: { text: "TornManager Status Monitor" }, + timestamp: Time.current.iso8601 + } + ) + + TornApiHealthCheckJob.set(wait: RECHECK_INTERVAL).perform_later(endpoint) + end + end +end diff --git a/app/models/torn_api.rb b/app/models/torn_api.rb index 9b75f34..da80764 100644 --- a/app/models/torn_api.rb +++ b/app/models/torn_api.rb @@ -32,7 +32,7 @@ def get(path, params = {}, retries: 0) log_request(uri) response = perform_request(uri) - body = parse_response(response) + body = parse_response(response, path) check_for_errors(body) @@ -74,10 +74,12 @@ def perform_request(uri) end end - def parse_response(response) - unless response.code.to_i == 200 - Rails.logger.error("HTTP #{response.code} from Torn API: #{response.body[0..500]}") - raise ApiError, "Torn API request failed (HTTP #{response.code})" + def parse_response(response, path = nil) + status = response.code.to_i + unless status == 200 + Rails.logger.error("HTTP #{status} from Torn API: #{response.body[0..500]}") + notify_torn_degraded(path, status) if status >= 500 + raise ApiError, "Torn API request failed (HTTP #{status})" end JSON.parse(response.body) @@ -205,6 +207,34 @@ def notify_discord_error(path, params, error) Rails.logger.error("[Discord API Error Notify] Failed: #{e.message}") end + def sanitize_path(path) + path.gsub(%r{/\d+(?=/|$)}, "/{id}") + end + + def notify_torn_degraded(path, status) + return unless Rails.env.production? + + cache_key = "torn_degraded:#{sanitize_path(path)}" + return if Rails.cache.exist?(cache_key) + + Rails.cache.write(cache_key, true, expires_in: 10.minutes) + + Discord::Notifier.send_to_channel( + "1491152993859670167", + embed: { + title: ":red_circle: Torn API Degraded", + description: "```Torn API request failed (HTTP #{status})```\n**Endpoint:** `#{sanitize_path(path)}`\n\nTornManager services may be affected. Next health check .", + color: 15_158_332, + footer: { text: "TornManager Status Monitor" }, + timestamp: Time.current.iso8601 + } + ) + + TornApiHealthCheckJob.perform_later(path) + rescue => e + Rails.logger.error("[Discord Torn Degraded Notify] Failed: #{e.message}") + end + def resolve_api_key_owner @resolved_user ||= begin found = ::User.find_by_api_key(api_key) diff --git a/bin/announce b/bin/announce index f88f342..75444bb 100755 --- a/bin/announce +++ b/bin/announce @@ -3,28 +3,31 @@ require_relative "../config/environment" require "net/http" require "json" -CHANNEL_ID = 1490353824169328690 -# CHANNEL_ID = 1490451144818819193 # test channel +# CHANNEL_ID = 1490353824169328690 +CHANNEL_ID = 1490451144818819193 # test channel embed = { - title: "Subscription Update", + title: "Armoury & Compliance Update", color: 0x5865F2, description: [ - "The subscription system has been reworked. This update improves how subscriptions work under the hood and introduces faction-wide subscriptions.", + "A first crack at the Armoury page and a redesigned Training Compliance table are now live! :-)", "", - "**What changed**", - "- Subscriptions are now **faction-wide**, one subscription covers all members", - "- No more individual subscriptions needed when your faction is subscribed", - "- Leadership of a faction can transfer their personal subscription time to the faction in **Leadership > Settings**", - "- All features included, no tiers", + "**Armoury**", + "- See who has what gear loaned across all equipment slots (Head, Chest, Gloves, Pants, Boots, Primary, Secondary, Melee)", + "- Items are color-coded by armor set (Assault, Delta, Riot, Dune, Combat)", + "- Filter by armor set and search by member name or item name with highlighted results", + "- Full activity log showing loans, returns, deposits, and item usage", + "- Expandable loan history per member", + "- Activity data is backfilled from January 1st 2026 and synced daily (you can do the daily sync yourself too by pressing the Sync button in the table)", + "- Available under **Leadership > Armoury**", "", - "**What about existing factions?**", - "I have reached out to every faction currently using TornManager and will manually extend your subscription since you are all helping me with testing. You don't need to do anything right now! :-)", + "**note:** still invetigating if we can make the UI more interesting by analyzing the logs of a faction and showing some extra data! e.g flag who is hoarding weapons or smth", "", - "**Pricing**", - "The pricing model is still being discussed and might change. For now the cost scales with faction size. I will share more details once finalized. Feel free to share your thoughts in <#1490344856134881383>.", + "**Training Compliance**", + "- Redesigned to fit more data on screen", "", - "New factions get a **14-day free trial** on setup." + "**Other**", + "- Updated landing page and legal/privacy documentation because of usage of the new api endpoints used for armoury" ].join("\n") } diff --git a/test/controllers/factions_controller_test.rb b/test/controllers/factions_controller_test.rb index 11134e3..29d6695 100644 --- a/test/controllers/factions_controller_test.rb +++ b/test/controllers/factions_controller_test.rb @@ -57,9 +57,9 @@ class FactionsControllerTest < ActionDispatch::IntegrationTest test "dashboard shows faction targets" do sign_in_as(@bram) get faction_path(@faction) - assert_select ".target-info li", /Xanax.*2\.5\/day/ - assert_select ".target-info li", /Energy Refills.*1\.0\/day/ - assert_select ".target-info li", /Nerve Refills.*1\.0\/day/ + assert_select ".target-info-inline", /Xanax 2\.5\/day/ + assert_select ".target-info-inline", /Energy 1\.0\/day/ + assert_select ".target-info-inline", /Nerve 1\.0\/day/ end test "dashboard shows scroll cards for navigation" do diff --git a/test/jobs/torn_api_health_check_job_test.rb b/test/jobs/torn_api_health_check_job_test.rb new file mode 100644 index 0000000..0307129 --- /dev/null +++ b/test/jobs/torn_api_health_check_job_test.rb @@ -0,0 +1,67 @@ +require "test_helper" + +class TornApiHealthCheckJobTest < ActiveJob::TestCase + setup do + @endpoint = "v2/faction/9055/members" + AdminCredentials.stubs(:api_key).returns("TEST_KEY_123") + Rails.env.stubs(:production?).returns(true) + end + + test "posts recovery when endpoint is healthy" do + TornApi::Base.any_instance.stubs(:get).returns({}) + Discord::Notifier.expects(:send_to_channel).with( + TornApiHealthCheckJob::DISCORD_CHANNEL_ID, + has_entry(:embed, has_entry(:title, ":green_circle: Torn API Recovered")) + ) + + TornApiHealthCheckJob.perform_now(@endpoint) + end + + test "clears degraded cache on recovery" do + TornApi::Base.any_instance.stubs(:get).returns({}) + Discord::Notifier.stubs(:send_to_channel) + + Rails.cache.write("torn_degraded:v2/faction/{id}/members", true) + TornApiHealthCheckJob.perform_now(@endpoint) + + assert_not Rails.cache.exist?("torn_degraded:v2/faction/{id}/members") + end + + test "re-enqueues and notifies when endpoint still failing with 5xx" do + TornApi::Base.any_instance.stubs(:get).raises(TornApi::ApiError, "Torn API request failed (HTTP 502)") + Discord::Notifier.expects(:send_to_channel).with( + TornApiHealthCheckJob::DISCORD_CHANNEL_ID, + has_entry(:embed, has_entry(:title, ":yellow_circle: Torn API Still Degraded")) + ) + + assert_enqueued_with(job: TornApiHealthCheckJob, args: [ @endpoint ]) do + TornApiHealthCheckJob.perform_now(@endpoint) + end + end + + test "does not re-enqueue for non-5xx api errors" do + TornApi::Base.any_instance.stubs(:get).raises(TornApi::ApiError, "API error 2: Invalid key") + Discord::Notifier.expects(:send_to_channel).never + + assert_no_enqueued_jobs(only: TornApiHealthCheckJob) do + TornApiHealthCheckJob.perform_now(@endpoint) + end + end + + test "does nothing without admin api key" do + AdminCredentials.stubs(:api_key).returns(nil) + TornApi::Base.any_instance.expects(:get).never + + TornApiHealthCheckJob.perform_now(@endpoint) + end + + test "sanitizes endpoint ids in messages" do + TornApi::Base.any_instance.stubs(:get).returns({}) + Discord::Notifier.expects(:send_to_channel).with( + anything, + has_entry(:embed, has_entry(:description, includes("v2/faction/{id}/members"))) + ) + + TornApiHealthCheckJob.perform_now(@endpoint) + end +end From 398d9476860fc491fdba27f2308759d9f5c9ce43 Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 7 Apr 2026 21:53:38 +0200 Subject: [PATCH 239/266] armoury stats admin page --- app/controllers/admin/stats_controller.rb | 22 ++++++++++++++ app/views/admin/stats/index.html.erb | 37 +++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb index 10e1dec..b551181 100644 --- a/app/controllers/admin/stats_controller.rb +++ b/app/controllers/admin/stats_controller.rb @@ -10,6 +10,7 @@ def index load_data_health load_api_stats load_sign_in_stats + load_armory_stats end private @@ -119,6 +120,27 @@ def load_sign_in_stats @new_sign_ins = User.where(id: first_session_dates.keys).order(created_at: :desc) end + def load_armory_stats + @armory_total_entries = ArmoryNewsEntry.count + @armory_earliest = ArmoryNewsEntry.minimum(:occurred_at) + @armory_latest = ArmoryNewsEntry.maximum(:occurred_at) + + @armory_by_faction = Faction + .where(setup_completed: true) + .includes(:torn_api_key) + .order(:name) + .map do |f| + entries = f.armory_news_entries + { + faction: f, + count: entries.count, + earliest: entries.minimum(:occurred_at), + latest: entries.maximum(:occurred_at), + backfill_pending: f.armory_backfill_pending? + } + end + end + def calculate_snapshot_gaps tracked_user_ids = User.tracked_for_stats.pluck(:id) return set_empty_gap_stats if tracked_user_ids.empty? diff --git a/app/views/admin/stats/index.html.erb b/app/views/admin/stats/index.html.erb index 1d28964..91dee82 100644 --- a/app/views/admin/stats/index.html.erb +++ b/app/views/admin/stats/index.html.erb @@ -164,6 +164,43 @@
    + <%# ── Armoury News ── %> +
    +

    Armoury News

    +
    +
    + Total entries + <%= number_with_delimiter(@armory_total_entries) %> +
    +
    + Earliest + <%= @armory_earliest&.strftime("%d %b %Y") || "N/A" %> +
    +
    + Latest + <%= @armory_latest&.strftime("%d %b %Y") || "N/A" %> +
    +
    + <% if @armory_by_faction.any? %> + + + + + + <% @armory_by_faction.each do |f| %> + + + + + + + + <% end %> + +
    FactionEntriesEarliestLatestBF
    <%= f[:faction].name %><%= number_with_delimiter(f[:count]) %><%= f[:earliest]&.strftime("%d %b") || "—".html_safe %><%= f[:latest]&.strftime("%d %b") || "—".html_safe %><%= f[:backfill_pending] ? "●".html_safe : "✓".html_safe %>
    + <% end %> +
    + <%# ── Torn API ── %>

    Torn API 30d retention

    From 63ff3535098a3e86daf4aedbf622c23fadef6ce0 Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 7 Apr 2026 22:18:34 +0200 Subject: [PATCH 240/266] armoury news entry stats on individuals --- app/assets/stylesheets/pages/armory.css | 42 +++++++++-- .../factions/leadership/armory_controller.rb | 18 +++++ app/models/torn_api/faction/armory_news.rb | 8 ++- .../factions/leadership/armory/show.html.erb | 72 +++++++++++++------ ...407200222_fix_armory_news_entry_actions.rb | 25 +++++++ db/schema.rb | 2 +- 6 files changed, 140 insertions(+), 27 deletions(-) create mode 100644 db/migrate/20260407200222_fix_armory_news_entry_actions.rb diff --git a/app/assets/stylesheets/pages/armory.css b/app/assets/stylesheets/pages/armory.css index 8942210..3c55649 100644 --- a/app/assets/stylesheets/pages/armory.css +++ b/app/assets/stylesheets/pages/armory.css @@ -102,11 +102,10 @@ .armory-chip { display: inline-block; - padding: 2px 8px; + padding: 2px 6px; margin: 1px; border-radius: 4px; - font-size: 0.6875rem; - white-space: nowrap; + font-size: 0.625rem; } .armory-chip-assault { @@ -164,6 +163,35 @@ border-radius: 12px; } +/* Member stats in detail row */ +.armory-member-stats { + display: flex; + gap: var(--space-4); + padding: 0.75rem 0; + border-bottom: 1px solid var(--gray-800); + margin-bottom: 0.75rem; +} + +.armory-member-stat { + display: flex; + flex-direction: column; + align-items: center; + min-width: 60px; +} + +.armory-member-stat-value { + font-size: 1.125rem; + font-weight: 700; + color: var(--gray-100); +} + +.armory-member-stat-label { + font-size: 0.625rem; + color: var(--gray-500); + text-transform: uppercase; + letter-spacing: 0.05em; +} + /* Loan history */ .armory-history { padding: 0.75rem 0; @@ -178,6 +206,11 @@ margin-bottom: 0.5rem; } +.armory-history-scroll { + max-height: 300px; + overflow-y: auto; +} + .armory-history-table { width: 100%; font-size: 0.8125rem; @@ -193,8 +226,9 @@ } .armory-history-table td { - padding: 0.375rem 0.75rem; + padding: 0.25rem 0.75rem; border-bottom: 1px solid var(--gray-850, var(--gray-800)); + font-size: 0.75rem; } /* Action badges */ diff --git a/app/controllers/factions/leadership/armory_controller.rb b/app/controllers/factions/leadership/armory_controller.rb index edda951..118b05e 100644 --- a/app/controllers/factions/leadership/armory_controller.rb +++ b/app/controllers/factions/leadership/armory_controller.rb @@ -23,6 +23,24 @@ def show @news_by_member = @armory_news .select { |e| e[:action].in?([ :loaned, :returned ]) } .group_by { |e| e[:player_id] } + + action_counts = @faction.armory_news_entries + .group(:player_id, :action) + .count + + blood_bag_counts = @faction.armory_news_entries + .where(action: "used") + .where("item LIKE ?", "Blood Bag%") + .group(:player_id) + .count + + @member_stats = Hash.new { |h, k| h[k] = {} } + action_counts.each do |(pid, action), count| + @member_stats[pid][action.to_sym] = count + end + blood_bag_counts.each do |pid, count| + @member_stats[pid][:blood_bags] = count + end rescue TornApi::InvalidKeyError => e redirect_to faction_leadership_path(@faction), alert: "API key error: #{e.message}" rescue TornApi::ApiError => e diff --git a/app/models/torn_api/faction/armory_news.rb b/app/models/torn_api/faction/armory_news.rb index 4fd6f8e..de9cb19 100644 --- a/app/models/torn_api/faction/armory_news.rb +++ b/app/models/torn_api/faction/armory_news.rb @@ -80,14 +80,20 @@ def parse_action(plain, player_name) case after_name when /\Aloaned (\d+)x (.+?) to/ [ :loaned, $2 ] + when /\Agave (\d+)x (.+?) to/ + [ :loaned, $2 ] when /\Areturned (\d+)x (.+)/ [ :returned, $2 ] - when /\Adeposited (\d+)x (.+)/ + when /\Aretrieved (\d+)x (.+?) from/ + [ :returned, $2 ] + when /\Adeposited (\d+)\s*x (.+)/ [ :deposited, $2 ] when /\Aused one of the faction's (.+?) items/ [ :used, $1 ] when /\Afilled .+ to create a (.+)/ [ :filled, $1 ] + when /\Afilled one of the faction's (.+?) items/ + [ :filled, $1 ] else [ :unknown, after_name ] end diff --git a/app/views/factions/leadership/armory/show.html.erb b/app/views/factions/leadership/armory/show.html.erb index adf93a1..64090c7 100644 --- a/app/views/factions/leadership/armory/show.html.erb +++ b/app/views/factions/leadership/armory/show.html.erb @@ -112,34 +112,64 @@ + <% stats = @member_stats[member[:torn_id]] || {} %> +
    +
    + <%= stats[:loaned] || 0 %> + Loans +
    +
    + <%= stats[:returned] || 0 %> + Returns +
    +
    + <%= stats[:blood_bags] || 0 %> + Blood Bags +
    +
    + <%= stats[:filled] || 0 %> + Filled +
    +
    + <%= stats[:deposited] || 0 %> + Deposited +
    +
    + <%= stats[:used] || 0 %> + Items Used +
    +
    +
    Loan History
    <% history = @news_by_member[member[:torn_id]] || [] %> <% if history.any? %> - - - - - - - - - - <% history.each do |entry| %> +
    +
    ActionItemDate
    + - - - + + + - <% end %> - -
    - - <%= entry[:action].to_s.capitalize %> - - <%= entry[:item] %><%= Time.at(entry[:timestamp]).strftime("%d %b %Y %H:%M") %>ActionItemDate
    + + + <% history.each do |entry| %> + + + + <%= entry[:action].to_s.capitalize %> + + + <%= entry[:item] %> + <%= Time.at(entry[:timestamp]).strftime("%d %b %Y %H:%M") %> + + <% end %> + + +
    <% else %> -

    No loan activity in the last year.

    +

    No loan activity in the last 2 months.

    <% end %>
    diff --git a/db/migrate/20260407200222_fix_armory_news_entry_actions.rb b/db/migrate/20260407200222_fix_armory_news_entry_actions.rb new file mode 100644 index 0000000..ad2458c --- /dev/null +++ b/db/migrate/20260407200222_fix_armory_news_entry_actions.rb @@ -0,0 +1,25 @@ +class FixArmoryNewsEntryActions < ActiveRecord::Migration[8.1] + def up + ArmoryNewsEntry.where(action: "unknown").find_each do |entry| + plain = entry.text.gsub(/<[^>]+>/, "").strip + name = entry.player_name.to_s + after_name = plain.sub(/\A#{Regexp.escape(name)}\s*/, "") + + new_action, new_item = case after_name + when /\Agave (\d+)x (.+?) to/ + [ "loaned", $2 ] + when /\Aretrieved (\d+)x (.+?) from/ + [ "returned", $2 ] + when /\Adeposited (\d+)\s*x (.+)/ + [ "deposited", $2 ] + when /\Afilled one of the faction's (.+?) items/ + [ "filled", $1 ] + end + + entry.update_columns(action: new_action, item: new_item) if new_action + end + end + + def down + end +end diff --git a/db/schema.rb b/db/schema.rb index 8501263..10c265b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_06_195106) do +ActiveRecord::Schema[8.1].define(version: 2026_04_07_200222) do create_table "api_calls", force: :cascade do |t| t.string "api_key", null: false t.datetime "created_at", null: false From 6c3724590c9c7e295c3a718432b349f3660f9423 Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 7 Apr 2026 22:24:41 +0200 Subject: [PATCH 241/266] armoury duplicate filters --- app/assets/stylesheets/pages/armory.css | 13 ++++++ .../factions/leadership/armory/show.html.erb | 43 ++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/pages/armory.css b/app/assets/stylesheets/pages/armory.css index 3c55649..676eef0 100644 --- a/app/assets/stylesheets/pages/armory.css +++ b/app/assets/stylesheets/pages/armory.css @@ -94,6 +94,19 @@ border: 1px solid rgba(148, 163, 184, 0.3); } +/* Duplicate filter chips (off by default) */ +.armory-dup-filter { + background: rgba(148, 163, 184, 0.15); + color: rgb(148, 163, 184); + border: 1px solid rgba(148, 163, 184, 0.3); +} + +.armory-dup-filter[data-filter-active="true"] { + background: rgba(59, 130, 246, 0.15); + color: rgb(96, 165, 250); + border: 1px solid rgba(59, 130, 246, 0.3); +} + /* Slot cells */ .armory-slot-cell { vertical-align: top; diff --git a/app/views/factions/leadership/armory/show.html.erb b/app/views/factions/leadership/armory/show.html.erb index 64090c7..de5e792 100644 --- a/app/views/factions/leadership/armory/show.html.erb +++ b/app/views/factions/leadership/armory/show.html.erb @@ -46,6 +46,14 @@ Other
    +
    + Duplicates +
    + Armor + Weapons + Both +
    +
    @@ -89,9 +97,13 @@ all_items = member[:slots].values.flatten.map(&:downcase) armor_sets = %w[assault delta riot dune combat].select { |s| all_items.any? { |i| i.start_with?(s) } } armor_sets << "other" if all_items.any? { |i| %w[assault delta riot dune combat].none? { |s| i.start_with?(s) } } + armor_slots = [:head, :chest, :gloves, :pants, :boots] + weapon_slots = [:primary, :secondary, :melee] + has_dup_armor = armor_slots.any? { |s| member[:slots][s].size > 1 } + has_dup_weapons = weapon_slots.any? { |s| member[:slots][s].size > 1 } %> <% search_text = ([member[:name]] + member[:slots].values.flatten).join(" ").downcase %> - + <%= member[:name] %> @@ -212,6 +224,24 @@ window.armoryFilter(); }; + window.armoryToggleDup = function(el) { + const wasActive = el.dataset.filterActive === "true"; + + document.querySelectorAll('[data-filter-group="dup"]').forEach(e => { + e.dataset.filterActive = "false"; + e.classList.remove("filter-disabled"); + }); + + if (!wasActive) { + el.dataset.filterActive = "true"; + document.querySelectorAll('[data-filter-group="dup"][data-filter-active="false"]').forEach(e => { + e.classList.add("filter-disabled"); + }); + } + + window.armoryFilter(); + }; + window.armoryHighlight = function(text, search) { if (!search) return text; const regex = new RegExp(`(${search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); @@ -222,11 +252,20 @@ const search = document.getElementById('armory-search').value.toLowerCase(); const activeSets = [...document.querySelectorAll('[data-filter-group="set"][data-filter-active="true"]')].map(e => e.dataset.filterValue); + const activeDup = document.querySelector('[data-filter-group="dup"][data-filter-active="true"]'); + const dupValue = activeDup ? activeDup.dataset.filterValue : null; + document.querySelectorAll('.armory-row-group').forEach(el => { const matchSearch = !search || el.dataset.search.includes(search); const memberSets = (el.dataset.sets || '').split(','); const matchSet = memberSets.some(s => activeSets.includes(s)); - el.style.display = (matchSearch && matchSet) ? '' : 'none'; + + let matchDup = true; + if (dupValue === 'armor') matchDup = el.dataset.dupArmor === 'true'; + else if (dupValue === 'weapons') matchDup = el.dataset.dupWeapons === 'true'; + else if (dupValue === 'both') matchDup = el.dataset.dupArmor === 'true' && el.dataset.dupWeapons === 'true'; + + el.style.display = (matchSearch && matchSet && matchDup) ? '' : 'none'; // Highlight matching text const row = el.querySelector('tr'); From 6068d498246ef4b57f2911bd65e68e0a1906520e Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 7 Apr 2026 22:31:50 +0200 Subject: [PATCH 242/266] latest announcement --- bin/announce | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/bin/announce b/bin/announce index 75444bb..5840cd0 100755 --- a/bin/announce +++ b/bin/announce @@ -7,27 +7,16 @@ require "json" CHANNEL_ID = 1490451144818819193 # test channel embed = { - title: "Armoury & Compliance Update", + title: "Armoury Update #2", color: 0x5865F2, description: [ - "A first crack at the Armoury page and a redesigned Training Compliance table are now live! :-)", + "Some improvements to the Armoury page!", "", "**Armoury**", - "- See who has what gear loaned across all equipment slots (Head, Chest, Gloves, Pants, Boots, Primary, Secondary, Melee)", - "- Items are color-coded by armor set (Assault, Delta, Riot, Dune, Combat)", - "- Filter by armor set and search by member name or item name with highlighted results", - "- Full activity log showing loans, returns, deposits, and item usage", - "- Expandable loan history per member", - "- Activity data is backfilled from January 1st 2026 and synced daily (you can do the daily sync yourself too by pressing the Sync button in the table)", - "- Available under **Leadership > Armoury**", + "- Per-member stats when expanding a row: Loans, Returns, Blood Bags consumed, Filled, Deposited, Items Used", + "- Duplicate filter: quickly find members hoarding multiple items in the same slot (Armor, Weapons, or Both)", "", - "**note:** still invetigating if we can make the UI more interesting by analyzing the logs of a faction and showing some extra data! e.g flag who is hoarding weapons or smth", - "", - "**Training Compliance**", - "- Redesigned to fit more data on screen", - "", - "**Other**", - "- Updated landing page and legal/privacy documentation because of usage of the new api endpoints used for armoury" + "If you'd like to see additional member stats pulled from the activity logs (e.g. energy point refills used), let me know in <#1490344856134881383>!" ].join("\n") } From dba7be7e1dca64d0911facfaa869184597514251 Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 7 Apr 2026 23:02:56 +0200 Subject: [PATCH 243/266] oc payout plan calculations --- docs/oc_payouts.md | 182 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 docs/oc_payouts.md diff --git a/docs/oc_payouts.md b/docs/oc_payouts.md new file mode 100644 index 0000000..a751137 --- /dev/null +++ b/docs/oc_payouts.md @@ -0,0 +1,182 @@ +# Organized Crime Payouts + +Feature suggestion from Woke [1457203] on 2026-04-07. + +## The Problem + +Factions running level 7-8 OCs use a rotation system where high-CPR members hop between OCs in the last day, filling critical roles to maximize completions. This pushes weekly completions from 3 to 6-7. The payout model is weekly and shared: everyone who participates gets a cut regardless of whether their specific OC succeeded. High-CPR members get a bonus split. + +Torn's built-in OC payout is per-crime, which doesn't work for this rotation model. + +## Desired Payout Model + +- Weekly payout cycle +- Flat base split for all participants (e.g. 30-50m/week even if none of your OCs completed) +- Faction cut (% or flat) to cover OC item costs +- CPR-weighted bonus pool for high-skill members +- Ability to exclude members (e.g. low-skill members running level 5-6 OCs independently) + +## What the API Provides + +**Endpoint:** `/v2/faction/crimes` (faction API key required) + +Per crime: +- Name, difficulty (5-8), status (Successful/Failed/Recruiting) +- Rewards: money, items, respect +- Timestamps: created, planning, executed, ready, expired + +Per slot within a crime: +- Position name and ID (Engineer, Looter, Muscle, etc.) +- `checkpoint_pass_rate` (CPR) per member per slot +- User ID, joined_at timestamp, progress, outcome +- Item requirement: item ID, reusable flag, UID, whether consumed + +**Endpoint:** `/v2/faction/{crimeId}/crime` for individual crime details + +Supports `cat=completed` filter and pagination via `from`/`to` timestamps. + +## Notes + +- CPR is not a global stat per member. It's per-slot on each OC. We derive member CPR by averaging across their completed OCs. +- CPR increases over time as a member completes more crimes. Use recent CPR (not all-time average) for payout weighting. Tracking CPR over time could show member growth. +- Item sellback prices can come from the `/v2/torn/items` endpoint. +- Woke indicated willingness to pay "1 xan a week or 10 xan a month" for this feature. + +--- + +## Implementation Plan + +### Models + +**`OrganizedCrime`** - Stores each OC from the API +- `faction_id` (FK) +- `torn_crime_id` (integer, unique per faction) +- `name` (string) +- `difficulty` (integer, 1-8) +- `status` (string: Successful, Failed, Expired, etc.) +- `money_reward` (bigint) +- `respect_reward` (integer) +- `created_at_torn` (datetime) +- `executed_at` (datetime) +- `ready_at` (datetime) +- Indexes: `[faction_id, torn_crime_id]` unique, `[faction_id, executed_at]` + +**`OrganizedCrimeSlot`** - Each participant slot in an OC +- `organized_crime_id` (FK) +- `position` (string: Engineer, Looter, Muscle, etc.) +- `position_id` (string: P1, P2, etc.) +- `user_torn_id` (integer, nullable if unfilled) +- `checkpoint_pass_rate` (integer) +- `outcome` (string: Successful, Jailed, Injured, null) +- `joined_at` (datetime) +- `item_required_id` (integer, nullable) +- `item_reusable` (boolean) +- `item_consumed` (boolean, default false) +- Indexes: `[organized_crime_id]`, `[user_torn_id]` + +**`OcPayoutGroup`** - Groups OCs for a weekly payout cycle +- `faction_id` (FK) +- `name` (string, e.g. "Week 14 - Apr 2026") +- `week_start` (date) +- `week_end` (date) +- `total_money` (bigint, sum of OC money rewards) +- `total_respect` (integer) +- `faction_cut_percent` (decimal) +- `cpr_bonus_percent` (decimal) +- `paid` (boolean, default false) +- `paid_at` (datetime, nullable) +- Indexes: `[faction_id, week_start]` + +**`OcPayoutMember`** - Per-member payout within a group +- `oc_payout_group_id` (FK) +- `user_torn_id` (integer) +- `ocs_participated` (integer) +- `avg_cpr` (decimal) +- `base_payout` (bigint) +- `cpr_bonus` (bigint) +- `total_payout` (bigint) +- Indexes: `[oc_payout_group_id, user_torn_id]` + +**`FactionSetting` additions** (existing model) +- `oc_payout_enabled` (boolean) +- `oc_min_difficulty` (integer, default 7, filter crimes below this) +- `oc_faction_cut_percent` (decimal, default 10) +- `oc_cpr_bonus_percent` (decimal, default 20) +- `oc_excluded_member_ids` (json, array of torn_ids to exclude) + +### Routes + +``` +resource :organized_crimes, only: [:show], controller: "factions/leadership/organized_crimes" do + post :sync + post :create_payout_group + patch :update_payout_group + patch :mark_paid +end +``` + +All under `factions/:faction_torn_id/leadership/`. + +### Sync Strategy + +**`Daily::OrganizedCrimeSyncJob`** - Runs daily at 6am UTC +- Fetches `/v2/faction/crimes?cat=completed` paginating from the latest stored `executed_at` +- Upserts `OrganizedCrime` + `OrganizedCrimeSlot` records +- Uses `insert_all` with `unique_by` on `[faction_id, torn_crime_id]` + +**`BackfillOrganizedCrimesJob`** - One-time, walks backwards like armory backfill +- Triggered on faction setup and from admin panel +- Fetches historical completed OCs + +**`FetchOrganizedCrimesJob`** - Incremental fetcher (same pattern as `FetchArmoryNewsJob`) +- Extends `FactionApiJob` with rate limiting and concurrency control + +No need to store active/recruiting OCs. Only completed (and failed) matter for payouts and CPR tracking. + +### What to Show + +**OC Overview Page** (`/factions/:id/leadership/organized_crimes`) + +1. **CPR Leaderboard** - Table of members sorted by recent average CPR + - Columns: Member, Avg CPR (last 30 days), OCs Completed, Roles (most common), CPR Trend (up/down) + - Expandable rows showing per-OC CPR history + +2. **Weekly OC Summary** - Grouped by week + - Number of OCs completed/failed + - Total money earned, total respect + - Item costs (non-reusable items consumed x sellback price) + - Net value (money - item costs) + +3. **Payout Calculator** - For a selected week or date range + - Configure: faction cut %, CPR bonus pool %, minimum difficulty + - Exclude specific members + - Shows per-member breakdown: base share, CPR bonus, total payout + - "Mark as Paid" button + - Export to clipboard (same pattern as ranked war payouts) + +4. **OC History Table** - Scrollable list of all completed OCs + - Columns: Name, Difficulty, Status, Money, Respect, Participants, Date + - Expandable rows showing slot details with CPR per member + +### Item Cost Calculation + +For consumed non-reusable items, fetch sellback prices from `/v2/torn/items`. Cache item prices (they rarely change). Sum consumed item costs per OC and subtract from the reward pool before splitting. + +### Payout Math + +Given a payout group with total_money (after faction cut and item costs): + +``` +net_pool = total_money - (total_money * faction_cut_percent / 100) - item_costs +base_pool = net_pool * (1 - cpr_bonus_percent / 100) +bonus_pool = net_pool * cpr_bonus_percent / 100 + +# Base split: equal share for all participants +base_per_member = base_pool / participating_members.count + +# CPR bonus: weighted by each member's average CPR +total_cpr = participating_members.sum(&:avg_cpr) +member_cpr_bonus = (member.avg_cpr / total_cpr) * bonus_pool + +member_total = base_per_member + member_cpr_bonus +``` From afc069c92dfcb2055f07c6820325942e390cddad Mon Sep 17 00:00:00 2001 From: Bram Date: Wed, 8 Apr 2026 21:09:54 +0200 Subject: [PATCH 244/266] error handling in jobs --- app/jobs/backfill_single_stat_job.rb | 2 +- app/jobs/fetch_personal_stats_job.rb | 25 +++++++++++++++++++++- app/models/torn_api.rb | 8 ++++++- test/jobs/fetch_personal_stats_job_test.rb | 17 +++++++++++++++ 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/app/jobs/backfill_single_stat_job.rb b/app/jobs/backfill_single_stat_job.rb index a39decd..6466314 100644 --- a/app/jobs/backfill_single_stat_job.rb +++ b/app/jobs/backfill_single_stat_job.rb @@ -27,7 +27,7 @@ def fetch_stats(user, date, api_key, batch) timestamp: date.end_of_day.to_i, stat_batch: stat_batch ).fetch - rescue TornApi::ApiError => e + rescue TornApi::ApiError, TornApi::InvalidKeyError => e Rails.logger.error("API error fetching stats: #{e.message}") nil end diff --git a/app/jobs/fetch_personal_stats_job.rb b/app/jobs/fetch_personal_stats_job.rb index e1a4327..e85b7d1 100644 --- a/app/jobs/fetch_personal_stats_job.rb +++ b/app/jobs/fetch_personal_stats_job.rb @@ -1,11 +1,34 @@ class FetchPersonalStatsJob < AdminApiJob - def perform(user, batch: 1, stats_date: Date.current.yesterday) + MAX_RETRIES = 3 + + def perform(user, batch: 1, stats_date: Date.current.yesterday, retries: 0) stats = fetch_stats(user, batch, stats_date) user.check_hof_eligibility!(stats[:items_used_stat_enhancers]) if batch == 1 save_snapshot(user, stats, stats_date) FetchPersonalStatsJob.perform_later(user, batch: 2, stats_date: stats_date) if batch == 1 + rescue TornApi::InvalidKeyError => e + if retries < MAX_RETRIES + Rails.logger.warn("FetchPersonalStatsJob: Failed for #{user.name} (#{user.torn_id}): #{e.message}, retry #{retries + 1}/#{MAX_RETRIES} in 1 hour") + FetchPersonalStatsJob.set(wait: 1.hour).perform_later(user, batch: batch, stats_date: stats_date, retries: retries + 1) + else + Rails.logger.error("FetchPersonalStatsJob: Giving up on #{user.name} (#{user.torn_id}) after #{MAX_RETRIES} retries: #{e.message}") + Discord::Notifier.notify( + webhook_key: :error_webhook_url, + embed: { + title: "Personal Stats Fetch Failed", + description: "Gave up after #{MAX_RETRIES} retries.\n```#{e.message}```", + color: 15_158_332, + fields: [ + { name: "User", value: "#{user.name} [#{user.torn_id}]", inline: true }, + { name: "Date", value: stats_date.to_s, inline: true } + ], + footer: { text: "TornManager Error Reporter" }, + timestamp: Time.current.iso8601 + } + ) + end end private diff --git a/app/models/torn_api.rb b/app/models/torn_api.rb index da80764..329d5f6 100644 --- a/app/models/torn_api.rb +++ b/app/models/torn_api.rb @@ -178,6 +178,11 @@ def log_api_call(endpoint, params, status, response_time, metadata, error_messag end def notify_discord_error(path, params, error) + cache_key = "discord_api_error:#{error.class}:#{sanitize_path(path)}" + return if Rails.cache.exist?(cache_key) + + Rails.cache.write(cache_key, true, expires_in: 10.minutes) + selections = params[:selections] owner = resolve_api_key_owner owner_label = if owner @@ -194,9 +199,10 @@ def notify_discord_error(path, params, error) description: "```#{error.message}```", color: 15_158_332, fields: [ - { name: "Endpoint", value: path, inline: true }, + { name: "Endpoint", value: sanitize_path(path), inline: true }, { name: "Selections", value: selections || "N/A", inline: true }, { name: "Owner", value: owner_label, inline: true }, + { name: "Key", value: "#{api_key[0..7]}...", inline: true }, { name: "Environment", value: Rails.env, inline: true } ], footer: { text: "TornManager API Monitor" }, diff --git a/test/jobs/fetch_personal_stats_job_test.rb b/test/jobs/fetch_personal_stats_job_test.rb index 933fc17..482c0eb 100644 --- a/test/jobs/fetch_personal_stats_job_test.rb +++ b/test/jobs/fetch_personal_stats_job_test.rb @@ -57,6 +57,23 @@ class FetchPersonalStatsJobTest < ActiveJob::TestCase refute @user.reload.hof_stats_user end + test "retries on InvalidKeyError" do + TornApi::User::PersonalStats.any_instance.stubs(:fetch).raises(TornApi::InvalidKeyError, "auth failed") + + assert_enqueued_with(job: FetchPersonalStatsJob) do + FetchPersonalStatsJob.perform_now(@user, batch: 1, stats_date: @stats_date, retries: 0) + end + end + + test "gives up after max retries on InvalidKeyError" do + TornApi::User::PersonalStats.any_instance.stubs(:fetch).raises(TornApi::InvalidKeyError, "auth failed") + Discord::Notifier.expects(:notify).once + + assert_no_enqueued_jobs(only: FetchPersonalStatsJob) do + FetchPersonalStatsJob.perform_now(@user, batch: 1, stats_date: @stats_date, retries: 3) + end + end + test "hof eligibility check only runs on batch 1" do stats_with_enhancers = @batch2_stats.merge(items_used_stat_enhancers: 500) TornApi::User::PersonalStats.any_instance.stubs(:fetch).returns(stats_with_enhancers) From c4b542aec99721051adf59d1e5607bd2d1d608c0 Mon Sep 17 00:00:00 2001 From: Bram Date: Wed, 8 Apr 2026 23:39:33 +0200 Subject: [PATCH 245/266] predict --- .gitignore | 6 + Gemfile | 1 + Gemfile.lock | 11 ++ app/assets/stylesheets/pages/admin.css | 128 +++++++++++++ app/controllers/admin/recon_controller.rb | 41 +++++ app/models/recon/feature_set.rb | 23 +++ app/models/recon/predictor.rb | 40 ++++ .../admin/recon/_predict_result.html.erb | 30 +++ app/views/admin/recon/show.html.erb | 16 ++ config/routes.rb | 1 + lib/recon/requirements.txt | 3 + lib/recon/train_model.py | 171 ++++++++++++++++++ lib/tasks/recon.rake | 20 ++ 13 files changed, 491 insertions(+) create mode 100644 app/models/recon/predictor.rb create mode 100644 app/views/admin/recon/_predict_result.html.erb create mode 100644 lib/recon/requirements.txt create mode 100644 lib/recon/train_model.py create mode 100644 lib/tasks/recon.rake diff --git a/.gitignore b/.gitignore index 45361e2..ee02d6a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,9 @@ # Ignore key files for decrypting credentials and more. /config/credentials/*.key +# Python venv for ML training +/lib/recon/.venv/ + +# Trained ONNX model (regenerate with: rake recon:train) +/lib/recon/model.onnx + diff --git a/Gemfile b/Gemfile index de9e62d..25db408 100644 --- a/Gemfile +++ b/Gemfile @@ -43,6 +43,7 @@ gem "thruster", require: false gem "image_processing", "~> 1.2" gem "appsignal" +gem "onnxruntime" gem "discordrb", require: false group :development, :test do diff --git a/Gemfile.lock b/Gemfile.lock index d5ff82a..dad473b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -246,6 +246,16 @@ GEM racc (~> 1.4) nokogiri (1.19.2-x86_64-linux-musl) racc (~> 1.4) + onnxruntime (0.11.1) + ffi + onnxruntime (0.11.1-aarch64-linux) + ffi + onnxruntime (0.11.1-arm64-darwin) + ffi + onnxruntime (0.11.1-x86_64-darwin) + ffi + onnxruntime (0.11.1-x86_64-linux) + ffi opus-ruby (1.0.1) ffi ostruct (0.6.3) @@ -460,6 +470,7 @@ DEPENDENCIES kamal mission_control-jobs mocha + onnxruntime propshaft puma (>= 5.0) rails (~> 8.1.1) diff --git a/app/assets/stylesheets/pages/admin.css b/app/assets/stylesheets/pages/admin.css index f22e60b..c5bd0cb 100644 --- a/app/assets/stylesheets/pages/admin.css +++ b/app/assets/stylesheets/pages/admin.css @@ -983,3 +983,131 @@ .admin-sub-info strong { color: var(--color-text); } + +/* Recon Predict */ +.recon-predict-card { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: var(--radius-lg); + padding: var(--space-6); + margin-bottom: var(--space-6); +} + +.recon-predict-card h2 { + margin: 0 0 var(--space-2); + font-size: 1.125rem; +} + +.predict-form { + margin-top: var(--space-4); +} + +.predict-input-row { + display: flex; + gap: var(--space-3); + align-items: center; +} + +.predict-input { + background: var(--gray-1000); + border: 1px solid var(--gray-800); + border-radius: var(--radius-md); + color: var(--gray-200); + font-size: 0.875rem; + padding: var(--space-2) var(--space-3); + width: 180px; +} + +.predict-input:focus { + outline: none; + border-color: var(--gray-600); +} + +.predict-input::placeholder { + color: var(--gray-600); +} + +.predict-error { + margin-top: var(--space-4); + padding: var(--space-3) var(--space-4); + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: var(--radius-md); + color: #fca5a5; + font-size: 0.8125rem; +} + +.predict-success { + margin-top: var(--space-4); +} + +.predict-main { + padding: var(--space-4) var(--space-5); + background: rgba(34, 197, 94, 0.05); + border: 1px solid rgba(34, 197, 94, 0.2); + border-radius: var(--radius-md); +} + +.predict-label { + font-size: 0.75rem; + color: var(--gray-400); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--space-1); +} + +.predict-value { + font-size: 1.75rem; + font-weight: 700; + color: #4ade80; + font-variant-numeric: tabular-nums; +} + +.predict-player { + margin-top: var(--space-2); + font-size: 0.8125rem; + color: var(--gray-400); + display: flex; + align-items: center; + gap: var(--space-3); +} + +.predict-meta { + color: var(--gray-500); +} + +.predict-features-details { + margin-top: var(--space-3); +} + +.predict-features-details summary { + font-size: 0.75rem; + color: var(--gray-500); + cursor: pointer; + user-select: none; +} + +.predict-features { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: var(--space-2); + margin-top: var(--space-3); +} + +.predict-feature { + display: flex; + justify-content: space-between; + padding: var(--space-1) var(--space-2); + background: var(--gray-1000); + border-radius: var(--radius-sm); + font-size: 0.75rem; +} + +.predict-feature-label { + color: var(--gray-400); +} + +.predict-feature-value { + color: var(--gray-200); + font-variant-numeric: tabular-nums; +} diff --git a/app/controllers/admin/recon_controller.rb b/app/controllers/admin/recon_controller.rb index c677ebc..e98e69e 100644 --- a/app/controllers/admin/recon_controller.rb +++ b/app/controllers/admin/recon_controller.rb @@ -82,6 +82,14 @@ def stats private + def fetch_personalstats_for_predict(api_key, torn_id) + batches = Recon::FeatureSet::API_STAT_NAMES.each_slice(10).to_a + batches.reduce({}) do |result, batch| + stats = Recon::TornApi::PersonalStats.new(api_key, torn_id, stats: batch, timestamp: Time.now.to_i).fetch + result.merge(stats) + end + end + def compute_distribution(col, values) sorted = values.sort count = sorted.size @@ -114,6 +122,39 @@ def compute_distribution(col, values) public + def predict + torn_id = params[:torn_id].to_s.strip + if torn_id.blank? + return render turbo_stream: turbo_stream.replace("predict-result", + partial: "admin/recon/predict_result", locals: { error: "Please enter a Torn ID." }) + end + + api_key = AdminCredentials.api_key + unless api_key + return render turbo_stream: turbo_stream.replace("predict-result", + partial: "admin/recon/predict_result", locals: { error: "Admin API key not configured." }) + end + + unless Recon::Predictor.trained? + return render turbo_stream: turbo_stream.replace("predict-result", + partial: "admin/recon/predict_result", locals: { error: "Model not trained. Run: rake recon:train" }) + end + + personalstats = fetch_personalstats_for_predict(api_key, torn_id) + profile = Recon::TornApi::Profile.new(api_key, torn_id).fetch + features = Recon::FeatureSet.build(personalstats: personalstats, profile: profile) + + predictor = Recon::Predictor.new + prediction = predictor.predict(features) + + render turbo_stream: turbo_stream.replace("predict-result", + partial: "admin/recon/predict_result", + locals: { prediction: prediction, torn_id: torn_id, features: features, profile: profile, error: nil }) + rescue TornApi::ApiError, TornApi::InvalidKeyError, TornApi::NotFoundError => e + render turbo_stream: turbo_stream.replace("predict-result", + partial: "admin/recon/predict_result", locals: { error: "API error: #{e.message}" }) + end + def import raw_data = params[:spy_data] diff --git a/app/models/recon/feature_set.rb b/app/models/recon/feature_set.rb index dbc5966..f8f062b 100644 --- a/app/models/recon/feature_set.rb +++ b/app/models/recon/feature_set.rb @@ -57,6 +57,29 @@ def self.build(personalstats:, profile:) features["property_happy"] = resolve_property_happy(profile.property) features["real_age"] = calculate_real_age(profile.age, profile.last_action_timestamp) + add_engineered_features(features) + + features + end + + def self.add_engineered_features(features) + age = [ features["real_age"], 1 ].max.to_f + xan = (features["xantaken"] || 0).to_f + refills = (features["refills"] || 0).to_f + edrink = (features["energydrinkused"] || 0).to_f + se = (features["statenhancersused"] || 0).to_f + boosters = (features["boostersused"] || 0).to_f + + total_energy = xan * 250 + refills * 150 + edrink * 100 + + features["xan_per_day"] = xan / age + features["refills_per_day"] = refills / age + features["edrink_per_day"] = edrink / age + features["se_per_day"] = se / age + features["total_energy"] = total_energy + features["energy_per_day"] = total_energy / age + features["boosters_per_day"] = boosters / age + features["has_se"] = se >= 3 ? 1.0 : 0.0 features end diff --git a/app/models/recon/predictor.rb b/app/models/recon/predictor.rb new file mode 100644 index 0000000..ddf6a8d --- /dev/null +++ b/app/models/recon/predictor.rb @@ -0,0 +1,40 @@ +require "onnxruntime" + +class Recon::Predictor + MODEL_PATH = Rails.root.join("lib/recon/model.onnx") + + # Must match ALL_FEATURES order in train_model.py + FEATURE_ORDER = ( + Recon::TrainingSample::FEATURE_COLUMNS + %w[ + xan_per_day refills_per_day edrink_per_day se_per_day + total_energy energy_per_day boosters_per_day has_se + ] + ).freeze + + # Must match LOG_FEATURES in train_model.py + LOG_FEATURES = %w[ + xantaken energydrinkused statenhancersused boostersused + lsdtaken revives exttaken victaken rehabs + attackswon networth hospital + total_energy + ].to_set.freeze + + def initialize + raise "Model not found at #{MODEL_PATH}. Run: rake recon:train" unless MODEL_PATH.exist? + + @model = OnnxRuntime::Model.new(MODEL_PATH.to_s) + end + + def predict(features) + input = FEATURE_ORDER.map do |f| + val = (features[f] || 0).to_f + LOG_FEATURES.include?(f) ? Math.log1p([ val, 0 ].max) : val + end + log_prediction = @model.predict({ "features" => [input] }).values.first.flatten.first + Math.expm1(log_prediction).round.to_i.clamp(0..) + end + + def self.trained? + MODEL_PATH.exist? + end +end diff --git a/app/views/admin/recon/_predict_result.html.erb b/app/views/admin/recon/_predict_result.html.erb new file mode 100644 index 0000000..5595ab5 --- /dev/null +++ b/app/views/admin/recon/_predict_result.html.erb @@ -0,0 +1,30 @@ +
    + <% if local_assigns[:error] && error.present? %> +
    <%= error %>
    + <% elsif local_assigns[:prediction] %> +
    +
    +
    Estimated Total Battle Stats
    +
    <%= number_with_delimiter(prediction) %>
    +
    + + Player <%= torn_id %> + + Level <%= features["level"] %> +
    +
    + +
    + Feature values used +
    + <% features.sort_by { |k, _| Recon::TrainingSample::FEATURE_COLUMNS.index(k) || 99 }.each do |key, value| %> +
    + <%= key %> + <%= number_with_delimiter(value) %> +
    + <% end %> +
    +
    +
    + <% end %> +
    diff --git a/app/views/admin/recon/show.html.erb b/app/views/admin/recon/show.html.erb index 3702c26..54033e2 100644 --- a/app/views/admin/recon/show.html.erb +++ b/app/views/admin/recon/show.html.erb @@ -19,6 +19,22 @@
    +<% if Recon::Predictor.trained? %> +
    +

    Predict Battle Stats

    +

    Enter a Torn player ID to estimate their total battle stats using the trained model.

    + + <%= form_with url: predict_admin_recon_path, method: :post, class: "predict-form", data: { turbo_stream: true } do |form| %> +
    + <%= form.text_field :torn_id, placeholder: "Torn ID", class: "predict-input", autofocus: true, inputmode: "numeric" %> + <%= form.submit "Predict", class: "btn btn-primary" %> +
    + <% end %> + +
    +
    +<% end %> + <% if @import_in_progress %> <% seconds_remaining = (@import_ends_at - Time.current).to_i %>
    diff --git a/config/routes.rb b/config/routes.rb index 4892202..5ab9e7f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -118,6 +118,7 @@ resource :recon, only: [ :show ], controller: "recon" do post :import get :stats + post :predict end end diff --git a/lib/recon/requirements.txt b/lib/recon/requirements.txt new file mode 100644 index 0000000..9af4005 --- /dev/null +++ b/lib/recon/requirements.txt @@ -0,0 +1,3 @@ +scikit-learn>=1.5 +onnx>=1.16 +skl2onnx>=1.17 diff --git a/lib/recon/train_model.py b/lib/recon/train_model.py new file mode 100644 index 0000000..ecb5731 --- /dev/null +++ b/lib/recon/train_model.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Train a Random Forest model on recon training samples and export to ONNX.""" + +import sqlite3 +import sys +from pathlib import Path + +import numpy as np +from sklearn.ensemble import RandomForestRegressor +from sklearn.model_selection import cross_val_score +from skl2onnx import convert_sklearn +from skl2onnx.common.data_types import FloatTensorType + +# Raw DB columns fetched from the database +DB_FEATURE_COLUMNS = [ + "xantaken", "energydrinkused", "refills", "daysbeendonator", + "statenhancersused", "boostersused", "lsdtaken", "revives", + "exttaken", "victaken", "rehabs", "highestbeaten", "hospital", + "jobpointsused", "trainsreceived", "attackswon", "awards", + "useractivity", "networth", "level", "property_happy", "real_age", +] + +# Engineered features appended after DB columns +ENGINEERED_FEATURES = [ + "xan_per_day", # xantaken / real_age — daily xan rate + "refills_per_day", # refills / real_age — daily refill rate + "edrink_per_day", # energydrinkused / real_age — daily energy drink rate + "se_per_day", # statenhancersused / real_age — daily SE rate + "total_energy", # xantaken * 250 + refills * 150 + energydrinkused * 100 — estimated lifetime energy from items + "energy_per_day", # total_energy / real_age — daily energy from items + "boosters_per_day", # boostersused / real_age + "has_se", # binary: statenhancersused > 0 +] + +# Full feature list as seen by the model (must match Ruby's Predictor) +ALL_FEATURES = DB_FEATURE_COLUMNS + ENGINEERED_FEATURES + +LABEL_COLUMNS = ["strength", "defense", "speed", "dexterity"] + +# Features with heavy-tailed distributions that benefit from log transform +LOG_FEATURES = { + "xantaken", "energydrinkused", "statenhancersused", "boostersused", + "lsdtaken", "revives", "exttaken", "victaken", "rehabs", + "attackswon", "networth", "hospital", + "total_energy", +} + +MIN_SAMPLES = 100 + + +def add_engineered_features(X, columns): + """Compute engineered features and append as new columns.""" + col_idx = {name: i for i, name in enumerate(columns)} + + xan = X[:, col_idx["xantaken"]] + refills = X[:, col_idx["refills"]] + edrink = X[:, col_idx["energydrinkused"]] + se = X[:, col_idx["statenhancersused"]] + boosters = X[:, col_idx["boostersused"]] + age = np.maximum(X[:, col_idx["real_age"]], 1) # avoid division by zero + + total_energy = xan * 250 + refills * 150 + edrink * 100 + + engineered = np.column_stack([ + xan / age, # xan_per_day + refills / age, # refills_per_day + edrink / age, # edrink_per_day + se / age, # se_per_day + total_energy, # total_energy + total_energy / age, # energy_per_day + boosters / age, # boosters_per_day + (se >= 3).astype(np.float32), # has_se (>=3 to exclude merit-only users) + ]) + + return np.hstack([X, engineered]) + + +def log1p_transform(X, columns): + """Apply log1p to skewed feature columns (in-place).""" + for i, col in enumerate(columns): + if col in LOG_FEATURES: + X[:, i] = np.log1p(X[:, i]) + return X + + +def main(): + db_path = sys.argv[1] if len(sys.argv) > 1 else "storage/development.sqlite3" + output_path = sys.argv[2] if len(sys.argv) > 2 else "lib/recon/model.onnx" + + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + + rows = conn.execute( + f"SELECT {', '.join(DB_FEATURE_COLUMNS + LABEL_COLUMNS)} " + "FROM recon_training_samples WHERE xantaken IS NOT NULL" + ).fetchall() + conn.close() + + if len(rows) < MIN_SAMPLES: + print(f"Only {len(rows)} samples (need {MIN_SAMPLES}). Skipping.", file=sys.stderr) + sys.exit(1) + + X = np.array([[max(row[col] or 0, 0) for col in DB_FEATURE_COLUMNS] for row in rows], dtype=np.float32) + y = np.array([max(sum(row[col] or 0 for col in LABEL_COLUMNS), 0) for row in rows], dtype=np.float32) + + n_before = len(y) + + # Remove zero-stat samples (missing spy data, not real zeroes) + nonzero = y > 0 + X, y = X[nonzero], y[nonzero] + + # Remove extremely unbalanced builds (one stat >95% of total) — likely data errors + stats = np.array([[max(row[col] or 0, 0) for col in LABEL_COLUMNS] for row in rows], dtype=np.float32) + stats = stats[nonzero] + max_stat_ratio = stats.max(axis=1) / np.maximum(y, 1) + balanced = max_stat_ratio <= 0.95 + X, y, stats = X[balanced], y[balanced], stats[balanced] + + # Remove outliers: clip top/bottom 1% by log(total_stats) + y_log_raw = np.log1p(y) + p1, p99 = np.percentile(y_log_raw, 1), np.percentile(y_log_raw, 99) + in_range = (y_log_raw >= p1) & (y_log_raw <= p99) + X, y = X[in_range], y[in_range] + + n_removed = n_before - len(y) + print(f"Removed {n_removed} samples ({n_removed*100/n_before:.1f}%): " + f"zero-stat, unbalanced, or outlier") + + # Add engineered features before transforms + X = add_engineered_features(X, DB_FEATURE_COLUMNS) + + # Log-transform skewed features + X = log1p_transform(X, ALL_FEATURES) + + # Log-transform target (inverse in Ruby via exp) + y_log = np.log1p(y) + + print(f"Training on {len(y)} samples, {len(ALL_FEATURES)} features") + print(f"Target range: {y.min():.0f} – {y.max():.0f} (log: {y_log.min():.2f} – {y_log.max():.2f})") + + model = RandomForestRegressor( + n_estimators=200, + max_depth=10, + min_samples_leaf=10, + n_jobs=-1, + random_state=42, + ) + model.fit(X, y_log) + + scores = cross_val_score(model, X, y_log, cv=5, scoring="r2") + print(f"Cross-val R² (log-space): {scores.mean():.4f} (±{scores.std():.4f})") + + importances = sorted( + zip(ALL_FEATURES, model.feature_importances_), key=lambda x: -x[1] + ) + print("Top features:") + for name, imp in importances[:15]: + print(f" {name}: {imp:.4f}") + + initial_type = [("features", FloatTensorType([None, len(ALL_FEATURES)]))] + onnx_model = convert_sklearn(model, initial_types=initial_type) + + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "wb") as f: + f.write(onnx_model.SerializeToString()) + + print(f"Model saved to {output_path}") + + +if __name__ == "__main__": + main() diff --git a/lib/tasks/recon.rake b/lib/tasks/recon.rake new file mode 100644 index 0000000..e8a2b17 --- /dev/null +++ b/lib/tasks/recon.rake @@ -0,0 +1,20 @@ +namespace :recon do + desc "Train the recon predictor model (Python + scikit-learn → ONNX)" + task train: :environment do + venv_python = Rails.root.join("lib/recon/.venv/bin/python3") + script = Rails.root.join("lib/recon/train_model.py") + db_path = ActiveRecord::Base.connection_db_config.database + output = Rails.root.join("lib/recon/model.onnx") + + unless venv_python.exist? + puts "Setting up Python venv..." + system("python3", "-m", "venv", venv_python.dirname.dirname.to_s, exception: true) + system(venv_python.dirname.join("pip").to_s, "install", "-r", + Rails.root.join("lib/recon/requirements.txt").to_s, exception: true) + end + + puts "Training model..." + system(venv_python.to_s, script.to_s, db_path.to_s, output.to_s, exception: true) + puts "Done! Model at #{output}" + end +end From 5603aa18510942e25892c5740e80bb8ab19c3740 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:47:55 +0000 Subject: [PATCH 246/266] Bump selenium-webdriver from 4.41.0 to 4.42.0 Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.41.0 to 4.42.0. - [Release notes](https://github.com/SeleniumHQ/selenium/releases) - [Changelog](https://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES) - [Commits](https://github.com/SeleniumHQ/selenium/compare/selenium-4.41.0...selenium-4.42.0) --- updated-dependencies: - dependency-name: selenium-webdriver dependency-version: 4.42.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index d5ff82a..fc79fa7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -357,7 +357,7 @@ GEM ruby2_keywords (0.0.5) rubyzip (3.2.2) securerandom (0.4.1) - selenium-webdriver (4.41.0) + selenium-webdriver (4.42.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) From b6d05cf183f269dac9682b02d0198788a2490d78 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:48:10 +0000 Subject: [PATCH 247/266] Bump puma from 7.2.0 to 8.0.0 Bumps [puma](https://github.com/puma/puma) from 7.2.0 to 8.0.0. - [Release notes](https://github.com/puma/puma/releases) - [Changelog](https://github.com/puma/puma/blob/main/History.md) - [Commits](https://github.com/puma/puma/compare/v7.2.0...v8.0.0) --- updated-dependencies: - dependency-name: puma dependency-version: 8.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index d5ff82a..ded5b6d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -265,7 +265,7 @@ GEM date stringio public_suffix (7.0.2) - puma (7.2.0) + puma (8.0.0) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) From 704280cc8222c22cb71f0e63270a28c7da46ba7e Mon Sep 17 00:00:00 2001 From: Bram Date: Thu, 9 Apr 2026 20:12:19 +0200 Subject: [PATCH 248/266] .. --- lib/recon/README.md | 136 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 lib/recon/README.md diff --git a/lib/recon/README.md b/lib/recon/README.md new file mode 100644 index 0000000..959889d --- /dev/null +++ b/lib/recon/README.md @@ -0,0 +1,136 @@ +# Recon: Battle Stat Predictor + +Predicts a Torn player's total battle stats (STR + DEF + SPD + DEX) from publicly available personalstats and profile data. + +## Architecture + +Training happens in Python (scikit-learn), inference in Ruby (ONNX Runtime). + +``` +lib/recon/train_model.py # Python: trains Random Forest, exports ONNX +lib/recon/model.onnx # Trained model artifact (gitignored) +lib/recon/.venv/ # Python venv (gitignored) +lib/recon/requirements.txt # Python deps: scikit-learn, onnx, skl2onnx +lib/tasks/recon.rake # rake recon:train + +app/models/recon/predictor.rb # Ruby: loads ONNX, runs inference +app/models/recon/feature_set.rb # Ruby: builds feature vector from API data +app/models/recon/training_sample.rb # ActiveRecord model for training data +``` + +## How to train + +```bash +rake recon:train +# or directly: +lib/recon/.venv/bin/python3 lib/recon/train_model.py storage/development.sqlite3 lib/recon/model.onnx +``` + +Training takes ~5 seconds on 11k samples (vs 5+ minutes with the old pure-Ruby Rumale approach). + +## How prediction works + +1. Fetch player's personalstats + profile via Torn API (admin key) +2. `Recon::FeatureSet.build` creates a feature hash (22 DB columns + 8 engineered features) +3. `Recon::Predictor` applies log transforms, runs ONNX inference, inverts with `expm1` + +The UI is at `/admin/recon` — enter a Torn ID, get a prediction via Turbo Stream. + +## Model details + +- **Algorithm**: Random Forest Regressor (200 trees, max_depth=10, min_samples_leaf=10) +- **Target**: `log1p(total_stats)` — log-space regression, inverted at prediction time +- **Cross-val R²**: 0.957 (log-space) +- **Training samples**: ~11,100 (after cleaning) + +### Features (30 total) + +22 raw DB features from personalstats/profile + 8 engineered: + +| Feature | Description | Importance | +|---|---|---| +| total_energy | xan*250 + refills*150 + edrink*100 | 86% | +| awards | Total awards earned | 7.3% | +| daysbeendonator | Donator days | 3.4% | +| jobpointsused | Job points used | 0.5% | +| xantaken | Xanax consumed | 0.4% | +| refills | Energy refills | 0.3% | +| ... | 24 others | <0.3% each | + +`total_energy` dominates because it directly measures lifetime training energy (xan, refills, energy drinks each give fixed energy amounts). + +### Log transforms + +Applied to heavy-tailed features before training: xantaken, energydrinkused, statenhancersused, boostersused, lsdtaken, revives, exttaken, victaken, rehabs, attackswon, networth, hospital, total_energy. Both Python and Ruby must apply the same transforms. + +### Data cleaning (removes ~3.6% of samples) + +1. **Zero-stat samples** (79): spy records where actual stats weren't captured — high-level players with 0 total stats +2. **Unbalanced builds** (106): one stat >95% of total — likely data errors +3. **Outliers**: top/bottom 1% by log(total_stats) + +## Known issues and limitations + +### Temporal gap (biggest source of error) +Training data pairs spy-time stats with spy-time personalstats. Live predictions use *current* personalstats, which are always higher than at spy time. This causes systematic overestimation, typically 5-20% depending on how much the player has grown since their features last matched the training distribution. + +### Stat enhancer users distort predictions +Players with SE >= 3 have ~7x the stats of SE=0 players at the same energy level. The model blends them, pulling predictions up for non-SE players. A `has_se` binary feature (SE >= 3, to exclude merit-only users) was added but `total_energy` dominates so heavily (86%) that the tree splits on energy first and the SE signal gets lost. The SE=1-2 group (merit hunters) also has 2x stats vs SE=0. + +### Sparse data ranges +The training data is heavily concentrated in the 1B-10B stat range (54% of samples). Sparse areas: + +| Stat Range | Count | Status | +|---|---|---| +| 50M - 100M | 193 | SPARSE | +| 25B - 50B | 123 | SPARSE | +| 50B+ | 147 | SPARSE | +| 10M - 50M | 469 | Thin | +| 100M - 250M | 379 | Thin | +| 250M - 500M | 449 | Thin | + +More spy data in the 10M-500M and 25B+ ranges would improve predictions significantly. + +### Real-world comparison (2026-04-08) + +Tested against real spies, BSP (userscript), and FFScouter estimates: + +| Player | Real Spy | BSP | FFScouter | Ours | Our Error | +|---|---|---|---|---|---| +| 2684760 | 5.49B | 5.3B (3.5% under) | 5.61B (2.1% over) | 5.66B | 3.0% over | +| 2445730 | 5.62B | 5.8B (3.2% over) | 5.81B (3.4% over) | 6.01B | 6.9% over | + +Model is competitive in the 1B-10B range (bulk of training data). Weaker in sparse ranges. + +### Min-maxers +Players who optimize stat growth efficiency have unusual energy-to-stats ratios. The model predicts the average relationship, which can be 20%+ off for these players. + +## Next steps to explore + +These were discussed but not yet implemented: + +1. **FFScouter API validation** — FFScouter (ffscouter.com) is an external service that also estimates battle stats. Their API (`GET /api/v1/get-stats?key=...&targets=...`) returns `bs_estimate` for players. Could be used to: + - **Validate**: compare both models' accuracy against known spy data + - **Feature**: use their estimate as an input feature (adds dependency) + - **Ensemble**: average predictions at inference time + - Do NOT use as training data (would learn their model's biases, not ground truth) + - Rate limit: 20 req/min, up to 205 targets per request + - Requires API key registration + +2. **Tier segmentation** — train separate models for different stat brackets (e.g., sub-1B, 1B-10B, 10B+). Each model would learn tighter relationships within its range. + +3. **More feature engineering** — ratios like `attackswon / level`, `networth / level`. Drop-one analysis showed most current features add negligible value; only daysbeendonator, total_energy, attackswon, and awards meaningfully contribute. + +4. **Feature selection** — the "Top 10" feature set (total_energy, awards, daysbeendonator, jobpointsused, xantaken, refills, attackswon, networth, highestbeaten, xan_per_day) achieves R²=0.9556 — nearly identical to all 30 features. Fewer features = less noise. + +5. **Alternative models** — GradientBoosting was tested but performed worse for edge cases (R²=0.78, worse calibration for specific players). Random Forest was better overall. + +## Contract between Python and Ruby + +The feature list and transforms must stay in sync across three files: + +- `lib/recon/train_model.py` — `ALL_FEATURES`, `LOG_FEATURES`, `add_engineered_features()` +- `app/models/recon/predictor.rb` — `FEATURE_ORDER`, `LOG_FEATURES` +- `app/models/recon/feature_set.rb` — `add_engineered_features()` + +If you add/remove/reorder features in Python, you must update both Ruby files and retrain. From f2332a7784658a7ff99c4c851a09650526ded7e0 Mon Sep 17 00:00:00 2001 From: Bram Date: Thu, 9 Apr 2026 20:15:09 +0200 Subject: [PATCH 249/266] torn api update --- app/models/torn_api.rb | 55 ++++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/app/models/torn_api.rb b/app/models/torn_api.rb index 329d5f6..0be2984 100644 --- a/app/models/torn_api.rb +++ b/app/models/torn_api.rb @@ -44,7 +44,7 @@ def get(path, params = {}, retries: 0) rescue InvalidKeyError, ApiError => e response_time = ((Time.current - start_time) * 1000).to_i log_api_call(path, merged_params, "error", response_time, nil, e.message) - notify_discord_error(path, merged_params, e) + notify_discord_error(path, merged_params, e) unless e.is_a?(InvalidKeyError) || e.is_a?(RateLimitError) || e.is_a?(NotFoundError) raise rescue Net::ReadTimeout, Net::OpenTimeout => e handle_timeout(uri, api_params, e, retries, start_time) @@ -95,20 +95,45 @@ def handle_api_error(error) error_msg = error["error"] case error_code - when 2 - raise InvalidKeyError, "Invalid API key" - when 5 - raise RateLimitError, "Too many requests: #{error_msg}" - when 6 - raise NotFoundError, "Incorrect ID: #{error_msg}" - when 8 - raise ApiError, "IP block: #{error_msg}" - when 9 - raise ApiError, "API disabled: #{error_msg}" - when 10 - raise InvalidKeyError, "Key owner is in federal jail" - when 13 - raise RateLimitError, "Key rate limit reached: #{error_msg}" + # Key errors — user-resolvable, no Discord alert needed + when 1 then raise InvalidKeyError, "API key is empty" + when 2 then raise InvalidKeyError, "Invalid API key" + when 10 then raise InvalidKeyError, "Key owner is in federal jail" + when 12 then raise InvalidKeyError, "Key read error" + when 13 then raise InvalidKeyError, "Key temporarily disabled due to owner inactivity" + when 16 then raise InvalidKeyError, "Access level of this key is not high enough" + when 18 then raise InvalidKeyError, "API key has been paused by the owner" + + # Rate limiting + when 5 then raise RateLimitError, "Too many requests" + when 8 then raise RateLimitError, "IP blocked for abuse" + when 14 then raise RateLimitError, "Daily read limit reached" + + # Bad request — wrong ID or selections + when 6 then raise NotFoundError, "Incorrect ID: #{error_msg}" + when 7 then raise NotFoundError, "Requested data is private" + + # Torn infrastructure issues + when 9 then raise ApiError, "Torn API is currently disabled" + when 17 then raise ApiError, "Torn backend error, please try again" + when 24 then raise ApiError, "Torn API temporarily closed" + + # Request parameter errors + when 3 then raise ApiError, "Wrong type requested: #{error_msg}" + when 4 then raise ApiError, "Wrong fields requested: #{error_msg}" + when 11 then raise ApiError, "Can only change API key once every 60 seconds" + when 15 then raise ApiError, "Temporary error: #{error_msg}" + when 19 then raise ApiError, "Must be migrated to crimes 2.0" + when 20 then raise ApiError, "Race not yet finished" + when 21 then raise ApiError, "Incorrect category: #{error_msg}" + when 22 then raise ApiError, "Selection only available in API v1" + when 23 then raise ApiError, "Selection only available in API v2" + when 25 then raise ApiError, "Invalid stat requested: #{error_msg}" + when 26 then raise ApiError, "Only category or stats can be requested" + when 27 then raise ApiError, "Must be migrated to organized crimes 2.0" + when 28 then raise ApiError, "Incorrect log ID" + when 29 then raise ApiError, "Category selection not available for interaction logs" + else raise ApiError, "API error #{error_code}: #{error_msg}" end From 5df5dfd63c3825996df17bac1f5610accccdde59 Mon Sep 17 00:00:00 2001 From: Bram Date: Thu, 9 Apr 2026 20:47:26 +0200 Subject: [PATCH 250/266] quick add form --- app/assets/stylesheets/pages/admin.css | 15 ++ app/controllers/admin/recon_controller.rb | 22 +++ app/views/admin/recon/show.html.erb | 16 +++ config/routes.rb | 1 + .../admin/recon_controller_test.rb | 131 ++++++++++++++++++ test/models/recon/feature_set_test.rb | 40 +++++- 6 files changed, 222 insertions(+), 3 deletions(-) diff --git a/app/assets/stylesheets/pages/admin.css b/app/assets/stylesheets/pages/admin.css index c5bd0cb..abe90c5 100644 --- a/app/assets/stylesheets/pages/admin.css +++ b/app/assets/stylesheets/pages/admin.css @@ -1111,3 +1111,18 @@ color: var(--gray-200); font-variant-numeric: tabular-nums; } + +.quick-add-grid { + display: flex; + gap: var(--space-3); + align-items: center; + flex-wrap: wrap; +} + +.quick-add-grid .predict-input { + width: 120px; +} + +.quick-add-grid .predict-input:first-child { + width: 140px; +} diff --git a/app/controllers/admin/recon_controller.rb b/app/controllers/admin/recon_controller.rb index e98e69e..f972f04 100644 --- a/app/controllers/admin/recon_controller.rb +++ b/app/controllers/admin/recon_controller.rb @@ -155,6 +155,28 @@ def predict partial: "admin/recon/predict_result", locals: { error: "API error: #{e.message}" }) end + def quick_add + torn_id = params[:torn_id].to_s.strip + strength = params[:strength].to_s.gsub(/[^0-9]/, "").to_i + defense = params[:defense].to_s.gsub(/[^0-9]/, "").to_i + speed = params[:speed].to_s.gsub(/[^0-9]/, "").to_i + dexterity = params[:dexterity].to_s.gsub(/[^0-9]/, "").to_i + + if torn_id.blank? || (strength + defense + speed + dexterity) == 0 + return redirect_to admin_recon_path, alert: "Torn ID and at least one stat are required." + end + + spied_at = Date.current + + sample = Recon::TrainingSample.find_or_initialize_by(player_id: torn_id, spied_at: spied_at) + sample.update!(strength: strength, defense: defense, speed: speed, dexterity: dexterity) + + Recon::CollectTrainingSampleJob.perform_later(player_id: torn_id.to_i, spied_at: spied_at.to_s) + + total = ActiveSupport::NumberHelper.number_to_delimited(strength + defense + speed + dexterity) + redirect_to admin_recon_path, notice: "Added sample for #{torn_id} (#{total} total). Collecting personalstats..." + end + def import raw_data = params[:spy_data] diff --git a/app/views/admin/recon/show.html.erb b/app/views/admin/recon/show.html.erb index 54033e2..59421cf 100644 --- a/app/views/admin/recon/show.html.erb +++ b/app/views/admin/recon/show.html.erb @@ -35,6 +35,22 @@
    <% end %> +
    +

    Quick Add Sample

    +

    Manually add a training sample. Personalstats will be fetched automatically from the API.

    + + <%= form_with url: quick_add_admin_recon_path, method: :post, class: "predict-form" do |form| %> +
    + <%= form.text_field :torn_id, placeholder: "Torn ID", class: "predict-input", inputmode: "numeric" %> + <%= form.text_field :strength, placeholder: "STR", class: "predict-input" %> + <%= form.text_field :defense, placeholder: "DEF", class: "predict-input" %> + <%= form.text_field :speed, placeholder: "SPD", class: "predict-input" %> + <%= form.text_field :dexterity, placeholder: "DEX", class: "predict-input" %> + <%= form.submit "Add Sample", class: "btn btn-primary" %> +
    + <% end %> +
    + <% if @import_in_progress %> <% seconds_remaining = (@import_ends_at - Time.current).to_i %>
    diff --git a/config/routes.rb b/config/routes.rb index 5ab9e7f..a01973a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -119,6 +119,7 @@ post :import get :stats post :predict + post :quick_add end end diff --git a/test/controllers/admin/recon_controller_test.rb b/test/controllers/admin/recon_controller_test.rb index 32df8f1..0aefa7a 100644 --- a/test/controllers/admin/recon_controller_test.rb +++ b/test/controllers/admin/recon_controller_test.rb @@ -166,6 +166,137 @@ class Admin::ReconControllerTest < ActionDispatch::IntegrationTest assert scheduled_at >= existing_end, "Job should be scheduled after existing queue ends" end + # -- Predict -- + + test "predict returns estimation for valid torn id" do + AdminCredentials.stubs(:api_key).returns("test_key") + Recon::TornApi::PersonalStats.any_instance.stubs(:fetch).returns({ + "xantaken" => 1000, "energydrinkused" => 50, "refills" => 200, + "daysbeendonator" => 500, "statenhancersused" => 0, "boostersused" => 100, + "lsdtaken" => 0, "revives" => 5, "exttaken" => 10, "victaken" => 3, + "rehabs" => 50, "highestbeaten" => 80, "hospital" => 200, + "jobpointsused" => 5000, "trainsreceived" => 300, "attackswon" => 1000, + "awards" => 200, "timeplayed" => 1000000, "networth" => 5_000_000_000 + }) + Recon::TornApi::Profile.any_instance.stubs(:fetch).returns( + Recon::TornApi::Profile::ProfileData.new(age: 1000, level: 80, property: "Private Island", last_action_timestamp: Time.now.to_i) + ) + Recon::Predictor.stubs(:trained?).returns(true) + Recon::Predictor.any_instance.stubs(:predict).returns(2_500_000_000) + + post predict_admin_recon_path, params: { torn_id: "12345" }, as: :turbo_stream + assert_response :success + end + + test "predict returns error for blank torn id" do + post predict_admin_recon_path, params: { torn_id: "" }, as: :turbo_stream + assert_response :success + assert_match /Please enter a Torn ID/, response.body + end + + test "predict returns error when admin key not configured" do + AdminCredentials.stubs(:api_key).returns(nil) + + post predict_admin_recon_path, params: { torn_id: "12345" }, as: :turbo_stream + assert_response :success + assert_match /Admin API key not configured/, response.body + end + + test "predict returns error when model not trained" do + AdminCredentials.stubs(:api_key).returns("test_key") + Recon::Predictor.stubs(:trained?).returns(false) + + post predict_admin_recon_path, params: { torn_id: "12345" }, as: :turbo_stream + assert_response :success + assert_match /Model not trained/, response.body + end + + test "predict handles API errors gracefully" do + AdminCredentials.stubs(:api_key).returns("test_key") + Recon::Predictor.stubs(:trained?).returns(true) + Recon::TornApi::PersonalStats.any_instance.stubs(:fetch).raises(TornApi::ApiError, "something broke") + + post predict_admin_recon_path, params: { torn_id: "12345" }, as: :turbo_stream + assert_response :success + assert_match /API error/, response.body + end + + # -- Quick Add -- + + test "quick_add creates sample and queues job" do + assert_difference "Recon::TrainingSample.count", 1 do + assert_enqueued_with(job: Recon::CollectTrainingSampleJob) do + post quick_add_admin_recon_path, params: { + torn_id: "999999", strength: "1,000,000", defense: "2,000,000", + speed: "500,000", dexterity: "500,000" + } + end + end + + sample = Recon::TrainingSample.last + assert_equal 999999, sample.player_id + assert_equal 1_000_000, sample.strength + assert_equal 2_000_000, sample.defense + assert_equal 500_000, sample.speed + assert_equal 500_000, sample.dexterity + assert_equal Date.current, sample.spied_at + + assert_redirected_to admin_recon_path + assert_match /Added sample for 999999/, flash[:notice] + end + + test "quick_add strips non-numeric characters from stats" do + post quick_add_admin_recon_path, params: { + torn_id: "999999", strength: "1,083,136,138", defense: "1,197,787,103", + speed: "593,848,799", dexterity: "13,731" + } + + sample = Recon::TrainingSample.last + assert_equal 1_083_136_138, sample.strength + assert_equal 1_197_787_103, sample.defense + assert_equal 593_848_799, sample.speed + assert_equal 13_731, sample.dexterity + end + + test "quick_add updates existing sample for same player and date" do + Recon::TrainingSample.create!( + player_id: 999999, strength: 1, defense: 1, speed: 1, dexterity: 1, + spied_at: Date.current + ) + + assert_no_difference "Recon::TrainingSample.count" do + post quick_add_admin_recon_path, params: { + torn_id: "999999", strength: "2000000", defense: "3000000", + speed: "1000000", dexterity: "1000000" + } + end + + sample = Recon::TrainingSample.find_by(player_id: 999999, spied_at: Date.current) + assert_equal 2_000_000, sample.strength + end + + test "quick_add rejects blank torn id" do + assert_no_difference "Recon::TrainingSample.count" do + post quick_add_admin_recon_path, params: { + torn_id: "", strength: "1000", defense: "1000", speed: "1000", dexterity: "1000" + } + end + + assert_redirected_to admin_recon_path + assert_match /required/, flash[:alert] + end + + test "quick_add rejects zero stats" do + assert_no_difference "Recon::TrainingSample.count" do + post quick_add_admin_recon_path, params: { + torn_id: "999999", strength: "0", defense: "0", speed: "0", dexterity: "0" + } + end + + assert_redirected_to admin_recon_path + assert_match /required/, flash[:alert] + end + test "import handles format 2 with separate rank column" do spy_data = "1\tPenicillin [1517799]\t100\t655,000,300,610,300\t2,761,464,594,401,500\t8,940,446,370,100\t12,344,518,438\t3,425,417,685,900,338\t30/03/26\t16 minutes ago\t81,243,777" diff --git a/test/models/recon/feature_set_test.rb b/test/models/recon/feature_set_test.rb index 19c93c0..0b503af 100644 --- a/test/models/recon/feature_set_test.rb +++ b/test/models/recon/feature_set_test.rb @@ -74,10 +74,44 @@ class Recon::FeatureSetTest < ActiveSupport::TestCase end end - test "returns no extra keys beyond feature columns" do + test "returns engineered feature keys" do features = build_features - extra = features.keys - Recon::TrainingSample::FEATURE_COLUMNS - assert_empty extra, "Unexpected keys: #{extra}" + %w[xan_per_day refills_per_day edrink_per_day se_per_day + total_energy energy_per_day boosters_per_day has_se].each do |col| + assert features.key?(col), "Missing engineered feature: #{col}" + end + end + + test "total_energy combines xan refills and energy drinks" do + features = build_features(personalstats: { + "xantaken" => 100, "refills" => 50, "energydrinkused" => 20 + }) + assert_equal 100 * 250 + 50 * 150 + 20 * 100, features["total_energy"] + end + + test "per_day features divide by real_age" do + features = build_features( + personalstats: { "xantaken" => 500, "refills" => 100 }, + age: 500, last_action_timestamp: Time.now.to_i + ) + assert_in_delta 1.0, features["xan_per_day"], 0.01 + assert_in_delta 0.2, features["refills_per_day"], 0.01 + end + + test "has_se is 1.0 when statenhancersused >= 3" do + features = build_features(personalstats: { "statenhancersused" => 3 }) + assert_equal 1.0, features["has_se"] + end + + test "has_se is 0.0 when statenhancersused < 3" do + features = build_features(personalstats: { "statenhancersused" => 2 }) + assert_equal 0.0, features["has_se"] + end + + test "engineered features handle zero real_age safely" do + features = build_features(age: 0, last_action_timestamp: nil) + assert features["xan_per_day"].finite? + assert features["energy_per_day"].finite? end private From a5af079b07c517e46b0bd56dfb0f1379262d9d55 Mon Sep 17 00:00:00 2001 From: Bram Date: Thu, 9 Apr 2026 21:38:27 +0200 Subject: [PATCH 251/266] tiered model --- .gitignore | 4 +- app/jobs/recon/collect_training_sample_job.rb | 2 +- app/models/recon/predictor.rb | 53 ++++++- lib/recon/README.md | 137 ++++++++++-------- lib/recon/train_model.py | 86 +++++++---- lib/tasks/recon.rake | 8 +- .../admin/recon_controller_test.rb | 4 +- 7 files changed, 192 insertions(+), 102 deletions(-) diff --git a/.gitignore b/.gitignore index ee02d6a..18b5f3c 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,6 @@ # Python venv for ML training /lib/recon/.venv/ -# Trained ONNX model (regenerate with: rake recon:train) -/lib/recon/model.onnx +# Trained ONNX models (regenerate with: rake recon:train) +/lib/recon/model_*.onnx diff --git a/app/jobs/recon/collect_training_sample_job.rb b/app/jobs/recon/collect_training_sample_job.rb index b121825..2cc54ab 100644 --- a/app/jobs/recon/collect_training_sample_job.rb +++ b/app/jobs/recon/collect_training_sample_job.rb @@ -20,7 +20,7 @@ def perform(player_id:, spied_at:) features = Recon::FeatureSet.build(personalstats: personalstats, profile: profile) - sample.update!(features) + sample.update!(features.slice(*Recon::TrainingSample::FEATURE_COLUMNS)) rescue TornApi::ApiError => e Rails.logger.error("Recon::CollectTrainingSampleJob failed for player #{player_id}: #{e.message}") end diff --git a/app/models/recon/predictor.rb b/app/models/recon/predictor.rb index ddf6a8d..b1af71e 100644 --- a/app/models/recon/predictor.rb +++ b/app/models/recon/predictor.rb @@ -1,7 +1,7 @@ require "onnxruntime" class Recon::Predictor - MODEL_PATH = Rails.root.join("lib/recon/model.onnx") + MODEL_DIR = Rails.root.join("lib/recon") # Must match ALL_FEATURES order in train_model.py FEATURE_ORDER = ( @@ -19,22 +19,59 @@ class Recon::Predictor total_energy ].to_set.freeze + # Must match TIERS in train_model.py + TIERS = [ + [ :low, 0, 1e9 ], + [ :mid, 1e9, 5e9 ], + [ :high, 5e9, Float::INFINITY ] + ].freeze + def initialize - raise "Model not found at #{MODEL_PATH}. Run: rake recon:train" unless MODEL_PATH.exist? + global_path = MODEL_DIR.join("model_global.onnx") + raise "Global model not found at #{global_path}. Run: rake recon:train" unless global_path.exist? + + @global_model = OnnxRuntime::Model.new(global_path.to_s) - @model = OnnxRuntime::Model.new(MODEL_PATH.to_s) + @tier_models = {} + TIERS.each do |name, _, _| + path = MODEL_DIR.join("model_#{name}.onnx") + @tier_models[name] = OnnxRuntime::Model.new(path.to_s) if path.exist? + end end def predict(features) - input = FEATURE_ORDER.map do |f| + input = build_input(features) + + # First pass: global model for rough estimate to pick tier + rough = run_model(@global_model, input) + + # Pick tier model + tier_name = TIERS.find { |_, lo, hi| rough >= lo && rough < hi }&.first + tier_model = @tier_models[tier_name] + + # Second pass: tier model for refined prediction (fall back to global) + if tier_model + run_model(tier_model, input) + else + rough + end + end + + def self.trained? + MODEL_DIR.join("model_global.onnx").exist? + end + + private + + def build_input(features) + FEATURE_ORDER.map do |f| val = (features[f] || 0).to_f LOG_FEATURES.include?(f) ? Math.log1p([ val, 0 ].max) : val end - log_prediction = @model.predict({ "features" => [input] }).values.first.flatten.first - Math.expm1(log_prediction).round.to_i.clamp(0..) end - def self.trained? - MODEL_PATH.exist? + def run_model(model, input) + log_prediction = model.predict({ "features" => [ input ] }).values.first.flatten.first + Math.expm1(log_prediction).round.to_i.clamp(0..) end end diff --git a/lib/recon/README.md b/lib/recon/README.md index 959889d..0b0abd6 100644 --- a/lib/recon/README.md +++ b/lib/recon/README.md @@ -7,15 +7,19 @@ Predicts a Torn player's total battle stats (STR + DEF + SPD + DEX) from publicl Training happens in Python (scikit-learn), inference in Ruby (ONNX Runtime). ``` -lib/recon/train_model.py # Python: trains Random Forest, exports ONNX -lib/recon/model.onnx # Trained model artifact (gitignored) -lib/recon/.venv/ # Python venv (gitignored) -lib/recon/requirements.txt # Python deps: scikit-learn, onnx, skl2onnx -lib/tasks/recon.rake # rake recon:train - -app/models/recon/predictor.rb # Ruby: loads ONNX, runs inference -app/models/recon/feature_set.rb # Ruby: builds feature vector from API data -app/models/recon/training_sample.rb # ActiveRecord model for training data +lib/recon/train_model.py # Python: trains models, exports ONNX +lib/recon/model_global.onnx # Global model (gitignored) +lib/recon/model_low.onnx # Tier: < 1B (gitignored) +lib/recon/model_mid.onnx # Tier: 1B - 5B (gitignored) +lib/recon/model_high.onnx # Tier: 5B+ (gitignored) +lib/recon/.venv/ # Python venv (gitignored) +lib/recon/requirements.txt # Python deps: scikit-learn, onnx, skl2onnx +lib/tasks/recon.rake # rake recon:train + +app/models/recon/predictor.rb # Ruby: loads ONNX models, routes between tiers +app/models/recon/feature_set.rb # Ruby: builds feature vector from API data +app/models/recon/training_sample.rb # ActiveRecord model for training data +app/controllers/admin/recon_controller.rb # Predict + Quick Add + Import ``` ## How to train @@ -23,24 +27,42 @@ app/models/recon/training_sample.rb # ActiveRecord model for training data ```bash rake recon:train # or directly: -lib/recon/.venv/bin/python3 lib/recon/train_model.py storage/development.sqlite3 lib/recon/model.onnx +lib/recon/.venv/bin/python3 lib/recon/train_model.py storage/development.sqlite3 lib/recon ``` -Training takes ~5 seconds on 11k samples (vs 5+ minutes with the old pure-Ruby Rumale approach). +If the venv doesn't exist yet: +```bash +python3 -m venv lib/recon/.venv +lib/recon/.venv/bin/pip install -r lib/recon/requirements.txt +``` + +Training takes ~5 seconds on 11k samples. ## How prediction works 1. Fetch player's personalstats + profile via Torn API (admin key) -2. `Recon::FeatureSet.build` creates a feature hash (22 DB columns + 8 engineered features) -3. `Recon::Predictor` applies log transforms, runs ONNX inference, inverts with `expm1` +2. `Recon::FeatureSet.build` creates a feature hash (22 DB columns + 8 engineered) +3. `Recon::Predictor` runs the global model to get a rough estimate +4. The rough estimate picks a tier (< 1B, 1B-5B, 5B+) +5. The tier-specific model gives a refined prediction + +The UI is at `/admin/recon` — enter a Torn ID to predict, or manually add training samples via Quick Add. + +## Tiered model strategy -The UI is at `/admin/recon` — enter a Torn ID, get a prediction via Turbo Stream. +Instead of one model predicting across the entire stat range, there are three specialized models plus a global router: + +- **Global** — trained on all data, used to pick the right tier +- **Low** (< 1B) — specialized for sub-1B players +- **Mid** (1B-5B) — specialized for the mid range +- **High** (5B+) — specialized for high-stat players + +This improves accuracy because the relationship between features and stats differs across tiers. A player with 500M stats looks very different from one with 10B, even with similar features. ## Model details - **Algorithm**: Random Forest Regressor (200 trees, max_depth=10, min_samples_leaf=10) - **Target**: `log1p(total_stats)` — log-space regression, inverted at prediction time -- **Cross-val R²**: 0.957 (log-space) - **Training samples**: ~11,100 (after cleaning) ### Features (30 total) @@ -57,80 +79,77 @@ The UI is at `/admin/recon` — enter a Torn ID, get a prediction via Turbo Stre | refills | Energy refills | 0.3% | | ... | 24 others | <0.3% each | -`total_energy` dominates because it directly measures lifetime training energy (xan, refills, energy drinks each give fixed energy amounts). +`total_energy` dominates because it directly measures lifetime training energy. ### Log transforms -Applied to heavy-tailed features before training: xantaken, energydrinkused, statenhancersused, boostersused, lsdtaken, revives, exttaken, victaken, rehabs, attackswon, networth, hospital, total_energy. Both Python and Ruby must apply the same transforms. +Applied to heavy-tailed features before training: xantaken, energydrinkused, statenhancersused, boostersused, lsdtaken, revives, exttaken, victaken, rehabs, attackswon, networth, hospital, total_energy. ### Data cleaning (removes ~3.6% of samples) -1. **Zero-stat samples** (79): spy records where actual stats weren't captured — high-level players with 0 total stats -2. **Unbalanced builds** (106): one stat >95% of total — likely data errors +1. **Zero-stat samples**: spy records where actual stats weren't captured +2. **Unbalanced builds**: one stat >95% of total — likely data errors 3. **Outliers**: top/bottom 1% by log(total_stats) -## Known issues and limitations +## Validated accuracy (out-of-sample, 5-fold CV) -### Temporal gap (biggest source of error) -Training data pairs spy-time stats with spy-time personalstats. Live predictions use *current* personalstats, which are always higher than at spy time. This causes systematic overestimation, typically 5-20% depending on how much the player has grown since their features last matched the training distribution. - -### Stat enhancer users distort predictions -Players with SE >= 3 have ~7x the stats of SE=0 players at the same energy level. The model blends them, pulling predictions up for non-SE players. A `has_se` binary feature (SE >= 3, to exclude merit-only users) was added but `total_energy` dominates so heavily (86%) that the tree splits on energy first and the SE signal gets lost. The SE=1-2 group (merit hunters) also has 2x stats vs SE=0. +### Single model -### Sparse data ranges -The training data is heavily concentrated in the 1B-10B stat range (54% of samples). Sparse areas: +| Tier | Samples | Median Error | Within ±10% | Within ±25% | +|---|---|---|---|---| +| < 100M | 2,082 | 50.5% | 11% | 27% | +| 100M - 500M | 803 | 48.2% | 13% | 29% | +| 500M - 1B | 733 | 28.1% | 19% | 45% | +| 1B - 5B | 4,076 | 12.8% | 40% | 76% | +| 5B - 10B | 2,151 | 9.2% | 54% | 88% | +| 10B+ | 1,296 | 10.5% | 48% | 84% | +| **ALL** | **11,141** | **16.0%** | **35%** | **64%** | -| Stat Range | Count | Status | -|---|---|---| -| 50M - 100M | 193 | SPARSE | -| 25B - 50B | 123 | SPARSE | -| 50B+ | 147 | SPARSE | -| 10M - 50M | 469 | Thin | -| 100M - 250M | 379 | Thin | -| 250M - 500M | 449 | Thin | +### Tiered model (current) -More spy data in the 10M-500M and 25B+ ranges would improve predictions significantly. +| Tier | Samples | Median Error | Within ±10% | Within ±25% | +|---|---|---|---|---| +| < 100M | 2,082 | 49.9% | 12% | 28% | +| 100M - 500M | 803 | 45.2% | 14% | 30% | +| 500M - 1B | 733 | 27.3% | 19% | 46% | +| 1B - 5B | 4,076 | 11.2% | 46% | 83% | +| 5B - 10B | 2,151 | 8.3% | 59% | 92% | +| 10B+ | 1,296 | 10.1% | 49% | 87% | +| **ALL** | **11,141** | **14.3%** | **38%** | **68%** | ### Real-world comparison (2026-04-08) -Tested against real spies, BSP (userscript), and FFScouter estimates: - | Player | Real Spy | BSP | FFScouter | Ours | Our Error | |---|---|---|---|---|---| | 2684760 | 5.49B | 5.3B (3.5% under) | 5.61B (2.1% over) | 5.66B | 3.0% over | | 2445730 | 5.62B | 5.8B (3.2% over) | 5.81B (3.4% over) | 6.01B | 6.9% over | -Model is competitive in the 1B-10B range (bulk of training data). Weaker in sparse ranges. - -### Min-maxers -Players who optimize stat growth efficiency have unusual energy-to-stats ratios. The model predicts the average relationship, which can be 20%+ off for these players. - -## Next steps to explore +## Known issues and limitations -These were discussed but not yet implemented: +### Stat enhancer users distort predictions +Players with SE >= 3 have ~7x the stats of SE=0 players at the same energy level. The model blends them, pulling predictions up for non-SE players. SE=1-2 (merit hunters) have 2x stats vs SE=0. -1. **FFScouter API validation** — FFScouter (ffscouter.com) is an external service that also estimates battle stats. Their API (`GET /api/v1/get-stats?key=...&targets=...`) returns `bs_estimate` for players. Could be used to: - - **Validate**: compare both models' accuracy against known spy data - - **Feature**: use their estimate as an input feature (adds dependency) - - **Ensemble**: average predictions at inference time - - Do NOT use as training data (would learn their model's biases, not ground truth) - - Rate limit: 20 req/min, up to 205 targets per request - - Requires API key registration +### Sparse data ranges +Training data is heavily concentrated in 1B-10B (54%). The sub-500M range has high error purely from lack of training data. Target: 20k+ samples with better sub-500M coverage. -2. **Tier segmentation** — train separate models for different stat brackets (e.g., sub-1B, 1B-10B, 10B+). Each model would learn tighter relationships within its range. +### Min-maxers +Players who optimize training efficiency unusually high or low break the "average" relationship. The model predicts the mean for a given feature profile. -3. **More feature engineering** — ratios like `attackswon / level`, `networth / level`. Drop-one analysis showed most current features add negligible value; only daysbeendonator, total_energy, attackswon, and awards meaningfully contribute. +### total_energy is a crude proxy +It ignores natural energy, gym unlocks, faction perks, happy jumps, and training efficiency. Two players with the same xan count can have very different actual energy spent training. -4. **Feature selection** — the "Top 10" feature set (total_energy, awards, daysbeendonator, jobpointsused, xantaken, refills, attackswon, networth, highestbeaten, xan_per_day) achieves R²=0.9556 — nearly identical to all 30 features. Fewer features = less noise. +## Next steps -5. **Alternative models** — GradientBoosting was tested but performed worse for edge cases (R²=0.78, worse calibration for specific players). Random Forest was better overall. +1. **More training data** — especially sub-500M. 20k+ samples recommended before revisiting model architecture. +2. **FFScouter API validation** — compare estimates systematically. API at `ffscouter.com/api/v1/get-stats`. Not yet integrated. +3. **Feature selection** — top 10 features get R²=0.9556 vs 0.9568 for all 30. Could simplify. ## Contract between Python and Ruby The feature list and transforms must stay in sync across three files: -- `lib/recon/train_model.py` — `ALL_FEATURES`, `LOG_FEATURES`, `add_engineered_features()` -- `app/models/recon/predictor.rb` — `FEATURE_ORDER`, `LOG_FEATURES` +- `lib/recon/train_model.py` — `ALL_FEATURES`, `LOG_FEATURES`, `TIERS`, `add_engineered_features()` +- `app/models/recon/predictor.rb` — `FEATURE_ORDER`, `LOG_FEATURES`, `TIERS` - `app/models/recon/feature_set.rb` — `add_engineered_features()` -If you add/remove/reorder features in Python, you must update both Ruby files and retrain. +If you add/remove/reorder features, update all three and retrain. diff --git a/lib/recon/train_model.py b/lib/recon/train_model.py index ecb5731..51c5516 100644 --- a/lib/recon/train_model.py +++ b/lib/recon/train_model.py @@ -1,5 +1,8 @@ #!/usr/bin/env python3 -"""Train a Random Forest model on recon training samples and export to ONNX.""" +"""Train Random Forest models on recon training samples and export to ONNX. + +Trains a global model (for routing) plus tier-specific models for refined predictions. +""" import sqlite3 import sys @@ -29,7 +32,7 @@ "total_energy", # xantaken * 250 + refills * 150 + energydrinkused * 100 — estimated lifetime energy from items "energy_per_day", # total_energy / real_age — daily energy from items "boosters_per_day", # boostersused / real_age - "has_se", # binary: statenhancersused > 0 + "has_se", # binary: statenhancersused >= 3 ] # Full feature list as seen by the model (must match Ruby's Predictor) @@ -45,7 +48,15 @@ "total_energy", } +# Tier boundaries for specialized models +TIERS = [ + ("low", 0, 1e9), # < 1B + ("mid", 1e9, 5e9), # 1B - 5B + ("high", 5e9, 1e18), # 5B+ +] + MIN_SAMPLES = 100 +MIN_TIER_SAMPLES = 50 def add_engineered_features(X, columns): @@ -83,9 +94,34 @@ def log1p_transform(X, columns): return X +def train_and_export(X, y_log, name, output_dir): + """Train a Random Forest and export to ONNX. Returns the model.""" + model = RandomForestRegressor( + n_estimators=200, + max_depth=10, + min_samples_leaf=10, + n_jobs=-1, + random_state=42, + ) + model.fit(X, y_log) + + scores = cross_val_score(model, X, y_log, cv=5, scoring="r2") + print(f" R² (log-space): {scores.mean():.4f} (±{scores.std():.4f})") + + initial_type = [("features", FloatTensorType([None, X.shape[1]]))] + onnx_model = convert_sklearn(model, initial_types=initial_type) + + output_path = output_dir / f"model_{name}.onnx" + with open(output_path, "wb") as f: + f.write(onnx_model.SerializeToString()) + print(f" Saved to {output_path}") + + return model + + def main(): db_path = sys.argv[1] if len(sys.argv) > 1 else "storage/development.sqlite3" - output_path = sys.argv[2] if len(sys.argv) > 2 else "lib/recon/model.onnx" + output_dir = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("lib/recon") conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row @@ -114,7 +150,7 @@ def main(): stats = stats[nonzero] max_stat_ratio = stats.max(axis=1) / np.maximum(y, 1) balanced = max_stat_ratio <= 0.95 - X, y, stats = X[balanced], y[balanced], stats[balanced] + X, y = X[balanced], y[balanced] # Remove outliers: clip top/bottom 1% by log(total_stats) y_log_raw = np.log1p(y) @@ -135,36 +171,32 @@ def main(): # Log-transform target (inverse in Ruby via exp) y_log = np.log1p(y) - print(f"Training on {len(y)} samples, {len(ALL_FEATURES)} features") - print(f"Target range: {y.min():.0f} – {y.max():.0f} (log: {y_log.min():.2f} – {y_log.max():.2f})") + output_dir.mkdir(parents=True, exist_ok=True) - model = RandomForestRegressor( - n_estimators=200, - max_depth=10, - min_samples_leaf=10, - n_jobs=-1, - random_state=42, - ) - model.fit(X, y_log) - - scores = cross_val_score(model, X, y_log, cv=5, scoring="r2") - print(f"Cross-val R² (log-space): {scores.mean():.4f} (±{scores.std():.4f})") + # Train global model (used for routing) + print(f"\n=== Global model ({len(y)} samples, {len(ALL_FEATURES)} features) ===") + global_model = train_and_export(X, y_log, "global", output_dir) importances = sorted( - zip(ALL_FEATURES, model.feature_importances_), key=lambda x: -x[1] + zip(ALL_FEATURES, global_model.feature_importances_), key=lambda x: -x[1] ) - print("Top features:") - for name, imp in importances[:15]: - print(f" {name}: {imp:.4f}") + print(" Top features:") + for name, imp in importances[:10]: + print(f" {name}: {imp:.4f}") - initial_type = [("features", FloatTensorType([None, len(ALL_FEATURES)]))] - onnx_model = convert_sklearn(model, initial_types=initial_type) + # Train tier-specific models + for tier_name, lo, hi in TIERS: + tier_mask = (y >= lo) & (y < hi) + n_tier = tier_mask.sum() - Path(output_path).parent.mkdir(parents=True, exist_ok=True) - with open(output_path, "wb") as f: - f.write(onnx_model.SerializeToString()) + if n_tier < MIN_TIER_SAMPLES: + print(f"\n=== Tier '{tier_name}' — skipped ({n_tier} samples, need {MIN_TIER_SAMPLES}) ===") + continue + + print(f"\n=== Tier '{tier_name}' ({n_tier} samples) ===") + train_and_export(X[tier_mask], y_log[tier_mask], tier_name, output_dir) - print(f"Model saved to {output_path}") + print("\nDone!") if __name__ == "__main__": diff --git a/lib/tasks/recon.rake b/lib/tasks/recon.rake index e8a2b17..cb404ba 100644 --- a/lib/tasks/recon.rake +++ b/lib/tasks/recon.rake @@ -4,7 +4,7 @@ namespace :recon do venv_python = Rails.root.join("lib/recon/.venv/bin/python3") script = Rails.root.join("lib/recon/train_model.py") db_path = ActiveRecord::Base.connection_db_config.database - output = Rails.root.join("lib/recon/model.onnx") + output_dir = Rails.root.join("lib/recon") unless venv_python.exist? puts "Setting up Python venv..." @@ -13,8 +13,8 @@ namespace :recon do Rails.root.join("lib/recon/requirements.txt").to_s, exception: true) end - puts "Training model..." - system(venv_python.to_s, script.to_s, db_path.to_s, output.to_s, exception: true) - puts "Done! Model at #{output}" + puts "Training models..." + system(venv_python.to_s, script.to_s, db_path.to_s, output_dir.to_s, exception: true) + puts "Done! Models at #{output_dir}/model_*.onnx" end end diff --git a/test/controllers/admin/recon_controller_test.rb b/test/controllers/admin/recon_controller_test.rb index 0aefa7a..1833b32 100644 --- a/test/controllers/admin/recon_controller_test.rb +++ b/test/controllers/admin/recon_controller_test.rb @@ -182,7 +182,9 @@ class Admin::ReconControllerTest < ActionDispatch::IntegrationTest Recon::TornApi::Profile::ProfileData.new(age: 1000, level: 80, property: "Private Island", last_action_timestamp: Time.now.to_i) ) Recon::Predictor.stubs(:trained?).returns(true) - Recon::Predictor.any_instance.stubs(:predict).returns(2_500_000_000) + mock_predictor = mock + mock_predictor.stubs(:predict).returns(2_500_000_000) + Recon::Predictor.stubs(:new).returns(mock_predictor) post predict_admin_recon_path, params: { torn_id: "12345" }, as: :turbo_stream assert_response :success From 4db1e7f6ba528966f5dc99112d71c83663a79449 Mon Sep 17 00:00:00 2001 From: Bram Date: Sun, 12 Apr 2026 13:33:29 +0200 Subject: [PATCH 252/266] invalid api key of faction now updates faction things --- app/jobs/daily/validate_faction_keys_job.rb | 14 ++--- app/jobs/fetch_member_activity_job.rb | 2 + app/models/faction.rb | 32 ++++++++++ app/models/torn_api.rb | 16 +++++ app/models/torn_api/faction/basic.rb | 2 +- app/models/torn_api/faction/members.rb | 2 +- app/models/torn_api/market.rb | 2 +- app/models/torn_api/torn/factionhof.rb | 2 +- app/models/torn_api/torn/items.rb | 2 +- app/models/torn_api/torn/stocks.rb | 2 +- app/models/torn_api/user/basic.rb | 2 +- app/models/torn_api/user/personal_stats.rb | 2 +- app/models/torn_api/user/profile.rb | 2 +- app/models/torn_api/user/stocks.rb | 2 +- test/jobs/fetch_member_activity_job_test.rb | 8 +++ .../torn_api/base_invalidate_key_test.rb | 58 +++++++++++++++++++ 16 files changed, 131 insertions(+), 19 deletions(-) create mode 100644 test/models/torn_api/base_invalidate_key_test.rb diff --git a/app/jobs/daily/validate_faction_keys_job.rb b/app/jobs/daily/validate_faction_keys_job.rb index 366dcff..9c414c3 100644 --- a/app/jobs/daily/validate_faction_keys_job.rb +++ b/app/jobs/daily/validate_faction_keys_job.rb @@ -14,15 +14,16 @@ def validate_faction_keys info = TornApi::Key::Info.new(api_key.key).fetch unless info.access.faction == true - Rails.logger.info("[ValidateKeys] Faction #{api_key.faction.name}: no faction access, destroying key") - destroy_faction_key(api_key) + Rails.logger.info("[ValidateKeys] Faction #{api_key.faction.name}: no faction access, invalidating key") + api_key.faction.handle_invalid_api_key! next end api_key.update!(faction_access: true) unless api_key.faction_access? rescue TornApi::InvalidKeyError - Rails.logger.info("[ValidateKeys] Faction #{api_key.faction.name}: invalid key, destroying") - destroy_faction_key(api_key) + # Usually already handled by TornApi::Base#invalidate_api_key!, but ensure cleanup + api_key.faction.handle_invalid_api_key! + Rails.logger.info("[ValidateKeys] Faction #{api_key.faction.name}: invalid key handled") rescue TornApi::ApiError => e Rails.logger.warn("[ValidateKeys] Faction #{api_key.faction.name}: #{e.message}") ensure @@ -43,10 +44,5 @@ def validate_user_keys end end - def destroy_faction_key(api_key) - faction = api_key.faction - api_key.destroy! - faction.update!(setup_completed: false) - end end end diff --git a/app/jobs/fetch_member_activity_job.rb b/app/jobs/fetch_member_activity_job.rb index ccfdf71..23eb019 100644 --- a/app/jobs/fetch_member_activity_job.rb +++ b/app/jobs/fetch_member_activity_job.rb @@ -34,6 +34,8 @@ def perform(faction_id) end MemberActivitySnapshot.insert_all(snapshots) if snapshots.any? + rescue TornApi::InvalidKeyError => e + Rails.logger.error("FetchMemberActivityJob: Invalid API key for faction #{faction_id}: #{e.message}") rescue TornApi::ApiError => e Rails.logger.error("FetchMemberActivityJob: Failed for faction #{faction_id}: #{e.message}") end diff --git a/app/models/faction.rb b/app/models/faction.rb index 8618dfd..331b451 100644 --- a/app/models/faction.rb +++ b/app/models/faction.rb @@ -77,6 +77,37 @@ def import_spy_report(spy) report.save! end + def handle_invalid_api_key! + transaction do + faction_name = name + torn_api_key&.destroy! + self.torn_api_key = nil + update!(setup_completed: false) + stop_war_polling! if war_polling_active? + clear_backfill_status! if backfill_in_progress? + cancel_background_jobs(users.pluck(:id)) + end + + Rails.logger.info("[InvalidKey] Invalidated API key for faction #{name} (#{torn_id}), cancelled jobs") + + Discord::Notifier.notify( + webhook_key: :error_webhook_url, + embed: { + title: "API Key Invalidated", + description: "The Torn API key for **#{name}** was rejected by Torn and has been removed. All background jobs for this faction have been cancelled.", + color: 15_105_570, + fields: [ + { name: "Faction", value: "#{name} [#{torn_id}]", inline: true }, + { name: "Action Required", value: "A leader must provide a new API key in faction settings", inline: false } + ], + footer: { text: "TornManager Key Monitor" }, + timestamp: Time.current.iso8601 + } + ) + rescue => e + Rails.logger.error("[InvalidKey] Discord notification failed: #{e.message}") + end + def delete_all_data! transaction do stop_war_polling! if war_polling_active? @@ -109,6 +140,7 @@ def cancel_background_jobs(user_ids) BackfillRankedWarsJob ClearBackfillStatusJob FetchArmoryNewsJob + FetchMemberActivityJob WarPollingJob ]) .where("arguments LIKE ?", "%\"arguments\":[#{id},%") diff --git a/app/models/torn_api.rb b/app/models/torn_api.rb index 0be2984..4544b8a 100644 --- a/app/models/torn_api.rb +++ b/app/models/torn_api.rb @@ -45,6 +45,7 @@ def get(path, params = {}, retries: 0) response_time = ((Time.current - start_time) * 1000).to_i log_api_call(path, merged_params, "error", response_time, nil, e.message) notify_discord_error(path, merged_params, e) unless e.is_a?(InvalidKeyError) || e.is_a?(RateLimitError) || e.is_a?(NotFoundError) + invalidate_api_key! if e.is_a?(InvalidKeyError) raise rescue Net::ReadTimeout, Net::OpenTimeout => e handle_timeout(uri, api_params, e, retries, start_time) @@ -266,6 +267,21 @@ def notify_torn_degraded(path, status) Rails.logger.error("[Discord Torn Degraded Notify] Failed: #{e.message}") end + def invalidate_api_key! + return if api_key == AdminCredentials.api_key + + record = ::ApiKey.find_by(key: api_key) + return unless record + + if record.faction_id? + record.faction.handle_invalid_api_key! + else + record.destroy! + end + rescue => e + Rails.logger.error("[TornAPI] Failed to invalidate API key: #{e.message}") + end + def resolve_api_key_owner @resolved_user ||= begin found = ::User.find_by_api_key(api_key) diff --git a/app/models/torn_api/faction/basic.rb b/app/models/torn_api/faction/basic.rb index 4519281..20e6686 100644 --- a/app/models/torn_api/faction/basic.rb +++ b/app/models/torn_api/faction/basic.rb @@ -17,7 +17,7 @@ def fetch if response["basic"].present? response["basic"] else - raise InvalidKeyError, "Torn API authentication failed: #{response}" + raise ApiError, "No faction data returned: #{response}" end end diff --git a/app/models/torn_api/faction/members.rb b/app/models/torn_api/faction/members.rb index 462cac5..b8a93eb 100644 --- a/app/models/torn_api/faction/members.rb +++ b/app/models/torn_api/faction/members.rb @@ -39,7 +39,7 @@ def fetch if response["members"].present? parse(response["members"]) else - raise InvalidKeyError, "Torn API authentication failed: #{response}" + raise ApiError, "No members data returned: #{response}" end end diff --git a/app/models/torn_api/market.rb b/app/models/torn_api/market.rb index 4f4108f..e1a5ebb 100644 --- a/app/models/torn_api/market.rb +++ b/app/models/torn_api/market.rb @@ -7,7 +7,7 @@ def fetch if response["pointsmarket"].present? response["pointsmarket"] else - raise InvalidKeyError, "Torn API authentication failed: #{response}" + raise ApiError, "No market data returned: #{response}" end end end diff --git a/app/models/torn_api/torn/factionhof.rb b/app/models/torn_api/torn/factionhof.rb index e8d9bf6..70151e2 100644 --- a/app/models/torn_api/torn/factionhof.rb +++ b/app/models/torn_api/torn/factionhof.rb @@ -25,7 +25,7 @@ def fetch if response["factionhof"].present? parse(response["factionhof"]) else - raise InvalidKeyError, "Torn API authentication failed: #{response}" + raise ApiError, "No faction HOF data returned: #{response}" end end diff --git a/app/models/torn_api/torn/items.rb b/app/models/torn_api/torn/items.rb index 889d8dc..f772312 100644 --- a/app/models/torn_api/torn/items.rb +++ b/app/models/torn_api/torn/items.rb @@ -8,7 +8,7 @@ def fetch if response["items"].present? build_items(response["items"]) else - raise InvalidKeyError, "Torn API authentication failed: #{response}" + raise ApiError, "No items data returned: #{response}" end end diff --git a/app/models/torn_api/torn/stocks.rb b/app/models/torn_api/torn/stocks.rb index 6b56825..fc17c36 100644 --- a/app/models/torn_api/torn/stocks.rb +++ b/app/models/torn_api/torn/stocks.rb @@ -8,7 +8,7 @@ def fetch if response["stocks"].present? build_stocks(response["stocks"]) else - raise InvalidKeyError, "Torn API authentication failed: #{response}" + raise ApiError, "No stocks data returned: #{response}" end end diff --git a/app/models/torn_api/user/basic.rb b/app/models/torn_api/user/basic.rb index 798fa7c..3c6c69e 100644 --- a/app/models/torn_api/user/basic.rb +++ b/app/models/torn_api/user/basic.rb @@ -29,7 +29,7 @@ def fetch if response["profile"].present? parse_profile(response["profile"]) else - raise InvalidKeyError, "Torn API authentication failed: #{response}" + raise ApiError, "No user data returned: #{response}" end end diff --git a/app/models/torn_api/user/personal_stats.rb b/app/models/torn_api/user/personal_stats.rb index a86d838..7299392 100644 --- a/app/models/torn_api/user/personal_stats.rb +++ b/app/models/torn_api/user/personal_stats.rb @@ -24,7 +24,7 @@ def fetch if response["personalstats"].present? || response["personalstats"].is_a?(Array) parse_personalstats(response["personalstats"], stat_batch) else - raise InvalidKeyError, "Torn API authentication failed: #{response}" + raise ApiError, "No personal stats data returned: #{response}" end end diff --git a/app/models/torn_api/user/profile.rb b/app/models/torn_api/user/profile.rb index e6c2b67..a439dda 100644 --- a/app/models/torn_api/user/profile.rb +++ b/app/models/torn_api/user/profile.rb @@ -17,7 +17,7 @@ def fetch if response["profile"].present? parse_profile(response["profile"]) else - raise InvalidKeyError, "Torn API authentication failed: #{response}" + raise ApiError, "No profile data returned: #{response}" end end diff --git a/app/models/torn_api/user/stocks.rb b/app/models/torn_api/user/stocks.rb index e87002c..3b0efe3 100644 --- a/app/models/torn_api/user/stocks.rb +++ b/app/models/torn_api/user/stocks.rb @@ -11,7 +11,7 @@ def fetch if response["stocks"].present? parse_user_stocks(response["stocks"]) else - raise InvalidKeyError, "Torn API authentication failed: #{response}" + raise ApiError, "No user stocks data returned: #{response}" end end diff --git a/test/jobs/fetch_member_activity_job_test.rb b/test/jobs/fetch_member_activity_job_test.rb index 9e119b1..11dc824 100644 --- a/test/jobs/fetch_member_activity_job_test.rb +++ b/test/jobs/fetch_member_activity_job_test.rb @@ -61,4 +61,12 @@ class FetchMemberActivityJobTest < ActiveJob::TestCase FetchMemberActivityJob.perform_now(@faction.id) end end + + test "handles invalid key errors gracefully" do + TornApi::Faction::Members.any_instance.stubs(:fetch).raises(TornApi::InvalidKeyError.new("Invalid API key")) + + assert_nothing_raised do + FetchMemberActivityJob.perform_now(@faction.id) + end + end end diff --git a/test/models/torn_api/base_invalidate_key_test.rb b/test/models/torn_api/base_invalidate_key_test.rb new file mode 100644 index 0000000..c77e9f8 --- /dev/null +++ b/test/models/torn_api/base_invalidate_key_test.rb @@ -0,0 +1,58 @@ +require "test_helper" + +class TornApi::BaseInvalidateKeyTest < ActiveSupport::TestCase + setup do + @faction = Faction.create!( + torn_id: 99999, name: "Test Faction", xanax_target: 2.5, + energy_refill_target: 1.0, nerve_refill_target: 1.0, setup_completed: true + ) + @api_key = ApiKey::Torn.create!(faction: @faction, key: "FACTION_KEY_123", access_type: "Limited Access") + end + + test "invalidates faction api key and resets faction setup" do + base = TornApi::Base.new("FACTION_KEY_123") + base.send(:invalidate_api_key!) + + assert_not ApiKey.exists?(@api_key.id), "API key should be destroyed" + assert_not @faction.reload.setup_completed?, "Faction setup should be reset" + end + + test "does not invalidate admin api key" do + admin_key = "ADMIN_KEY_SECRET" + AdminCredentials.stubs(:api_key).returns(admin_key) + + base = TornApi::Base.new(admin_key) + base.send(:invalidate_api_key!) + + assert ApiKey.exists?(@api_key.id), "Faction API key should not be touched" + end + + test "handles missing api key record gracefully" do + base = TornApi::Base.new("NONEXISTENT_KEY") + assert_nothing_raised do + base.send(:invalidate_api_key!) + end + end + + test "invalidates user api key" do + user = users(:bram) + user.set_api_key!("USER_KEY_ABC", "Limited Access") + user_key = user.torn_api_key + + base = TornApi::Base.new("USER_KEY_ABC") + base.send(:invalidate_api_key!) + + assert_not ApiKey.exists?(user_key.id), "User API key should be destroyed" + end + + test "is idempotent when called multiple times" do + base = TornApi::Base.new("FACTION_KEY_123") + + assert_nothing_raised do + base.send(:invalidate_api_key!) + base.send(:invalidate_api_key!) + end + + assert_not ApiKey.exists?(@api_key.id) + end +end From b8ac0cdc047e29d0f828cd6b4412344a9a6979ab Mon Sep 17 00:00:00 2001 From: Bram Date: Sun, 12 Apr 2026 13:38:46 +0200 Subject: [PATCH 253/266] ci fix --- app/jobs/daily/validate_faction_keys_job.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/jobs/daily/validate_faction_keys_job.rb b/app/jobs/daily/validate_faction_keys_job.rb index 9c414c3..addb083 100644 --- a/app/jobs/daily/validate_faction_keys_job.rb +++ b/app/jobs/daily/validate_faction_keys_job.rb @@ -43,6 +43,5 @@ def validate_user_keys sleep 1 end end - end end From 4fc9a5b0d962a10bc8140a915646eebb4ce4797f Mon Sep 17 00:00:00 2001 From: Bram Date: Sun, 12 Apr 2026 14:17:41 +0200 Subject: [PATCH 254/266] json recon import --- app/controllers/admin/recon_controller.rb | 112 +++++++++++------- app/jobs/daily/validate_faction_keys_job.rb | 3 +- app/models/faction.rb | 38 +++--- app/models/recon/spy_data_parser.rb | 35 +++++- app/views/admin/recon/show.html.erb | 12 ++ .../leadership/spy_reports/show.html.erb | 1 + config/routes.rb | 1 + .../admin/recon_controller_test.rb | 67 +++++++++++ test/models/recon/spy_data_parser_test.rb | 67 +++++++++++ 9 files changed, 269 insertions(+), 67 deletions(-) diff --git a/app/controllers/admin/recon_controller.rb b/app/controllers/admin/recon_controller.rb index f972f04..535a921 100644 --- a/app/controllers/admin/recon_controller.rb +++ b/app/controllers/admin/recon_controller.rb @@ -82,6 +82,55 @@ def stats private + def import_rows(rows) + seconds_per_job = 9 # 3 API calls/job, ~20 calls/min max + + existing_ends_at = Rails.cache.read("recon:import_ends_at") + queue_starts_at = if existing_ends_at.present? && existing_ends_at > Time.current + existing_ends_at + else + Time.current + end + + queued = 0 + skipped = 0 + rows.each do |row| + if Recon::TrainingSample.exists?(player_id: row.player_id, spied_at: row.spied_at) + skipped += 1 + next + end + + Recon::TrainingSample.create!( + player_id: row.player_id, + strength: row.strength, + defense: row.defense, + speed: row.speed, + dexterity: row.dexterity, + spied_at: row.spied_at + ) + + Recon::CollectTrainingSampleJob.set(wait_until: queue_starts_at + (queued * seconds_per_job).seconds).perform_later( + player_id: row.player_id, + spied_at: row.spied_at.to_s + ) + queued += 1 + end + + if queued > 0 + new_ends_at = queue_starts_at + (queued * seconds_per_job).seconds + remaining_seconds = (new_ends_at - Time.current).to_i + + Rails.cache.write("recon:import_ends_at", new_ends_at, expires_in: remaining_seconds.seconds) + end + + skip_msg = skipped > 0 ? " #{skipped} already collected." : "" + if queued > 0 + redirect_to admin_recon_path, notice: "Queued #{queued} training samples.#{skip_msg} ~#{queued * 3} API calls, ~#{((queued * seconds_per_job) / 60.0).ceil} min." + else + redirect_to admin_recon_path, notice: "All #{skipped} samples already collected. Nothing to queue." + end + end + def fetch_personalstats_for_predict(api_key, torn_id) batches = Recon::FeatureSet::API_STAT_NAMES.each_slice(10).to_a batches.reduce({}) do |result, batch| @@ -177,65 +226,36 @@ def quick_add redirect_to admin_recon_path, notice: "Added sample for #{torn_id} (#{total} total). Collecting personalstats..." end - def import - raw_data = params[:spy_data] - - if raw_data.blank? - return redirect_to admin_recon_path, alert: "No data provided." + def import_file + file = params[:file] + unless file.present? + return redirect_to admin_recon_path, alert: "Please select a file to upload." end - rows = Recon::SpyDataParser.parse(raw_data) + content = file.read.force_encoding("UTF-8") + rows = Recon::SpyDataParser.parse_jsonl(content) if rows.empty? - return redirect_to admin_recon_path, alert: "Could not parse any rows. Check the format." - end - - seconds_per_job = 9 # 3 API calls/job, ~20 calls/min max - - existing_ends_at = Rails.cache.read("recon:import_ends_at") - queue_starts_at = if existing_ends_at.present? && existing_ends_at > Time.current - existing_ends_at - else - Time.current + return redirect_to admin_recon_path, alert: "Could not parse any rows. Check the file format." end - queued = 0 - skipped = 0 - rows.each do |row| - if Recon::TrainingSample.exists?(player_id: row.player_id, spied_at: row.spied_at) - skipped += 1 - next - end + import_rows(rows) + end - Recon::TrainingSample.create!( - player_id: row.player_id, - strength: row.strength, - defense: row.defense, - speed: row.speed, - dexterity: row.dexterity, - spied_at: row.spied_at - ) + def import + raw_data = params[:spy_data] - Recon::CollectTrainingSampleJob.set(wait_until: queue_starts_at + (queued * seconds_per_job).seconds).perform_later( - player_id: row.player_id, - spied_at: row.spied_at.to_s - ) - queued += 1 + if raw_data.blank? + return redirect_to admin_recon_path, alert: "No data provided." end - if queued > 0 - new_ends_at = queue_starts_at + (queued * seconds_per_job).seconds - remaining_seconds = (new_ends_at - Time.current).to_i + rows = Recon::SpyDataParser.parse(raw_data) - Rails.cache.write("recon:import_ends_at", new_ends_at, expires_in: remaining_seconds.seconds) + if rows.empty? + return redirect_to admin_recon_path, alert: "Could not parse any rows. Check the format." end - skip_msg = skipped > 0 ? " #{skipped} already collected." : "" - if queued > 0 - redirect_to admin_recon_path, notice: "Queued #{queued} training samples.#{skip_msg} ~#{queued * 3} API calls, ~#{((queued * seconds_per_job) / 60.0).ceil} min." - else - redirect_to admin_recon_path, notice: "All #{skipped} samples already collected. Nothing to queue." - end + import_rows(rows) end end end diff --git a/app/jobs/daily/validate_faction_keys_job.rb b/app/jobs/daily/validate_faction_keys_job.rb index addb083..499c5c4 100644 --- a/app/jobs/daily/validate_faction_keys_job.rb +++ b/app/jobs/daily/validate_faction_keys_job.rb @@ -21,9 +21,8 @@ def validate_faction_keys api_key.update!(faction_access: true) unless api_key.faction_access? rescue TornApi::InvalidKeyError - # Usually already handled by TornApi::Base#invalidate_api_key!, but ensure cleanup api_key.faction.handle_invalid_api_key! - Rails.logger.info("[ValidateKeys] Faction #{api_key.faction.name}: invalid key handled") + Rails.logger.info("[ValidateKeys] Faction #{api_key.faction.name}: invalid key, invalidated") rescue TornApi::ApiError => e Rails.logger.warn("[ValidateKeys] Faction #{api_key.faction.name}: #{e.message}") ensure diff --git a/app/models/faction.rb b/app/models/faction.rb index 331b451..663268a 100644 --- a/app/models/faction.rb +++ b/app/models/faction.rb @@ -79,7 +79,6 @@ def import_spy_report(spy) def handle_invalid_api_key! transaction do - faction_name = name torn_api_key&.destroy! self.torn_api_key = nil update!(setup_completed: false) @@ -89,23 +88,7 @@ def handle_invalid_api_key! end Rails.logger.info("[InvalidKey] Invalidated API key for faction #{name} (#{torn_id}), cancelled jobs") - - Discord::Notifier.notify( - webhook_key: :error_webhook_url, - embed: { - title: "API Key Invalidated", - description: "The Torn API key for **#{name}** was rejected by Torn and has been removed. All background jobs for this faction have been cancelled.", - color: 15_105_570, - fields: [ - { name: "Faction", value: "#{name} [#{torn_id}]", inline: true }, - { name: "Action Required", value: "A leader must provide a new API key in faction settings", inline: false } - ], - footer: { text: "TornManager Key Monitor" }, - timestamp: Time.current.iso8601 - } - ) - rescue => e - Rails.logger.error("[InvalidKey] Discord notification failed: #{e.message}") + notify_key_invalidated end def delete_all_data! @@ -158,4 +141,23 @@ def cancel_background_jobs(user_ids) rescue => e Rails.logger.warn("Failed to cancel faction jobs for faction #{torn_id}: #{e.class} - #{e.message}") end + + def notify_key_invalidated + Discord::Notifier.notify( + webhook_key: :error_webhook_url, + embed: { + title: "API Key Invalidated", + description: "The Torn API key for **#{name}** was rejected by Torn and has been removed. All background jobs for this faction have been cancelled.", + color: 15_105_570, + fields: [ + { name: "Faction", value: "#{name} [#{torn_id}]", inline: true }, + { name: "Action Required", value: "A leader must provide a new API key in faction settings", inline: false } + ], + footer: { text: "TornManager Key Monitor" }, + timestamp: Time.current.iso8601 + } + ) + rescue => e + Rails.logger.error("[InvalidKey] Discord notification failed for #{name}: #{e.message}") + end end diff --git a/app/models/recon/spy_data_parser.rb b/app/models/recon/spy_data_parser.rb index 07f336e..8003a22 100644 --- a/app/models/recon/spy_data_parser.rb +++ b/app/models/recon/spy_data_parser.rb @@ -107,6 +107,39 @@ def self.parse_date(str) nil end - private_class_method :parse_row, :parse_format_1, :parse_format_2, + def self.parse_jsonl(input) + return [] if input.blank? + + input.each_line.filter_map do |line| + line = line.strip + next if line.empty? + + parse_jsonl_line(line) + end + end + + def self.parse_jsonl_line(line) + data = JSON.parse(line) + _, player_id = extract_name_and_id(data["Name"]) + return nil unless player_id + + spied_at = parse_date(data["Last Update"]) + return nil unless spied_at + + SpyRow.new( + player_id: player_id, + name: data["Name"]&.sub(/\s*\[\d+\]\s*$/, ""), + level: data["Level"]&.to_i, + strength: parse_number(data["Strength"]), + defense: parse_number(data["Defense"]), + speed: parse_number(data["Speed"]), + dexterity: parse_number(data["Dexterity"]), + spied_at: spied_at + ) + rescue JSON::ParserError + nil + end + + private_class_method :parse_row, :parse_format_1, :parse_format_2, :parse_jsonl_line, :format_with_faction?, :extract_name_and_id, :parse_number, :parse_level, :parse_date end diff --git a/app/views/admin/recon/show.html.erb b/app/views/admin/recon/show.html.erb index 59421cf..b5ece21 100644 --- a/app/views/admin/recon/show.html.erb +++ b/app/views/admin/recon/show.html.erb @@ -87,6 +87,18 @@ <% end %>
    +
    +

    Upload JSONL File

    +

    Upload a JSONL file with spy data. Each line should be a JSON object with Name (including [ID]), Strength, Defense, Speed, Dexterity, and Last Update fields.

    + + <%= form_with url: import_file_admin_recon_path, method: :post, multipart: true, class: "recon-import-form" do |form| %> +
    + <%= form.file_field :file, accept: ".jsonl,.json", class: "form-input" %> + <%= form.submit "Upload & Queue Jobs", class: "btn btn-primary" %> +
    + <% end %> +
    +

    Training Samples (<%= number_with_delimiter(@training_sample_count) %>)

    diff --git a/app/views/factions/leadership/spy_reports/show.html.erb b/app/views/factions/leadership/spy_reports/show.html.erb index 989d05e..d9ad953 100644 --- a/app/views/factions/leadership/spy_reports/show.html.erb +++ b/app/views/factions/leadership/spy_reports/show.html.erb @@ -42,6 +42,7 @@ <% end %>
    <% end %> +
    diff --git a/config/routes.rb b/config/routes.rb index a01973a..9e75343 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -117,6 +117,7 @@ end resource :recon, only: [ :show ], controller: "recon" do post :import + post :import_file get :stats post :predict post :quick_add diff --git a/test/controllers/admin/recon_controller_test.rb b/test/controllers/admin/recon_controller_test.rb index 1833b32..787ff10 100644 --- a/test/controllers/admin/recon_controller_test.rb +++ b/test/controllers/admin/recon_controller_test.rb @@ -299,6 +299,73 @@ class Admin::ReconControllerTest < ActionDispatch::IntegrationTest assert_match /required/, flash[:alert] end + # -- Import file -- + + test "import_file uploads JSONL and queues jobs" do + tmpfile = Tempfile.new([ "spies", ".jsonl" ]) + tmpfile.write('{"Name": "Alice [111]", "Level": "100", "Faction": "Test", "Strength": "1,000", "Defense": "2,000", "Speed": "500", "Dexterity": "300", "Total": "3,800", "FF Bonus": "3.00", "Last Update": "14/03/24"}') + tmpfile.rewind + + file = Rack::Test::UploadedFile.new(tmpfile.path, "application/json") + + assert_difference "Recon::TrainingSample.count", 1 do + assert_enqueued_with(job: Recon::CollectTrainingSampleJob) do + post import_file_admin_recon_path, params: { file: file } + end + end + + sample = Recon::TrainingSample.last + assert_equal 111, sample.player_id + assert_equal 1000, sample.strength + assert_equal 2000, sample.defense + assert_equal Date.new(2024, 3, 14), sample.spied_at + + assert_redirected_to admin_recon_path + assert_match /Queued 1/, flash[:notice] + ensure + tmpfile&.close! + end + + test "import_file rejects missing file" do + post import_file_admin_recon_path + assert_redirected_to admin_recon_path + assert_match /select a file/, flash[:alert] + end + + test "import_file rejects empty JSONL" do + tmpfile = Tempfile.new([ "empty", ".jsonl" ]) + tmpfile.rewind + + file = Rack::Test::UploadedFile.new(tmpfile.path, "application/json") + post import_file_admin_recon_path, params: { file: file } + + assert_redirected_to admin_recon_path + assert_match /Could not parse/, flash[:alert] + ensure + tmpfile&.close! + end + + test "import_file skips existing samples" do + Recon::TrainingSample.create!( + player_id: 111, strength: 1, defense: 1, speed: 1, dexterity: 1, + spied_at: Date.new(2024, 3, 14) + ) + + tmpfile = Tempfile.new([ "spies", ".jsonl" ]) + tmpfile.write('{"Name": "Alice [111]", "Level": "100", "Faction": "Test", "Strength": "1,000", "Defense": "2,000", "Speed": "500", "Dexterity": "300", "Total": "3,800", "FF Bonus": "3.00", "Last Update": "14/03/24"}') + tmpfile.rewind + + file = Rack::Test::UploadedFile.new(tmpfile.path, "application/json") + + assert_no_difference "Recon::TrainingSample.count" do + post import_file_admin_recon_path, params: { file: file } + end + + assert_match /already collected/, flash[:notice] + ensure + tmpfile&.close! + end + test "import handles format 2 with separate rank column" do spy_data = "1\tPenicillin [1517799]\t100\t655,000,300,610,300\t2,761,464,594,401,500\t8,940,446,370,100\t12,344,518,438\t3,425,417,685,900,338\t30/03/26\t16 minutes ago\t81,243,777" diff --git a/test/models/recon/spy_data_parser_test.rb b/test/models/recon/spy_data_parser_test.rb index c760f1f..2a4cef2 100644 --- a/test/models/recon/spy_data_parser_test.rb +++ b/test/models/recon/spy_data_parser_test.rb @@ -164,4 +164,71 @@ class Recon::SpyDataParserTest < ActiveSupport::TestCase assert_equal [], Recon::SpyDataParser.parse("") assert_equal [], Recon::SpyDataParser.parse(nil) end + + # -- JSONL format -- + + test "jsonl: parses a single line" do + input = '{"Name": "Alice [111]", "Level": "100", "Faction": "Test", "Strength": "1,000", "Defense": "2,000", "Speed": "500", "Dexterity": "300", "Total": "3,800", "FF Bonus": "3.00", "Last Update": "14/03/24"}' + + results = Recon::SpyDataParser.parse_jsonl(input) + assert_equal 1, results.size + + row = results.first + assert_equal 111, row.player_id + assert_equal "Alice", row.name + assert_equal 100, row.level + assert_equal 1000, row.strength + assert_equal 2000, row.defense + assert_equal 500, row.speed + assert_equal 300, row.dexterity + assert_equal Date.new(2024, 3, 14), row.spied_at + end + + test "jsonl: parses multiple lines" do + input = <<~JSONL + {"Name": "Alice [111]", "Level": "100", "Strength": "1,000", "Defense": "2,000", "Speed": "500", "Dexterity": "300", "Total": "3,800", "FF Bonus": "3.00", "Last Update": "14/03/24"} + {"Name": "Bob [222]", "Level": "50", "Strength": "500", "Defense": "500", "Speed": "100", "Dexterity": "100", "Total": "1,200", "FF Bonus": "3.00", "Last Update": "01/07/23"} + JSONL + + results = Recon::SpyDataParser.parse_jsonl(input) + assert_equal 2, results.size + end + + test "jsonl: strips commas from large numbers" do + input = '{"Name": "Tim [179208]", "Level": "100", "Strength": "1,000,004,235,571,900", "Defense": "1,232,505,463", "Speed": "423,436,139,476", "Dexterity": "5,017,752,739,987", "Total": "1,005,446,656,956,826", "FF Bonus": "3.00", "Last Update": "17/02/26"}' + + row = Recon::SpyDataParser.parse_jsonl(input).first + assert_equal 1_000_004_235_571_900, row.strength + assert_equal 1_232_505_463, row.defense + end + + test "jsonl: skips lines without valid player ID" do + input = '{"Name": "BadData", "Strength": "100", "Last Update": "14/03/24"}' + + results = Recon::SpyDataParser.parse_jsonl(input) + assert_equal 0, results.size + end + + test "jsonl: skips lines without valid date" do + input = '{"Name": "Alice [111]", "Strength": "100", "Defense": "100", "Speed": "100", "Dexterity": "100", "Last Update": "N/A"}' + + results = Recon::SpyDataParser.parse_jsonl(input) + assert_equal 0, results.size + end + + test "jsonl: skips malformed JSON lines" do + input = <<~JSONL + not valid json + {"Name": "Alice [111]", "Level": "100", "Strength": "1,000", "Defense": "2,000", "Speed": "500", "Dexterity": "300", "Total": "3,800", "FF Bonus": "3.00", "Last Update": "14/03/24"} + JSONL + + results = Recon::SpyDataParser.parse_jsonl(input) + assert_equal 1, results.size + assert_equal 111, results.first.player_id + end + + test "jsonl: returns empty array for empty input" do + assert_equal [], Recon::SpyDataParser.parse_jsonl("") + assert_equal [], Recon::SpyDataParser.parse_jsonl(nil) + end end From 65a566b8a48f4dbcadf8818b95c950f950c8ac88 Mon Sep 17 00:00:00 2001 From: Bram Date: Sun, 12 Apr 2026 14:40:01 +0200 Subject: [PATCH 255/266] subscriptions admin overview --- app/controllers/admin/stats_controller.rb | 13 ++++++++++ app/views/admin/stats/index.html.erb | 31 +++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb index b551181..5e5a41c 100644 --- a/app/controllers/admin/stats_controller.rb +++ b/app/controllers/admin/stats_controller.rb @@ -4,6 +4,7 @@ class StatsController < ApplicationController def index load_user_stats + load_subscription_stats load_faction_stats load_snapshot_stats load_activity_stats @@ -23,6 +24,18 @@ def load_user_stats @api_keys_configured = ApiKey::Torn.where.not(user_id: nil).count end + def load_subscription_stats + @total_subscribers = Subscription.where("expires_at > ?", Time.current).count + @subscribed_factions = Faction + .joins(:subscription) + .where("subscriptions.expires_at > ?", Time.current) + .includes(:subscription) + .order(:name) + @xanax_received_past_month = XanaxPayment + .where("processed_at >= ?", 1.month.ago) + .sum(:xanax_amount) + end + def load_faction_stats @total_factions = Faction.count @factions_with_backfill = Faction.where("backfill_ends_at > ?", Time.current).count diff --git a/app/views/admin/stats/index.html.erb b/app/views/admin/stats/index.html.erb index 91dee82..57e123d 100644 --- a/app/views/admin/stats/index.html.erb +++ b/app/views/admin/stats/index.html.erb @@ -61,6 +61,37 @@ <% end %>
    + <%# ── Subscriptions ── %> +
    +

    Subscriptions

    +
    +
    + Total active + <%= @total_subscribers %> +
    +
    + Xanax received (30d) + <%= @xanax_received_past_month %> +
    +
    + <% if @subscribed_factions.any? %> + + + + + + <% @subscribed_factions.each do |f| %> + + + + + + <% end %> + +
    FactionExpiresDays
    <%= f.name %><%= f.subscription.expires_at.strftime("%d %b %Y") %><%= f.subscription.days_remaining %>
    + <% end %> +
    + <%# ── Factions ── %>

    Factions <%= @total_factions %> / 15

    From cab0f19606b1c7c20e7cbfc4b069e51e1390f895 Mon Sep 17 00:00:00 2001 From: Bram Date: Mon, 13 Apr 2026 10:03:26 +0200 Subject: [PATCH 256/266] initial plan --- .../admin/snapshot_management_controller.rb | 14 +- app/controllers/admin/stats_controller.rb | 35 ++++ app/jobs/backfill_hof_stats_job.rb | 11 +- app/jobs/backfill_personal_stats_job.rb | 3 +- app/jobs/backfill_ranked_wars_job.rb | 4 +- app/jobs/backfill_single_stat_job.rb | 3 +- app/jobs/backfill_user_stats_job.rb | 14 +- app/jobs/daily/faction_member_sync_job.rb | 81 +++++++++ app/jobs/daily/faction_membership_sync_job.rb | 11 -- app/jobs/daily/hof_member_sync_job.rb | 45 +++++ app/jobs/daily/personal_stats_job.rb | 11 -- app/jobs/fetch_personal_stats_job.rb | 16 +- .../snapshot_management/_gap_table.html.erb | 68 ++++++++ .../admin/snapshot_management/index.html.erb | 85 ++-------- app/views/admin/stats/index.html.erb | 18 ++ config/credentials.yml.enc | 2 +- config/recurring.yml | 16 +- .../snapshot_management_controller_test.rb | 8 +- test/jobs/admin_api_job_test.rb | 1 - test/jobs/backfill_personal_stats_job_test.rb | 47 +----- .../daily/faction_member_sync_job_test.rb | 157 ++++++++++++++++++ test/jobs/daily/hof_member_sync_job_test.rb | 90 ++++++++++ .../daily_faction_membership_sync_job_test.rb | 29 ---- test/jobs/daily_stats_harvest_job_test.rb | 42 ----- test/jobs/fetch_personal_stats_job_test.rb | 59 ++++--- 25 files changed, 600 insertions(+), 270 deletions(-) create mode 100644 app/jobs/daily/faction_member_sync_job.rb delete mode 100644 app/jobs/daily/faction_membership_sync_job.rb create mode 100644 app/jobs/daily/hof_member_sync_job.rb delete mode 100644 app/jobs/daily/personal_stats_job.rb create mode 100644 app/views/admin/snapshot_management/_gap_table.html.erb create mode 100644 test/jobs/daily/faction_member_sync_job_test.rb create mode 100644 test/jobs/daily/hof_member_sync_job_test.rb delete mode 100644 test/jobs/daily_faction_membership_sync_job_test.rb delete mode 100644 test/jobs/daily_stats_harvest_job_test.rb diff --git a/app/controllers/admin/snapshot_management_controller.rb b/app/controllers/admin/snapshot_management_controller.rb index 30ecf72..cdafcb1 100644 --- a/app/controllers/admin/snapshot_management_controller.rb +++ b/app/controllers/admin/snapshot_management_controller.rb @@ -5,14 +5,16 @@ class SnapshotManagementController < ApplicationController SECONDS_PER_API_CALL = 1.1 def index - @users_with_gaps = users_with_missing_snapshots + all_gaps = users_with_missing_snapshots + @faction_users_with_gaps = all_gaps.select { |u| u[:user].faction_id.present? } + @hof_users_with_gaps = all_gaps.reject { |u| u[:user].faction_id.present? } @summary = calculate_summary end def backfill_user user = User.find(params[:id]) missing_dates = missing_dates_for_user(user) - api_key = user.faction&.torn_api_key&.key || AdminCredentials.api_key + api_key = resolve_api_key(user) return render json: { success: false, message: "No API key available for #{user.name}" }, status: :unprocessable_entity if api_key.blank? @@ -32,6 +34,14 @@ def backfill_user private + def resolve_api_key(user) + if user.faction&.torn_api_key&.key + user.faction.torn_api_key.key + else + Rails.application.credentials.dig(:kaneki, :api_key) + end + end + def users_with_missing_snapshots tracked_user_ids = User.tracked_for_stats.pluck(:id) return [] if tracked_user_ids.empty? diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb index 5e5a41c..4eb02b7 100644 --- a/app/controllers/admin/stats_controller.rb +++ b/app/controllers/admin/stats_controller.rb @@ -108,6 +108,27 @@ def load_api_stats @admin_api_total = admin_calls.count @admin_api_peak_all_time = peak_rate(admin_calls) @admin_api_peak_today = peak_rate(admin_calls.today) + + @api_key_breakdown = ApiCall.where("created_at > ?", 1.day.ago) + .group(:api_key) + .select( + "api_key", + "COUNT(*) as total_calls", + "SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error_count" + ) + .order("total_calls DESC") + .map do |row| + key = row.api_key + owner = resolve_key_owner(key) + { + key: key, + display_key: "#{key[0..3]}...#{key[-4..]}", + owner: owner, + total: row.total_calls, + errors: row.error_count, + peak_rate: peak_rate(ApiCall.where(api_key: key)) + } + end end def peak_rate(scope) @@ -154,6 +175,20 @@ def load_armory_stats end end + def resolve_key_owner(key) + return "Admin" if key == AdminCredentials.api_key + return "Kaneki (HoF)" if key == Rails.application.credentials.dig(:kaneki, :api_key) + + api_key = ApiKey.find_by(key: key) + if api_key&.faction + api_key.faction.name + elsif api_key&.user + api_key.user.name + else + "Unknown" + end + end + def calculate_snapshot_gaps tracked_user_ids = User.tracked_for_stats.pluck(:id) return set_empty_gap_stats if tracked_user_ids.empty? diff --git a/app/jobs/backfill_hof_stats_job.rb b/app/jobs/backfill_hof_stats_job.rb index d15c8b1..eec303f 100644 --- a/app/jobs/backfill_hof_stats_job.rb +++ b/app/jobs/backfill_hof_stats_job.rb @@ -4,6 +4,9 @@ class BackfillHofStatsJob < ApplicationJob SECONDS_PER_API_CALL = 1.1 def perform(start_date = nil, end_date = nil) + api_key = Rails.application.credentials.dig(:kaneki, :api_key) + return Rails.logger.warn("BackfillHofStatsJob: No kaneki API key configured, skipping") unless api_key + start_date = (start_date || PersonalStatSnapshot.tracking_start_date).to_date end_date = (end_date || PersonalStatSnapshot.tracking_end_date).to_date @@ -17,15 +20,11 @@ def perform(start_date = nil, end_date = nil) users.each_with_index do |user, user_index| dates.each_with_index do |date, date_index| delay = (user_index * dates.size) + date_index - BackfillSingleStatJob.set(wait: delay.seconds).perform_later(user.id, date.to_s, faction_id: user.faction_id) + BackfillSingleStatJob.set(wait: delay.seconds).perform_later(user.id, date.to_s, faction_id: user.faction_id, api_key: api_key) jobs_scheduled += 1 end end - existing_queued_jobs = SolidQueue::Job.where(queue_name: "faction", finished_at: nil).count - total_api_calls = existing_queued_jobs + (jobs_scheduled * 2) - estimated_seconds = [ total_api_calls * SECONDS_PER_API_CALL, 1 ].max.to_i - - Rails.logger.info("Scheduled #{jobs_scheduled} HOF stat fetch jobs, completing in ~#{estimated_seconds}s") + Rails.logger.info("BackfillHofStatsJob: Scheduled #{jobs_scheduled} jobs") end end diff --git a/app/jobs/backfill_personal_stats_job.rb b/app/jobs/backfill_personal_stats_job.rb index 1945afb..94d8b4a 100644 --- a/app/jobs/backfill_personal_stats_job.rb +++ b/app/jobs/backfill_personal_stats_job.rb @@ -5,7 +5,8 @@ class BackfillPersonalStatsJob < ApplicationJob def perform(faction_id, start_date, end_date) faction = Faction.find(faction_id) - api_key = faction.torn_api_key&.key || AdminCredentials.api_key + api_key = faction.torn_api_key&.key + return Rails.logger.warn("BackfillPersonalStatsJob: No API key for faction #{faction.name}, skipping") unless api_key users = faction.users.active.to_a dates = (start_date.to_date..end_date.to_date).to_a diff --git a/app/jobs/backfill_ranked_wars_job.rb b/app/jobs/backfill_ranked_wars_job.rb index af4c745..5a70ba5 100644 --- a/app/jobs/backfill_ranked_wars_job.rb +++ b/app/jobs/backfill_ranked_wars_job.rb @@ -5,8 +5,8 @@ def perform(faction_id, limit: 20) faction = Faction.find_by(id: faction_id) return unless faction - api_key = faction.torn_api_key&.key || AdminCredentials.api_key - return unless api_key.present? + api_key = faction.torn_api_key&.key + return Rails.logger.warn("BackfillRankedWarsJob: No API key for faction #{faction.name}, skipping") unless api_key wars = TornApi::Faction::RankedWars.new(api_key, faction.torn_id).fetch(limit: limit) wars_needing_reports = [] diff --git a/app/jobs/backfill_single_stat_job.rb b/app/jobs/backfill_single_stat_job.rb index 6466314..dd116a3 100644 --- a/app/jobs/backfill_single_stat_job.rb +++ b/app/jobs/backfill_single_stat_job.rb @@ -4,9 +4,8 @@ class BackfillSingleStatJob < FactionApiJob def perform(user_id, date_str, faction_id:, batch: 1, api_key: nil) user = User.find(user_id) date = Date.parse(date_str) - api_key = api_key.presence || AdminCredentials.api_key - return Rails.logger.error("No API key found") if api_key.blank? + return Rails.logger.error("BackfillSingleStatJob: No API key for #{user.name}, skipping") if api_key.blank? stats = fetch_stats(user, date, api_key, batch) return if stats.nil? diff --git a/app/jobs/backfill_user_stats_job.rb b/app/jobs/backfill_user_stats_job.rb index a401659..f1cf002 100644 --- a/app/jobs/backfill_user_stats_job.rb +++ b/app/jobs/backfill_user_stats_job.rb @@ -1,26 +1,26 @@ class BackfillUserStatsJob < ApplicationJob queue_as :faction - def perform(user_id, start_date, end_date) + def perform(user_id, start_date, end_date, api_key: nil) user = User.find(user_id) - dates = (start_date.to_date..end_date.to_date).to_a - - Rails.logger.info("Scheduling backfill for user #{user.name} (#{user.torn_id}): #{dates.size} days") + api_key ||= user.faction&.torn_api_key&.key + return Rails.logger.warn("BackfillUserStatsJob: No API key for #{user.name}, skipping") unless api_key - jobs_scheduled = 0 + dates = (start_date.to_date..end_date.to_date).to_a existing_dates = user.personal_stat_snapshots .where(date: dates.first..dates.last) .pluck(:date) .to_set + jobs_scheduled = 0 dates.each do |date| next if existing_dates.include?(date) - BackfillSingleStatJob.perform_later(user.id, date.to_s, faction_id: user.faction_id) + BackfillSingleStatJob.perform_later(user.id, date.to_s, faction_id: user.faction_id, api_key: api_key) jobs_scheduled += 1 end - Rails.logger.info("Scheduled #{jobs_scheduled} stat fetch jobs for user #{user.name}") + Rails.logger.info("BackfillUserStatsJob: Scheduled #{jobs_scheduled} jobs for #{user.name}") end end diff --git a/app/jobs/daily/faction_member_sync_job.rb b/app/jobs/daily/faction_member_sync_job.rb new file mode 100644 index 0000000..9565c06 --- /dev/null +++ b/app/jobs/daily/faction_member_sync_job.rb @@ -0,0 +1,81 @@ +module Daily + class FactionMemberSyncJob < ApplicationJob + queue_as :default + + def perform + Faction.where(setup_completed: true).includes(:torn_api_key).find_each do |faction| + next unless faction.torn_api_key&.key + + sync_and_enqueue(faction) + rescue TornApi::ApiError, TornApi::InvalidKeyError => e + Rails.logger.error("[FactionMemberSync] Failed for #{faction.name}: #{e.message}") + end + end + + private + + def sync_and_enqueue(faction) + api_key = faction.torn_api_key.key + members = TornApi::Faction::Members.new(api_key, faction.torn_id).fetch + + sync_members(faction, members, api_key) + + members.each do |member| + next if member.status_state == "Fallen" + + user = User.find_by(torn_id: member.id) + next unless user + + FetchPersonalStatsJob.perform_later(user, api_key: api_key) + backfill_gaps(user, api_key) + end + end + + def sync_members(faction, members, api_key) + member_torn_ids = members.map(&:id) + + User.where(faction_id: faction.id) + .where.not(torn_id: member_torn_ids) + .update_all(faction_id: nil) + + members.each do |member| + user = User.find_or_initialize_by(torn_id: member.id) + new_member = user.new_record? + user.assign_attributes( + name: member.name, + level: member.level, + faction_id: faction.id, + position: member.position, + fallen: member.status_state == "Fallen" + ) + user.save! + + schedule_backfill(user, api_key) if new_member + end + end + + def backfill_gaps(user, api_key) + existing = user.personal_stat_snapshots.pluck(:date).to_set + expected = (PersonalStatSnapshot.tracking_start_date..PersonalStatSnapshot.tracking_end_date).to_a + yesterday = Date.current.yesterday + + missing = expected.reject { |d| existing.include?(d) || d == yesterday } + return if missing.empty? + + missing.each do |date| + BackfillSingleStatJob.perform_later(user.id, date.to_s, faction_id: user.faction_id, api_key: api_key) + end + end + + def schedule_backfill(user, api_key) + start_date = PersonalStatSnapshot.tracking_start_date + end_date = PersonalStatSnapshot.tracking_end_date + days = (end_date - start_date).to_i + 1 + + estimated_seconds = (days * 2 * BackfillPersonalStatsJob::SECONDS_PER_API_CALL).ceil + user.update!(backfill_ends_at: Time.current + estimated_seconds.seconds) + + BackfillUserStatsJob.perform_later(user.id, start_date.to_s, end_date.to_s, api_key: api_key) + end + end +end diff --git a/app/jobs/daily/faction_membership_sync_job.rb b/app/jobs/daily/faction_membership_sync_job.rb deleted file mode 100644 index 26e732c..0000000 --- a/app/jobs/daily/faction_membership_sync_job.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Daily - class FactionMembershipSyncJob < ApplicationJob - queue_as :default - - def perform - Faction.find_each do |faction| - SyncFactionMembersJob.perform_later(faction.id) - end - end - end -end diff --git a/app/jobs/daily/hof_member_sync_job.rb b/app/jobs/daily/hof_member_sync_job.rb new file mode 100644 index 0000000..873d01e --- /dev/null +++ b/app/jobs/daily/hof_member_sync_job.rb @@ -0,0 +1,45 @@ +module Daily + class HofMemberSyncJob < ApplicationJob + queue_as :default + + def perform + api_key = Rails.application.credentials.dig(:kaneki, :api_key) + return Rails.logger.warn("[HofMemberSync] No kaneki API key configured, skipping") unless api_key + + hof_users_without_faction_key.find_each do |user| + FetchPersonalStatsJob.perform_later(user, api_key: api_key) + backfill_gaps(user, api_key) + end + end + + private + + def backfill_gaps(user, api_key) + existing = user.personal_stat_snapshots.pluck(:date).to_set + expected = (PersonalStatSnapshot.tracking_start_date..PersonalStatSnapshot.tracking_end_date).to_a + yesterday = Date.current.yesterday + + missing = expected.reject { |d| existing.include?(d) || d == yesterday } + return if missing.empty? + + missing.each do |date| + BackfillSingleStatJob.perform_later(user.id, date.to_s, faction_id: user.faction_id, api_key: api_key) + end + end + + def hof_users_without_faction_key + covered_faction_ids = Faction + .joins(:api_keys) + .where(setup_completed: true, api_keys: { type: "ApiKey::Torn" }) + .pluck(:id) + + base = User.where(hof_stats_user: true, fallen: false) + + if covered_faction_ids.any? + base.where(faction_id: nil).or(base.where.not(faction_id: covered_faction_ids)) + else + base + end + end + end +end diff --git a/app/jobs/daily/personal_stats_job.rb b/app/jobs/daily/personal_stats_job.rb deleted file mode 100644 index bbde4e4..0000000 --- a/app/jobs/daily/personal_stats_job.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Daily - class PersonalStatsJob < ApplicationJob - queue_as :default - - def perform - User.tracked_for_stats.find_each do |user| - FetchPersonalStatsJob.perform_later(user) - end - end - end -end diff --git a/app/jobs/fetch_personal_stats_job.rb b/app/jobs/fetch_personal_stats_job.rb index e85b7d1..324ac40 100644 --- a/app/jobs/fetch_personal_stats_job.rb +++ b/app/jobs/fetch_personal_stats_job.rb @@ -1,17 +1,19 @@ -class FetchPersonalStatsJob < AdminApiJob +class FetchPersonalStatsJob < FactionApiJob + limits_concurrency to: 1, key: ->(user, api_key:, **) { api_key }, group: "PersonalStatsApiCalls" + MAX_RETRIES = 3 - def perform(user, batch: 1, stats_date: Date.current.yesterday, retries: 0) - stats = fetch_stats(user, batch, stats_date) + def perform(user, api_key:, batch: 1, stats_date: Date.current.yesterday, retries: 0) + stats = fetch_stats(api_key, user, batch, stats_date) user.check_hof_eligibility!(stats[:items_used_stat_enhancers]) if batch == 1 save_snapshot(user, stats, stats_date) - FetchPersonalStatsJob.perform_later(user, batch: 2, stats_date: stats_date) if batch == 1 + FetchPersonalStatsJob.perform_later(user, api_key: api_key, batch: 2, stats_date: stats_date) if batch == 1 rescue TornApi::InvalidKeyError => e if retries < MAX_RETRIES Rails.logger.warn("FetchPersonalStatsJob: Failed for #{user.name} (#{user.torn_id}): #{e.message}, retry #{retries + 1}/#{MAX_RETRIES} in 1 hour") - FetchPersonalStatsJob.set(wait: 1.hour).perform_later(user, batch: batch, stats_date: stats_date, retries: retries + 1) + FetchPersonalStatsJob.set(wait: 1.hour).perform_later(user, api_key: api_key, batch: batch, stats_date: stats_date, retries: retries + 1) else Rails.logger.error("FetchPersonalStatsJob: Giving up on #{user.name} (#{user.torn_id}) after #{MAX_RETRIES} retries: #{e.message}") Discord::Notifier.notify( @@ -33,11 +35,11 @@ def perform(user, batch: 1, stats_date: Date.current.yesterday, retries: 0) private - def fetch_stats(user, batch, stats_date) + def fetch_stats(api_key, user, batch, stats_date) stat_batch = batch == 1 ? PersonalStatSnapshot::TRACKED_STATS_BATCH_1 : PersonalStatSnapshot::TRACKED_STATS_BATCH_2 TornApi::User::PersonalStats.new( - AdminCredentials.api_key, + api_key, user.torn_id, timestamp: stats_date.end_of_day.to_i, stat_batch: stat_batch diff --git a/app/views/admin/snapshot_management/_gap_table.html.erb b/app/views/admin/snapshot_management/_gap_table.html.erb new file mode 100644 index 0000000..955460d --- /dev/null +++ b/app/views/admin/snapshot_management/_gap_table.html.erb @@ -0,0 +1,68 @@ +
    + + + + + + + + + + + + <% users_with_gaps.each do |data| %> + + + + + + + + + + + + + + <% end %> +
    UserFactionMissingOldest GapLatest SnapshotActions
    + + <%= data[:user].name %> + + [<%= data[:user].torn_id %>] + + <% if data[:user].faction %> + <%= data[:user].faction.name %> + <% else %> + HoF + <% end %> + + + <%= data[:missing_count] %> days + + <%= data[:oldest_missing]&.strftime("%d-%m-%Y") || "-" %><%= data[:latest_snapshot]&.strftime("%d-%m-%Y") || "Never" %> + <%= button_to "Backfill", + backfill_user_admin_snapshot_management_path(data[:user]), + method: :post, + class: "btn-small btn-primary", + data: { action: "click->backfill-row#remove" } %> + + +
    +
    diff --git a/app/views/admin/snapshot_management/index.html.erb b/app/views/admin/snapshot_management/index.html.erb index 87cc4a5..d7e7865 100644 --- a/app/views/admin/snapshot_management/index.html.erb +++ b/app/views/admin/snapshot_management/index.html.erb @@ -36,81 +36,20 @@
    -
    -

    Users With Missing Snapshots (<%= @users_with_gaps.size %>)

    -
    +

    Faction Members With Missing Snapshots (<%= @faction_users_with_gaps.size %>)

    + <% if @faction_users_with_gaps.any? %> + <%= render partial: "admin/snapshot_management/gap_table", locals: { users_with_gaps: @faction_users_with_gaps } %> + <% else %> +

    All faction members have complete snapshot coverage!

    + <% end %> +
    - <% if @users_with_gaps.any? %> -
    - - - - - - - - - - - - <% @users_with_gaps.each do |data| %> - - - - - - - - - - - - - - <% end %> -
    UserFactionMissingOldest GapLatest SnapshotActions
    - - <%= data[:user].name %> - - [<%= data[:user].torn_id %>] - - <% if data[:user].faction %> - <%= data[:user].faction.name %> - <% else %> - - - <% end %> - - - <%= data[:missing_count] %> days - - <%= data[:oldest_missing]&.strftime("%d-%m-%Y") || "-" %><%= data[:latest_snapshot]&.strftime("%d-%m-%Y") || "Never" %> - <%= button_to "Backfill", - backfill_user_admin_snapshot_management_path(data[:user]), - method: :post, - class: "btn-small btn-primary", - data: { action: "click->backfill-row#remove" } %> - - -
    -
    +
    +

    HoF Users With Missing Snapshots (<%= @hof_users_with_gaps.size %>)

    + <% if @hof_users_with_gaps.any? %> + <%= render partial: "admin/snapshot_management/gap_table", locals: { users_with_gaps: @hof_users_with_gaps } %> <% else %> -

    All tracked users have complete snapshot coverage!

    +

    All HoF users have complete snapshot coverage!

    <% end %>
    diff --git a/app/views/admin/stats/index.html.erb b/app/views/admin/stats/index.html.erb index 57e123d..40687db 100644 --- a/app/views/admin/stats/index.html.erb +++ b/app/views/admin/stats/index.html.erb @@ -271,6 +271,24 @@ <%= @admin_api_peak_today %>/min + <% if @api_key_breakdown.any? %> + + + + + + <% @api_key_breakdown.each do |row| %> + + + + + + + + <% end %> + +
    KeyOwnerCalls (24h)Peak/minErrors
    <%= row[:display_key] %><%= row[:owner] %><%= number_with_delimiter(row[:total]) %><%= row[:peak_rate] %>/min<%= row[:errors] %>
    + <% end %> <%# ── Data Health ── %> diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index 2fa623d..fa3f412 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -JWY4QPOwE0VS4J9ZW+6hmXwmOEgO39gsoBFwIGOejR7EN/Me/IcazHO7yRhXAvaP1X+pYEYDUKTVpQqLCswv0buafwki6Z63x/vksXfXkFNNutVariBeGTys3L+AIw/uD0hHzp/6XlSyNSlwtgMrXqJXJd7hM/thDQ1Hivs9SiWiDRXTQ7n7a/peABzM05kshEgw8zRpL10wcE8Xlb5BEcathJE2W6w0CRfMOKh3m4M38yi2NA1mrgdq2BK/nubCGrcEjWrz0yHW3Tt0tjgBaQLQHnXeg52DyEFMXQ54yFzjaoODK5YZth9qCw8q5SWnh9rS8h3CQ+p7tXbd5qZMQLuIL0+gtnNFTXOhx4nfPr5AFVNKuBCC7G5UoD8AdGDZnkMn/hUs3TtcBApv5wlXF1KBNkFukxPyJlkzUCDYFrRpEN2QAOO+Lkdfs2X7ek0DO2YmArGjVIjkhyOXel6XC770Cy4A7HXlBu+Trtsussa2Crqf/Ya4sc49ocIedaZkE0iF41uxuyaN70PFD59kVFKjNj8V4tS17u8PovFlZTQ84wQ+FeANUWsJUSvQrflGP8nzLzYMiZ+gFn5xY1xRGfmOWZ0F4Oi0OsgqHFG6166YgyKp812iNhdxSbYCgxbFZhQzZDgKx81lDnghhFj3/GbN+C+8AsPsNyVxxnKLz/cPbmwj5YJZmZM6pjchMgtjbX8/xo2hJ9tacFrMDDIQJ/CoyAZiE3iWXl9HFsepP1rl+wN1ygvZ1pi9ZRTQmLsQblDrkHl7zovvUMsWecY1c9LoX5CnBaf4ACljD9INJtrqr+6k/4PX1T81BFgMXjoGUeZwqI0Xnf39As1hWE7HdmPjiroqgw8GAlxbAfw8MdJLOV04G64n82vkBciOSjXY5zB2dsGB9Sc5VYDpEd8srQrJYG1aBltxuI3AcpKqjhnO0OhzLaiCtJGEZKHzr2rtP4X6ru791cKXGaMu8QVBeCcxw4QH9f8UV35Qe5MCJpclM5v8NAQPu5p/BD0iigmUKxmyTh79LVbo4y3/Gxm9YIYoIAAu4PjNvAEnMsLvebApHl9wDjlctrfNJk8c11Bc2vXxJTF9QAhEWuYrakC+CkO4gWemAHjIlOQAQcXpiITSErVneY6BrDMjGRx/tXcAXk7dilu8fgQ/8VU1tvtmLOSm3OBzmY/XHnMVv1OGbbCQbDCNpNDCjZGZJr5PYoHT07erAuYHnj+TED8EW7CcstgA7F7GnjxA8k/j/Sph0rZTo/Jw8vWv0NVenQZPN4Dc49hZ64ILEH7U6SRKxT/fvi2FdjddRX4uyJoeiflBeClhu2bh1T8/xf3JJe1XBNOyF51sCmUE88rWCMWmGXUniePheOHPeUn5VdRMBlBN42pplrTEBuUemgrYRwcmNtDA4mr8ov1BCazwBFyJ6gYJ5WeRrQgB6Dm2KsSSzvxcF15ZaAYe4Tmfzf0Bl1f0wNrujqkX8QhU3Hs5cLw=--oTPKwAEHeaIo538s--8dF7cQU7ue3uQrq/YVpWYA== \ No newline at end of file +jE3UuB16xIPUn0Abl2kP0WOTFAhc5Dq9FTbtc8nNISWpkKnYsv3xLmVrmr0b4i7mXHLmx161GW0xteP0eDFDs69uncdeaFlACuMIB3CmgVCBdx3RFw1p76zklKgK4cUs1MSfMHolbeRr96Pr6RS1bT711Tb4z8kcsJSM1MpzZdLdWjLu9is8QRkXeg+A7fsIvNmeN9UEwOPKqNwQB9HGgsvagJx37oE2uhGOsuGduoGejTurkzrYTWLMM48Zs6kk7p/Pngc7V+P5UtR5Rp1rWytg4bZdWEsylCoKieipY/cUC6Bnzjk+/1q27Cxw2UulP7LPpMyOZOAfIPcNaCu2wOHtQk9Sw/o3cpO77beFn1Zs0eaHRpI03oEMkiNbz4RGU/zcBKQmipTHVKe8xsWsd06CrrubrgCntngoJEfO7+68kSBNOPRCH+q0JQqQsh5GGCogxjCwumATA5AqVaQgsh9xdus66mtyDNHrdkoPsrI5JTW/UcSUnpfaShP9P836MsBoAOE/VkTuG/9szTuOe1gRba5lX3gnBoRJHYRVN8Ns3eQBQCLNb9nO+HdG0I6tFvyD1L9cDj4MByJ4XJE0Syo5CEaiJUT9hm7eHn3ZYj84koUb3RUTHr1z8Dv4UCq3cWWxMQDobmtoV/qxpRAxD/U00vE6rB/A+E2GbAscWVOc5phpO4qIn07ciauo13zMvfZf5V4RpqYXpSzYEpbTQZzlQVByxOSStZ0/4GXzECiLvbEm+7G3FN4zMZMx8T+bjWp9e+lBK/rd6ky55eXT7OERhNV1rALSZi0+hp8nqy0STAsUm2wDY+F/CYDiIzUrWYa6DvK6n28GO/h9Z/EjuiGxvJjaOGUBZGnGpS/O+lI28cQdJZsoJarQFXtdnqlpXb4BK1MFp21g70QQAwf7tUrq9FSFSWuhU05DTvJdQK6tgl/kAA6hyX/LRJh9q0XxAD1tu+KlOVg08mZ3Fq4FWTiCklralGzEwUfS/igCguSjauA9XxxMF/FuKUtquaBqN+BzK1fQCMHybglx+PXdDGk2dsudLTShOSUe/yF/1rcpm9tQvT8zuyOopTEHDQpPgc3PQhsVCsmEznjtFZbhONiETPMGFTP2i+OEFrnGG9rDt3tquaRGIGI0VIfm/XhkNUyxSaTf3rBa0jFGk4B9ZKeJ9r40DmfONW6akFoboYZ1HLStSYl73Fh4+24T19FsDpzXLa1rnsa+I6/j3i+8xbcrQcc5/Kdo2L8EMbOP1YwitoBfvIKDdrOCxreMKsZblZsfmC5YWhgdnP3fuvapFCshRQs6GgsFz5p8h6s5Oo6qO/hZwWUMi13+rtciYW9PSnejfKTqKvf+mzRTu5YFReRZ7XQuiJ5bDd+n6RmI42irPd9aXaGIJ1KHMJz/BdDihcSfnK6BTyIj5t5boSFDNChv5QOkH5pcQMOlqDgoO1O4lE3t8XSe7hC0IO3BEad8Mu249vukLffceHUcVeUw+lSOO37ojBhHHRACdwAVHYyku6FnyBD5--TGZ8os58WPPJPDWf--5RdTGh6m1Naf1RlZnCbvzw== \ No newline at end of file diff --git a/config/recurring.yml b/config/recurring.yml index 5a1cd8d..f3fb78b 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -18,18 +18,18 @@ production: class: Daily::DataRetentionCleanupJob queue: default schedule: at 12am every day - faction_membership_sync: - class: Daily::FactionMembershipSyncJob + faction_member_sync: + class: Daily::FactionMemberSyncJob queue: default schedule: at 2:30am every day + hof_member_sync: + class: Daily::HofMemberSyncJob + queue: default + schedule: at 3am every day ranked_war_refresh: class: Daily::RankedWarRefreshJob queue: default schedule: at 2am every day - daily_stats_harvest: - class: Daily::PersonalStatsJob - queue: default - schedule: at 3am every day fetch_xanax_payments: class: Daily::XanaxPaymentsJob queue: default @@ -51,8 +51,4 @@ production: # class: Daily::StockDividendJob # queue: default # schedule: every 20 seconds -# daily_stats_harvest: -# class: Daily::PersonalStatsJob -# queue: default -# schedule: every 20 seconds diff --git a/test/controllers/admin/snapshot_management_controller_test.rb b/test/controllers/admin/snapshot_management_controller_test.rb index d243692..9545854 100644 --- a/test/controllers/admin/snapshot_management_controller_test.rb +++ b/test/controllers/admin/snapshot_management_controller_test.rb @@ -21,16 +21,16 @@ class Admin::SnapshotManagementControllerTest < ActionDispatch::IntegrationTest end end - test "backfill_user falls back to admin key for hof user without faction" do + test "backfill_user falls back to kaneki key for hof user without faction" do sign_in_as(@bram) stub_solid_queue_count - AdminCredentials.stubs(:api_key).returns("admin_fallback_key") + Rails.application.credentials.stubs(:dig).with(:kaneki, :api_key).returns("kaneki_key") hof_user = users(:user_hof_no_faction) assert_enqueued_with( job: BackfillSingleStatJob, - args: ->(args) { args.last[:api_key] == "admin_fallback_key" } + args: ->(args) { args.last[:api_key] == "kaneki_key" } ) do post backfill_user_admin_snapshot_management_path(hof_user) end @@ -39,7 +39,7 @@ class Admin::SnapshotManagementControllerTest < ActionDispatch::IntegrationTest test "backfill_user returns error when no api key available" do sign_in_as(@bram) stub_solid_queue_count - AdminCredentials.stubs(:api_key).returns(nil) + Rails.application.credentials.stubs(:dig).with(:kaneki, :api_key).returns(nil) hof_user = users(:user_hof_no_faction) post backfill_user_admin_snapshot_management_path(hof_user) diff --git a/test/jobs/admin_api_job_test.rb b/test/jobs/admin_api_job_test.rb index 05945ad..3cc8a2b 100644 --- a/test/jobs/admin_api_job_test.rb +++ b/test/jobs/admin_api_job_test.rb @@ -6,7 +6,6 @@ class AdminApiJobTest < ActiveJob::TestCase end test "subclasses inherit the admin queue" do - assert_equal "admin", FetchPersonalStatsJob.new.queue_name assert_equal "admin", SyncFactionMembersJob.new.queue_name assert_equal "admin", Daily::XanaxPaymentsJob.new.queue_name assert_equal "admin", Daily::StockDividendJob.new.queue_name diff --git a/test/jobs/backfill_personal_stats_job_test.rb b/test/jobs/backfill_personal_stats_job_test.rb index e22d143..af17138 100644 --- a/test/jobs/backfill_personal_stats_job_test.rb +++ b/test/jobs/backfill_personal_stats_job_test.rb @@ -10,12 +10,11 @@ class BackfillSingleStatJobTest < ActiveJob::TestCase .merge(date: Date.parse(@date_str)) end - test "batch 1 saves snapshot and chains batch 2" do - AdminCredentials.stubs(:api_key).returns("test_key") + test "batch 1 saves snapshot and chains batch 2 with api_key" do TornApi::User::PersonalStats.any_instance.stubs(:fetch).returns(@batch1_stats) assert_enqueued_with(job: BackfillSingleStatJob, args: [ @user.id, @date_str, { faction_id: @faction.id, batch: 2, api_key: "test_key" } ]) do - BackfillSingleStatJob.perform_now(@user.id, @date_str, faction_id: @faction.id, batch: 1) + BackfillSingleStatJob.perform_now(@user.id, @date_str, faction_id: @faction.id, batch: 1, api_key: "test_key") end snapshot = @user.personal_stat_snapshots.find_by(date: @date_str) @@ -24,7 +23,6 @@ class BackfillSingleStatJobTest < ActiveJob::TestCase end test "batch 2 does not chain further" do - AdminCredentials.stubs(:api_key).returns("test_key") @user.personal_stat_snapshots.create!(date: @date_str, drugs_xanax: 42) batch2_stats = PersonalStatSnapshot::TRACKED_STATS_BATCH_2.values.index_with { |_| 99 } @@ -32,13 +30,11 @@ class BackfillSingleStatJobTest < ActiveJob::TestCase TornApi::User::PersonalStats.any_instance.stubs(:fetch).returns(batch2_stats) assert_no_enqueued_jobs(only: BackfillSingleStatJob) do - BackfillSingleStatJob.perform_now(@user.id, @date_str, faction_id: @faction.id, batch: 2) + BackfillSingleStatJob.perform_now(@user.id, @date_str, faction_id: @faction.id, batch: 2, api_key: "test_key") end end - test "logs error and returns when api key is blank" do - AdminCredentials.stubs(:api_key).returns(nil) - + test "skips when no api_key passed" do assert_nothing_raised do BackfillSingleStatJob.perform_now(@user.id, @date_str, faction_id: @faction.id) end @@ -47,37 +43,15 @@ class BackfillSingleStatJobTest < ActiveJob::TestCase end test "handles API error gracefully without saving snapshot" do - AdminCredentials.stubs(:api_key).returns("test_key") TornApi::User::PersonalStats.any_instance.stubs(:fetch).raises(TornApi::ApiError, "Rate limited") assert_nothing_raised do - BackfillSingleStatJob.perform_now(@user.id, @date_str, faction_id: @faction.id) + BackfillSingleStatJob.perform_now(@user.id, @date_str, faction_id: @faction.id, api_key: "test_key") end assert_nil @user.personal_stat_snapshots.find_by(date: @date_str) end - test "uses passed api_key instead of AdminCredentials" do - TornApi::User::PersonalStats.any_instance.stubs(:fetch).returns(@batch1_stats) - - AdminCredentials.expects(:api_key).never - - BackfillSingleStatJob.perform_now(@user.id, @date_str, faction_id: @faction.id, batch: 1, api_key: "faction_key_123") - - snapshot = @user.personal_stat_snapshots.find_by(date: @date_str) - assert snapshot, "Expected snapshot to be saved" - end - - test "falls back to AdminCredentials when no api_key passed" do - AdminCredentials.stubs(:api_key).returns("owner_key") - TornApi::User::PersonalStats.any_instance.stubs(:fetch).returns(@batch1_stats) - - BackfillSingleStatJob.perform_now(@user.id, @date_str, faction_id: @faction.id, batch: 1) - - snapshot = @user.personal_stat_snapshots.find_by(date: @date_str) - assert snapshot, "Expected snapshot to be saved" - end - test "chains batch 2 with the same api_key" do TornApi::User::PersonalStats.any_instance.stubs(:fetch).returns(@batch1_stats) @@ -111,18 +85,14 @@ class BackfillPersonalStatsJobTest < ActiveJob::TestCase end end - test "falls back to AdminCredentials api_key when faction has no api key" do + test "skips when faction has no api key" do @faction.api_keys.destroy_all @faction.reload - AdminCredentials.stubs(:api_key).returns("owner_fallback_key") start_date = Date.new(2026, 2, 20) end_date = Date.new(2026, 2, 20) - assert_enqueued_with( - job: BackfillSingleStatJob, - args: [ @user.id, "2026-02-20", { faction_id: @faction.id, api_key: "owner_fallback_key" } ] - ) do + assert_no_enqueued_jobs(only: BackfillSingleStatJob) do BackfillPersonalStatsJob.perform_now(@faction.id, start_date.to_s, end_date.to_s) end end @@ -140,7 +110,6 @@ class BackfillPersonalStatsJobTest < ActiveJob::TestCase BackfillPersonalStatsJob.perform_now(@faction.id, start_date.to_s, end_date.to_s) @faction.reload - assert_equal original_ends_at.to_i, @faction.backfill_ends_at.to_i, - "backfill_ends_at should not be overwritten by the job" + assert_equal original_ends_at.to_i, @faction.backfill_ends_at.to_i end end diff --git a/test/jobs/daily/faction_member_sync_job_test.rb b/test/jobs/daily/faction_member_sync_job_test.rb new file mode 100644 index 0000000..810e8f8 --- /dev/null +++ b/test/jobs/daily/faction_member_sync_job_test.rb @@ -0,0 +1,157 @@ +require "test_helper" + +class Daily::FactionMemberSyncJobTest < ActiveJob::TestCase + setup do + Faction.update_all(setup_completed: false) + + @faction = Faction.create!( + torn_id: 99999, name: "Test Faction", xanax_target: 2.5, + energy_refill_target: 1.0, nerve_refill_target: 1.0, setup_completed: true + ) + ApiKey::Torn.create!(faction: @faction, key: "FACTION_KEY_123", access_type: "Limited Access") + + @bram = users(:bram) + @bram.update!(faction: @faction) + + @member_data = TornApi::Faction::Members::Member.new( + @bram.torn_id, "Bram", 69, 100, + "Online", 1708000000, "5 minutes ago", + "Okay", "", "Okay", "green", 0, nil, + "Everyone", "Leader", true, false, false, false + ) + end + + test "runs for each faction with setup completed and api key" do + faction2 = Faction.create!( + torn_id: 88888, name: "Other Faction", xanax_target: 2.5, + energy_refill_target: 1.0, nerve_refill_target: 1.0, setup_completed: true + ) + ApiKey::Torn.create!(faction: faction2, key: "OTHER_KEY", access_type: "Limited Access") + + bert = users(:bert) + bert.update!(faction: faction2) + + bert_member = TornApi::Faction::Members::Member.new( + bert.torn_id, "Bert", 50, 30, + "Online", 1708000000, "1 minute ago", + "Okay", "", "Okay", "green", 0, nil, + "Everyone", "Member", true, false, false, false + ) + + TornApi::Faction::Members.any_instance.stubs(:fetch).returns([ @member_data ]).then.returns([ bert_member ]) + + Daily::FactionMemberSyncJob.perform_now + + assert_equal 2, enqueued_jobs.count { |j| j["job_class"] == "FetchPersonalStatsJob" } + end + + test "skips factions without api key" do + @faction.torn_api_key.destroy! + + Daily::FactionMemberSyncJob.perform_now + + assert_no_enqueued_jobs(only: FetchPersonalStatsJob) + end + + test "skips factions without setup completed" do + @faction.update!(setup_completed: false) + + Daily::FactionMemberSyncJob.perform_now + + assert_no_enqueued_jobs(only: FetchPersonalStatsJob) + end + + test "syncs members then enqueues personal stats for each" do + new_member = TornApi::Faction::Members::Member.new( + 9999999, "NewPlayer", 15, 5, + "Online", 1708000000, "1 minute ago", + "Okay", "", "Okay", "green", 0, nil, + "Everyone", "Member", true, false, false, false + ) + TornApi::Faction::Members.any_instance.stubs(:fetch).returns([ @member_data, new_member ]) + + Daily::FactionMemberSyncJob.perform_now + + assert_equal 2, enqueued_jobs.count { |j| j["job_class"] == "FetchPersonalStatsJob" } + assert User.exists?(torn_id: 9999999) + end + + test "clears faction_id for departed members" do + bert = users(:bert) + bert.update!(faction: @faction) + + TornApi::Faction::Members.any_instance.stubs(:fetch).returns([ @member_data ]) + + Daily::FactionMemberSyncJob.perform_now + + assert_nil bert.reload.faction_id + end + + test "skips fallen members for personal stats" do + fallen_member = TornApi::Faction::Members::Member.new( + @bram.torn_id, "Bram", 69, 100, + "Offline", 1708000000, "2 days ago", + "Fallen", "", "Fallen", "red", 0, nil, + "Everyone", "Leader", false, false, false, false + ) + TornApi::Faction::Members.any_instance.stubs(:fetch).returns([ fallen_member ]) + + Daily::FactionMemberSyncJob.perform_now + + assert_no_enqueued_jobs(only: FetchPersonalStatsJob) + end + + test "handles API error gracefully" do + TornApi::Faction::Members.any_instance.stubs(:fetch).raises(TornApi::ApiError, "Rate limited") + + assert_nothing_raised do + Daily::FactionMemberSyncJob.perform_now + end + end + + test "backfills missing days for existing members" do + TornApi::Faction::Members.any_instance.stubs(:fetch).returns([ @member_data ]) + + existing_dates = PersonalStatSnapshot.tracking_start_date..3.days.ago.to_date + existing_dates.each do |date| + @bram.personal_stat_snapshots.find_or_create_by!(date: date) do |s| + s.drugs_xanax = 100 + end + end + + Daily::FactionMemberSyncJob.perform_now + + backfill_jobs = enqueued_jobs.select { |j| j["job_class"] == "BackfillSingleStatJob" } + assert backfill_jobs.size > 0, "Expected backfill jobs for missing days" + assert backfill_jobs.size <= 2, "Expected at most 2 missing days (yesterday handled by FetchPersonalStatsJob)" + end + + test "does not backfill when no gaps exist" do + TornApi::Faction::Members.any_instance.stubs(:fetch).returns([ @member_data ]) + + (PersonalStatSnapshot.tracking_start_date..PersonalStatSnapshot.tracking_end_date).each do |date| + @bram.personal_stat_snapshots.find_or_create_by!(date: date) do |s| + s.drugs_xanax = 100 + end + end + + Daily::FactionMemberSyncJob.perform_now + + backfill_jobs = enqueued_jobs.select { |j| j["job_class"] == "BackfillSingleStatJob" } + assert_equal 0, backfill_jobs.size + end + + test "schedules backfill for new members" do + new_member = TornApi::Faction::Members::Member.new( + 9999999, "NewPlayer", 15, 5, + "Online", 1708000000, "1 minute ago", + "Okay", "", "Okay", "green", 0, nil, + "Everyone", "Member", true, false, false, false + ) + TornApi::Faction::Members.any_instance.stubs(:fetch).returns([ @member_data, new_member ]) + + assert_enqueued_with(job: BackfillUserStatsJob) do + Daily::FactionMemberSyncJob.perform_now + end + end +end diff --git a/test/jobs/daily/hof_member_sync_job_test.rb b/test/jobs/daily/hof_member_sync_job_test.rb new file mode 100644 index 0000000..6bb019b --- /dev/null +++ b/test/jobs/daily/hof_member_sync_job_test.rb @@ -0,0 +1,90 @@ +require "test_helper" + +class Daily::HofMemberSyncJobTest < ActiveJob::TestCase + setup do + User.update_all(hof_stats_user: false, faction_id: nil) + @kaneki = users(:kaneki) + @kaneki.update!(hof_stats_user: true, fallen: false) + Rails.application.credentials.stubs(:dig).with(:kaneki, :api_key).returns("KANEKI_KEY") + end + + test "enqueues FetchPersonalStatsJob with kaneki key for each hof user" do + assert_enqueued_with(job: FetchPersonalStatsJob, args: [ @kaneki, { api_key: "KANEKI_KEY" } ]) do + Daily::HofMemberSyncJob.perform_now + end + end + + test "skips when no kaneki key configured" do + Rails.application.credentials.stubs(:dig).with(:kaneki, :api_key).returns(nil) + + assert_no_enqueued_jobs(only: FetchPersonalStatsJob) do + Daily::HofMemberSyncJob.perform_now + end + end + + test "skips fallen hof users" do + @kaneki.update!(fallen: true) + + assert_no_enqueued_jobs(only: FetchPersonalStatsJob) do + Daily::HofMemberSyncJob.perform_now + end + end + + test "skips hof users who belong to a setup faction with api key" do + faction = Faction.create!( + torn_id: 99999, name: "Test", xanax_target: 2.5, + energy_refill_target: 1.0, nerve_refill_target: 1.0, setup_completed: true + ) + ApiKey::Torn.create!(faction: faction, key: "FK", access_type: "Limited Access") + @kaneki.update!(faction: faction) + + assert_no_enqueued_jobs(only: FetchPersonalStatsJob) do + Daily::HofMemberSyncJob.perform_now + end + end + + test "includes hof users with no faction" do + assert_enqueued_jobs 1, only: FetchPersonalStatsJob do + Daily::HofMemberSyncJob.perform_now + end + end + + test "backfills missing days for hof users" do + existing_dates = PersonalStatSnapshot.tracking_start_date..3.days.ago.to_date + existing_dates.each do |date| + @kaneki.personal_stat_snapshots.find_or_create_by!(date: date) do |s| + s.drugs_xanax = 100 + end + end + + Daily::HofMemberSyncJob.perform_now + + backfill_jobs = enqueued_jobs.select { |j| j["job_class"] == "BackfillSingleStatJob" } + assert backfill_jobs.size > 0, "Expected backfill jobs for missing days" + end + + test "does not backfill when hof user has no gaps" do + (PersonalStatSnapshot.tracking_start_date..PersonalStatSnapshot.tracking_end_date).each do |date| + @kaneki.personal_stat_snapshots.find_or_create_by!(date: date) do |s| + s.drugs_xanax = 100 + end + end + + Daily::HofMemberSyncJob.perform_now + + backfill_jobs = enqueued_jobs.select { |j| j["job_class"] == "BackfillSingleStatJob" } + assert_equal 0, backfill_jobs.size + end + + test "includes hof users whose faction has no api key" do + faction = Faction.create!( + torn_id: 99999, name: "No Key Faction", xanax_target: 2.5, + energy_refill_target: 1.0, nerve_refill_target: 1.0, setup_completed: true + ) + @kaneki.update!(faction: faction) + + assert_enqueued_jobs 1, only: FetchPersonalStatsJob do + Daily::HofMemberSyncJob.perform_now + end + end +end diff --git a/test/jobs/daily_faction_membership_sync_job_test.rb b/test/jobs/daily_faction_membership_sync_job_test.rb deleted file mode 100644 index 1639b01..0000000 --- a/test/jobs/daily_faction_membership_sync_job_test.rb +++ /dev/null @@ -1,29 +0,0 @@ -require "test_helper" - -class DailyFactionMembershipSyncJobTest < ActiveJob::TestCase - test "enqueues to the default queue" do - assert_equal "default", Daily::FactionMembershipSyncJob.new.queue_name - end - - test "enqueues SyncFactionMembersJob for each faction" do - faction1 = Faction.create!(torn_id: 11111, name: "Faction One", xanax_target: 2.5) - faction2 = Faction.create!(torn_id: 22222, name: "Faction Two", xanax_target: 2.5) - - perform_enqueued_jobs(only: Daily::FactionMembershipSyncJob) do - Daily::FactionMembershipSyncJob.perform_now - end - - enqueued = queue_adapter.enqueued_jobs.select { |j| j["job_class"] == "SyncFactionMembersJob" } - faction_ids = enqueued.map { |j| j["arguments"].first } - assert_includes faction_ids, faction1.id - assert_includes faction_ids, faction2.id - end - - test "does nothing when no factions exist" do - Faction.destroy_all - - assert_no_enqueued_jobs(only: SyncFactionMembersJob) do - Daily::FactionMembershipSyncJob.perform_now - end - end -end diff --git a/test/jobs/daily_stats_harvest_job_test.rb b/test/jobs/daily_stats_harvest_job_test.rb deleted file mode 100644 index 17e9eb0..0000000 --- a/test/jobs/daily_stats_harvest_job_test.rb +++ /dev/null @@ -1,42 +0,0 @@ -require "test_helper" - -class DailyPersonalStatsJobTest < ActiveJob::TestCase - setup do - # Clear fixture users from tracked scope so we control exactly who is tracked - User.where.not(torn_id: [ 2728237, 1234567, 2685512 ]).update_all(faction_id: nil, hof_stats_user: false) - end - - test "enqueues to the default queue" do - assert_equal "default", Daily::PersonalStatsJob.new.queue_name - end - - test "enqueues a FetchPersonalStatsJob for each tracked user" do - faction = Faction.create!(torn_id: 99999, name: "Test Faction", xanax_target: 2.5) - FactionSetting.create!(faction: faction) - ApiKey::Torn.create!(faction: faction, key: "test_key") - bram = users(:bram) - bert = users(:bert) - bram.update!(faction: faction, fallen: false) - bert.update!(faction: faction, fallen: false) - - # kaneki has no faction but is hof_stats_user - kaneki = users(:kaneki) - kaneki.update!(hof_stats_user: true, fallen: false) - - assert_enqueued_jobs 3, only: FetchPersonalStatsJob do - Daily::PersonalStatsJob.perform_now - end - end - - test "skips fallen users" do - faction = Faction.create!(torn_id: 99999, name: "Test Faction", xanax_target: 2.5) - FactionSetting.create!(faction: faction) - ApiKey::Torn.create!(faction: faction, key: "test_key") - bram = users(:bram) - bram.update!(faction: faction, fallen: true) - - assert_no_enqueued_jobs(only: FetchPersonalStatsJob) do - Daily::PersonalStatsJob.perform_now - end - end -end diff --git a/test/jobs/fetch_personal_stats_job_test.rb b/test/jobs/fetch_personal_stats_job_test.rb index 482c0eb..ae9bfd0 100644 --- a/test/jobs/fetch_personal_stats_job_test.rb +++ b/test/jobs/fetch_personal_stats_job_test.rb @@ -2,35 +2,49 @@ class FetchPersonalStatsJobTest < ActiveJob::TestCase setup do + @faction = Faction.create!( + torn_id: 99999, name: "Test Faction", xanax_target: 2.5, + energy_refill_target: 1.0, nerve_refill_target: 1.0, setup_completed: true + ) + ApiKey::Torn.create!(faction: @faction, key: "FACTION_KEY_123", access_type: "Limited Access") + @user = users(:bram) + @user.update!(faction: @faction) + @api_key = "FACTION_KEY_123" @stats_date = Date.new(2026, 2, 19) @batch1_stats = PersonalStatSnapshot::TRACKED_STATS_BATCH_1.values.index_with { |_| 100 } .merge(date: @stats_date) @batch2_stats = PersonalStatSnapshot::TRACKED_STATS_BATCH_2.values.index_with { |_| 200 } .merge(date: @stats_date) - AdminCredentials.stubs(:api_key).returns("test_key") end - test "batch 1 saves snapshot and chains batch 2" do + test "uses the provided api key" do + TornApi::User::PersonalStats.expects(:new) + .with(@api_key, @user.torn_id, anything) + .returns(stub(fetch: @batch1_stats)) + .at_least_once + + FetchPersonalStatsJob.perform_now(@user, api_key: @api_key, batch: 1, stats_date: @stats_date) + end + + test "batch 1 saves snapshot and chains batch 2 with api_key" do TornApi::User::PersonalStats.any_instance.stubs(:fetch).returns(@batch1_stats) - assert_enqueued_with(job: FetchPersonalStatsJob, args: [ @user, { batch: 2, stats_date: @stats_date } ]) do - FetchPersonalStatsJob.perform_now(@user, batch: 1, stats_date: @stats_date) + assert_enqueued_with(job: FetchPersonalStatsJob, args: [ @user, { api_key: @api_key, batch: 2, stats_date: @stats_date } ]) do + FetchPersonalStatsJob.perform_now(@user, api_key: @api_key, batch: 1, stats_date: @stats_date) end snapshot = @user.personal_stat_snapshots.find_by(date: @stats_date) - assert snapshot, "Expected a snapshot to be saved" + assert snapshot assert_equal 100, snapshot.drugs_xanax end test "batch 2 saves snapshot and does not chain further" do - # Create initial snapshot from batch 1 @user.personal_stat_snapshots.create!(date: @stats_date, drugs_xanax: 100) - TornApi::User::PersonalStats.any_instance.stubs(:fetch).returns(@batch2_stats) assert_no_enqueued_jobs(only: FetchPersonalStatsJob) do - FetchPersonalStatsJob.perform_now(@user, batch: 2, stats_date: @stats_date) + FetchPersonalStatsJob.perform_now(@user, api_key: @api_key, batch: 2, stats_date: @stats_date) end snapshot = @user.personal_stat_snapshots.find_by(date: @stats_date) @@ -42,9 +56,7 @@ class FetchPersonalStatsJobTest < ActiveJob::TestCase TornApi::User::PersonalStats.any_instance.stubs(:fetch).returns(stats_with_enhancers) refute @user.hof_stats_user - - FetchPersonalStatsJob.perform_now(@user, batch: 1, stats_date: @stats_date) - + FetchPersonalStatsJob.perform_now(@user, api_key: @api_key, batch: 1, stats_date: @stats_date) assert @user.reload.hof_stats_user end @@ -52,8 +64,16 @@ class FetchPersonalStatsJobTest < ActiveJob::TestCase stats_low_enhancers = @batch1_stats.merge(items_used_stat_enhancers: 50) TornApi::User::PersonalStats.any_instance.stubs(:fetch).returns(stats_low_enhancers) - FetchPersonalStatsJob.perform_now(@user, batch: 1, stats_date: @stats_date) + FetchPersonalStatsJob.perform_now(@user, api_key: @api_key, batch: 1, stats_date: @stats_date) + refute @user.reload.hof_stats_user + end + + test "hof eligibility check only runs on batch 1" do + stats_with_enhancers = @batch2_stats.merge(items_used_stat_enhancers: 500) + TornApi::User::PersonalStats.any_instance.stubs(:fetch).returns(stats_with_enhancers) + @user.personal_stat_snapshots.create!(date: @stats_date) + FetchPersonalStatsJob.perform_now(@user, api_key: @api_key, batch: 2, stats_date: @stats_date) refute @user.reload.hof_stats_user end @@ -61,7 +81,7 @@ class FetchPersonalStatsJobTest < ActiveJob::TestCase TornApi::User::PersonalStats.any_instance.stubs(:fetch).raises(TornApi::InvalidKeyError, "auth failed") assert_enqueued_with(job: FetchPersonalStatsJob) do - FetchPersonalStatsJob.perform_now(@user, batch: 1, stats_date: @stats_date, retries: 0) + FetchPersonalStatsJob.perform_now(@user, api_key: @api_key, batch: 1, stats_date: @stats_date, retries: 0) end end @@ -70,17 +90,12 @@ class FetchPersonalStatsJobTest < ActiveJob::TestCase Discord::Notifier.expects(:notify).once assert_no_enqueued_jobs(only: FetchPersonalStatsJob) do - FetchPersonalStatsJob.perform_now(@user, batch: 1, stats_date: @stats_date, retries: 3) + FetchPersonalStatsJob.perform_now(@user, api_key: @api_key, batch: 1, stats_date: @stats_date, retries: 3) end end - test "hof eligibility check only runs on batch 1" do - stats_with_enhancers = @batch2_stats.merge(items_used_stat_enhancers: 500) - TornApi::User::PersonalStats.any_instance.stubs(:fetch).returns(stats_with_enhancers) - @user.personal_stat_snapshots.create!(date: @stats_date) - - FetchPersonalStatsJob.perform_now(@user, batch: 2, stats_date: @stats_date) - - refute @user.reload.hof_stats_user, "Batch 2 should not check HoF eligibility" + test "inherits from FactionApiJob" do + assert FetchPersonalStatsJob < FactionApiJob + assert_equal "faction", FetchPersonalStatsJob.new.queue_name end end From 168bed5c2ffc15cf0998e505b2d54a4549d19328 Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 14 Jul 2026 20:33:31 +0200 Subject: [PATCH 257/266] bundle update --- Gemfile.lock | 173 ++++++++++++++++++++++++++------------------------- 1 file changed, 87 insertions(+), 86 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index d5c1e0a..620ce80 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ GEM remote: https://rubygems.org/ specs: - action_text-trix (2.1.18) + action_text-trix (2.1.19) railties actioncable (8.1.3) actionpack (= 8.1.3) @@ -75,9 +75,9 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - addressable (2.8.8) + addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) - appsignal (4.8.4) + appsignal (4.8.6) logger rack (>= 2.0.0) ast (2.4.3) @@ -86,11 +86,11 @@ GEM bcrypt_pbkdf (1.1.2) bcrypt_pbkdf (1.1.2-arm64-darwin) bcrypt_pbkdf (1.1.2-x86_64-darwin) - bigdecimal (4.1.0) + bigdecimal (4.1.2) bindex (0.8.1) - bootsnap (1.23.0) + bootsnap (1.24.6) msgpack (~> 1.2) - brakeman (8.0.4) + brakeman (8.0.5) racc builder (3.3.0) bundler-audit (0.9.3) @@ -105,48 +105,48 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - concurrent-ruby (1.3.6) + concurrent-ruby (1.3.7) connection_pool (3.0.2) - crass (1.0.6) + crass (1.0.7) date (3.5.1) debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) - discordrb (3.7.2) + discordrb (3.8.0) base64 (~> 0.2) - discordrb-webhooks (~> 3.7.2) + discordrb-webhooks (~> 3.8.0) ffi (>= 1.9.24) opus-ruby rest-client (>= 2.0.0) websocket-client-simple (>= 0.9.0) - discordrb-webhooks (3.7.2) + discordrb-webhooks (3.8.0) rest-client (>= 2.0.0) domain_name (0.6.20240107) dotenv (3.2.0) drb (2.2.3) ed25519 (1.4.0) - erb (6.0.2) + erb (6.0.4) erubi (1.13.1) et-orbi (1.4.0) tzinfo event_emitter (0.2.6) - ffi (1.17.3-aarch64-linux-gnu) - ffi (1.17.3-aarch64-linux-musl) - ffi (1.17.3-arm-linux-gnu) - ffi (1.17.3-arm-linux-musl) - ffi (1.17.3-arm64-darwin) - ffi (1.17.3-x86_64-darwin) - ffi (1.17.3-x86_64-linux-gnu) - ffi (1.17.3-x86_64-linux-musl) - fugit (1.12.1) + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-aarch64-linux-musl) + ffi (1.17.4-arm-linux-gnu) + ffi (1.17.4-arm-linux-musl) + ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x86_64-darwin) + ffi (1.17.4-x86_64-linux-gnu) + ffi (1.17.4-x86_64-linux-musl) + fugit (1.13.0) et-orbi (~> 1.4) raabro (~> 1.4) - globalid (1.3.0) + globalid (1.4.0) activesupport (>= 6.1) http-accept (1.7.0) - http-cookie (1.1.0) + http-cookie (1.1.6) domain_name (~> 0.5) - i18n (1.14.8) + i18n (1.15.2) concurrent-ruby (~> 1.0) image_processing (1.14.0) mini_magick (>= 4.9.5, < 6) @@ -156,16 +156,16 @@ GEM activesupport (>= 6.0.0) railties (>= 6.0.0) io-console (0.8.2) - irb (1.17.0) + irb (1.18.0) pp (>= 0.6.0) prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - jbuilder (2.14.1) + jbuilder (2.15.1) actionview (>= 7.0.0) activesupport (>= 7.0.0) - json (2.19.3) - kamal (2.11.0) + json (2.21.1) + kamal (2.12.0) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) @@ -176,28 +176,28 @@ GEM sshkit (>= 1.23.0, < 2.0) thor (~> 1.3) zeitwerk (>= 2.6.18, < 3.0) - language_server-protocol (3.17.0.5) + language_server-protocol (3.17.0.6) lint_roller (1.1.0) logger (1.7.0) loofah (2.25.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.9.0) + mail (2.9.1) logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.1.0) + marcel (1.2.1) matrix (0.4.3) mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2026.0331) + mime-types-data (3.2026.0701) mini_magick (5.3.1) logger mini_mime (1.1.5) - minitest (6.0.3) + minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) mission_control-jobs (1.1.0) @@ -212,9 +212,9 @@ GEM turbo-rails mocha (3.1.0) ruby2_keywords (>= 0.0.5) - msgpack (1.8.0) + msgpack (1.8.3) mutex_m (0.3.0) - net-imap (0.6.3) + net-imap (0.6.4.1) date net-protocol net-pop (0.1.2) @@ -227,60 +227,57 @@ GEM net-ssh (>= 5.0.0, < 8.0.0) net-smtp (0.5.1) net-protocol - net-ssh (7.3.2) + net-ssh (7.3.3) netrc (0.11.0) nio4r (2.7.5) - nokogiri (1.19.2-aarch64-linux-gnu) + nokogiri (1.19.4-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.2-aarch64-linux-musl) + nokogiri (1.19.4-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.2-arm-linux-gnu) + nokogiri (1.19.4-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.2-arm-linux-musl) + nokogiri (1.19.4-arm-linux-musl) racc (~> 1.4) - nokogiri (1.19.2-arm64-darwin) + nokogiri (1.19.4-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.2-x86_64-darwin) + nokogiri (1.19.4-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.2-x86_64-linux-gnu) + nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.2-x86_64-linux-musl) + nokogiri (1.19.4-x86_64-linux-musl) racc (~> 1.4) - onnxruntime (0.11.1) + onnxruntime (0.11.4) ffi - onnxruntime (0.11.1-aarch64-linux) + onnxruntime (0.11.4-aarch64-linux) ffi - onnxruntime (0.11.1-arm64-darwin) + onnxruntime (0.11.4-arm64-darwin) ffi - onnxruntime (0.11.1-x86_64-darwin) + onnxruntime (0.11.4-x86_64-darwin) ffi - onnxruntime (0.11.1-x86_64-linux) + onnxruntime (0.11.4-x86_64-linux) ffi opus-ruby (1.0.1) ffi ostruct (0.6.3) - parallel (1.27.0) - parser (3.3.10.2) + parallel (2.1.0) + parser (3.3.11.1) ast (~> 2.4.1) racc - pp (0.6.3) + pp (0.6.4) prettyprint prettyprint (0.2.0) prism (1.9.0) - propshaft (1.3.1) + propshaft (1.3.2) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - psych (5.3.1) - date - stringio - public_suffix (7.0.2) - puma (8.0.0) + public_suffix (7.0.5) + puma (8.0.2) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) rack (3.2.6) - rack-session (2.1.1) + rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) @@ -318,12 +315,17 @@ GEM tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.3.1) - rdoc (7.2.0) + rake (13.4.2) + rbs (4.0.3) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) erb - psych (>= 4.0.0) + prism (>= 1.6.0) + rbs (>= 4.0.0) tsort - regexp_parser (2.11.3) + regexp_parser (2.12.0) reline (0.6.3) io-console (~> 0.5) rest-client (2.1.0) @@ -332,25 +334,25 @@ GEM mime-types (>= 1.16, < 4.0) netrc (~> 0.8) rexml (3.4.4) - rubocop (1.84.2) + rubocop (1.88.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) - parallel (~> 1.10) + parallel (>= 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.49.0) + rubocop-ast (1.50.0) parser (>= 3.3.7.2) prism (~> 1.7) rubocop-performance (1.26.1) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.47.1, < 2.0) - rubocop-rails (2.34.3) + rubocop-rails (2.36.0) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -365,15 +367,15 @@ GEM ffi (~> 1.12) logger ruby2_keywords (0.0.5) - rubyzip (3.2.2) + rubyzip (3.4.1) securerandom (0.4.1) - selenium-webdriver (4.42.0) + selenium-webdriver (4.46.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) - solid_cable (3.0.12) + solid_cable (4.0.0) actioncable (>= 7.2) activejob (>= 7.2) activerecord (>= 7.2) @@ -389,14 +391,14 @@ GEM fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) - sqlite3 (2.9.2-aarch64-linux-gnu) - sqlite3 (2.9.2-aarch64-linux-musl) - sqlite3 (2.9.2-arm-linux-gnu) - sqlite3 (2.9.2-arm-linux-musl) - sqlite3 (2.9.2-arm64-darwin) - sqlite3 (2.9.2-x86_64-darwin) - sqlite3 (2.9.2-x86_64-linux-gnu) - sqlite3 (2.9.2-x86_64-linux-musl) + sqlite3 (2.9.5-aarch64-linux-gnu) + sqlite3 (2.9.5-aarch64-linux-musl) + sqlite3 (2.9.5-arm-linux-gnu) + sqlite3 (2.9.5-arm-linux-musl) + sqlite3 (2.9.5-arm64-darwin) + sqlite3 (2.9.5-x86_64-darwin) + sqlite3 (2.9.5-x86_64-linux-gnu) + sqlite3 (2.9.5-x86_64-linux-musl) sshkit (1.25.0) base64 logger @@ -406,13 +408,12 @@ GEM ostruct stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.2.0) thor (1.5.0) - thruster (0.1.20) - thruster (0.1.20-aarch64-linux) - thruster (0.1.20-arm64-darwin) - thruster (0.1.20-x86_64-darwin) - thruster (0.1.20-x86_64-linux) + thruster (0.1.22) + thruster (0.1.22-aarch64-linux) + thruster (0.1.22-arm64-darwin) + thruster (0.1.22-x86_64-darwin) + thruster (0.1.22-x86_64-linux) timeout (0.6.1) tsort (0.2.0) turbo-rails (2.0.23) @@ -435,13 +436,13 @@ GEM event_emitter mutex_m websocket - websocket-driver (0.8.0) + websocket-driver (0.8.2) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.5) + zeitwerk (2.8.2) PLATFORMS aarch64-linux From 17cbde7ffd8eb0dada2e7a149bad7e9d7f67675d Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 14 Jul 2026 21:37:53 +0200 Subject: [PATCH 258/266] improved jobs --- app/jobs/admin_api_job.rb | 11 +-- app/jobs/backfill_armory_news_job.rb | 3 +- app/jobs/backfill_ranked_wars_job.rb | 3 +- app/jobs/backfill_single_stat_job.rb | 8 +- app/jobs/concerns/gap_backfill.rb | 22 +++++ app/jobs/daily/faction_member_sync_job.rb | 15 +-- app/jobs/daily/hof_member_sync_job.rb | 15 +-- app/jobs/faction_api_job.rb | 11 +-- app/jobs/fetch_armory_news_job.rb | 3 +- app/jobs/fetch_member_activity_job.rb | 3 +- app/jobs/fetch_personal_stats_job.rb | 3 +- app/jobs/torn_api_job.rb | 30 ++++++ app/models/discord/error_notifier.rb | 12 +++ app/models/torn_api.rb | 31 ++++++- app/models/torn_api/rate_limiter.rb | 66 ++++++++++++++ app/models/torn_api/user/personal_stats.rb | 4 +- config/queue.yml | 12 ++- test/integration/torn_api_budget_flow_test.rb | 52 +++++++++++ test/jobs/admin_api_job_test.rb | 21 +++-- test/jobs/backfill_personal_stats_job_test.rb | 4 +- test/jobs/backfill_single_stat_job_test.rb | 69 ++++++++++++++ test/jobs/daily/backfill_gap_limit_test.rb | 54 +++++++++++ test/jobs/daily_xanax_payments_job_test.rb | 4 +- test/jobs/fetch_member_activity_job_test.rb | 5 +- test/jobs/fetch_personal_stats_job_test.rb | 5 +- test/jobs/stock_dividend_job_test.rb | 4 +- test/jobs/torn_api_concurrency_test.rb | 57 ++++++++++++ test/jobs/torn_api_queue_test.rb | 56 ++++++++++++ test/jobs/torn_api_retry_test.rb | 84 +++++++++++++++++ test/models/discord/error_notifier_test.rb | 34 +++++++ test/models/torn_api/base_budget_test.rb | 44 +++++++++ test/models/torn_api/base_retry_test.rb | 62 +++++++++++++ test/models/torn_api/rate_limiter_test.rb | 91 +++++++++++++++++++ .../torn_api/user/personal_stats_test.rb | 25 +++++ 34 files changed, 841 insertions(+), 82 deletions(-) create mode 100644 app/jobs/concerns/gap_backfill.rb create mode 100644 app/jobs/torn_api_job.rb create mode 100644 app/models/torn_api/rate_limiter.rb create mode 100644 test/integration/torn_api_budget_flow_test.rb create mode 100644 test/jobs/backfill_single_stat_job_test.rb create mode 100644 test/jobs/daily/backfill_gap_limit_test.rb create mode 100644 test/jobs/torn_api_concurrency_test.rb create mode 100644 test/jobs/torn_api_queue_test.rb create mode 100644 test/jobs/torn_api_retry_test.rb create mode 100644 test/models/torn_api/base_budget_test.rb create mode 100644 test/models/torn_api/base_retry_test.rb create mode 100644 test/models/torn_api/rate_limiter_test.rb create mode 100644 test/models/torn_api/user/personal_stats_test.rb diff --git a/app/jobs/admin_api_job.rb b/app/jobs/admin_api_job.rb index f6a74d0..e1f959c 100644 --- a/app/jobs/admin_api_job.rb +++ b/app/jobs/admin_api_job.rb @@ -1,10 +1,3 @@ -class AdminApiJob < ApplicationJob - queue_as :admin - - RATE_LIMIT_SLEEP = 1.0 - - around_perform do |_job, block| - block.call - sleep(RATE_LIMIT_SLEEP) - end +# Transitional alias — all API discipline lives in TornApiJob. +class AdminApiJob < TornApiJob end diff --git a/app/jobs/backfill_armory_news_job.rb b/app/jobs/backfill_armory_news_job.rb index 7bcd367..7d52034 100644 --- a/app/jobs/backfill_armory_news_job.rb +++ b/app/jobs/backfill_armory_news_job.rb @@ -2,7 +2,8 @@ class BackfillArmoryNewsJob < FactionApiJob BACKFILL_FLOOR = Time.utc(2026, 1, 1).to_i PAGE_DELAY = 1.minute - limits_concurrency to: 1, key: ->(faction_id, _cursor = nil) { faction_id }, group: "FactionApiCalls" + queue_with_priority 100 + limits_concurrency to: 1, key: FACTION_KEY_LOOKUP, group: CONCURRENCY_GROUP def perform(faction_id, cursor = nil) faction = Faction.find_by(id: faction_id) diff --git a/app/jobs/backfill_ranked_wars_job.rb b/app/jobs/backfill_ranked_wars_job.rb index 5a70ba5..5ae25f9 100644 --- a/app/jobs/backfill_ranked_wars_job.rb +++ b/app/jobs/backfill_ranked_wars_job.rb @@ -1,5 +1,6 @@ class BackfillRankedWarsJob < FactionApiJob - limits_concurrency to: 1, key: ->(faction_id, **) { faction_id }, group: "FactionApiCalls" + queue_with_priority 100 + limits_concurrency to: 1, key: FACTION_KEY_LOOKUP, group: CONCURRENCY_GROUP def perform(faction_id, limit: 20) faction = Faction.find_by(id: faction_id) diff --git a/app/jobs/backfill_single_stat_job.rb b/app/jobs/backfill_single_stat_job.rb index dd116a3..fb4c37d 100644 --- a/app/jobs/backfill_single_stat_job.rb +++ b/app/jobs/backfill_single_stat_job.rb @@ -1,5 +1,6 @@ class BackfillSingleStatJob < FactionApiJob - limits_concurrency to: 1, key: ->(user_id, date_str, faction_id:, **) { faction_id }, group: "FactionApiCalls" + queue_with_priority 100 + limits_concurrency to: 1, key: ->(user_id, date_str, faction_id:, api_key: nil, **) { api_key }, group: CONCURRENCY_GROUP def perform(user_id, date_str, faction_id:, batch: 1, api_key: nil) user = User.find(user_id) @@ -26,7 +27,10 @@ def fetch_stats(user, date, api_key, batch) timestamp: date.end_of_day.to_i, stat_batch: stat_batch ).fetch - rescue TornApi::ApiError, TornApi::InvalidKeyError => e + rescue TornApi::NotFoundError, TornApi::InvalidKeyError => e + # Unrecoverable for this user/key — skip the date. Rate limits and + # transient errors propagate so retry_on reschedules instead of the + # gap silently re-enqueueing every night. Rails.logger.error("API error fetching stats: #{e.message}") nil end diff --git a/app/jobs/concerns/gap_backfill.rb b/app/jobs/concerns/gap_backfill.rb new file mode 100644 index 0000000..2148d65 --- /dev/null +++ b/app/jobs/concerns/gap_backfill.rb @@ -0,0 +1,22 @@ +# Shared by the nightly sync jobs: re-fetch missing snapshot dates, capped so +# one user with a long history can't dump hundreds of API calls onto a single +# key in one night. Newest gaps first — recent data is the valuable data, and +# older gaps fill on later nights. +module GapBackfill + BACKFILL_GAP_LIMIT = 30 + + private + + def backfill_gaps(user, api_key) + existing = user.personal_stat_snapshots.pluck(:date).to_set + expected = (PersonalStatSnapshot.tracking_start_date..PersonalStatSnapshot.tracking_end_date).to_a + yesterday = Date.current.yesterday + + missing = expected.reject { |d| existing.include?(d) || d == yesterday } + return if missing.empty? + + missing.last(BACKFILL_GAP_LIMIT).each do |date| + BackfillSingleStatJob.perform_later(user.id, date.to_s, faction_id: user.faction_id, api_key: api_key) + end + end +end diff --git a/app/jobs/daily/faction_member_sync_job.rb b/app/jobs/daily/faction_member_sync_job.rb index 9565c06..8ba41ca 100644 --- a/app/jobs/daily/faction_member_sync_job.rb +++ b/app/jobs/daily/faction_member_sync_job.rb @@ -1,5 +1,7 @@ module Daily class FactionMemberSyncJob < ApplicationJob + include GapBackfill + queue_as :default def perform @@ -54,19 +56,6 @@ def sync_members(faction, members, api_key) end end - def backfill_gaps(user, api_key) - existing = user.personal_stat_snapshots.pluck(:date).to_set - expected = (PersonalStatSnapshot.tracking_start_date..PersonalStatSnapshot.tracking_end_date).to_a - yesterday = Date.current.yesterday - - missing = expected.reject { |d| existing.include?(d) || d == yesterday } - return if missing.empty? - - missing.each do |date| - BackfillSingleStatJob.perform_later(user.id, date.to_s, faction_id: user.faction_id, api_key: api_key) - end - end - def schedule_backfill(user, api_key) start_date = PersonalStatSnapshot.tracking_start_date end_date = PersonalStatSnapshot.tracking_end_date diff --git a/app/jobs/daily/hof_member_sync_job.rb b/app/jobs/daily/hof_member_sync_job.rb index 873d01e..d9ce191 100644 --- a/app/jobs/daily/hof_member_sync_job.rb +++ b/app/jobs/daily/hof_member_sync_job.rb @@ -1,5 +1,7 @@ module Daily class HofMemberSyncJob < ApplicationJob + include GapBackfill + queue_as :default def perform @@ -14,19 +16,6 @@ def perform private - def backfill_gaps(user, api_key) - existing = user.personal_stat_snapshots.pluck(:date).to_set - expected = (PersonalStatSnapshot.tracking_start_date..PersonalStatSnapshot.tracking_end_date).to_a - yesterday = Date.current.yesterday - - missing = expected.reject { |d| existing.include?(d) || d == yesterday } - return if missing.empty? - - missing.each do |date| - BackfillSingleStatJob.perform_later(user.id, date.to_s, faction_id: user.faction_id, api_key: api_key) - end - end - def hof_users_without_faction_key covered_faction_ids = Faction .joins(:api_keys) diff --git a/app/jobs/faction_api_job.rb b/app/jobs/faction_api_job.rb index cdd0063..f23ea36 100644 --- a/app/jobs/faction_api_job.rb +++ b/app/jobs/faction_api_job.rb @@ -1,10 +1,3 @@ -class FactionApiJob < ApplicationJob - queue_as :faction - - RATE_LIMIT_SLEEP = 1.0 - - around_perform do |_job, block| - block.call - sleep(RATE_LIMIT_SLEEP) - end +# Transitional alias — all API discipline lives in TornApiJob. +class FactionApiJob < TornApiJob end diff --git a/app/jobs/fetch_armory_news_job.rb b/app/jobs/fetch_armory_news_job.rb index 973dc8f..52323a2 100644 --- a/app/jobs/fetch_armory_news_job.rb +++ b/app/jobs/fetch_armory_news_job.rb @@ -1,5 +1,6 @@ class FetchArmoryNewsJob < FactionApiJob - limits_concurrency to: 1, key: ->(faction_id) { faction_id }, group: "FactionApiCalls" + queue_with_priority 50 + limits_concurrency to: 1, key: FACTION_KEY_LOOKUP, group: CONCURRENCY_GROUP def perform(faction_id) faction = Faction.find_by(id: faction_id) diff --git a/app/jobs/fetch_member_activity_job.rb b/app/jobs/fetch_member_activity_job.rb index 23eb019..f9e470f 100644 --- a/app/jobs/fetch_member_activity_job.rb +++ b/app/jobs/fetch_member_activity_job.rb @@ -1,5 +1,6 @@ class FetchMemberActivityJob < FactionApiJob - limits_concurrency to: 1, key: ->(faction_id) { faction_id }, group: "FactionApiCalls" + queue_with_priority 10 + limits_concurrency to: 1, key: FACTION_KEY_LOOKUP, group: CONCURRENCY_GROUP def perform(faction_id) faction = Faction.find_by(id: faction_id) diff --git a/app/jobs/fetch_personal_stats_job.rb b/app/jobs/fetch_personal_stats_job.rb index 324ac40..301f64f 100644 --- a/app/jobs/fetch_personal_stats_job.rb +++ b/app/jobs/fetch_personal_stats_job.rb @@ -1,5 +1,6 @@ class FetchPersonalStatsJob < FactionApiJob - limits_concurrency to: 1, key: ->(user, api_key:, **) { api_key }, group: "PersonalStatsApiCalls" + queue_with_priority 50 + limits_concurrency to: 1, key: ->(user, api_key:, **) { api_key }, group: CONCURRENCY_GROUP MAX_RETRIES = 3 diff --git a/app/jobs/torn_api_job.rb b/app/jobs/torn_api_job.rb new file mode 100644 index 0000000..1fa2859 --- /dev/null +++ b/app/jobs/torn_api_job.rb @@ -0,0 +1,30 @@ +# Base class for every job that calls the Torn API. API discipline lives here +# exactly once: +# +# 1. Serialize — subclasses declare `limits_concurrency` in CONCURRENCY_GROUP +# keyed by the api key, so each key has ONE job in flight cluster-wide. +# On a contended key, solid_queue releases blocked jobs by priority: +# 0 interactive · 10 recurring polls · 50 nightly · 100 backfill. +# 2. Pace — the 1s sleep keeps a serialized stream at ~38 calls/min, safely +# under the 50/min per-key budget TornApi::RateLimiter enforces. +# 3. Retry — rate limits heal in a minute, Torn hiccups heal in a few; +# both re-schedule instead of failing (and feeding tomorrow's backfill). +class TornApiJob < ApplicationJob + queue_as :torn_api + queue_with_priority 0 + + CONCURRENCY_GROUP = "TornApiCalls" + + # Concurrency key for jobs that receive a faction_id instead of an api key. + FACTION_KEY_LOOKUP = ->(faction_id, *, **) { Faction.find_by(id: faction_id)&.torn_api_key&.key } + + RATE_LIMIT_SLEEP = 1.0 + + retry_on TornApi::RateLimitError, wait: 2.minutes, attempts: 5 + retry_on TornApi::TransientError, wait: 15.minutes, attempts: 3 + + around_perform do |_job, block| + block.call + sleep(RATE_LIMIT_SLEEP) + end +end diff --git a/app/models/discord/error_notifier.rb b/app/models/discord/error_notifier.rb index 873c027..078e67d 100644 --- a/app/models/discord/error_notifier.rb +++ b/app/models/discord/error_notifier.rb @@ -1,9 +1,13 @@ module Discord class ErrorNotifier APP_TRACE_LIMIT = 8 + # A failing serialized job stream produces the same error every ~2s; + # collapse identical errors into one embed per window. + DEDUP_WINDOW = 10.minutes def report(error, handled:, severity:, context:, source: nil) return if handled + return if duplicate_report?(error) fields = [ { name: "Source", value: source || "unknown", inline: true }, @@ -31,6 +35,14 @@ def report(error, handled:, severity:, context:, source: nil) private + def duplicate_report?(error) + cache_key = "discord_error_report:#{error.class}:#{Digest::MD5.hexdigest(error.message.to_s)}" + return true if Rails.cache.exist?(cache_key) + + Rails.cache.write(cache_key, true, expires_in: DEDUP_WINDOW) + false + end + def app_backtrace(error) return nil unless error.backtrace diff --git a/app/models/torn_api.rb b/app/models/torn_api.rb index 4544b8a..f1eaaaf 100644 --- a/app/models/torn_api.rb +++ b/app/models/torn_api.rb @@ -5,6 +5,10 @@ module TornApi class InvalidKeyError < StandardError; end class ApiError < StandardError; end class RateLimitError < ApiError; end + # Torn-side hiccups that heal on their own (5xx, empty payloads during the + # nightly stats-cache rebuild, "backend error, please try again") — jobs + # retry these with a delay instead of failing. + class TransientError < ApiError; end class NotFoundError < ApiError; end class TimeoutError < ApiError; end @@ -31,6 +35,7 @@ def get(path, params = {}, retries: 0) log_request(uri) + RateLimiter.acquire!(api_key) response = perform_request(uri) body = parse_response(response, path) @@ -41,6 +46,8 @@ def get(path, params = {}, retries: 0) log_success(uri) body + rescue TransientError => e + handle_transient(uri, api_params, e, retries, start_time) rescue InvalidKeyError, ApiError => e response_time = ((Time.current - start_time) * 1000).to_i log_api_call(path, merged_params, "error", response_time, nil, e.message) @@ -79,7 +86,10 @@ def parse_response(response, path = nil) status = response.code.to_i unless status == 200 Rails.logger.error("HTTP #{status} from Torn API: #{response.body[0..500]}") - notify_torn_degraded(path, status) if status >= 500 + if status >= 500 + notify_torn_degraded(path, status) + raise TransientError, "Torn API request failed (HTTP #{status})" + end raise ApiError, "Torn API request failed (HTTP #{status})" end @@ -114,16 +124,16 @@ def handle_api_error(error) when 6 then raise NotFoundError, "Incorrect ID: #{error_msg}" when 7 then raise NotFoundError, "Requested data is private" - # Torn infrastructure issues + # Torn infrastructure issues — 15/17 heal on retry, 9/24 can last hours when 9 then raise ApiError, "Torn API is currently disabled" - when 17 then raise ApiError, "Torn backend error, please try again" + when 17 then raise TransientError, "Torn backend error, please try again" when 24 then raise ApiError, "Torn API temporarily closed" # Request parameter errors when 3 then raise ApiError, "Wrong type requested: #{error_msg}" when 4 then raise ApiError, "Wrong fields requested: #{error_msg}" when 11 then raise ApiError, "Can only change API key once every 60 seconds" - when 15 then raise ApiError, "Temporary error: #{error_msg}" + when 15 then raise TransientError, "Temporary error: #{error_msg}" when 19 then raise ApiError, "Must be migrated to crimes 2.0" when 20 then raise ApiError, "Race not yet finished" when 21 then raise ApiError, "Incorrect category: #{error_msg}" @@ -140,6 +150,19 @@ def handle_api_error(error) end end + def handle_transient(uri, api_params, error, retries, start_time) + if retries < MAX_RETRIES + Rails.logger.warn("Transient Torn error for #{uri}, retrying (#{retries + 1}/#{MAX_RETRIES}): #{error.message}") + sleep(1 * (retries + 1)) + get(uri.path.sub(/^\//, ""), api_params, retries: retries + 1) + else + response_time = ((Time.current - start_time) * 1000).to_i + log_api_call(uri.path.sub(/^\//, ""), api_params, "error", response_time, nil, error.message) + Rails.logger.error("Transient Torn error for #{uri} after #{MAX_RETRIES} retries: #{error.message}") + raise error + end + end + def handle_timeout(uri, api_params, error, retries, start_time) if retries < MAX_RETRIES Rails.logger.warn("Timeout for #{uri}, retrying (#{retries + 1}/#{MAX_RETRIES}): #{error.message}") diff --git a/app/models/torn_api/rate_limiter.rb b/app/models/torn_api/rate_limiter.rb new file mode 100644 index 0000000..218c6fd --- /dev/null +++ b/app/models/torn_api/rate_limiter.rb @@ -0,0 +1,66 @@ +module TornApi + # Client-side budget for Torn API calls, enforced in TornApi::Base#get + # before any HTTP happens — a rejected call must not cost a real request, + # since Torn counts rejected requests toward its own limit. + # + # Torn's hard limit is 100 requests/minute per player across all their + # keys. We take half per key, leaving headroom for the key owner's own + # usage (YATA, Torn PDA, browsing) that we can't see. The global budget + # caps what this server's IP sends in total — Torn documents no aggregate + # limit, but error code 8 ("IP blocked for abuse") exists. + # + # Counters live in Rails.cache (solid_cache in production) so web requests + # and every job worker process draw from the same budget. Fixed 60-second + # windows: worst case in any rolling minute is 2x the budget, which is why + # the per-key budget must stay at half the hard limit. + class RateLimiter + TORN_HARD_LIMIT = 100 + REQUESTS_PER_MINUTE = TORN_HARD_LIMIT / 2 + GLOBAL_REQUESTS_PER_MINUTE = 300 + WINDOW_SECONDS = 60 + + class << self + def acquire!(api_key) + key_count = increment(key_window(api_key)) + if key_count > REQUESTS_PER_MINUTE + raise RateLimitError, "Too many requests (client-side key budget of #{REQUESTS_PER_MINUTE}/min spent)" + end + + global_count = increment(global_window) + if global_count > GLOBAL_REQUESTS_PER_MINUTE + raise RateLimitError, "Too many requests (client-side global budget of #{GLOBAL_REQUESTS_PER_MINUTE}/min spent)" + end + + true + end + + def remaining(api_key) + [ REQUESTS_PER_MINUTE - current(key_window(api_key)), 0 ].max + end + + private + + # Null cache stores (test default) return nil from increment; treat as + # unlimited rather than blocking every call. + def increment(cache_key) + Rails.cache.increment(cache_key, 1, expires_in: WINDOW_SECONDS * 2) || 1 + end + + def current(cache_key) + Rails.cache.read(cache_key, raw: true).to_i + end + + def key_window(api_key) + "torn_api_budget:#{window_stamp}:#{api_key}" + end + + def global_window + "torn_api_budget:#{window_stamp}:global" + end + + def window_stamp + Time.current.to_i / WINDOW_SECONDS + end + end + end +end diff --git a/app/models/torn_api/user/personal_stats.rb b/app/models/torn_api/user/personal_stats.rb index 7299392..423e704 100644 --- a/app/models/torn_api/user/personal_stats.rb +++ b/app/models/torn_api/user/personal_stats.rb @@ -24,7 +24,9 @@ def fetch if response["personalstats"].present? || response["personalstats"].is_a?(Array) parse_personalstats(response["personalstats"], stat_batch) else - raise ApiError, "No personal stats data returned: #{response}" + # Torn intermittently returns a nil payload around the nightly + # stats-cache rebuild — retryable, not fatal. + raise TransientError, "No personal stats data returned: #{response}" end end diff --git a/config/queue.yml b/config/queue.yml index f2e4db3..cf969e4 100644 --- a/config/queue.yml +++ b/config/queue.yml @@ -3,11 +3,13 @@ default: &default - polling_interval: 1 batch_size: 500 workers: - - queues: admin - threads: 1 - processes: 1 - polling_interval: 1.1 - - queues: faction + # torn_api thread count is a RATE LIMIT, not a performance knob: + # each thread sustains ~40 Torn API calls/min (1 call + 1s pacing sleep), + # so aggregate outbound throughput ≈ threads × 40/min from this server's + # IP. TornApi::RateLimiter caps 50/min per key and 300/min globally on + # top — keep threads × 40 comfortably under the global budget. + # (faction/admin are drained legacy queue names; remove after deploy settles.) + - queues: [ torn_api, faction, admin ] threads: 5 processes: 1 polling_interval: 0.5 diff --git a/test/integration/torn_api_budget_flow_test.rb b/test/integration/torn_api_budget_flow_test.rb new file mode 100644 index 0000000..0cc95f5 --- /dev/null +++ b/test/integration/torn_api_budget_flow_test.rb @@ -0,0 +1,52 @@ +require "test_helper" + +# End-to-end composition of the three layers: a job whose key is out of budget +# is rejected before any HTTP happens and re-scheduled, while a job on a +# different key sails through the whole real pipeline (budget gate -> HTTP -> +# parse -> snapshot) at the same moment. +class TornApiBudgetFlowTest < ActiveJob::TestCase + setup do + Rails.stubs(:cache).returns(ActiveSupport::Cache::MemoryStore.new) + + @faction = Faction.create!( + torn_id: 99999, name: "Test Faction", xanax_target: 2.5, + energy_refill_target: 1.0, nerve_refill_target: 1.0, setup_completed: true + ) + ApiKey::Torn.create!(faction: @faction, key: "KEY_A", access_type: "Limited Access") + + @user = users(:bram) + @user.update!(faction: @faction) + @stats_date = Date.new(2026, 2, 19) + end + + test "a job on an exhausted key is rescheduled without touching the network" do + 50.times { TornApi::RateLimiter.acquire!("KEY_A") } + TornApi::Base.any_instance.expects(:perform_request).never + + assert_enqueued_with(job: FetchPersonalStatsJob) do + FetchPersonalStatsJob.perform_now(@user, api_key: "KEY_A", batch: 1, stats_date: @stats_date) + end + + assert_not @user.personal_stat_snapshots.exists?(date: @stats_date) + end + + test "a job on a fresh key completes the full pipeline while another key is exhausted" do + 50.times { TornApi::RateLimiter.acquire!("KEY_A") } + + payload = { + "personalstats" => [ + { "name" => "xantaken", "value" => 42, "timestamp" => @stats_date.end_of_day.to_i } + ] + } + TornApi::Base.any_instance.stubs(:perform_request) + .returns(stub(code: "200", body: payload.to_json)) + + FetchPersonalStatsJob.perform_now(@user, api_key: "KEY_B", batch: 1, stats_date: @stats_date) + + snapshot = @user.personal_stat_snapshots.find_by(date: @stats_date) + assert snapshot, "fresh key must complete and persist the snapshot" + assert_equal 42, snapshot.drugs_xanax + assert_operator TornApi::RateLimiter.remaining("KEY_B"), :<, 50, + "the successful call must have consumed KEY_B budget" + end +end diff --git a/test/jobs/admin_api_job_test.rb b/test/jobs/admin_api_job_test.rb index 3cc8a2b..231532a 100644 --- a/test/jobs/admin_api_job_test.rb +++ b/test/jobs/admin_api_job_test.rb @@ -1,14 +1,14 @@ require "test_helper" class AdminApiJobTest < ActiveJob::TestCase - test "enqueues to the admin queue" do - assert_equal "admin", AdminApiJob.new.queue_name + test "routes to the shared torn_api queue" do + assert_equal "torn_api", AdminApiJob.new.queue_name end - test "subclasses inherit the admin queue" do - assert_equal "admin", SyncFactionMembersJob.new.queue_name - assert_equal "admin", Daily::XanaxPaymentsJob.new.queue_name - assert_equal "admin", Daily::StockDividendJob.new.queue_name + test "subclasses inherit the torn_api queue" do + assert_equal "torn_api", SyncFactionMembersJob.new.queue_name + assert_equal "torn_api", Daily::XanaxPaymentsJob.new.queue_name + assert_equal "torn_api", Daily::StockDividendJob.new.queue_name end test "has a 1 second rate limit sleep constant" do @@ -27,13 +27,14 @@ def perform job.perform_now end - test "rate limit math stays under 50 req per minute" do + test "pacing keeps a single stream under the per-key budget" do sleep_duration = AdminApiJob::RATE_LIMIT_SLEEP - polling_interval = 1.1 + api_call_time = 0.5 - cycle_time = sleep_duration + polling_interval + cycle_time = sleep_duration + api_call_time max_requests_per_minute = 60.0 / cycle_time - assert max_requests_per_minute < 50, "Expected < 50 req/min, got #{max_requests_per_minute.round(1)}" + assert max_requests_per_minute < TornApi::RateLimiter::REQUESTS_PER_MINUTE, + "Expected < #{TornApi::RateLimiter::REQUESTS_PER_MINUTE} req/min, got #{max_requests_per_minute.round(1)}" end end diff --git a/test/jobs/backfill_personal_stats_job_test.rb b/test/jobs/backfill_personal_stats_job_test.rb index af17138..e94f521 100644 --- a/test/jobs/backfill_personal_stats_job_test.rb +++ b/test/jobs/backfill_personal_stats_job_test.rb @@ -42,8 +42,8 @@ class BackfillSingleStatJobTest < ActiveJob::TestCase assert_nil @user.personal_stat_snapshots.find_by(date: @date_str) end - test "handles API error gracefully without saving snapshot" do - TornApi::User::PersonalStats.any_instance.stubs(:fetch).raises(TornApi::ApiError, "Rate limited") + test "skips unrecoverable errors without saving snapshot" do + TornApi::User::PersonalStats.any_instance.stubs(:fetch).raises(TornApi::NotFoundError, "Requested data is private") assert_nothing_raised do BackfillSingleStatJob.perform_now(@user.id, @date_str, faction_id: @faction.id, api_key: "test_key") diff --git a/test/jobs/backfill_single_stat_job_test.rb b/test/jobs/backfill_single_stat_job_test.rb new file mode 100644 index 0000000..84d6556 --- /dev/null +++ b/test/jobs/backfill_single_stat_job_test.rb @@ -0,0 +1,69 @@ +require "test_helper" + +# BackfillSingleStatJob currently swallows every API error and returns nil, +# which silently leaves the date missing — so the nightly gap scan re-enqueues +# it forever, compounding API load. Retryable errors must propagate to the +# job-level retry; only genuinely unrecoverable ones may skip. +class BackfillSingleStatJobTest < ActiveJob::TestCase + setup do + @faction = Faction.create!( + torn_id: 99999, name: "Test Faction", xanax_target: 2.5, + energy_refill_target: 1.0, nerve_refill_target: 1.0, setup_completed: true + ) + ApiKey::Torn.create!(faction: @faction, key: "FACTION_KEY_123", access_type: "Limited Access") + + @user = users(:bram) + @user.update!(faction: @faction) + @date_str = "2026-07-01" + @stats = PersonalStatSnapshot::TRACKED_STATS_BATCH_1.values.index_with { |_| 100 } + .merge(date: Date.parse(@date_str)) + end + + test "saves the snapshot and chains batch 2 on success" do + TornApi::User::PersonalStats.any_instance.stubs(:fetch).returns(@stats) + + assert_enqueued_with(job: BackfillSingleStatJob) do + BackfillSingleStatJob.perform_now(@user.id, @date_str, faction_id: @faction.id, api_key: "FACTION_KEY_123") + end + + assert @user.personal_stat_snapshots.exists?(date: Date.parse(@date_str)) + end + + test "rate limit errors propagate so the job retries instead of losing the date" do + TornApi::User::PersonalStats.any_instance.stubs(:fetch) + .raises(TornApi::RateLimitError, "Too many requests") + + assert_enqueued_with(job: BackfillSingleStatJob) do + BackfillSingleStatJob.perform_now(@user.id, @date_str, faction_id: @faction.id, api_key: "FACTION_KEY_123") + end + + assert_not @user.personal_stat_snapshots.exists?(date: Date.parse(@date_str)) + end + + test "transient errors propagate so the job retries" do + TornApi::User::PersonalStats.any_instance.stubs(:fetch) + .raises(TornApi::TransientError, "Torn backend error, please try again") + + assert_enqueued_with(job: BackfillSingleStatJob) do + BackfillSingleStatJob.perform_now(@user.id, @date_str, faction_id: @faction.id, api_key: "FACTION_KEY_123") + end + end + + test "not found errors skip the date without retrying" do + TornApi::User::PersonalStats.any_instance.stubs(:fetch) + .raises(TornApi::NotFoundError, "Requested data is private") + + assert_no_enqueued_jobs(only: BackfillSingleStatJob) do + BackfillSingleStatJob.perform_now(@user.id, @date_str, faction_id: @faction.id, api_key: "FACTION_KEY_123") + end + end + + test "invalid key errors skip the date without retrying" do + TornApi::User::PersonalStats.any_instance.stubs(:fetch) + .raises(TornApi::InvalidKeyError, "Invalid API key") + + assert_no_enqueued_jobs(only: BackfillSingleStatJob) do + BackfillSingleStatJob.perform_now(@user.id, @date_str, faction_id: @faction.id, api_key: "FACTION_KEY_123") + end + end +end diff --git a/test/jobs/daily/backfill_gap_limit_test.rb b/test/jobs/daily/backfill_gap_limit_test.rb new file mode 100644 index 0000000..9decaa5 --- /dev/null +++ b/test/jobs/daily/backfill_gap_limit_test.rb @@ -0,0 +1,54 @@ +require "test_helper" + +# The nightly gap scans enqueue one backfill per missing date per user with no +# upper bound — a user with 100 gaps adds ~200 API calls to a single key in +# one night. Cap the per-user enqueue and take the most recent dates first +# (recent snapshots are the valuable ones; older gaps fill on later nights). +class BackfillGapLimitTest < ActiveJob::TestCase + setup do + @faction = Faction.create!( + torn_id: 99999, name: "Test Faction", xanax_target: 2.5, + energy_refill_target: 1.0, nerve_refill_target: 1.0, setup_completed: true + ) + @user = User.create!(torn_id: 424_242, name: "Gapster", level: 10, faction_id: @faction.id) + + # 100-day tracking window, user has no snapshots at all -> 100 missing dates + @window_start = Date.new(2026, 3, 1) + @window_end = Date.new(2026, 6, 8) + PersonalStatSnapshot.stubs(:tracking_start_date).returns(@window_start) + PersonalStatSnapshot.stubs(:tracking_end_date).returns(@window_end) + end + + test "faction member sync caps backfills per user per night" do + assert_enqueued_jobs Daily::FactionMemberSyncJob::BACKFILL_GAP_LIMIT, only: BackfillSingleStatJob do + Daily::FactionMemberSyncJob.new.send(:backfill_gaps, @user, "FACTION_KEY_123") + end + end + + test "faction member sync backfills the most recent gaps first" do + Daily::FactionMemberSyncJob.new.send(:backfill_gaps, @user, "FACTION_KEY_123") + + enqueued_dates = enqueued_jobs + .select { |j| j[:job] == BackfillSingleStatJob } + .map { |j| j[:args][1] } + + assert_includes enqueued_dates, @window_end.to_s, "newest gap must be scheduled" + assert_not_includes enqueued_dates, @window_start.to_s, "oldest gap waits for a later night" + end + + test "hof member sync caps backfills per user per night" do + assert_enqueued_jobs Daily::HofMemberSyncJob::BACKFILL_GAP_LIMIT, only: BackfillSingleStatJob do + Daily::HofMemberSyncJob.new.send(:backfill_gaps, @user, "KANEKI_KEY") + end + end + + test "no jobs are enqueued when there are no gaps" do + (@window_start..@window_end).each do |date| + @user.personal_stat_snapshots.create!(date: date) + end + + assert_no_enqueued_jobs(only: BackfillSingleStatJob) do + Daily::FactionMemberSyncJob.new.send(:backfill_gaps, @user, "FACTION_KEY_123") + end + end +end diff --git a/test/jobs/daily_xanax_payments_job_test.rb b/test/jobs/daily_xanax_payments_job_test.rb index 4b99783..6015ef1 100644 --- a/test/jobs/daily_xanax_payments_job_test.rb +++ b/test/jobs/daily_xanax_payments_job_test.rb @@ -13,8 +13,8 @@ class DailyXanaxPaymentsJobTest < ActiveJob::TestCase ) end - test "enqueues to the admin queue" do - assert_equal "admin", Daily::XanaxPaymentsJob.new.queue_name + test "enqueues to the torn_api queue" do + assert_equal "torn_api", Daily::XanaxPaymentsJob.new.queue_name end test "processes new xanax payment from existing user" do diff --git a/test/jobs/fetch_member_activity_job_test.rb b/test/jobs/fetch_member_activity_job_test.rb index 11dc824..a072942 100644 --- a/test/jobs/fetch_member_activity_job_test.rb +++ b/test/jobs/fetch_member_activity_job_test.rb @@ -46,12 +46,11 @@ class FetchMemberActivityJobTest < ActiveJob::TestCase assert FetchMemberActivityJob < FactionApiJob end - test "limits concurrency per faction in FactionApiCalls group" do + test "limits concurrency per api key in the shared TornApiCalls group" do assert_equal 1, FetchMemberActivityJob.concurrency_limit - assert_equal "FactionApiCalls", FetchMemberActivityJob.concurrency_group job = FetchMemberActivityJob.new(@faction.id) - assert_equal "FactionApiCalls/#{@faction.id}", job.concurrency_key + assert_equal "TornApiCalls/#{@faction.torn_api_key.key}", job.concurrency_key end test "handles api errors gracefully" do diff --git a/test/jobs/fetch_personal_stats_job_test.rb b/test/jobs/fetch_personal_stats_job_test.rb index ae9bfd0..6eb3a89 100644 --- a/test/jobs/fetch_personal_stats_job_test.rb +++ b/test/jobs/fetch_personal_stats_job_test.rb @@ -94,8 +94,9 @@ class FetchPersonalStatsJobTest < ActiveJob::TestCase end end - test "inherits from FactionApiJob" do + test "inherits from TornApiJob via the FactionApiJob alias" do assert FetchPersonalStatsJob < FactionApiJob - assert_equal "faction", FetchPersonalStatsJob.new.queue_name + assert FetchPersonalStatsJob < TornApiJob + assert_equal "torn_api", FetchPersonalStatsJob.new.queue_name end end diff --git a/test/jobs/stock_dividend_job_test.rb b/test/jobs/stock_dividend_job_test.rb index 73b3b3b..8411f97 100644 --- a/test/jobs/stock_dividend_job_test.rb +++ b/test/jobs/stock_dividend_job_test.rb @@ -6,8 +6,8 @@ class StockDividendJobTest < ActiveJob::TestCase @prn_stock = torn_stocks(:prn) end - test "enqueues to the admin queue" do - assert_equal "admin", Daily::StockDividendJob.new.queue_name + test "enqueues to the torn_api queue" do + assert_equal "torn_api", Daily::StockDividendJob.new.queue_name end test "updates dividend values from API response" do diff --git a/test/jobs/torn_api_concurrency_test.rb b/test/jobs/torn_api_concurrency_test.rb new file mode 100644 index 0000000..f70a3d5 --- /dev/null +++ b/test/jobs/torn_api_concurrency_test.rb @@ -0,0 +1,57 @@ +require "test_helper" + +# All jobs that hit the Torn API with the same api key must share ONE +# solid_queue semaphore, regardless of job class. Today FetchPersonalStatsJob +# serializes per api_key while the FactionApiCalls group serializes per +# faction_id — so several parallel streams can hammer the same key (worst on +# the kaneki key during HOF sync, where users span many faction_ids). +class TornApiConcurrencyTest < ActiveJob::TestCase + setup do + @faction = Faction.create!( + torn_id: 99999, name: "Test Faction", xanax_target: 2.5, + energy_refill_target: 1.0, nerve_refill_target: 1.0, setup_completed: true + ) + ApiKey::Torn.create!(faction: @faction, key: "FACTION_KEY_123", access_type: "Limited Access") + + @user = users(:bram) + @user.update!(faction: @faction) + end + + test "personal stats and backfill jobs with the same api key share one semaphore" do + personal_stats = FetchPersonalStatsJob.new(@user, api_key: "FACTION_KEY_123") + backfill = BackfillSingleStatJob.new(@user.id, "2026-07-01", faction_id: @faction.id, api_key: "FACTION_KEY_123") + + assert_equal personal_stats.concurrency_key, backfill.concurrency_key + end + + test "faction-scoped jobs serialize on the faction's api key, not the faction id" do + expected = FetchPersonalStatsJob.new(@user, api_key: "FACTION_KEY_123").concurrency_key + + [ + FetchMemberActivityJob.new(@faction.id), + FetchArmoryNewsJob.new(@faction.id), + BackfillArmoryNewsJob.new(@faction.id), + BackfillRankedWarsJob.new(@faction.id) + ].each do |job| + assert_equal expected, job.concurrency_key, + "#{job.class} must share the per-key semaphore with FetchPersonalStatsJob" + end + end + + test "hof backfills without a faction still serialize on the shared key" do + # This is the 04:30 incident: HOF users have faction_id nil (or an + # uncovered faction), so faction_id-keyed semaphores let 5 worker threads + # run in parallel on the single kaneki key. + hof_backfill = BackfillSingleStatJob.new(@user.id, "2026-07-01", faction_id: nil, api_key: "KANEKI_KEY") + hof_personal_stats = FetchPersonalStatsJob.new(@user, api_key: "KANEKI_KEY") + + assert_equal hof_personal_stats.concurrency_key, hof_backfill.concurrency_key + end + + test "jobs with different api keys do not block each other" do + a = FetchPersonalStatsJob.new(@user, api_key: "FACTION_KEY_123") + b = FetchPersonalStatsJob.new(@user, api_key: "OTHER_KEY_456") + + assert_not_equal a.concurrency_key, b.concurrency_key + end +end diff --git a/test/jobs/torn_api_queue_test.rb b/test/jobs/torn_api_queue_test.rb new file mode 100644 index 0000000..4c5e7d4 --- /dev/null +++ b/test/jobs/torn_api_queue_test.rb @@ -0,0 +1,56 @@ +require "test_helper" + +# Queues express latency class, not key type: every Torn API worker shares one +# torn_api queue whose thread count bounds aggregate throughput (threads x ~40 +# calls/min). Priority decides who goes next on a contended key's semaphore — +# solid_queue releases blocked executions by (priority, job_id), so a 15-minute +# activity poll never waits behind hundreds of queued backfills. +class TornApiQueueTest < ActiveJob::TestCase + PRIORITY_INTERACTIVE = 0 + PRIORITY_RECURRING = 10 + PRIORITY_NIGHTLY = 50 + PRIORITY_BACKFILL = 100 + + test "all torn api workers share the torn_api queue" do + [ + FetchPersonalStatsJob, + BackfillSingleStatJob, + FetchMemberActivityJob, + FetchArmoryNewsJob, + BackfillArmoryNewsJob, + BackfillRankedWarsJob, + SyncFactionMembersJob, + Daily::XanaxPaymentsJob, + Daily::StockDividendJob + ].each do |job_class| + assert_equal "torn_api", job_class.new.queue_name, + "#{job_class} must run on the torn_api queue" + end + end + + test "the base aliases route to torn_api during migration" do + assert_equal "torn_api", FactionApiJob.new.queue_name + assert_equal "torn_api", AdminApiJob.new.queue_name + end + + test "war polling keeps its own latency-sensitive queue" do + assert_equal "war", WarPollingJob.new.queue_name + assert_equal "war", PublicWarPollingJob.new.queue_name + end + + test "recurring polls outrank nightly work on a contended key" do + assert_equal PRIORITY_RECURRING, FetchMemberActivityJob.new.priority + assert_equal PRIORITY_NIGHTLY, FetchPersonalStatsJob.new.priority + end + + test "backfills always yield to everything else" do + [ BackfillSingleStatJob, BackfillArmoryNewsJob, BackfillRankedWarsJob ].each do |job_class| + assert_equal PRIORITY_BACKFILL, job_class.new.priority, + "#{job_class} must carry backfill priority" + end + end + + test "torn api jobs default to interactive priority" do + assert_equal PRIORITY_INTERACTIVE, TornApiJob.new.priority + end +end diff --git a/test/jobs/torn_api_retry_test.rb b/test/jobs/torn_api_retry_test.rb new file mode 100644 index 0000000..b17e3f4 --- /dev/null +++ b/test/jobs/torn_api_retry_test.rb @@ -0,0 +1,84 @@ +require "test_helper" + +# Rate limits heal in ~60 seconds, so a rate-limited job must be retried with +# a delay instead of failing permanently (which loses the day's snapshot and +# feeds tomorrow's backfill load). Same for transient Torn-side errors +# (5xx, empty personalstats payloads, "backend error, please try again"). +class TornApiRetryTest < ActiveJob::TestCase + class RateLimitedAdminJob < AdminApiJob + def perform + raise TornApi::RateLimitError, "Too many requests" + end + end + + setup do + @faction = Faction.create!( + torn_id: 99999, name: "Test Faction", xanax_target: 2.5, + energy_refill_target: 1.0, nerve_refill_target: 1.0, setup_completed: true + ) + ApiKey::Torn.create!(faction: @faction, key: "FACTION_KEY_123", access_type: "Limited Access") + + @user = users(:bram) + @user.update!(faction: @faction) + @api_key = "FACTION_KEY_123" + @stats_date = Date.new(2026, 2, 19) + end + + test "a rate-limited personal stats fetch is retried instead of failing" do + TornApi::User::PersonalStats.any_instance.stubs(:fetch) + .raises(TornApi::RateLimitError, "Too many requests") + + assert_enqueued_with(job: FetchPersonalStatsJob) do + FetchPersonalStatsJob.perform_now(@user, api_key: @api_key, batch: 1, stats_date: @stats_date) + end + end + + test "the rate limit retry is scheduled with a delay, not immediately" do + TornApi::User::PersonalStats.any_instance.stubs(:fetch) + .raises(TornApi::RateLimitError, "Too many requests") + + freeze_time do + FetchPersonalStatsJob.perform_now(@user, api_key: @api_key, batch: 1, stats_date: @stats_date) + + retry_job = enqueued_jobs.last + assert retry_job[:at].present?, "retry must be scheduled in the future" + assert retry_job[:at] >= 60.seconds.from_now.to_f, + "retry must wait at least a minute for the rate limit window to reset" + end + end + + test "gives up and re-raises after exhausting rate limit retries" do + TornApi::User::PersonalStats.any_instance.stubs(:fetch) + .raises(TornApi::RateLimitError, "Too many requests") + + job = FetchPersonalStatsJob.new(@user, api_key: @api_key, batch: 1, stats_date: @stats_date) + job.executions = 4 + job.exception_executions = { "[TornApi::RateLimitError]" => 4 } + + assert_raises(TornApi::RateLimitError) { job.perform_now } + end + + test "transient torn errors are retried instead of failing" do + TornApi::User::PersonalStats.any_instance.stubs(:fetch) + .raises(TornApi::TransientError, "No personal stats data returned") + + assert_enqueued_with(job: FetchPersonalStatsJob) do + FetchPersonalStatsJob.perform_now(@user, api_key: @api_key, batch: 1, stats_date: @stats_date) + end + end + + test "admin api jobs also retry on rate limits" do + assert_enqueued_with(job: RateLimitedAdminJob) do + RateLimitedAdminJob.perform_now + end + end + + test "invalid key errors keep the existing manual retry path" do + TornApi::User::PersonalStats.any_instance.stubs(:fetch) + .raises(TornApi::InvalidKeyError, "auth failed") + + assert_enqueued_with(job: FetchPersonalStatsJob) do + FetchPersonalStatsJob.perform_now(@user, api_key: @api_key, batch: 1, stats_date: @stats_date, retries: 0) + end + end +end diff --git a/test/models/discord/error_notifier_test.rb b/test/models/discord/error_notifier_test.rb index 6c30958..09ee89c 100644 --- a/test/models/discord/error_notifier_test.rb +++ b/test/models/discord/error_notifier_test.rb @@ -51,4 +51,38 @@ class Discord::ErrorNotifierTest < ActiveSupport::TestCase Discord::ErrorNotifier.new.report(error, handled: true, severity: :warning, context: {}) end + + # During the 04:30 rate-limit incident the reporter posted ~25 identical + # embeds in one minute. Identical errors within a short window must collapse + # into a single Discord notification (mirroring the 10-minute dedupe + # TornApi::Base already applies to its own API-error notifications). + + test "identical errors within the window are reported once" do + Rails.stubs(:cache).returns(ActiveSupport::Cache::MemoryStore.new) + error = TornApi::RateLimitError.new("Too many requests") + Discord::Notifier.expects(:notify).once + + 5.times do + Discord::ErrorNotifier.new.report(error, handled: false, severity: :error, context: {}, source: "application.solid_queue") + end + end + + test "different error classes are reported separately" do + Rails.stubs(:cache).returns(ActiveSupport::Cache::MemoryStore.new) + Discord::Notifier.expects(:notify).twice + + Discord::ErrorNotifier.new.report(TornApi::RateLimitError.new("Too many requests"), handled: false, severity: :error, context: {}, source: "application.solid_queue") + Discord::ErrorNotifier.new.report(TornApi::ApiError.new("Too many requests"), handled: false, severity: :error, context: {}, source: "application.solid_queue") + end + + test "the same error is reported again after the window expires" do + Rails.stubs(:cache).returns(ActiveSupport::Cache::MemoryStore.new) + error = TornApi::RateLimitError.new("Too many requests") + Discord::Notifier.expects(:notify).twice + + Discord::ErrorNotifier.new.report(error, handled: false, severity: :error, context: {}, source: "application.solid_queue") + travel 11.minutes do + Discord::ErrorNotifier.new.report(error, handled: false, severity: :error, context: {}, source: "application.solid_queue") + end + end end diff --git a/test/models/torn_api/base_budget_test.rb b/test/models/torn_api/base_budget_test.rb new file mode 100644 index 0000000..59f5487 --- /dev/null +++ b/test/models/torn_api/base_budget_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +# The budget must be enforced at the client, before the HTTP request goes +# out: a rejected request that still reaches Torn counts toward the real +# 100/minute hard limit, which is exactly what we're trying to protect. +class TornApi::BaseBudgetTest < ActiveSupport::TestCase + setup do + Rails.stubs(:cache).returns(ActiveSupport::Cache::MemoryStore.new) + @base = TornApi::Base.new("FACTION_KEY_123") + end + + test "get consumes budget from the key's rate limiter" do + stub_success + + assert_difference -> { 50 - TornApi::RateLimiter.remaining("FACTION_KEY_123") }, 2 do + @base.get("v2/user/12345/personalstats") + @base.get("v2/user/12345/personalstats") + end + end + + test "get raises RateLimitError without hitting the network when budget is spent" do + 50.times { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } + @base.expects(:perform_request).never + + assert_raises(TornApi::RateLimitError) do + @base.get("v2/user/12345/personalstats") + end + end + + test "an exhausted key does not block requests on other keys" do + 50.times { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } + + other = TornApi::Base.new("OTHER_KEY_456") + other.stubs(:perform_request).returns(stub(code: "200", body: { "ok" => 1 }.to_json)) + + assert_equal 1, other.get("v2/user/12345/personalstats")["ok"] + end + + private + + def stub_success + @base.stubs(:perform_request).returns(stub(code: "200", body: { "ok" => 1 }.to_json)) + end +end diff --git a/test/models/torn_api/base_retry_test.rb b/test/models/torn_api/base_retry_test.rb new file mode 100644 index 0000000..45d4b5a --- /dev/null +++ b/test/models/torn_api/base_retry_test.rb @@ -0,0 +1,62 @@ +require "test_helper" + +# HTTP 5xx responses from Torn (the 02:00 "HTTP 504" errors) are transient and +# must be retried at the client level like timeouts already are. When retries +# are exhausted, the failure must surface as TornApi::TransientError so the +# job layer can back off and retry later. Torn's own "try again" error codes +# (15, 17) get the same treatment. +class TornApi::BaseRetryTest < ActiveSupport::TestCase + setup do + @base = TornApi::Base.new("FACTION_KEY_123") + @base.stubs(:sleep) + end + + test "retries an HTTP 5xx response and succeeds" do + bad = stub(code: "504", body: "gateway timeout") + good = stub(code: "200", body: { "members" => [] }.to_json) + @base.stubs(:perform_request).returns(bad).then.returns(good) + + result = @base.get("v2/faction/99999/members") + + assert_equal [], result["members"] + end + + test "raises TransientError after exhausting 5xx retries" do + bad = stub(code: "504", body: "gateway timeout") + @base.stubs(:perform_request).returns(bad) + + assert_raises(TornApi::TransientError) do + @base.get("v2/faction/99999/members") + end + end + + test "does not retry 4xx responses" do + @base.expects(:perform_request).once.returns(stub(code: "403", body: "forbidden")) + + assert_raises(TornApi::ApiError) do + @base.get("v2/faction/99999/members") + end + end + + test "torn 'temporary error' code 15 raises TransientError" do + assert_raises(TornApi::TransientError) do + @base.send(:handle_api_error, { "code" => 15, "error" => "Temporary error" }) + end + end + + test "torn 'backend error' code 17 raises TransientError" do + assert_raises(TornApi::TransientError) do + @base.send(:handle_api_error, { "code" => 17, "error" => "Backend error occurred" }) + end + end + + test "TransientError is still an ApiError for existing rescues" do + assert TornApi::TransientError < TornApi::ApiError + end + + test "rate limit code 5 still raises RateLimitError" do + assert_raises(TornApi::RateLimitError) do + @base.send(:handle_api_error, { "code" => 5, "error" => "Too many requests" }) + end + end +end diff --git a/test/models/torn_api/rate_limiter_test.rb b/test/models/torn_api/rate_limiter_test.rb new file mode 100644 index 0000000..ece3b88 --- /dev/null +++ b/test/models/torn_api/rate_limiter_test.rb @@ -0,0 +1,91 @@ +require "test_helper" + +# Torn enforces 100 requests/minute per player across all their keys, and +# exceeding it (or spamming invalid keys) risks temporary IP bans. We +# self-impose HALF the hard limit per key, leaving headroom for the key +# owner's own usage (YATA, Torn PDA, browsing) that we can't see. +class TornApi::RateLimiterTest < ActiveSupport::TestCase + setup do + Rails.stubs(:cache).returns(ActiveSupport::Cache::MemoryStore.new) + end + + test "budget is half of torn's hard limit" do + assert_equal 100, TornApi::RateLimiter::TORN_HARD_LIMIT + assert_equal 50, TornApi::RateLimiter::REQUESTS_PER_MINUTE + end + + test "allows requests up to the per-key budget" do + assert_nothing_raised do + 50.times { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } + end + end + + test "rejects the request that exceeds the budget" do + 50.times { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } + + assert_raises(TornApi::RateLimitError) do + TornApi::RateLimiter.acquire!("FACTION_KEY_123") + end + end + + test "budget resets after the window passes" do + freeze_time do + 50.times { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } + end + + travel 61.seconds do + assert_nothing_raised do + TornApi::RateLimiter.acquire!("FACTION_KEY_123") + end + end + end + + test "each key has an independent budget" do + 50.times { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } + + assert_nothing_raised do + TornApi::RateLimiter.acquire!("OTHER_KEY_456") + end + end + + test "remaining reports the unused budget for a key" do + assert_equal 50, TornApi::RateLimiter.remaining("FACTION_KEY_123") + + 10.times { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } + + assert_equal 40, TornApi::RateLimiter.remaining("FACTION_KEY_123") + end + + # The per-key budget alone lets 30 keys sum to ~1500/min from one server IP. + # Torn documents no aggregate limit, but error code 8 ("IP blocked for + # abuse") exists — so the aggregate ceiling must be an explicit budget, not + # an accident of the worker thread count. + + test "global budget is 300 requests per minute across all keys" do + assert_equal 300, TornApi::RateLimiter::GLOBAL_REQUESTS_PER_MINUTE + end + + test "the global budget trips even when no single key is over" do + 6.times do |i| + 50.times { TornApi::RateLimiter.acquire!("KEY_#{i}") } + end + + assert_raises(TornApi::RateLimitError) do + TornApi::RateLimiter.acquire!("FRESH_KEY") + end + end + + test "global budget resets after the window passes" do + freeze_time do + 6.times do |i| + 50.times { TornApi::RateLimiter.acquire!("KEY_#{i}") } + end + end + + travel 61.seconds do + assert_nothing_raised do + TornApi::RateLimiter.acquire!("FRESH_KEY") + end + end + end +end diff --git a/test/models/torn_api/user/personal_stats_test.rb b/test/models/torn_api/user/personal_stats_test.rb new file mode 100644 index 0000000..9564f25 --- /dev/null +++ b/test/models/torn_api/user/personal_stats_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class TornApi::User::PersonalStatsTest < ActiveSupport::TestCase + test "an empty personalstats payload raises TransientError so jobs retry" do + # Torn intermittently returns {"personalstats" => nil} around the nightly + # stats-cache rebuild (the 04:50 errors). That's retryable, not fatal. + stats = TornApi::User::PersonalStats.new("FACTION_KEY_123", 12345) + stats.stubs(:get).returns({ "personalstats" => nil }) + + assert_raises(TornApi::TransientError) { stats.fetch } + end + + test "a populated personalstats payload parses normally" do + stats = TornApi::User::PersonalStats.new( + "FACTION_KEY_123", 12345, + stat_batch: { "xantaken" => :drugs_xanax } + ) + stats.stubs(:get).returns( + { "personalstats" => [ { "name" => "xantaken", "value" => 42, "timestamp" => 1_750_000_000 } ] } + ) + + result = stats.fetch + assert_equal 42, result[:drugs_xanax] + end +end From a32c34223778ca4ec7c863fa58b90c7413a394a7 Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 14 Jul 2026 22:01:22 +0200 Subject: [PATCH 259/266] add indexes for admin stats --- app/controllers/admin/stats_controller.rb | 88 +++++++++++-------- .../20260714210000_add_admin_stats_indexes.rb | 15 ++++ db/schema.rb | 5 +- .../admin/stats_controller_test.rb | 38 ++++++++ 4 files changed, 108 insertions(+), 38 deletions(-) create mode 100644 db/migrate/20260714210000_add_admin_stats_indexes.rb diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb index 4eb02b7..2ed6ea5 100644 --- a/app/controllers/admin/stats_controller.rb +++ b/app/controllers/admin/stats_controller.rb @@ -51,14 +51,26 @@ def load_snapshot_stats @total_snapshots = PersonalStatSnapshot.count @earliest_snapshot = PersonalStatSnapshot.minimum(:timestamp) @latest_snapshot = PersonalStatSnapshot.maximum(:timestamp) - @unique_snapshot_days = PersonalStatSnapshot.distinct.pluck(Arel.sql("DATE(timestamp, 'unixepoch')")).count + @unique_snapshot_days = PersonalStatSnapshot.distinct.count(:date) + + # member_activity_snapshots is millions of rows and grows ~5.7k/day; + # the distinct counts are full scans, so serve them from cache. + activity = Rails.cache.fetch("admin_stats:member_activity", expires_in: 15.minutes) do + { + total: MemberActivitySnapshot.count, + polls: MemberActivitySnapshot.distinct.count(:recorded_at), + members: MemberActivitySnapshot.distinct.count(:torn_member_id), + earliest: MemberActivitySnapshot.minimum(:recorded_at), + latest: MemberActivitySnapshot.maximum(:recorded_at) + } + end - @activity_total_snapshots = MemberActivitySnapshot.count - @activity_total_polls = MemberActivitySnapshot.distinct.count(:recorded_at) - @activity_members_tracked = MemberActivitySnapshot.distinct.count(:torn_member_id) + @activity_total_snapshots = activity[:total] + @activity_total_polls = activity[:polls] + @activity_members_tracked = activity[:members] @activity_factions_polled = Faction.where(setup_completed: true).joins(:torn_api_key).count - @activity_earliest = MemberActivitySnapshot.minimum(:recorded_at) - @activity_latest = MemberActivitySnapshot.maximum(:recorded_at) + @activity_earliest = activity[:earliest] + @activity_latest = activity[:latest] @activity_daily_growth = @activity_total_polls > 0 ? (@activity_total_snapshots / [ @activity_total_polls, 1 ].max) * 96 : 0 end @@ -109,6 +121,16 @@ def load_api_stats @admin_api_peak_all_time = peak_rate(admin_calls) @admin_api_peak_today = peak_rate(admin_calls.today) + # One pass over the 24h window for every key's peak — per-key + # peak_rate() calls were a full table scan each. + peak_by_key = Hash.new(0) + ApiCall.where("created_at > ?", 1.day.ago) + .group(:api_key, ApiCall::MINUTE_BUCKET) + .count + .each do |(key, _bucket), calls| + peak_by_key[key] = calls if calls > peak_by_key[key] + end + @api_key_breakdown = ApiCall.where("created_at > ?", 1.day.ago) .group(:api_key) .select( @@ -126,7 +148,7 @@ def load_api_stats owner: owner, total: row.total_calls, errors: row.error_count, - peak_rate: peak_rate(ApiCall.where(api_key: key)) + peak_rate: peak_by_key[key] } end end @@ -159,17 +181,20 @@ def load_armory_stats @armory_earliest = ArmoryNewsEntry.minimum(:occurred_at) @armory_latest = ArmoryNewsEntry.maximum(:occurred_at) + counts = ArmoryNewsEntry.group(:faction_id).count + earliest = ArmoryNewsEntry.group(:faction_id).minimum(:occurred_at) + latest = ArmoryNewsEntry.group(:faction_id).maximum(:occurred_at) + @armory_by_faction = Faction .where(setup_completed: true) .includes(:torn_api_key) .order(:name) .map do |f| - entries = f.armory_news_entries { faction: f, - count: entries.count, - earliest: entries.minimum(:occurred_at), - latest: entries.maximum(:occurred_at), + count: counts.fetch(f.id, 0), + earliest: earliest[f.id], + latest: latest[f.id], backfill_pending: f.armory_backfill_pending? } end @@ -193,34 +218,23 @@ def calculate_snapshot_gaps tracked_user_ids = User.tracked_for_stats.pluck(:id) return set_empty_gap_stats if tracked_user_ids.empty? - existing_snapshots = PersonalStatSnapshot - .where(user_id: tracked_user_ids) - .pluck(:user_id, :date) - .group_by(&:first) - .transform_values { |pairs| pairs.map(&:last).to_set } - start_date = PersonalStatSnapshot.tracking_start_date end_date = PersonalStatSnapshot.tracking_end_date - expected_dates = (start_date..end_date).to_a - - users_with_gaps = 0 - total_missing = 0 - missing_by_date = Hash.new(0) - - tracked_user_ids.each do |user_id| - user_snapshots = existing_snapshots[user_id] || Set.new - missing_dates = expected_dates - user_snapshots.to_a - - if missing_dates.any? - users_with_gaps += 1 - total_missing += missing_dates.size - missing_dates.each { |d| missing_by_date[d] += 1 } - end - end - - @users_with_gaps = users_with_gaps - @total_missing_snapshot_days = total_missing - @missing_dates_summary = missing_by_date.sort_by { |date, _| date }.last(10).reverse + expected_days = (start_date..end_date).count + window = PersonalStatSnapshot.where(user_id: tracked_user_ids, date: start_date..end_date) + + # Two grouped queries instead of loading every (user_id, date) pair + # into Ruby. + days_per_user = window.group(:user_id).distinct.count(:date) + users_per_date = window.group(:date).distinct.count(:user_id) + + @users_with_gaps = tracked_user_ids.count { |id| days_per_user.fetch(id, 0) < expected_days } + @total_missing_snapshot_days = tracked_user_ids.sum { |id| expected_days - days_per_user.fetch(id, 0) } + @missing_dates_summary = (start_date..end_date) + .map { |date| [ date, tracked_user_ids.size - users_per_date.fetch(date, 0) ] } + .select { |_, missing| missing.positive? } + .last(10) + .reverse end def set_empty_gap_stats diff --git a/db/migrate/20260714210000_add_admin_stats_indexes.rb b/db/migrate/20260714210000_add_admin_stats_indexes.rb new file mode 100644 index 0000000..c5b0a17 --- /dev/null +++ b/db/migrate/20260714210000_add_admin_stats_indexes.rb @@ -0,0 +1,15 @@ +class AddAdminStatsIndexes < ActiveRecord::Migration[8.1] + def change + # Per-key aggregations (admin stats breakdown, peak rates) previously + # full-scanned api_calls once per key. + add_index :api_calls, [ :api_key, :created_at ] + + # Global min/max/range queries on a ~3.8M-row table; the existing + # composite (faction_id, recorded_at) can't serve them. + add_index :member_activity_snapshots, :recorded_at + + # Global earliest/latest on admin stats and the retention cleanup's + # `occurred_at < cutoff` delete. + add_index :armory_news_entries, :occurred_at + end +end diff --git a/db/schema.rb b/db/schema.rb index 10c265b..811cbec 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_07_200222) do +ActiveRecord::Schema[8.1].define(version: 2026_07_14_210000) do create_table "api_calls", force: :cascade do |t| t.string "api_key", null: false t.datetime "created_at", null: false @@ -23,6 +23,7 @@ t.integer "torn_api_timestamp" t.datetime "updated_at", null: false t.integer "user_id", null: false + t.index ["api_key", "created_at"], name: "index_api_calls_on_api_key_and_created_at" t.index ["created_at"], name: "index_api_calls_on_created_at" t.index ["faction_id"], name: "index_api_calls_on_faction_id" t.index ["user_id", "created_at"], name: "index_api_calls_on_user_id_and_created_at" @@ -57,6 +58,7 @@ t.index ["faction_id", "player_id", "action"], name: "idx_on_faction_id_player_id_action_86316add96" t.index ["faction_id", "torn_news_id"], name: "index_armory_news_entries_on_faction_id_and_torn_news_id", unique: true t.index ["faction_id"], name: "index_armory_news_entries_on_faction_id" + t.index ["occurred_at"], name: "index_armory_news_entries_on_occurred_at" end create_table "faction_settings", force: :cascade do |t| @@ -113,6 +115,7 @@ t.index ["faction_id", "recorded_at"], name: "index_member_activity_snapshots_on_faction_id_and_recorded_at" t.index ["faction_id", "torn_member_id", "recorded_at"], name: "idx_activity_faction_member_time" t.index ["faction_id"], name: "index_member_activity_snapshots_on_faction_id" + t.index ["recorded_at"], name: "index_member_activity_snapshots_on_recorded_at" end create_table "personal_stat_snapshots", force: :cascade do |t| diff --git a/test/controllers/admin/stats_controller_test.rb b/test/controllers/admin/stats_controller_test.rb index 76788f3..33a4e4f 100644 --- a/test/controllers/admin/stats_controller_test.rb +++ b/test/controllers/admin/stats_controller_test.rb @@ -164,4 +164,42 @@ class Admin::StatsControllerTest < ActionDispatch::IntegrationTest assert_select "h1", text: "System Stats" end + + test "api key breakdown peak reflects only the last 24 hours" do + key = "PEAKWINDOWKEY123" + # burst of 3 calls in one minute, two days ago — outside the 24h window + travel_to 2.days.ago.change(min: 0, sec: 0) do + 3.times { ApiCall.create!(user: @admin, endpoint: "/test", status: "success", api_key: key) } + end + # one call now — inside the window + ApiCall.create!(user: @admin, endpoint: "/test", status: "success", api_key: key) + + get admin_stats_path + assert_response :success + + row = css_select(".admin-stats-table tr").find { |r| r.text.include?("PEAK...Y123") } + assert row, "breakdown row for the key must render" + assert_includes row.text, "1/min", "peak must come from the 24h window, not all history" + end + + test "gap stats are computed correctly" do + PersonalStatSnapshot.stubs(:tracking_start_date).returns(Date.new(2024, 1, 1)) + PersonalStatSnapshot.stubs(:tracking_end_date).returns(Date.new(2024, 1, 2)) + + gapless = User.create!(torn_id: 555_111, name: "Gapless", level: 5, hof_stats_user: true) + gapless.personal_stat_snapshots.create!(date: Date.new(2024, 1, 1)) + gapless.personal_stat_snapshots.create!(date: Date.new(2024, 1, 2)) + + tracked_count = User.tracked_for_stats.count + + get admin_stats_path + assert_response :success + + # every tracked user except Gapless misses both window days + gap_row = css_select(".admin-stat-row").find { |r| r.text.include?("Users with gaps") } + assert_equal (tracked_count - 1).to_s, gap_row.css(".admin-stat-value").text.strip + + missing_row = css_select(".admin-stat-row").find { |r| r.text.include?("Total missing days") } + assert_equal ((tracked_count - 1) * 2).to_s, missing_row.css(".admin-stat-value").text.strip + end end From 072c44902daaa719df2f09d3325743b7cea410b2 Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 14 Jul 2026 22:17:29 +0200 Subject: [PATCH 260/266] update stats ui --- app/assets/stylesheets/pages/admin.css | 125 ++++ app/controllers/admin/stats_controller.rb | 56 +- app/views/admin/stats/index.html.erb | 695 ++++++++++-------- .../admin/stats_controller_test.rb | 67 +- 4 files changed, 617 insertions(+), 326 deletions(-) diff --git a/app/assets/stylesheets/pages/admin.css b/app/assets/stylesheets/pages/admin.css index abe90c5..24eb8b0 100644 --- a/app/assets/stylesheets/pages/admin.css +++ b/app/assets/stylesheets/pages/admin.css @@ -1126,3 +1126,128 @@ .quick-add-grid .predict-input:first-child { width: 140px; } + +/* ── System Stats redesign ──────────────────────────────────────────── */ + +.admin-stats-asof { + margin-left: auto; + color: var(--gray-500); + font-size: 11.5px; +} + +/* health strip */ +.admin-health { display: flex; gap: 10px; flex-wrap: wrap; margin: var(--space-4) 0 var(--space-5); } +.admin-hpill { + display: inline-flex; align-items: center; gap: 8px; + border: 1px solid var(--gray-700); border-radius: 999px; + padding: 6px 14px 6px 10px; font-size: 12.5px; + background: var(--gray-900); color: var(--gray-200); + text-decoration: none; +} +.admin-hpill .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; } +.admin-hpill b { font-weight: 700; } +.admin-hpill .why { color: var(--gray-400); } +.admin-hpill.ok { border-color: rgba(34, 197, 94, 0.4); } +.admin-hpill.ok .dot { background: #22c55e; } +.admin-hpill.warn { border-color: rgba(245, 158, 11, 0.5); background: rgba(245, 158, 11, 0.08); } +.admin-hpill.warn .dot { background: #f59e0b; } +.admin-hpill.bad { border-color: rgba(239, 68, 68, 0.5); background: rgba(239, 68, 68, 0.08); } +.admin-hpill.bad .dot { background: #ef4444; } + +/* KPI hero row */ +.admin-kpis { + display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; margin-bottom: var(--space-8); +} +.admin-kpi { + background: var(--gray-900); border: 1px solid var(--gray-800); + border-radius: var(--radius-md); padding: 12px 14px; +} +.admin-kpi .v { font-size: 22px; font-weight: 700; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; } +.admin-kpi .v small { font-size: 12px; color: var(--gray-500); font-weight: 400; } +.admin-kpi .k { font-size: 11px; color: var(--gray-500); text-transform: uppercase; letter-spacing: 0.08em; margin-top: 2px; } +.admin-kpi .sub { font-size: 11.5px; color: var(--gray-400); margin-top: 4px; } +.admin-kpi.warn .v { color: #f59e0b; } +.admin-kpi.bad .v { color: #ef4444; } +.admin-kpi.good .v { color: #22c55e; } + +/* sections */ +.admin-stats-section { margin-bottom: var(--space-8); } +.admin-stats-sechead { + display: flex; align-items: baseline; gap: 10px; + border-bottom: 1px solid var(--gray-800); + padding-bottom: 8px; margin-bottom: var(--space-4); +} +.admin-stats-sechead h2 { + font-size: 13px; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.12em; margin: 0; border: none; padding: 0; +} +.admin-stats-panel h3 { + font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; + color: var(--gray-400); margin: 0 0 10px; +} +.admin-span2 { grid-column: span 2; } +.admin-stats-grid-api { grid-template-columns: minmax(280px, 1fr) 2fr; } +@media (max-width: 900px) { + .admin-stats-grid-api { grid-template-columns: 1fr; } + .admin-span2 { grid-column: auto; } +} + +/* scrollable tables: constant page height, whatever the row count */ +.admin-tscroll { + overflow: auto; + max-height: 320px; + border: 1px solid var(--gray-800); + border-radius: var(--radius-sm); + background: var(--gray-1000); +} +.admin-tscroll.tall { max-height: 420px; } +.admin-tscroll .admin-stats-table { margin-top: 0; } +.admin-tscroll thead th { + position: sticky; top: 0; z-index: 1; + background: var(--gray-1000); +} +.admin-tscroll td, .admin-tscroll th { white-space: nowrap; padding: 5px 12px; } +.admin-tscroll thead th { padding-top: 7px; padding-bottom: 7px; } +.admin-num { text-align: right; font-variant-numeric: tabular-nums; } +th.admin-num { text-align: right; } +.admin-muted { color: var(--gray-500); } +.admin-folded td { color: var(--gray-500); } +.admin-stats-footnote { color: var(--gray-500); font-size: 11px; margin: 8px 0 0; } +.admin-plain-link { color: inherit; text-decoration: underline; text-underline-offset: 2px; } + +/* status chips */ +.admin-chip { + display: inline-flex; align-items: center; gap: 6px; + font-size: 11px; font-weight: 700; border-radius: 4px; padding: 1px 8px; +} +.admin-chip::before { content: ""; width: 6px; height: 6px; border-radius: 50%; } +.admin-chip.ok { background: rgba(34, 197, 94, 0.12); color: #22c55e; } +.admin-chip.ok::before { background: #22c55e; } +.admin-chip.warn { background: rgba(245, 158, 11, 0.12); color: #f59e0b; } +.admin-chip.warn::before { background: #f59e0b; } +.admin-chip.off { background: rgba(115, 115, 115, 0.15); color: var(--gray-500); } +.admin-chip.off::before { background: var(--gray-500); } + +/* faction capability dots */ +.admin-caps { display: inline-flex; gap: 5px; } +.admin-cap { width: 8px; height: 8px; border-radius: 2px; background: var(--gray-700); } +.admin-cap.on { background: #22c55e; } + +/* api key budget bars */ +.admin-bud { display: inline-flex; align-items: center; gap: 8px; } +.admin-bud .track { width: 72px; height: 6px; border-radius: 3px; background: var(--gray-700); overflow: hidden; flex: none; } +.admin-bud .fill { height: 100%; border-radius: 3px; background: var(--blue-500); } +.admin-bud.over .fill { background: #ef4444; } +.admin-bud .n { min-width: 58px; text-align: right; font-variant-numeric: tabular-nums; } +.admin-bud.over .n { color: #ef4444; font-weight: 700; } + +/* 7-day snapshot chart */ +.admin-chart { padding: 6px 2px 0; margin-top: var(--space-3); } +.admin-chart-title { font-size: 11px; color: var(--gray-500); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 10px; } +.admin-chart-plot { display: flex; align-items: flex-end; gap: 6px; height: 110px; } +.admin-chart-bar { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 100%; gap: 4px; } +.admin-chart-bar .b { width: 100%; max-width: 44px; background: var(--blue-500); border-radius: 3px 3px 0 0; } +.admin-chart-bar .n { font-size: 11px; color: var(--gray-400); font-variant-numeric: tabular-nums; } +.admin-chart-x { display: flex; gap: 6px; margin-top: 6px; } +.admin-chart-x span { flex: 1; text-align: center; font-size: 10.5px; color: var(--gray-500); } diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb index 2ed6ea5..ac280e0 100644 --- a/app/controllers/admin/stats_controller.rb +++ b/app/controllers/admin/stats_controller.rb @@ -2,6 +2,10 @@ module Admin class StatsController < ApplicationController before_action :require_admin + # Factions with no completed setup and no key that haven't changed in this + # long are surfaced as "stale" (and are the target of the upcoming cleanup). + STALE_FACTION_AFTER = 30.days + def index load_user_stats load_subscription_stats @@ -12,6 +16,7 @@ def index load_api_stats load_sign_in_stats load_armory_stats + load_pipeline_stats end private @@ -39,12 +44,39 @@ def load_subscription_stats def load_faction_stats @total_factions = Faction.count @factions_with_backfill = Faction.where("backfill_ends_at > ?", Time.current).count - @faction_details = Faction + + last_sync_by_faction = Rails.cache.fetch("admin_stats:faction_last_sync", expires_in: 15.minutes) do + MemberActivitySnapshot.group(:faction_id).maximum(:recorded_at) + end + + details = Faction .left_joins(:users) .includes(:torn_api_key, :tornstats_api_key) .group("factions.id") .select("factions.*, COUNT(users.id) as member_count") - .order(setup_completed: :desc, name: :asc) + + @faction_rows = details.map do |f| + status, stale_days = + if f.setup_completed? + [ :active, nil ] + elsif f.updated_at < STALE_FACTION_AFTER.ago + [ :stale, (Date.current - f.updated_at.to_date).to_i ] + else + [ :no_setup, nil ] + end + + { + faction: f, + member_count: f.member_count, + status: status, + stale_days: stale_days, + caps: [ f.setup_completed?, f.torn_api_key.present?, f.tornstats_api_key.present?, + f.setup_completed? && f.torn_api_key.present?, f.backfill_in_progress? ], + last_sync: last_sync_by_faction[f.id] + } + end.sort_by { |row| [ { active: 0, stale: 1, no_setup: 2 }[row[:status]], -row[:member_count] ] } + + @active_factions = @faction_rows.count { |r| r[:status] == :active } end def load_snapshot_stats @@ -109,6 +141,7 @@ def load_data_health ) .count @incomplete_snapshots = @total_snapshots - @complete_snapshots + @completeness_pct = @total_snapshots > 0 ? ((@complete_snapshots.to_f / @total_snapshots) * 100).round(1) : nil end def load_api_stats @@ -151,6 +184,25 @@ def load_api_stats peak_rate: peak_by_key[key] } end + + @per_key_budget = TornApi::RateLimiter::REQUESTS_PER_MINUTE + @global_budget = TornApi::RateLimiter::GLOBAL_REQUESTS_PER_MINUTE + @keys_over_budget = @api_key_breakdown.count { |r| r[:peak_rate] > @per_key_budget } + @api_key_rows, singles = @api_key_breakdown.partition { |r| r[:total] > 1 || r[:errors] > 0 } + @api_single_call_keys = singles.size + end + + # Failed/blocked counts live in the solid_queue database; guard so a + # queue-db hiccup can't take down the stats page. + def load_pipeline_stats + @pipeline_stats = { + failed: SolidQueue::FailedExecution.count, + blocked: SolidQueue::BlockedExecution.count, + scheduled: SolidQueue::ScheduledExecution.count + } + rescue => e + Rails.logger.error("[Admin::Stats] pipeline stats unavailable: #{e.message}") + @pipeline_stats = nil end def peak_rate(scope) diff --git a/app/views/admin/stats/index.html.erb b/app/views/admin/stats/index.html.erb index 40687db..583343c 100644 --- a/app/views/admin/stats/index.html.erb +++ b/app/views/admin/stats/index.html.erb @@ -2,336 +2,417 @@
    <%= link_to "← Admin".html_safe, admin_dashboard_path, class: "back-link" %>

    System Stats

    + as of <%= Time.current.strftime("%d %b %Y %H:%M") %> TCT · activity metrics cached ≤15 min
    -
    - <%# ── Users & Sign-ins ── %> -
    -

    Users

    -
    -
    - Total - <%= number_with_delimiter(@total_users) %> -
    -
    - Tracked - <%= @tracked_users %> -
    -
    - Subscribers - <%= @active_subscribers %> -
    -
    - HoF Users - <%= @hof_stats_users %> -
    -
    - API Keys - <%= @api_keys_configured %> -
    -
    -
    -
    - Sessions this week - <%= @sign_ins_this_week %> -
    -
    - Unique this week - <%= @unique_sign_ins_this_week %> -
    -
    - First-time - <%= @new_sign_ins.size %> -
    -
    - <% if @new_sign_ins.any? %> - - - - - - <% @new_sign_ins.each do |user| %> - - - - - <% end %> - -
    New UserJoined
    <%= user.name %><%= @first_session_dates[user.id]&.strftime("%d %b") || "—".html_safe %>
    - <% end %> -
    + <%# ── 1. Health strip: do I need to act? ── %> +
    + <% if @users_missing_yesterday > 0 %> + Collection<%= @users_missing_yesterday %> users missing yesterday's snapshot + <% else %> + Collectionall tracked users snapshotted yesterday + <% end %> - <%# ── Subscriptions ── %> -
    -

    Subscriptions

    -
    -
    - Total active - <%= @total_subscribers %> -
    -
    - Xanax received (30d) - <%= @xanax_received_past_month %> -
    -
    - <% if @subscribed_factions.any? %> - - - - - - <% @subscribed_factions.each do |f| %> - - - - - - <% end %> - -
    FactionExpiresDays
    <%= f.name %><%= f.subscription.expires_at.strftime("%d %b %Y") %><%= f.subscription.days_remaining %>
    - <% end %> -
    + <% if @keys_over_budget > 0 %> + Budget<%= @keys_over_budget %> keys peaked over <%= @per_key_budget %>/min (24h) + <% else %> + Budgetall keys ≤<%= @per_key_budget %>/min (24h) + <% end %> - <%# ── Factions ── %> -
    -

    Factions <%= @total_factions %> / 15

    - <% if @faction_details.any? %> - - - - - - <% @faction_details.each do |f| %> - <% polling = f.setup_completed? && f.torn_api_key %> - <% backfilling = f.backfill_in_progress? %> - - - - - - - - - - <% end %> - -
    FactionMbrSetAPITSPolBF
    <%= f.name %><%= f.member_count %><%= f.setup_completed? ? "✓".html_safe : "✗".html_safe %><%= f.torn_api_key ? "✓".html_safe : "✗".html_safe %><%= f.tornstats_api_key ? "✓".html_safe : "✗".html_safe %><%= polling ? "✓".html_safe : "✗".html_safe %><%= backfilling ? "✓".html_safe : "✗".html_safe %>
    + <% if @pipeline_stats %> + <% if @pipeline_stats[:failed] > 0 %> + <%= link_to "/jobs", class: "admin-hpill bad", data: { turbo_prefetch: false, turbo: false } do %>Queue<%= number_with_delimiter(@pipeline_stats[:failed]) %> failed jobs awaiting triage<% end %> + <% else %> + Queueno failed jobs <% end %> + <% end %> + + <% if @api_peak_rate_today > @global_budget %> + Aggregate API<%= @api_peak_rate_today %>/min peak today · global cap <%= @global_budget %> + <% else %> + Aggregate API<%= @api_peak_rate_today %>/min peak today · global cap <%= @global_budget %> + <% end %> + + <% if @completeness_pct %> + Completeness<%= @completeness_pct %>% of <%= number_with_delimiter(@total_snapshots) %> snapshots + <% end %> +
    + + <%# ── 2. KPI hero row ── %> +
    +
    +
    <%= @tracked_users %>
    +
    Tracked users
    +
    of <%= number_with_delimiter(@total_users) %> total
    +
    +
    +
    <%= @total_subscribers %>
    +
    Active subs
    +
    <%= @xanax_received_past_month %> xanax (30d)
    +
    +
    <%= number_with_delimiter(@api_calls_today) %>
    +
    API calls today
    +
    <%= number_with_delimiter(@api_calls_this_week) %> this week
    +
    +
    +
    <%= @api_peak_rate_today %>/min
    +
    Peak today
    +
    global budget <%= @global_budget %>/min
    +
    +
    +
    <%= @users_missing_yesterday %>
    +
    Missing yesterday
    +
    of <%= @tracked_users %> tracked (03:00 TCT)
    +
    +
    +
    <%= number_with_delimiter(@snapshots_this_week) %>
    +
    Snapshots this week
    +
    today: <%= @snapshots_today %>
    +
    +
    - <%# ── Personal Stat Snapshots ── %> -
    -

    Personal Stat Snapshots

    -
    -
    - Total - <%= number_with_delimiter(@total_snapshots) %> -
    -
    - Days with data - <%= @unique_snapshot_days %> -
    -
    - Earliest - <%= @earliest_snapshot ? Time.at(@earliest_snapshot).utc.strftime("%d %b %Y") : "N/A" %> -
    -
    - Latest - <%= @latest_snapshot ? Time.at(@latest_snapshot).utc.strftime("%d %b %Y") : "N/A" %> -
    + <%# ── 3. Data collection ── %> +
    +

    Data Collection

    snapshots · gaps · the nightly pipeline's output
    +
    + +
    +

    Snapshot coverage

    +
    +
    + Total + <%= number_with_delimiter(@total_snapshots) %> +
    +
    + Days with data + <%= @unique_snapshot_days %> +
    +
    + Range + <%= @earliest_snapshot ? Time.at(@earliest_snapshot).utc.strftime("%d %b %Y") : "N/A" %> – <%= @latest_snapshot ? Time.at(@latest_snapshot).utc.strftime("%d %b %Y") : "N/A" %> +
    +
    + Complete / incomplete + <%= number_with_delimiter(@complete_snapshots) %> / <%= number_with_delimiter(@incomplete_snapshots) %> +
    +
    + <% if @daily_snapshots.any? %> + <% max_daily = [ @daily_snapshots.map(&:last).max, 1 ].max %> +
    +
    Snapshots per day — last <%= @daily_snapshots.size %>
    +
    + <% @daily_snapshots.each do |_date, count| %> +
    + <%= count %> +
    +
    + <% end %> +
    +
    + <% @daily_snapshots.each do |date, _count| %> + <%= Date.parse(date.to_s).strftime("%d") %> + <% end %> +
    +
    + <% end %>
    -
    -
    - Today (03:00 TCT) - <%= @snapshots_today %> -
    -
    - This week - <%= @snapshots_this_week %> -
    + +
    +

    Gaps — what backfill chases tonight

    +
    +
    + Users with gaps + <%= @users_with_gaps %> +
    +
    + Total missing days + <%= number_with_delimiter(@total_missing_snapshot_days) %> +
    +
    + Missing yesterday (03:00 TCT) + <%= @users_missing_yesterday %> +
    +
    + <% if @missing_dates_summary.any? %> +
    + + + + + + <% @missing_dates_summary.each do |date, count| %> + + + + + + <% end %> + +
    DateMissingof tracked
    <%= date.strftime("%d %b") %><%= count %><%= @tracked_users > 0 ? "#{((count.to_f / @tracked_users) * 100).round(1)}%" : "—" %>
    +
    + <% end %>
    - <% if @daily_snapshots.any? %> - - - - - - <% @daily_snapshots.each do |date, count| %> - - <% end %> - -
    DateCount
    <%= date %><%= count %>
    - <% end %> -
    - <%# ── Member Activity Snapshots ── %> -
    -

    Member Activity

    -
    -
    - Total snapshots - <%= number_with_delimiter(@activity_total_snapshots) %> -
    -
    - Polls completed - <%= number_with_delimiter(@activity_total_polls) %> -
    -
    - Members tracked - <%= @activity_members_tracked %> -
    -
    - Est. daily rows - <%= number_with_delimiter(@activity_daily_growth) %> -
    -
    - Earliest - <%= @activity_earliest&.strftime("%d %b %Y %H:%M") || "N/A" %> -
    -
    - Latest - <%= @activity_latest&.strftime("%d %b %Y %H:%M") || "N/A" %> -
    +
    +

    Pipeline health

    + <% if @pipeline_stats %> +
    +
    + Failed jobs + <%= link_to number_with_delimiter(@pipeline_stats[:failed]), "/jobs", class: "admin-plain-link", data: { turbo_prefetch: false, turbo: false } %> +
    +
    + Blocked (semaphores) + <%= number_with_delimiter(@pipeline_stats[:blocked]) %> +
    +
    + Scheduled retries + <%= number_with_delimiter(@pipeline_stats[:scheduled]) %> +
    +
    + Latest snapshot written + <%= @latest_snapshot ? Time.at(@latest_snapshot).utc.strftime("%d %b %H:%M") : "N/A" %> +
    +
    + Latest armoury entry + <%= @armory_latest&.strftime("%d %b %H:%M") || "N/A" %> +
    +
    +

    Counts come from solid_queue. Failed > 0 turns the header pill red — it's the "act now" signal.

    + <% else %> +

    Queue database unavailable.

    + <% end %>
    +
    +
    - <%# ── Armoury News ── %> -
    -

    Armoury News

    -
    -
    - Total entries - <%= number_with_delimiter(@armory_total_entries) %> -
    -
    - Earliest - <%= @armory_earliest&.strftime("%d %b %Y") || "N/A" %> -
    -
    - Latest - <%= @armory_latest&.strftime("%d %b %Y") || "N/A" %> + <%# ── 4. Torn API ── %> +
    +

    Torn API

    30d retention · budget: <%= @per_key_budget %>/min per key · <%= @global_budget %>/min global
    +
    + +
    +

    Aggregate

    +
    +
    + Total calls + <%= number_with_delimiter(@total_api_calls) %> +
    +
    + Peak rate + <%= @api_peak_rate_all_time %>/min +
    +
    + Peak today + <%= @api_peak_rate_today %>/min +
    +
    +
    +
    + Admin key calls + <%= number_with_delimiter(@admin_api_total) %> +
    +
    + Admin peak rate + <%= @admin_api_peak_all_time %>/min +
    +
    + Admin peak today + <%= @admin_api_peak_today %>/min +
    - <% if @armory_by_faction.any? %> - - - - - - <% @armory_by_faction.each do |f| %> - - - - - - - - <% end %> - -
    FactionEntriesEarliestLatestBF
    <%= f[:faction].name %><%= number_with_delimiter(f[:count]) %><%= f[:earliest]&.strftime("%d %b") || "—".html_safe %><%= f[:latest]&.strftime("%d %b") || "—".html_safe %><%= f[:backfill_pending] ? "●".html_safe : "✓".html_safe %>
    - <% end %> + +
    +

    Per key — last 24h · single-call keys folded

    + <% if @api_key_breakdown.any? %> +
    + + + + + + <% @api_key_rows.each do |row| %> + <% over = row[:peak_rate] > @per_key_budget %> + + + + + + + + <% end %> + <% if @api_single_call_keys > 0 %> + + + + + + + + <% end %> + +
    OwnerKeyCallsPeak vs <%= @per_key_budget %>/minErrors
    <%= row[:owner] %><%= row[:display_key] %><%= number_with_delimiter(row[:total]) %> + + + <%= row[:peak_rate] %>/min + + <%= row[:errors] %>
    + <%= @api_single_call_keys %> keys with 1 call<%= @api_single_call_keys %>1/min0
    +
    +

    Bars measure 24h peak against the <%= @per_key_budget %>/min client budget — a red bar after the redesign deploy is a real violation.

    + <% end %> +
    +
    +
    - <%# ── Torn API ── %> + <%# ── 5. Factions ── %> +
    +

    Factions

    <%= @total_factions %> total · <%= @active_factions %> active · sorted by status, then members
    -

    Torn API 30d retention

    -
    -
    - Total calls - <%= number_with_delimiter(@total_api_calls) %> -
    -
    - Peak rate - <%= @api_peak_rate_all_time %>/min -
    -
    - Peak today - <%= @api_peak_rate_today %>/min -
    -
    - API calls today - <%= @api_calls_today %> -
    -
    - API calls this week - <%= @api_calls_this_week %> -
    -
    -
    -
    - Admin key calls - <%= number_with_delimiter(@admin_api_total) %> -
    -
    - Admin peak rate - <%= @admin_api_peak_all_time %>/min -
    -
    - Admin peak today - <%= @admin_api_peak_today %>/min -
    -
    - <% if @api_key_breakdown.any? %> - - - - - - <% @api_key_breakdown.each do |row| %> - - - - - - - - <% end %> - -
    KeyOwnerCalls (24h)Peak/minErrors
    <%= row[:display_key] %><%= row[:owner] %><%= number_with_delimiter(row[:total]) %><%= row[:peak_rate] %>/min<%= row[:errors] %>
    + <% if @faction_rows.any? %> +
    + + + + + + <% @faction_rows.each do |row| %> + + + + + + + + <% end %> + +
    FactionMembersStatusSetup · API · TS · Poll · BFLast sync
    <%= row[:faction].name %><%= row[:member_count] %> + <% case row[:status] %> + <% when :active %> + active + <% when :stale %> + stale <%= row[:stale_days] %>d + <% else %> + no setup + <% end %> + + + <% row[:caps].each do |on| %> + + <% end %> + + <%= row[:last_sync]&.strftime("%d %b %H:%M") || "—".html_safe %>
    +
    +

    "stale" = no setup, no key, unchanged for 30+ days — the target of the upcoming faction cleanup.

    <% end %>
    +
    - <%# ── Data Health ── %> -
    -

    Data Health

    -
    -
    - Missing yesterday (03:00 TCT) - <%= @users_missing_yesterday %> -
    -
    - Users with gaps - <%= @users_with_gaps %> -
    -
    - Total missing days - <%= number_with_delimiter(@total_missing_snapshot_days) %> -
    -
    - Complete - <%= number_with_delimiter(@complete_snapshots) %> -
    -
    - Incomplete - <%= number_with_delimiter(@incomplete_snapshots) %> -
    -
    - Completeness - <%= @total_snapshots > 0 ? "#{((@complete_snapshots.to_f / @total_snapshots) * 100).round(1)}%" : "N/A" %> + <%# ── 6. Storage & volume ── %> +
    +

    Storage & Volume

    big tables, slow growth
    +
    + +
    +

    Member activity · kept forever

    +
    +
    + Snapshots + <%= number_with_delimiter(@activity_total_snapshots) %> +
    +
    + Growth + ~<%= number_with_delimiter(@activity_daily_growth) %>/day +
    +
    + Polls / members + <%= number_with_delimiter(@activity_total_polls) %> / <%= @activity_members_tracked %> +
    +
    + Range + <%= @activity_earliest&.strftime("%d %b %Y") || "N/A" %> – <%= @activity_latest&.strftime("%d %b %Y") || "N/A" %> +
    - <% if @missing_dates_summary.any? %> - - - - - - <% @missing_dates_summary.each do |date, count| %> - + +
    +

    Armoury news · 365d retention

    +
    +
    + Entries + <%= number_with_delimiter(@armory_total_entries) %> +
    +
    + Range + <%= @armory_earliest&.strftime("%d %b %Y") || "N/A" %> – <%= @armory_latest&.strftime("%d %b %Y") || "N/A" %> +
    +
    + <% if @armory_by_faction.any? %> +
    +
    DateMissing
    <%= date.strftime("%d %b") %><%= count %>
    + + + + + <% @armory_by_faction.sort_by { |f| -f[:count] }.each do |f| %> + + + + + + <% end %> + +
    FactionEntriesBackfill
    <%= f[:faction].name %><%= number_with_delimiter(f[:count]) %><%= f[:backfill_pending] ? "running" : "done" %>
    +
    + <% end %> +
    + +
    +

    Users & sessions

    +
    +
    + HoF users + <%= @hof_stats_users %> +
    +
    + API keys + <%= @api_keys_configured %> +
    +
    + Sessions this week + <%= @sign_ins_this_week %> · <%= @unique_sign_ins_this_week %> unique +
    +
    + <% if @subscribed_factions.any? %> +
    + <% @subscribed_factions.each do |f| %> +
    + <%= f.name %> + <%= f.subscription.days_remaining %>d left +
    <% end %> - - - <% end %> +
    + <% end %> + <% if @new_sign_ins.any? %> + + + + + + <% @new_sign_ins.each do |user| %> + + + + + <% end %> + +
    New UserJoined
    <%= user.name %><%= @first_session_dates[user.id]&.strftime("%d %b") || "—".html_safe %>
    + <% end %> +
    +
    -
    +
    diff --git a/test/controllers/admin/stats_controller_test.rb b/test/controllers/admin/stats_controller_test.rb index 33a4e4f..9140d03 100644 --- a/test/controllers/admin/stats_controller_test.rb +++ b/test/controllers/admin/stats_controller_test.rb @@ -19,25 +19,33 @@ class Admin::StatsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to new_session_path end - test "renders all panels" do + test "renders all sections" do get admin_stats_path assert_response :success - assert_select "h2", text: "Users" - assert_select "h2", text: /Factions/ - assert_select "h2", text: "Personal Stat Snapshots" - assert_select "h2", text: "Member Activity" - assert_select "h2", text: /Torn API/ - assert_select "h2", text: "Data Health" + assert_select "h2", text: "Data Collection" + assert_select "h2", text: "Torn API" + assert_select "h2", text: "Factions" + assert_select "h2", text: /Storage/ end - test "shows user stats" do + test "health strip renders verdict pills" do get admin_stats_path assert_response :success - assert_select ".admin-stat-label", text: "Total" - assert_select ".admin-stat-label", text: "Subscribers" - assert_select ".admin-stat-label", text: "API Keys" + assert_select ".admin-hpill", minimum: 3 + assert_select ".admin-hpill b", text: "Collection" + assert_select ".admin-hpill b", text: "Budget" + end + + test "shows kpi tiles and user stats" do + get admin_stats_path + assert_response :success + + assert_select ".admin-kpi .k", text: "Tracked users" + assert_select ".admin-kpi .k", text: "Active subs" + assert_select ".admin-stat-label", text: "API keys" + assert_select ".admin-stat-label", text: "HoF users" end test "shows faction stats with member table" do @@ -92,8 +100,6 @@ class Admin::StatsControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_select ".admin-stat-label", text: "Sessions this week" - assert_select ".admin-stat-label", text: "Unique this week" - assert_select ".admin-stat-label", text: "First-time" end test "shows first-time sign-ins table" do @@ -139,13 +145,13 @@ class Admin::StatsControllerTest < ActionDispatch::IntegrationTest assert_select ".admin-stats-table td a", text: /OldPlayer/, count: 0 end - test "shows daily snapshots table" do + test "shows daily snapshots chart" do PersonalStatSnapshot.create!(user: @admin, date: Date.yesterday, timestamp: Date.yesterday.to_time.to_i) get admin_stats_path assert_response :success - assert_select ".admin-stats-table th", text: "Count" + assert_select ".admin-chart-bar", minimum: 1 end test "shows data health warning colors for missing data" do @@ -171,8 +177,13 @@ class Admin::StatsControllerTest < ActionDispatch::IntegrationTest travel_to 2.days.ago.change(min: 0, sec: 0) do 3.times { ApiCall.create!(user: @admin, endpoint: "/test", status: "success", api_key: key) } end - # one call now — inside the window - ApiCall.create!(user: @admin, endpoint: "/test", status: "success", api_key: key) + # two calls now, in different minutes — inside the window + travel_to Time.current.change(sec: 0) do + ApiCall.create!(user: @admin, endpoint: "/test", status: "success", api_key: key) + end + travel_to Time.current.change(sec: 0) + 1.minute do + ApiCall.create!(user: @admin, endpoint: "/test", status: "success", api_key: key) + end get admin_stats_path assert_response :success @@ -182,6 +193,28 @@ class Admin::StatsControllerTest < ActionDispatch::IntegrationTest assert_includes row.text, "1/min", "peak must come from the 24h window, not all history" end + test "single-call keys are folded into one row" do + # fixture key ABCDEF has exactly 1 call and 0 errors -> folded + get admin_stats_path + assert_response :success + + assert_select ".admin-folded td", text: /keys with 1 call/ + folded_away = css_select(".admin-stats-table tr").none? { |r| r.text.include?("ABCD...CDEF") } + assert folded_away, "single-call keys must not get their own row" + end + + test "abandoned factions show a stale chip" do + faction = Faction.create!(torn_id: 88888, name: "Ghost Crew", xanax_target: 2.5, setup_completed: false) + faction.update_column(:updated_at, 40.days.ago) + + get admin_stats_path + assert_response :success + + row = css_select(".admin-stats-table tr").find { |r| r.text.include?("Ghost Crew") } + assert row, "faction row must render" + assert_match(/stale 40d/, row.text) + end + test "gap stats are computed correctly" do PersonalStatSnapshot.stubs(:tracking_start_date).returns(Date.new(2024, 1, 1)) PersonalStatSnapshot.stubs(:tracking_end_date).returns(Date.new(2024, 1, 2)) From 29119ba0963a8e6b106f35248e42e6bb95935e26 Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 14 Jul 2026 22:30:01 +0200 Subject: [PATCH 261/266] stale factions cleanup job --- app/controllers/admin/stats_controller.rb | 6 +-- app/jobs/daily/data_retention_cleanup_job.rb | 15 ++++++ app/models/faction.rb | 13 +++++ app/views/pages/_privacy_policy.html.erb | 5 ++ .../daily_data_retention_cleanup_job_test.rb | 52 +++++++++++++++++++ 5 files changed, 86 insertions(+), 5 deletions(-) diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb index ac280e0..c320cb0 100644 --- a/app/controllers/admin/stats_controller.rb +++ b/app/controllers/admin/stats_controller.rb @@ -2,10 +2,6 @@ module Admin class StatsController < ApplicationController before_action :require_admin - # Factions with no completed setup and no key that haven't changed in this - # long are surfaced as "stale" (and are the target of the upcoming cleanup). - STALE_FACTION_AFTER = 30.days - def index load_user_stats load_subscription_stats @@ -59,7 +55,7 @@ def load_faction_stats status, stale_days = if f.setup_completed? [ :active, nil ] - elsif f.updated_at < STALE_FACTION_AFTER.ago + elsif f.torn_api_key.blank? && f.updated_at < Faction::STALE_AFTER.ago [ :stale, (Date.current - f.updated_at.to_date).to_i ] else [ :no_setup, nil ] diff --git a/app/jobs/daily/data_retention_cleanup_job.rb b/app/jobs/daily/data_retention_cleanup_job.rb index 1a17df0..a53b512 100644 --- a/app/jobs/daily/data_retention_cleanup_job.rb +++ b/app/jobs/daily/data_retention_cleanup_job.rb @@ -10,6 +10,7 @@ def perform cleanup_old_sessions cleanup_old_api_calls cleanup_old_armory_news + cleanup_stale_factions end private @@ -34,5 +35,19 @@ def cleanup_old_armory_news Rails.logger.info "DataRetentionCleanupJob: Deleted #{deleted_count} armory news entries older than #{ARMORY_NEWS_RETENTION_DAYS} days" end + + # destroy (not delete_all) so dependent data goes with the faction and + # members are detached via the users association's nullify. + def cleanup_stale_factions + removed = [] + Faction.stale.find_each do |faction| + faction.destroy! + removed << "#{faction.name} [#{faction.torn_id}]" + rescue => e + Rails.logger.error "DataRetentionCleanupJob: Failed to remove stale faction #{faction.name}: #{e.message}" + end + + Rails.logger.info "DataRetentionCleanupJob: Removed #{removed.size} stale factions (#{Faction::STALE_AFTER.inspect} without setup or key): #{removed.join(', ')}" if removed.any? + end end end diff --git a/app/models/faction.rb b/app/models/faction.rb index 663268a..444680b 100644 --- a/app/models/faction.rb +++ b/app/models/faction.rb @@ -14,6 +14,19 @@ class Faction < ApplicationRecord has_many :leadership, -> { where(leadership_access: true) }, class_name: "User" has_one :subscription, as: :subscribable, dependent: :destroy + # Abandoned signups: never completed setup, hold no API key, and haven't + # changed in this long. Surfaced as "stale" on admin stats and removed by + # the nightly retention cleanup (see the privacy policy's retention table). + STALE_AFTER = 30.days + + scope :stale, -> { + where(setup_completed: false) + .where.missing(:torn_api_key) + .where(updated_at: ...STALE_AFTER.ago) + .left_joins(:subscription) + .where("subscriptions.id IS NULL OR subscriptions.expires_at <= ?", Time.current) + } + validates :torn_id, presence: true, uniqueness: true validates :name, presence: true validates :xanax_target, presence: true, numericality: { greater_than: 0 } diff --git a/app/views/pages/_privacy_policy.html.erb b/app/views/pages/_privacy_policy.html.erb index 634db25..41a72cb 100644 --- a/app/views/pages/_privacy_policy.html.erb +++ b/app/views/pages/_privacy_policy.html.erb @@ -185,6 +185,11 @@ Until faction is removed from tracking Access control for faction data sharing + + Incomplete faction signups + 30 days + Factions that never complete setup and hold no API key are automatically removed, along with their faction data, after 30 days of inactivity. Member accounts are detached, not deleted. +
    diff --git a/test/jobs/daily_data_retention_cleanup_job_test.rb b/test/jobs/daily_data_retention_cleanup_job_test.rb index 66fbd66..a39c935 100644 --- a/test/jobs/daily_data_retention_cleanup_job_test.rb +++ b/test/jobs/daily_data_retention_cleanup_job_test.rb @@ -35,6 +35,58 @@ class DailyDataRetentionCleanupJobTest < ActiveJob::TestCase assert ApiCall.exists?(recent_call.id), "Recent API call should be kept" end + test "deletes stale factions with their data but keeps their users" do + faction = Faction.create!(torn_id: 77001, name: "Stale Crew", xanax_target: 2.5, setup_completed: false) + member = User.create!(torn_id: 770_011, name: "StaleMember", level: 5, faction_id: faction.id) + faction.armory_news_entries.create!(torn_news_id: "sn1", player_id: 1, player_name: "x", action: "used", occurred_at: 2.months.ago) + faction.update_column(:updated_at, 31.days.ago) + + Daily::DataRetentionCleanupJob.perform_now + + assert_not Faction.exists?(faction.id), "stale faction should be removed" + assert User.exists?(member.id), "members must never be deleted" + assert_nil member.reload.faction_id, "members are detached, not destroyed" + assert_equal 0, ArmoryNewsEntry.where(torn_news_id: "sn1").count + end + + test "keeps stale factions that completed setup" do + faction = Faction.create!(torn_id: 77002, name: "Old But Active", xanax_target: 2.5, setup_completed: true) + faction.update_column(:updated_at, 100.days.ago) + + Daily::DataRetentionCleanupJob.perform_now + + assert Faction.exists?(faction.id) + end + + test "keeps unconfigured factions that changed recently" do + faction = Faction.create!(torn_id: 77003, name: "Fresh Signup", xanax_target: 2.5, setup_completed: false) + faction.update_column(:updated_at, 29.days.ago) + + Daily::DataRetentionCleanupJob.perform_now + + assert Faction.exists?(faction.id) + end + + test "keeps stale factions that still have a torn api key" do + faction = Faction.create!(torn_id: 77004, name: "Keyed Crew", xanax_target: 2.5, setup_completed: false) + ApiKey::Torn.create!(faction: faction, key: "STALE_BUT_KEYED", access_type: "Limited Access") + faction.update_column(:updated_at, 60.days.ago) + + Daily::DataRetentionCleanupJob.perform_now + + assert Faction.exists?(faction.id) + end + + test "keeps stale factions with an active subscription" do + faction = Faction.create!(torn_id: 77005, name: "Paying Ghost", xanax_target: 2.5, setup_completed: false) + grant_subscription(faction, expires_at: 1.month.from_now) + faction.update_column(:updated_at, 60.days.ago) + + Daily::DataRetentionCleanupJob.perform_now + + assert Faction.exists?(faction.id) + end + test "handles empty tables gracefully" do Session.delete_all ApiCall.delete_all From 2115d8bb8057b0cd67b35531dd19f026c5a8005d Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 14 Jul 2026 22:56:21 +0200 Subject: [PATCH 262/266] Treat partially-filled snapshots as gaps --- app/controllers/admin/stats_controller.rb | 11 ++----- app/jobs/concerns/gap_backfill.rb | 11 ++++--- app/models/personal_stat_snapshot.rb | 7 +++++ .../admin/stats_controller_test.rb | 19 ++++++++++++ test/jobs/daily/backfill_gap_limit_test.rb | 29 ++++++++++++++++++- .../daily/faction_member_sync_job_test.rb | 8 ++--- test/jobs/daily/hof_member_sync_job_test.rb | 4 +-- test/test_helper.rb | 9 ++++++ 8 files changed, 75 insertions(+), 23 deletions(-) diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb index c320cb0..ee4f9d3 100644 --- a/app/controllers/admin/stats_controller.rb +++ b/app/controllers/admin/stats_controller.rb @@ -128,15 +128,8 @@ def load_data_health calculate_snapshot_gaps - @complete_snapshots = PersonalStatSnapshot - .where.not( - drugs_xanax: nil, items_used_energy_drinks: nil, other_refills_energy: nil, - other_refills_nerve: nil, items_used_boosters: nil, items_used_stat_enhancers: nil, - missions_contracts_total: nil, crimes_offenses_total: nil, other_activity_time: nil, - networth_total: nil, attacking_networth_money_mugged: nil - ) - .count - @incomplete_snapshots = @total_snapshots - @complete_snapshots + @incomplete_snapshots = PersonalStatSnapshot.partial.count + @complete_snapshots = @total_snapshots - @incomplete_snapshots @completeness_pct = @total_snapshots > 0 ? ((@complete_snapshots.to_f / @total_snapshots) * 100).round(1) : nil end diff --git a/app/jobs/concerns/gap_backfill.rb b/app/jobs/concerns/gap_backfill.rb index 2148d65..50eb9f7 100644 --- a/app/jobs/concerns/gap_backfill.rb +++ b/app/jobs/concerns/gap_backfill.rb @@ -8,14 +8,17 @@ module GapBackfill private def backfill_gaps(user, api_key) + window_start = PersonalStatSnapshot.tracking_start_date + window_end = PersonalStatSnapshot.tracking_end_date existing = user.personal_stat_snapshots.pluck(:date).to_set - expected = (PersonalStatSnapshot.tracking_start_date..PersonalStatSnapshot.tracking_end_date).to_a + partial = user.personal_stat_snapshots.partial.where(date: window_start..window_end).pluck(:date) yesterday = Date.current.yesterday - missing = expected.reject { |d| existing.include?(d) || d == yesterday } - return if missing.empty? + missing = (window_start..window_end).reject { |d| existing.include?(d) || d == yesterday } + targets = (missing + partial.reject { |d| d == yesterday }).uniq.sort + return if targets.empty? - missing.last(BACKFILL_GAP_LIMIT).each do |date| + targets.last(BACKFILL_GAP_LIMIT).each do |date| BackfillSingleStatJob.perform_later(user.id, date.to_s, faction_id: user.faction_id, api_key: api_key) end end diff --git a/app/models/personal_stat_snapshot.rb b/app/models/personal_stat_snapshot.rb index 1d8ebcd..7cff896 100644 --- a/app/models/personal_stat_snapshot.rb +++ b/app/models/personal_stat_snapshot.rb @@ -3,6 +3,13 @@ class PersonalStatSnapshot < ApplicationRecord validates :date, presence: true, uniqueness: { scope: :user_id } + # Rows missing any tracked stat: written before a column existed, or a + # batch-2 fetch that never completed. The nightly gap scan re-fetches these + # the same as fully missing dates. + scope :partial, -> { + TRACKED_STATS.values.map { |column| where(column => nil) }.reduce(:or) + } + def self.tracking_start_date Date.new(2026, 1, 1) end diff --git a/test/controllers/admin/stats_controller_test.rb b/test/controllers/admin/stats_controller_test.rb index 9140d03..af475ef 100644 --- a/test/controllers/admin/stats_controller_test.rb +++ b/test/controllers/admin/stats_controller_test.rb @@ -215,6 +215,25 @@ class Admin::StatsControllerTest < ActionDispatch::IntegrationTest assert_match(/stale 40d/, row.text) end + test "completeness counts partially-filled snapshots as incomplete" do + PersonalStatSnapshot.delete_all + PersonalStatSnapshot.create!( + user: @admin, date: 3.days.ago.to_date, + drugs_xanax: 1, items_used_energy_drinks: 1, other_refills_energy: 1, + other_refills_nerve: 1, items_used_boosters: 1, items_used_stat_enhancers: 1, + missions_contracts_total: 1, crimes_offenses_total: 1, other_activity_time: 1, + networth_total: 1, attacking_networth_money_mugged: 1 + ) + PersonalStatSnapshot.create!(user: @bert, date: 3.days.ago.to_date, drugs_xanax: 5) + + get admin_stats_path + assert_response :success + + row = css_select(".admin-stat-row").find { |r| r.text.include?("Complete / incomplete") } + assert_includes row.css(".admin-stat-value").text, "1 / 1", + "a row missing batch-2 columns must count as incomplete" + end + test "gap stats are computed correctly" do PersonalStatSnapshot.stubs(:tracking_start_date).returns(Date.new(2024, 1, 1)) PersonalStatSnapshot.stubs(:tracking_end_date).returns(Date.new(2024, 1, 2)) diff --git a/test/jobs/daily/backfill_gap_limit_test.rb b/test/jobs/daily/backfill_gap_limit_test.rb index 9decaa5..c1b07c6 100644 --- a/test/jobs/daily/backfill_gap_limit_test.rb +++ b/test/jobs/daily/backfill_gap_limit_test.rb @@ -42,9 +42,36 @@ class BackfillGapLimitTest < ActiveJob::TestCase end end + test "partial rows count as gaps and get re-fetched" do + (@window_start..@window_end).each do |date| + @user.personal_stat_snapshots.create!( + date: date, + drugs_xanax: 1, items_used_energy_drinks: 1, other_refills_energy: 1, + other_refills_nerve: 1, items_used_boosters: 1, items_used_stat_enhancers: 1, + missions_contracts_total: 1, crimes_offenses_total: 1, other_activity_time: 1, + networth_total: 1, attacking_networth_money_mugged: 1 + ) + end + partial = @user.personal_stat_snapshots.find_by(date: @window_start + 10) + partial.update!(attacking_networth_money_mugged: nil) + + assert_enqueued_jobs 1, only: BackfillSingleStatJob do + Daily::FactionMemberSyncJob.new.send(:backfill_gaps, @user, "FACTION_KEY_123") + end + + enqueued_date = enqueued_jobs.find { |j| j[:job] == BackfillSingleStatJob }[:args][1] + assert_equal (@window_start + 10).to_s, enqueued_date + end + test "no jobs are enqueued when there are no gaps" do (@window_start..@window_end).each do |date| - @user.personal_stat_snapshots.create!(date: date) + @user.personal_stat_snapshots.create!( + date: date, + drugs_xanax: 1, items_used_energy_drinks: 1, other_refills_energy: 1, + other_refills_nerve: 1, items_used_boosters: 1, items_used_stat_enhancers: 1, + missions_contracts_total: 1, crimes_offenses_total: 1, other_activity_time: 1, + networth_total: 1, attacking_networth_money_mugged: 1 + ) end assert_no_enqueued_jobs(only: BackfillSingleStatJob) do diff --git a/test/jobs/daily/faction_member_sync_job_test.rb b/test/jobs/daily/faction_member_sync_job_test.rb index 810e8f8..073352a 100644 --- a/test/jobs/daily/faction_member_sync_job_test.rb +++ b/test/jobs/daily/faction_member_sync_job_test.rb @@ -114,9 +114,7 @@ class Daily::FactionMemberSyncJobTest < ActiveJob::TestCase existing_dates = PersonalStatSnapshot.tracking_start_date..3.days.ago.to_date existing_dates.each do |date| - @bram.personal_stat_snapshots.find_or_create_by!(date: date) do |s| - s.drugs_xanax = 100 - end + create_complete_snapshot(@bram, date) end Daily::FactionMemberSyncJob.perform_now @@ -130,9 +128,7 @@ class Daily::FactionMemberSyncJobTest < ActiveJob::TestCase TornApi::Faction::Members.any_instance.stubs(:fetch).returns([ @member_data ]) (PersonalStatSnapshot.tracking_start_date..PersonalStatSnapshot.tracking_end_date).each do |date| - @bram.personal_stat_snapshots.find_or_create_by!(date: date) do |s| - s.drugs_xanax = 100 - end + create_complete_snapshot(@bram, date) end Daily::FactionMemberSyncJob.perform_now diff --git a/test/jobs/daily/hof_member_sync_job_test.rb b/test/jobs/daily/hof_member_sync_job_test.rb index 6bb019b..5e08188 100644 --- a/test/jobs/daily/hof_member_sync_job_test.rb +++ b/test/jobs/daily/hof_member_sync_job_test.rb @@ -65,9 +65,7 @@ class Daily::HofMemberSyncJobTest < ActiveJob::TestCase test "does not backfill when hof user has no gaps" do (PersonalStatSnapshot.tracking_start_date..PersonalStatSnapshot.tracking_end_date).each do |date| - @kaneki.personal_stat_snapshots.find_or_create_by!(date: date) do |s| - s.drugs_xanax = 100 - end + create_complete_snapshot(@kaneki, date) end Daily::HofMemberSyncJob.perform_now diff --git a/test/test_helper.rb b/test/test_helper.rb index 21511b8..91be0d7 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -14,6 +14,15 @@ class TestCase # Add more helper methods to be used by all tests here... + # A snapshot with every tracked stat filled — rows with any nil column + # count as "partial" and are re-fetched by the nightly gap scan. + def create_complete_snapshot(user, date) + attrs = PersonalStatSnapshot::TRACKED_STATS.values.index_with { 1 } + user.personal_stat_snapshots.find_or_create_by!(date: date) do |s| + s.assign_attributes(attrs) + end + end + # Helper to grant a subscription to a user or faction via the Subscription model def grant_subscription(subscribable, expires_at:) sub = subscribable.subscription || subscribable.build_subscription From 8731617de44cae0c5c54d63e8cfb5a59f80c9837 Mon Sep 17 00:00:00 2001 From: Bram Date: Wed, 15 Jul 2026 10:17:05 +0200 Subject: [PATCH 263/266] Tombstone dates Torn has no data for --- app/controllers/admin/stats_controller.rb | 7 +++++-- app/jobs/backfill_single_stat_job.rb | 6 ++++++ app/jobs/torn_api_job.rb | 5 ++++- app/models/personal_stat_snapshot.rb | 4 +++- app/models/torn_api.rb | 4 ++++ app/models/torn_api/user/personal_stats.rb | 4 +--- app/views/admin/stats/index.html.erb | 6 ++++++ ...orn_data_missing_to_personal_stat_snapshots.rb | 8 ++++++++ db/schema.rb | 3 ++- test/jobs/admin_api_job_test.rb | 8 +++++--- test/jobs/backfill_single_stat_job_test.rb | 15 +++++++++++++++ test/jobs/daily/backfill_gap_limit_test.rb | 10 ++++++++++ test/models/torn_api/user/personal_stats_test.rb | 13 +++++++++---- 13 files changed, 78 insertions(+), 15 deletions(-) create mode 100644 db/migrate/20260715070000_add_torn_data_missing_to_personal_stat_snapshots.rb diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb index ee4f9d3..56e6aa0 100644 --- a/app/controllers/admin/stats_controller.rb +++ b/app/controllers/admin/stats_controller.rb @@ -129,8 +129,11 @@ def load_data_health calculate_snapshot_gaps @incomplete_snapshots = PersonalStatSnapshot.partial.count - @complete_snapshots = @total_snapshots - @incomplete_snapshots - @completeness_pct = @total_snapshots > 0 ? ((@complete_snapshots.to_f / @total_snapshots) * 100).round(1) : nil + @tombstoned_snapshots = PersonalStatSnapshot.where(torn_data_missing: true).count + @complete_snapshots = @total_snapshots - @incomplete_snapshots - @tombstoned_snapshots + # Tombstoned rows are resolved (Torn has no data to fetch), so they + # count as accounted-for — otherwise 100% would be unreachable. + @completeness_pct = @total_snapshots > 0 ? (((@complete_snapshots + @tombstoned_snapshots).to_f / @total_snapshots) * 100).round(1) : nil end def load_api_stats diff --git a/app/jobs/backfill_single_stat_job.rb b/app/jobs/backfill_single_stat_job.rb index fb4c37d..e08b938 100644 --- a/app/jobs/backfill_single_stat_job.rb +++ b/app/jobs/backfill_single_stat_job.rb @@ -14,6 +14,12 @@ def perform(user_id, date_str, faction_id:, batch: 1, api_key: nil) save_snapshot(user, stats, date) BackfillSingleStatJob.perform_later(user_id, date_str, faction_id: faction_id, batch: 2, api_key: api_key) if batch == 1 + rescue TornApi::NoDataError + # Torn has nothing for this player/date — tombstone the date so the + # nightly gap scan stops re-fetching it forever. + tombstone = user.personal_stat_snapshots.find_or_initialize_by(date: date) + tombstone.update!(torn_data_missing: true) + Rails.logger.info("BackfillSingleStatJob: tombstoned #{user.name} #{date} — no data at Torn") end private diff --git a/app/jobs/torn_api_job.rb b/app/jobs/torn_api_job.rb index 1fa2859..bf9d66d 100644 --- a/app/jobs/torn_api_job.rb +++ b/app/jobs/torn_api_job.rb @@ -18,7 +18,10 @@ class TornApiJob < ApplicationJob # Concurrency key for jobs that receive a faction_id instead of an api key. FACTION_KEY_LOOKUP = ->(faction_id, *, **) { Faction.find_by(id: faction_id)&.torn_api_key&.key } - RATE_LIMIT_SLEEP = 1.0 + # 1.0s paced a stream at ~46 calls/min — close enough to the 50/min budget + # that batch chains and activity polls on the same key tripped it nightly. + # 1.5s paces at ~31/min, leaving genuine headroom. + RATE_LIMIT_SLEEP = 1.5 retry_on TornApi::RateLimitError, wait: 2.minutes, attempts: 5 retry_on TornApi::TransientError, wait: 15.minutes, attempts: 3 diff --git a/app/models/personal_stat_snapshot.rb b/app/models/personal_stat_snapshot.rb index 7cff896..253e70f 100644 --- a/app/models/personal_stat_snapshot.rb +++ b/app/models/personal_stat_snapshot.rb @@ -5,9 +5,11 @@ class PersonalStatSnapshot < ApplicationRecord # Rows missing any tracked stat: written before a column existed, or a # batch-2 fetch that never completed. The nightly gap scan re-fetches these - # the same as fully missing dates. + # the same as fully missing dates. Tombstoned rows (Torn has no data for + # that player/date) are excluded — re-fetching them can never succeed. scope :partial, -> { TRACKED_STATS.values.map { |column| where(column => nil) }.reduce(:or) + .where(torn_data_missing: false) } def self.tracking_start_date diff --git a/app/models/torn_api.rb b/app/models/torn_api.rb index f1eaaaf..4e01dc1 100644 --- a/app/models/torn_api.rb +++ b/app/models/torn_api.rb @@ -9,6 +9,10 @@ class RateLimitError < ApiError; end # nightly stats-cache rebuild, "backend error, please try again") — jobs # retry these with a delay instead of failing. class TransientError < ApiError; end + # Torn returned an empty payload. For live fetches this is transient (cache + # rebuild); for historical backfills it usually means no data exists for + # that player/date and the date should be tombstoned, not retried forever. + class NoDataError < TransientError; end class NotFoundError < ApiError; end class TimeoutError < ApiError; end diff --git a/app/models/torn_api/user/personal_stats.rb b/app/models/torn_api/user/personal_stats.rb index 423e704..959a19b 100644 --- a/app/models/torn_api/user/personal_stats.rb +++ b/app/models/torn_api/user/personal_stats.rb @@ -24,9 +24,7 @@ def fetch if response["personalstats"].present? || response["personalstats"].is_a?(Array) parse_personalstats(response["personalstats"], stat_batch) else - # Torn intermittently returns a nil payload around the nightly - # stats-cache rebuild — retryable, not fatal. - raise TransientError, "No personal stats data returned: #{response}" + raise NoDataError, "No personal stats data returned: #{response}" end end diff --git a/app/views/admin/stats/index.html.erb b/app/views/admin/stats/index.html.erb index 583343c..f4079a2 100644 --- a/app/views/admin/stats/index.html.erb +++ b/app/views/admin/stats/index.html.erb @@ -96,6 +96,12 @@ Complete / incomplete <%= number_with_delimiter(@complete_snapshots) %> / <%= number_with_delimiter(@incomplete_snapshots) %>
    + <% if @tombstoned_snapshots > 0 %> +
    + No data at Torn (tombstoned) + <%= number_with_delimiter(@tombstoned_snapshots) %> +
    + <% end %>
    <% if @daily_snapshots.any? %> <% max_daily = [ @daily_snapshots.map(&:last).max, 1 ].max %> diff --git a/db/migrate/20260715070000_add_torn_data_missing_to_personal_stat_snapshots.rb b/db/migrate/20260715070000_add_torn_data_missing_to_personal_stat_snapshots.rb new file mode 100644 index 0000000..716e23d --- /dev/null +++ b/db/migrate/20260715070000_add_torn_data_missing_to_personal_stat_snapshots.rb @@ -0,0 +1,8 @@ +class AddTornDataMissingToPersonalStatSnapshots < ActiveRecord::Migration[8.1] + def change + # Tombstone flag: Torn has no personalstats for this user/date (account + # inactive or nonexistent then). Stops the nightly gap scan from + # re-fetching the same unfillable dates forever. + add_column :personal_stat_snapshots, :torn_data_missing, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 811cbec..c5476f7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_14_210000) do +ActiveRecord::Schema[8.1].define(version: 2026_07_15_070000) do create_table "api_calls", force: :cascade do |t| t.string "api_key", null: false t.datetime "created_at", null: false @@ -133,6 +133,7 @@ t.integer "other_refills_energy" t.integer "other_refills_nerve" t.integer "timestamp" + t.boolean "torn_data_missing", default: false, null: false t.datetime "updated_at", null: false t.integer "user_id", null: false t.index ["user_id", "date"], name: "index_personal_stat_snapshots_on_user_id_and_date", unique: true diff --git a/test/jobs/admin_api_job_test.rb b/test/jobs/admin_api_job_test.rb index 231532a..7e55666 100644 --- a/test/jobs/admin_api_job_test.rb +++ b/test/jobs/admin_api_job_test.rb @@ -11,8 +11,10 @@ class AdminApiJobTest < ActiveJob::TestCase assert_equal "torn_api", Daily::StockDividendJob.new.queue_name end - test "has a 1 second rate limit sleep constant" do - assert_equal 1.0, AdminApiJob::RATE_LIMIT_SLEEP + test "pacing sleep leaves real headroom under the per-key budget" do + # 1.0s pacing produced ~46 calls/min in production - close enough to the + # 50/min budget that batch chains and activity polls tripped it nightly. + assert_operator AdminApiJob::RATE_LIMIT_SLEEP, :>=, 1.5 end test "around_perform sleeps after execution" do @@ -23,7 +25,7 @@ def perform end job = job_class.new - job.expects(:sleep).with(1.0).once + job.expects(:sleep).with(AdminApiJob::RATE_LIMIT_SLEEP).once job.perform_now end diff --git a/test/jobs/backfill_single_stat_job_test.rb b/test/jobs/backfill_single_stat_job_test.rb index 84d6556..6e414a8 100644 --- a/test/jobs/backfill_single_stat_job_test.rb +++ b/test/jobs/backfill_single_stat_job_test.rb @@ -40,6 +40,21 @@ class BackfillSingleStatJobTest < ActiveJob::TestCase assert_not @user.personal_stat_snapshots.exists?(date: Date.parse(@date_str)) end + test "a date torn has no data for gets a tombstone instead of endless retries" do + TornApi::User::PersonalStats.any_instance.stubs(:fetch) + .raises(TornApi::NoDataError, "No personal stats data returned") + + assert_no_enqueued_jobs(only: BackfillSingleStatJob) do + BackfillSingleStatJob.perform_now(@user.id, @date_str, faction_id: @faction.id, api_key: "FACTION_KEY_123") + end + + tombstone = @user.personal_stat_snapshots.find_by(date: Date.parse(@date_str)) + assert tombstone, "tombstone row must exist so the gap scan stops re-fetching" + assert tombstone.torn_data_missing? + assert_not PersonalStatSnapshot.partial.exists?(id: tombstone.id), + "tombstones must not count as partial rows" + end + test "transient errors propagate so the job retries" do TornApi::User::PersonalStats.any_instance.stubs(:fetch) .raises(TornApi::TransientError, "Torn backend error, please try again") diff --git a/test/jobs/daily/backfill_gap_limit_test.rb b/test/jobs/daily/backfill_gap_limit_test.rb index c1b07c6..1eaa88e 100644 --- a/test/jobs/daily/backfill_gap_limit_test.rb +++ b/test/jobs/daily/backfill_gap_limit_test.rb @@ -63,6 +63,16 @@ class BackfillGapLimitTest < ActiveJob::TestCase assert_equal (@window_start + 10).to_s, enqueued_date end + test "tombstoned dates are not re-fetched" do + (@window_start..@window_end).each { |date| create_complete_snapshot(@user, date) } + @user.personal_stat_snapshots.find_by(date: @window_start + 5) + .update!(drugs_xanax: nil, torn_data_missing: true) + + assert_no_enqueued_jobs(only: BackfillSingleStatJob) do + Daily::FactionMemberSyncJob.new.send(:backfill_gaps, @user, "FACTION_KEY_123") + end + end + test "no jobs are enqueued when there are no gaps" do (@window_start..@window_end).each do |date| @user.personal_stat_snapshots.create!( diff --git a/test/models/torn_api/user/personal_stats_test.rb b/test/models/torn_api/user/personal_stats_test.rb index 9564f25..4378529 100644 --- a/test/models/torn_api/user/personal_stats_test.rb +++ b/test/models/torn_api/user/personal_stats_test.rb @@ -1,13 +1,18 @@ require "test_helper" class TornApi::User::PersonalStatsTest < ActiveSupport::TestCase - test "an empty personalstats payload raises TransientError so jobs retry" do - # Torn intermittently returns {"personalstats" => nil} around the nightly - # stats-cache rebuild (the 04:50 errors). That's retryable, not fatal. + test "an empty personalstats payload raises NoDataError" do + # Torn returns {"personalstats" => nil} both transiently (nightly cache + # rebuild) and permanently (no data exists for that player/date). Jobs + # decide which: daily fetches retry it, historical backfills tombstone it. stats = TornApi::User::PersonalStats.new("FACTION_KEY_123", 12345) stats.stubs(:get).returns({ "personalstats" => nil }) - assert_raises(TornApi::TransientError) { stats.fetch } + assert_raises(TornApi::NoDataError) { stats.fetch } + end + + test "NoDataError is still a TransientError so daily fetches retry it" do + assert TornApi::NoDataError < TornApi::TransientError end test "a populated personalstats payload parses normally" do From 95a737d95170ba979b887de9a21a54b4f145787e Mon Sep 17 00:00:00 2001 From: Bram Date: Mon, 20 Jul 2026 22:04:06 +0200 Subject: [PATCH 264/266] fix --- app/jobs/backfill_single_stat_job.rb | 15 +++++++++++ app/jobs/fetch_personal_stats_job.rb | 9 +++++++ app/jobs/torn_api_job.rb | 6 ++++- app/models/torn_api/rate_limiter.rb | 30 ++++++++++++++++++++-- test/jobs/backfill_single_stat_job_test.rb | 18 +++++++++++++ test/jobs/fetch_personal_stats_job_test.rb | 13 ++++++++++ test/models/torn_api/rate_limiter_test.rb | 29 +++++++++++++++++++++ 7 files changed, 117 insertions(+), 3 deletions(-) diff --git a/app/jobs/backfill_single_stat_job.rb b/app/jobs/backfill_single_stat_job.rb index e08b938..cb00da6 100644 --- a/app/jobs/backfill_single_stat_job.rb +++ b/app/jobs/backfill_single_stat_job.rb @@ -2,6 +2,21 @@ class BackfillSingleStatJob < FactionApiJob queue_with_priority 100 limits_concurrency to: 1, key: ->(user_id, date_str, faction_id:, api_key: nil, **) { api_key }, group: CONCURRENCY_GROUP + # Backfill is the lowest-priority work on a contended key. A faction signup + # fans thousands of these onto one key at once (Jan 1 → yesterday per member), + # right when the leader is actively browsing their new dashboard — so the key + # budget is regularly spent and these trip the client-side rate limiter. That + # is expected back-pressure, not a failure: background work must yield to live + # traffic. So retry patiently and with jitter (a re-enqueue releases the worker + # and the per-key concurrency lock means retries never pile up, so attempts are + # cheap) — this lets a signup backfill drain itself once traffic quiets instead + # of dribbling in at the gap scan's 30/user/night cap. If even that patience is + # exhausted, drop quietly with a log line; the nightly gap scan is the backstop. + retry_on TornApi::RateLimitError, wait: 2.minutes, attempts: 15, jitter: 0.5 do |job, error| + user_id, date_str = job.arguments + Rails.logger.warn("BackfillSingleStatJob: gave up on user #{user_id} #{date_str} after rate-limit retries — nightly gap scan will retry (#{error.message})") + end + def perform(user_id, date_str, faction_id:, batch: 1, api_key: nil) user = User.find(user_id) date = Date.parse(date_str) diff --git a/app/jobs/fetch_personal_stats_job.rb b/app/jobs/fetch_personal_stats_job.rb index 301f64f..554ae91 100644 --- a/app/jobs/fetch_personal_stats_job.rb +++ b/app/jobs/fetch_personal_stats_job.rb @@ -11,6 +11,15 @@ def perform(user, api_key:, batch: 1, stats_date: Date.current.yesterday, retrie save_snapshot(user, stats, stats_date) FetchPersonalStatsJob.perform_later(user, api_key: api_key, batch: 2, stats_date: stats_date) if batch == 1 + rescue TornApi::NoDataError => e + # Torn returned no personalstats for this user/date. This runs at 2:30am + # TCT — well after the stats cache settles (~1am) — so a nil payload here + # is almost always a new/inactive member or data Torn simply doesn't have, + # not a cache-rebuild blip. Skip quietly: the nightly gap scan re-attempts + # the date once it's older and BackfillSingleStatJob tombstones it if the + # gap is permanent. Letting this propagate just burns 3x15min of retries + # and pages a false alarm for something the gap scan already recovers. + Rails.logger.info("FetchPersonalStatsJob: no data for #{user.name} (#{user.torn_id}) on #{stats_date}, batch #{batch} — leaving to nightly gap scan (#{e.message})") rescue TornApi::InvalidKeyError => e if retries < MAX_RETRIES Rails.logger.warn("FetchPersonalStatsJob: Failed for #{user.name} (#{user.torn_id}): #{e.message}, retry #{retries + 1}/#{MAX_RETRIES} in 1 hour") diff --git a/app/jobs/torn_api_job.rb b/app/jobs/torn_api_job.rb index bf9d66d..2c9ed65 100644 --- a/app/jobs/torn_api_job.rb +++ b/app/jobs/torn_api_job.rb @@ -26,8 +26,12 @@ class TornApiJob < ApplicationJob retry_on TornApi::RateLimitError, wait: 2.minutes, attempts: 5 retry_on TornApi::TransientError, wait: 15.minutes, attempts: 3 + # Every Torn call made from a job runs in "background" mode: the rate limiter + # reserves the top slice of each key's budget for the owner's live web traffic + # (which isn't paced or serialized), so a saturating backfill can't starve an + # interactive request on the same key. around_perform do |_job, block| - block.call + TornApi::RateLimiter.reserving_headroom_for_live_traffic { block.call } sleep(RATE_LIMIT_SLEEP) end end diff --git a/app/models/torn_api/rate_limiter.rb b/app/models/torn_api/rate_limiter.rb index 218c6fd..62dfd0b 100644 --- a/app/models/torn_api/rate_limiter.rb +++ b/app/models/torn_api/rate_limiter.rb @@ -19,11 +19,33 @@ class RateLimiter GLOBAL_REQUESTS_PER_MINUTE = 300 WINDOW_SECONDS = 60 + # Background jobs (nightly fetches, backfills, activity polls) yield the top + # slice of each key's budget to the key owner's live web traffic: they stop + # at 85% of the per-key budget so the remaining ~15% stays available for + # interactive requests, which aren't paced or serialized and would otherwise + # be starved whenever a signup backfill is saturating that key. Web requests + # run outside background mode and may use the full per-key budget. + BACKGROUND_RESERVE = 0.15 + BACKGROUND_REQUESTS_PER_MINUTE = (REQUESTS_PER_MINUTE * (1 - BACKGROUND_RESERVE)).floor + class << self + # Runs the block in background mode — acquire! applies the reduced budget + # that reserves headroom for live traffic. Reentrant and thread-scoped so + # it's safe across the shared job worker pool. + def reserving_headroom_for_live_traffic + prior = Thread.current[:torn_api_reserve_headroom] + Thread.current[:torn_api_reserve_headroom] = true + yield + ensure + Thread.current[:torn_api_reserve_headroom] = prior + end + def acquire!(api_key) + limit = reserving_headroom? ? BACKGROUND_REQUESTS_PER_MINUTE : REQUESTS_PER_MINUTE + key_count = increment(key_window(api_key)) - if key_count > REQUESTS_PER_MINUTE - raise RateLimitError, "Too many requests (client-side key budget of #{REQUESTS_PER_MINUTE}/min spent)" + if key_count > limit + raise RateLimitError, "Too many requests (client-side key budget of #{limit}/min spent)" end global_count = increment(global_window) @@ -40,6 +62,10 @@ def remaining(api_key) private + def reserving_headroom? + Thread.current[:torn_api_reserve_headroom] == true + end + # Null cache stores (test default) return nil from increment; treat as # unlimited rather than blocking every call. def increment(cache_key) diff --git a/test/jobs/backfill_single_stat_job_test.rb b/test/jobs/backfill_single_stat_job_test.rb index 6e414a8..695c0a1 100644 --- a/test/jobs/backfill_single_stat_job_test.rb +++ b/test/jobs/backfill_single_stat_job_test.rb @@ -55,6 +55,24 @@ class BackfillSingleStatJobTest < ActiveJob::TestCase "tombstones must not count as partial rows" end + test "exhausted rate-limit retries drop quietly instead of paging" do + TornApi::User::PersonalStats.any_instance.stubs(:fetch) + .raises(TornApi::RateLimitError, "Too many requests") + Discord::Notifier.expects(:notify).never + + job = BackfillSingleStatJob.new(@user.id, @date_str, faction_id: @faction.id, api_key: "FACTION_KEY_123") + # Pretend the shared retry budget is already spent so the next failure + # exhausts retry_on and runs the give-up block rather than re-enqueueing. + job.exception_executions = { "[TornApi::RateLimitError]" => 15 } + + assert_no_enqueued_jobs(only: BackfillSingleStatJob) do + assert_nothing_raised { job.perform_now } + end + + assert_not @user.personal_stat_snapshots.exists?(date: Date.parse(@date_str)), + "a rate-limited backfill leaves the date for the nightly gap scan" + end + test "transient errors propagate so the job retries" do TornApi::User::PersonalStats.any_instance.stubs(:fetch) .raises(TornApi::TransientError, "Torn backend error, please try again") diff --git a/test/jobs/fetch_personal_stats_job_test.rb b/test/jobs/fetch_personal_stats_job_test.rb index 6eb3a89..b9f8fbb 100644 --- a/test/jobs/fetch_personal_stats_job_test.rb +++ b/test/jobs/fetch_personal_stats_job_test.rb @@ -94,6 +94,19 @@ class FetchPersonalStatsJobTest < ActiveJob::TestCase end end + test "no data from Torn is skipped quietly without retrying or paging" do + TornApi::User::PersonalStats.any_instance.stubs(:fetch) + .raises(TornApi::NoDataError, "No personal stats data returned") + Discord::Notifier.expects(:notify).never + + assert_no_enqueued_jobs(only: FetchPersonalStatsJob) do + FetchPersonalStatsJob.perform_now(@user, api_key: @api_key, batch: 1, stats_date: @stats_date) + end + + assert_not @user.personal_stat_snapshots.exists?(date: @stats_date), + "nightly fetch must leave the date to the gap scan, not tombstone or save it" + end + test "inherits from TornApiJob via the FactionApiJob alias" do assert FetchPersonalStatsJob < FactionApiJob assert FetchPersonalStatsJob < TornApiJob diff --git a/test/models/torn_api/rate_limiter_test.rb b/test/models/torn_api/rate_limiter_test.rb index ece3b88..93d3d3f 100644 --- a/test/models/torn_api/rate_limiter_test.rb +++ b/test/models/torn_api/rate_limiter_test.rb @@ -56,6 +56,35 @@ class TornApi::RateLimiterTest < ActiveSupport::TestCase assert_equal 40, TornApi::RateLimiter.remaining("FACTION_KEY_123") end + test "background work stops at 85% of the key budget" do + assert_equal 42, TornApi::RateLimiter::BACKGROUND_REQUESTS_PER_MINUTE + + TornApi::RateLimiter.reserving_headroom_for_live_traffic do + assert_nothing_raised { 42.times { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } } + assert_raises(TornApi::RateLimitError) { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } + end + end + + test "live traffic keeps the headroom a saturating background job leaves" do + # Background work (a backfill) fills up to its reserved threshold and yields. + TornApi::RateLimiter.reserving_headroom_for_live_traffic do + 42.times { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } + assert_raises(TornApi::RateLimitError) { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } + end + + # An interactive web request may still use the reserved slice of the budget. + assert_nothing_raised { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } + end + + test "background mode does not leak across the thread after the block" do + TornApi::RateLimiter.reserving_headroom_for_live_traffic { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } + + # Back in interactive mode, the full per-key budget is available again. + assert_nothing_raised do + 49.times { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } + end + end + # The per-key budget alone lets 30 keys sum to ~1500/min from one server IP. # Torn documents no aggregate limit, but error code 8 ("IP blocked for # abuse") exists — so the aggregate ceiling must be an explicit budget, not From 3f11614a93a09b607ec3b79955c557f331471605 Mon Sep 17 00:00:00 2001 From: Bram Date: Wed, 29 Jul 2026 20:50:55 +0200 Subject: [PATCH 265/266] chore: cleanup code for presenting --- .gitignore | 4 + app/controllers/admin/api_logs_controller.rb | 2 +- app/jobs/backfill_single_stat_job.rb | 20 +- app/jobs/torn_api_job.rb | 23 +- app/models/personal_stat_snapshot.rb | 6 +- app/models/torn_api.rb | 2 +- app/models/torn_api/rate_limiter.rb | 30 +- app/models/user.rb | 4 +- config/routes.rb | 2 - db/development.sqlite3 | 0 docs/BATTLE_STAT_PREDICTOR.md | 622 ------------------ test/integration/torn_api_budget_flow_test.rb | 5 +- test/jobs/backfill_single_stat_job_test.rb | 27 +- test/jobs/torn_api_concurrency_test.rb | 7 +- test/models/torn_api/rate_limiter_test.rb | 30 +- test/services/compliance_summary_test.rb | 61 +- 16 files changed, 115 insertions(+), 730 deletions(-) delete mode 100644 db/development.sqlite3 delete mode 100644 docs/BATTLE_STAT_PREDICTOR.md diff --git a/.gitignore b/.gitignore index 18b5f3c..3072ec6 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,10 @@ !/tmp/pids/ !/tmp/pids/.keep +# Ignore SQLite databases. +/db/*.sqlite3 +/db/*.sqlite3-* + # Ignore storage (uploaded files in development and any SQLite databases). /storage/* !/storage/.keep diff --git a/app/controllers/admin/api_logs_controller.rb b/app/controllers/admin/api_logs_controller.rb index 35e6dc7..a6cd781 100644 --- a/app/controllers/admin/api_logs_controller.rb +++ b/app/controllers/admin/api_logs_controller.rb @@ -4,7 +4,7 @@ class ApiLogsController < ApplicationController def index admin_api_key = AdminCredentials.api_key - admin_user = User.find_by(torn_id: 2728237) + admin_user = User.find_by(torn_id: User::ADMIN_TORN_ID) base_scope = ApiCall .where(api_key: admin_api_key) diff --git a/app/jobs/backfill_single_stat_job.rb b/app/jobs/backfill_single_stat_job.rb index cb00da6..d4d1fdb 100644 --- a/app/jobs/backfill_single_stat_job.rb +++ b/app/jobs/backfill_single_stat_job.rb @@ -2,16 +2,9 @@ class BackfillSingleStatJob < FactionApiJob queue_with_priority 100 limits_concurrency to: 1, key: ->(user_id, date_str, faction_id:, api_key: nil, **) { api_key }, group: CONCURRENCY_GROUP - # Backfill is the lowest-priority work on a contended key. A faction signup - # fans thousands of these onto one key at once (Jan 1 → yesterday per member), - # right when the leader is actively browsing their new dashboard — so the key - # budget is regularly spent and these trip the client-side rate limiter. That - # is expected back-pressure, not a failure: background work must yield to live - # traffic. So retry patiently and with jitter (a re-enqueue releases the worker - # and the per-key concurrency lock means retries never pile up, so attempts are - # cheap) — this lets a signup backfill drain itself once traffic quiets instead - # of dribbling in at the gap scan's 30/user/night cap. If even that patience is - # exhausted, drop quietly with a log line; the nightly gap scan is the backstop. + # A faction signup fans thousands of these onto one key at once, so rate-limit + # rejections here are expected back-pressure, not failures: retry patiently, + # and if retries run out, drop quietly — the nightly gap scan is the backstop. retry_on TornApi::RateLimitError, wait: 2.minutes, attempts: 15, jitter: 0.5 do |job, error| user_id, date_str = job.arguments Rails.logger.warn("BackfillSingleStatJob: gave up on user #{user_id} #{date_str} after rate-limit retries — nightly gap scan will retry (#{error.message})") @@ -30,8 +23,7 @@ def perform(user_id, date_str, faction_id:, batch: 1, api_key: nil) BackfillSingleStatJob.perform_later(user_id, date_str, faction_id: faction_id, batch: 2, api_key: api_key) if batch == 1 rescue TornApi::NoDataError - # Torn has nothing for this player/date — tombstone the date so the - # nightly gap scan stops re-fetching it forever. + # Tombstone the date so the nightly gap scan stops re-fetching it. tombstone = user.personal_stat_snapshots.find_or_initialize_by(date: date) tombstone.update!(torn_data_missing: true) Rails.logger.info("BackfillSingleStatJob: tombstoned #{user.name} #{date} — no data at Torn") @@ -49,9 +41,7 @@ def fetch_stats(user, date, api_key, batch) stat_batch: stat_batch ).fetch rescue TornApi::NotFoundError, TornApi::InvalidKeyError => e - # Unrecoverable for this user/key — skip the date. Rate limits and - # transient errors propagate so retry_on reschedules instead of the - # gap silently re-enqueueing every night. + # Unrecoverable for this user/key — skip the date; retryable errors propagate to retry_on. Rails.logger.error("API error fetching stats: #{e.message}") nil end diff --git a/app/jobs/torn_api_job.rb b/app/jobs/torn_api_job.rb index 2c9ed65..5d1fa1a 100644 --- a/app/jobs/torn_api_job.rb +++ b/app/jobs/torn_api_job.rb @@ -1,35 +1,20 @@ -# Base class for every job that calls the Torn API. API discipline lives here -# exactly once: -# -# 1. Serialize — subclasses declare `limits_concurrency` in CONCURRENCY_GROUP -# keyed by the api key, so each key has ONE job in flight cluster-wide. -# On a contended key, solid_queue releases blocked jobs by priority: -# 0 interactive · 10 recurring polls · 50 nightly · 100 backfill. -# 2. Pace — the 1s sleep keeps a serialized stream at ~38 calls/min, safely -# under the 50/min per-key budget TornApi::RateLimiter enforces. -# 3. Retry — rate limits heal in a minute, Torn hiccups heal in a few; -# both re-schedule instead of failing (and feeding tomorrow's backfill). +# Base class for every job that calls the Torn API: one job in flight per api +# key cluster-wide, paced under the budget TornApi::RateLimiter enforces. +# Queue priorities: 0 interactive · 10 recurring polls · 50 nightly · 100 backfill. class TornApiJob < ApplicationJob queue_as :torn_api queue_with_priority 0 CONCURRENCY_GROUP = "TornApiCalls" - # Concurrency key for jobs that receive a faction_id instead of an api key. FACTION_KEY_LOOKUP = ->(faction_id, *, **) { Faction.find_by(id: faction_id)&.torn_api_key&.key } - # 1.0s paced a stream at ~46 calls/min — close enough to the 50/min budget - # that batch chains and activity polls on the same key tripped it nightly. - # 1.5s paces at ~31/min, leaving genuine headroom. + # 1.0s paced ~46 calls/min and tripped the 50/min budget nightly; 1.5s leaves headroom. RATE_LIMIT_SLEEP = 1.5 retry_on TornApi::RateLimitError, wait: 2.minutes, attempts: 5 retry_on TornApi::TransientError, wait: 15.minutes, attempts: 3 - # Every Torn call made from a job runs in "background" mode: the rate limiter - # reserves the top slice of each key's budget for the owner's live web traffic - # (which isn't paced or serialized), so a saturating backfill can't starve an - # interactive request on the same key. around_perform do |_job, block| TornApi::RateLimiter.reserving_headroom_for_live_traffic { block.call } sleep(RATE_LIMIT_SLEEP) diff --git a/app/models/personal_stat_snapshot.rb b/app/models/personal_stat_snapshot.rb index 253e70f..00e39b0 100644 --- a/app/models/personal_stat_snapshot.rb +++ b/app/models/personal_stat_snapshot.rb @@ -3,10 +3,8 @@ class PersonalStatSnapshot < ApplicationRecord validates :date, presence: true, uniqueness: { scope: :user_id } - # Rows missing any tracked stat: written before a column existed, or a - # batch-2 fetch that never completed. The nightly gap scan re-fetches these - # the same as fully missing dates. Tombstoned rows (Torn has no data for - # that player/date) are excluded — re-fetching them can never succeed. + # Rows missing any tracked stat — the nightly gap scan re-fetches them. + # Tombstoned rows are excluded: re-fetching those can never succeed. scope :partial, -> { TRACKED_STATS.values.map { |column| where(column => nil) }.reduce(:or) .where(torn_data_missing: false) diff --git a/app/models/torn_api.rb b/app/models/torn_api.rb index 4e01dc1..036ba2c 100644 --- a/app/models/torn_api.rb +++ b/app/models/torn_api.rb @@ -314,7 +314,7 @@ def resolve_api_key_owner found = ::User.find_by_api_key(api_key) return found if found - ::User.find_by(torn_id: 2728237) if api_key == AdminCredentials.api_key + ::User.find_by(torn_id: ::User::ADMIN_TORN_ID) if api_key == AdminCredentials.api_key end end end diff --git a/app/models/torn_api/rate_limiter.rb b/app/models/torn_api/rate_limiter.rb index 62dfd0b..afef2d1 100644 --- a/app/models/torn_api/rate_limiter.rb +++ b/app/models/torn_api/rate_limiter.rb @@ -1,37 +1,18 @@ module TornApi - # Client-side budget for Torn API calls, enforced in TornApi::Base#get - # before any HTTP happens — a rejected call must not cost a real request, - # since Torn counts rejected requests toward its own limit. - # - # Torn's hard limit is 100 requests/minute per player across all their - # keys. We take half per key, leaving headroom for the key owner's own - # usage (YATA, Torn PDA, browsing) that we can't see. The global budget - # caps what this server's IP sends in total — Torn documents no aggregate - # limit, but error code 8 ("IP blocked for abuse") exists. - # - # Counters live in Rails.cache (solid_cache in production) so web requests - # and every job worker process draw from the same budget. Fixed 60-second - # windows: worst case in any rolling minute is 2x the budget, which is why - # the per-key budget must stay at half the hard limit. + # Client-side request budget, enforced before any HTTP happens. Torn allows + # 100 requests/minute per player across all their keys; we budget half per + # key plus a global cap per server IP, shared across processes via Rails.cache. class RateLimiter TORN_HARD_LIMIT = 100 REQUESTS_PER_MINUTE = TORN_HARD_LIMIT / 2 GLOBAL_REQUESTS_PER_MINUTE = 300 WINDOW_SECONDS = 60 - # Background jobs (nightly fetches, backfills, activity polls) yield the top - # slice of each key's budget to the key owner's live web traffic: they stop - # at 85% of the per-key budget so the remaining ~15% stays available for - # interactive requests, which aren't paced or serialized and would otherwise - # be starved whenever a signup backfill is saturating that key. Web requests - # run outside background mode and may use the full per-key budget. + # Background jobs leave the top 15% of each key's budget for live web traffic. BACKGROUND_RESERVE = 0.15 BACKGROUND_REQUESTS_PER_MINUTE = (REQUESTS_PER_MINUTE * (1 - BACKGROUND_RESERVE)).floor class << self - # Runs the block in background mode — acquire! applies the reduced budget - # that reserves headroom for live traffic. Reentrant and thread-scoped so - # it's safe across the shared job worker pool. def reserving_headroom_for_live_traffic prior = Thread.current[:torn_api_reserve_headroom] Thread.current[:torn_api_reserve_headroom] = true @@ -66,8 +47,7 @@ def reserving_headroom? Thread.current[:torn_api_reserve_headroom] == true end - # Null cache stores (test default) return nil from increment; treat as - # unlimited rather than blocking every call. + # Null cache stores (test default) return nil; treat as unlimited. def increment(cache_key) Rails.cache.increment(cache_key, 1, expires_in: WINDOW_SECONDS * 2) || 1 end diff --git a/app/models/user.rb b/app/models/user.rb index b3b7d74..d00de7f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -68,8 +68,10 @@ def deduct_subscription!(weeks) subscription.update!(expires_at: subscription.expires_at - weeks.weeks) end + ADMIN_TORN_ID = 2728237 + def admin? - torn_id == 2728237 + torn_id == ADMIN_TORN_ID end def hof_access? diff --git a/config/routes.rb b/config/routes.rb index 9e75343..914f8b9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -73,8 +73,6 @@ end end - resources :welcome, only: [ :index ] - get "hall-of-famers", to: "hall_of_famers#index", as: :hall_of_famers get "user/api-calls", to: "user_api_calls#index", as: :user_api_calls diff --git a/db/development.sqlite3 b/db/development.sqlite3 deleted file mode 100644 index e69de29..0000000 diff --git a/docs/BATTLE_STAT_PREDICTOR.md b/docs/BATTLE_STAT_PREDICTOR.md deleted file mode 100644 index 16d1b2c..0000000 --- a/docs/BATTLE_STAT_PREDICTOR.md +++ /dev/null @@ -1,622 +0,0 @@ -# Recon -- Battle Stat Predictor - -Predict any Torn player's total battle stats using ML trained on spy reports + public personalstats. Exposed as a public API endpoint. - -All Recon code lives under the `Recon::` namespace, fully separated from the rest of TornManager. - ---- - -## Namespace: `Recon::` - -``` -app/ - models/ - recon/ - training_sample.rb # Recon::TrainingSample - model.rb # Recon::Model - prediction.rb # Recon::Prediction (cached predictions) - predictor.rb # Recon::Predictor (prediction service) - trainer.rb # Recon::Trainer (model training logic) - feature_extractor.rb # Recon::FeatureExtractor (API -> features hash) - outlier_detector.rb # Recon::OutlierDetector - recon/ - torn_api/ - personal_stats.rb # Recon::TornApi::PersonalStats (cat=popular or stat+timestamp) - profile.rb # Recon::TornApi::Profile (age, level, property, last_action) - jobs/ - recon/ - collect_training_sample_job.rb - backfill_training_samples_job.rb - controllers/ - api/ - recon_controller.rb -``` - -Database tables are prefixed with `recon_`. Currently only `recon_training_samples` exists. Other tables (`recon_models`, `recon_predictions`, `recon_ff_observations`) are documented below for future use. - ---- - -## Core Concept - -1. **Spy reports** give us exact stats (labels) -- used for training only, never exposed -2. **Public personalstats + profile** give us player activity data (features) -3. Train a random forest on (features -> total stats), then predict for anyone -4. **FF observations** from faction attacks act as a floor and validation layer - ---- - -## Features (ML Inputs) - -Based on TDup's confirmed BSP feature list and our own analysis. TDup uses 12 features -- we start with a similar lean set and let feature importance guide expansion. - -### Core Features (proven by BSP) - -These are TDup's confirmed working features, adapted to our API field names: - -| Feature | API field | Notes | -|---------|-----------|-------| -| Xanax taken | `xantaken` | #1 predictor. Each xanax = 250E for gym. | -| Energy drinks used | `energydrinkused` | Each can = 5-30E. | -| Energy refills | `refills` | Each refill = full energy bar. | -| Stat enhancers used | `statenhancersused` | +1% to a single stat per use. Scales with base stats. | -| Boosters used | `boostersused` | FHC = 100-150E on 6h cooldown. Key endgame energy source. | -| LSD taken | `lsdtaken` | Each LSD = 50E. Minor energy source but TDup includes it. | -| Revives | `revives` | Active player signal. TDup confirmed useful. | -| Attacks won | `attackswon` | Combat activity. | -| Awards | `awards` | General progression. | -| Donator days | `daysbeendonator` | 150E/5h vs 100E/5h natural regen. | -| Activity time | `useractivity` | Raw seconds of playtime. | -| Real age | derived | `age - days_since_last_action`. Inactive accounts treated as younger. (TDup: "AgeNonInactive") | - -### Additional Features (our additions, validate via feature importance) - -| Feature | API field | Why | -|---------|-----------|-----| -| Level | `level` (profile) | General progression. | -| Property happy cap | `property` (profile) | PI = 4,225 happy vs Castle ~2,700. Impacts gym gains. | -| Networth | `networth` | Wealth enables xanax/SE purchasing. | -| Ecstasy taken | `exttaken` | Doubles happiness for happy jumps. | -| Vicodin taken | `victaken` | Happy jump strategy signal. | -| Rehabs | `rehabs` | Restores happiness for training. | -| Highest beaten | `highestbeaten` | Direct combat capability signal. | -| Times hospitalized | `hospital` | Combat exposure. | -| Job points used | `jobpointsused` | Company job points convert to energy. | -| Trains received | `trainsreceived` | Direct stat trains from company. | - -After the first training cycle, prune any feature with importance < 0.01. - -### Features We Skip - -| Field | Why skip | -|-------|----------| -| Mails, personals, classified ads | No stat relevance | -| Travel destinations | No stat correlation | -| Crime skills | Crime progression != stat progression | -| Bazaar/trading stats | Economic activity, not stat-related | -| Bounty stats | No stat relevance | -| Weapon hit types | Fighting style, not stat level | - ---- - -## Outlier Detection & Flagging - -### The Problem - -Real data shows extreme stat distributions: -- **Trole**: 18.6b total, but only 297m DEX (1.6% of total) -- **Wasbeer**: 2.5b total, but only 1,124 DEX (0.00004%) -- **CockyNudist** (TDup's example): Takes xanax but never trains. BSP predicts 3b, actual is 40 TBS. - -Two categories of outliers: -1. **Unbalanced builds** -- high total but lopsided distribution. ML predicts total correctly, but BSS will be lower than expected. -2. **Non-trainers** -- high activity stats (xanax, etc.) but spend energy on attacks/crimes, not gym. ML overpredicts. These are rare edge cases. - -### `Recon::OutlierDetector` - -Uses FF observations as validation: - -```ruby -class Recon::OutlierDetector - def detect(player_id) - prediction = Recon::Prediction.find_by(player_id:) - ff_bss = latest_ff_derived_bss(player_id) - - return :unknown unless prediction && ff_bss - - expected_bss = 2 * Math.sqrt(prediction.predicted_total) - ratio = ff_bss / expected_bss - - if ratio < 0.7 - :unbalanced - else - :balanced - end - end -end -``` - -### API Response Flags - -```json -{ - "player_id": 1485341, - "predicted_total_stats": 18600000000, - "display": "18.6b", - "confidence": 0.7, - "flags": ["unbalanced_build"], - "updated_at": "2026-03-27T12:00:00Z" -} -``` - -Possible flags: -- `"unbalanced_build"` -- FF-derived BSS much lower than expected for predicted total -- `"low_confidence"` -- model confidence below threshold -- `"ff_floor_applied"` -- FF data proved player is stronger than ML predicted - ---- - -## Architecture - -### Phase 1: Training Data Collection - -**Goal**: Build a labeled dataset pairing spy reports with public personalstats. Target: 1000+ samples/month. - -#### Pipeline - -``` -Spy report imported (TornStats, manual, or faction member) - | - v -Recon::CollectTrainingSampleJob: - 1. Fetch player's personalstats (cat=popular) from Torn API - 2. Fetch player's profile from Torn API - 3. Extract features via Recon::FeatureExtractor - 4. Store (features, labels) pair in recon_training_samples -``` - -#### `Recon::FeatureExtractor` - -```ruby -class Recon::FeatureExtractor - PERSONALSTAT_KEYS = %w[ - xantaken energydrinkused refills statenhancersused boostersused - lsdtaken revives attackswon awards daysbeendonator useractivity - networth exttaken victaken rehabs highestbeaten hospital - jobpointsused trainsreceived - ].freeze - - PROPERTY_HAPPY = { - "Private Island" => 4225, "Palace" => 3000, "Castle" => 2700, - "Mansion" => 1500, "Villa" => 1000, "Penthouse" => 500 - }.freeze - - def extract(personalstats:, profile:) - features = {} - - PERSONALSTAT_KEYS.each do |key| - features[key] = personalstats[key] || 0 - end - - features["level"] = profile["level"] || 0 - features["property_happy"] = PROPERTY_HAPPY.find { |k, _| profile["property"]&.include?(k) }&.last || 200 - - # Derived: real age = account age minus inactivity (hat tip: TDup/BSP "AgeNonInactive") - age = profile["age"] || 0 - last_action_ts = profile.dig("last_action", "timestamp") - days_inactive = last_action_ts ? ((Time.now.to_i - last_action_ts) / 86_400.0).round : 0 - features["real_age"] = [age - days_inactive, 0].max - - features - end -end -``` - -#### Database Table - -One table holds everything -- spy data (labels) and personalstats (features) together. Completely separate from the faction `spy_reports` table. No `faction_id`, no trace of who contributed. - -```ruby -create_table :recon_training_samples do |t| - t.integer :player_id, null: false - - # Labels (from spy reports) - t.bigint :strength, null: false - t.bigint :defense, null: false - t.bigint :speed, null: false - t.bigint :dexterity, null: false - # total_stats is calculated: strength + defense + speed + dexterity - - # Features: Direct energy sources - t.integer :xantaken - t.integer :energydrinkused - t.integer :refills - t.integer :daysbeendonator - - # Features: Training multipliers & optimization - t.integer :statenhancersused - t.integer :boostersused - t.integer :lsdtaken - t.integer :revives - t.integer :exttaken - t.integer :victaken - t.integer :rehabs - t.integer :highestbeaten - t.integer :hospital - t.integer :jobpointsused - t.integer :trainsreceived - - # Features: Progression & activity proxies - t.integer :attackswon - t.integer :awards - t.integer :useractivity - t.bigint :networth - t.integer :level - t.integer :property_happy - t.integer :real_age - - # Metadata - t.datetime :spied_at, null: false - t.timestamps -end -add_index :recon_training_samples, :player_id -add_index :recon_training_samples, :spied_at -``` - -Feature columns allow nulls -- historical backfills may have partial data, and some stats may not exist at a given timestamp. - -#### Data Flow - -``` -Spy data (voluntarily contributed) - + personalstats fetched at spied_at timestamp - + profile data - | - v -Recon::CollectTrainingSampleJob: - - Combine labels + features into one row in recon_training_samples -``` - -#### How Spy Data Gets Into Recon - -For now: manually import from your own faction's spy data or directly from TornStats. The job fetches matching personalstats and stores everything in one row. - -Future: factions can opt-in to auto-sync new spy reports into Recon training data. - -#### Historical Backfill - -The Torn API supports fetching personalstats at any point in time via the `timestamp` parameter. This means we can pair **old spy reports** with historical personalstats to create valid training samples immediately. - -``` -For each historical spy (spied_at = e.g. 3 months ago): - 1. Fetch personalstats at spied_at timestamp (2 API calls, 10 stats each) - 2. Fetch profile (age/level are less time-sensitive, current values acceptable) - 3. Store spy labels + features as one row in recon_training_samples -``` - -**Constraints:** -- `timestamp` only works with the `stat` parameter (not `cat=popular`), max 10 stats per request -- 2 API calls per player: batch 1 (10 stats) + batch 2 (remaining stats) -- Rate limited: ~25 players/min = ~1500 players/hour -- 3,000 old spies ≈ 2 hours of backfill - -**Worst case:** If historical data turns out to be noisy, truncate `recon_training_samples`, re-import with fresh spies only, retrain. Infrastructure stays the same. - -### Phase 2: ML Model - -**Goal**: Train a random forest that predicts total stats from personalstats features. - -#### Why Random Forest - -Gym training in Torn is deeply non-linear: -- Gains formula: `gain ∝ ln(happy) * current_stat_total` -- Growth decays: 212%/month at 50m → 3.4%/month at 10b → 2.2%/month at 100b -- SE gives +1% per use to a single stat -- value scales with base stats -- Specialist gyms (8.0 multiplier) require stat ratios, creating single-stat builds -- Below ~400k stats: happiness > energy. Above: energy > happiness -- Modifiers stack multiplicatively (faction, education, company, books, property) - -Random forest discovers these thresholds and interactions automatically. Confirmed as the winning model type by TDup/BSP via AutoML (ML.NET tried many models, random forest won). - -```ruby -require "rumale" - -model = Rumale::Ensemble::RandomForestRegressor.new( - n_estimators: 200, - max_depth: 15, - min_samples_leaf: 5, - random_seed: 42 -) -model.fit(x_train, y_train) # raw feature values, raw total_stats -``` - -#### SE Shortcut - -BSP uses a shortcut: if `statenhancersused > 250`, skip ML and calculate from SE alone. At high SE counts, SE dominates all other stat sources. Consider implementing: - -```ruby -# If SE > 250, rough estimate: each SE ≈ 1% of total, so total ≈ SE * avg_stat_at_time_of_use -# This is a fallback heuristic, not a primary prediction method -``` - -Evaluate whether our random forest handles high-SE players well enough to skip this. - -#### Feature Importance - -After training, extract importance to guide feature selection: - -```ruby -importance = model.feature_importances -# Prune features with importance < 0.01 in next training cycle -``` - -#### Training: `Recon::Predictor` - -For now, trains a fresh model on every prediction request (~1-2 seconds with 3000 samples). No model storage needed. - -```ruby -class Recon::Predictor - FEATURE_COLUMNS = %w[ - xantaken energydrinkused refills daysbeendonator - statenhancersused boostersused lsdtaken revives exttaken victaken - rehabs highestbeaten hospital jobpointsused trainsreceived - attackswon awards useractivity networth level property_happy real_age - ].freeze - - def predict(features) - samples = Recon::TrainingSample.all.to_a - return nil if samples.size < 100 - - x_train = Numo::DFloat.cast(samples.map { |s| FEATURE_COLUMNS.map { |f| s.send(f) || 0 } }) - y_train = Numo::DFloat.cast(samples.map { |s| s.strength + s.defense + s.speed + s.dexterity }) - - model = Rumale::Ensemble::RandomForestRegressor.new( - n_estimators: 200, max_depth: 15, min_samples_leaf: 5, random_seed: 42 - ) - model.fit(x_train, y_train) - - x_new = Numo::DFloat.cast([FEATURE_COLUMNS.map { |f| features[f] || 0 }]) - model.predict(x_new).first.round - end -end -``` - -#### Future: Model Storage & Prediction Caching (TODO) - -When training per request becomes too slow, add: -- `recon_models` table -- persist serialized models, track metrics (R², MAE, MAPE), activate best model -- `recon_predictions` table -- cache results per player_id (5 day TTL) -- Weekly scheduled retraining job instead of train-per-request - -### Phase 3: Public Prediction API - -**Goal**: `GET /api/recon/:player_id` returns a stat prediction. - -#### Single Lookup - -``` -GET /api/recon/:player_id - -{ - "player_id": 249471, - "predicted_total_stats": 14500000000, - "display": "14.5b", - "confidence": 0.72, - "flags": [], - "updated_at": "2026-03-27T12:00:00Z" -} -``` - -#### Bulk Lookup - -``` -GET /api/recon?player_ids=249471,1485341,3570129 - -{ - "predictions": { - "249471": { "predicted_total_stats": 14500000000, "display": "14.5b", "confidence": 0.72, "flags": [] }, - "1485341": { "predicted_total_stats": 18600000000, "display": "18.6b", "confidence": 0.68, "flags": ["unbalanced_build"] }, - "3570129": { "predicted_total_stats": 170390, "display": "170k", "confidence": 0.55, "flags": [] } - } -} -``` - -#### Prediction Flow - -``` -GET /api/recon/:player_id - | - v -[1] Cached prediction < 5 days old? --> return it - | - no - v -[2] Fetch personalstats (cat=popular) + profile (cached 24h per player) - | - v -[3] Recon::FeatureExtractor --> features hash - | - v -[4] Recon::Predictor: feed features into active random forest model - | - v -[5] FF floor check: is there an FF observation (< 15 days old) - where FF-derived total > ML predicted total? - | - yes --> use FF-derived total instead, add flag "ff_floor_applied" - | - no - v -[6] Recon::OutlierDetector: check for unbalanced build flag - | - v -[7] Cache in recon_predictions (5 day TTL), return response -``` - -**Key design decisions:** -- All predictions come from ML. Spy data is training-only, never exposed. -- FF acts as a **floor** -- if FF proves the player is stronger than ML thinks, use the higher value. (Learned from BSP architecture.) -- Predictions cached 5 days (matching BSP). Personalstats/profile cached 24h. -- 2 Torn API calls per uncached prediction (personalstats + profile). - -#### Stat Formatting - -```ruby -module Recon - module StatsFormatter - def self.format(total) - if total >= 1_000_000_000 - "#{(total / 1_000_000_000.0).round(1)}b" - elsif total >= 1_000_000 - "#{(total / 1_000_000.0).round(0)}m" - elsif total >= 1_000 - "#{(total / 1_000.0).round(0)}k" - else - total.to_s - end - end - end -end -``` - -#### Auth & Rate Limiting - -- Authenticated via existing user session or API token -- Rate limited: 50 Torn API calls/min max (staying under Torn's 100/min hard limit) -- Bulk endpoint capped at 25 player IDs per request (each needs 2 API calls if uncached) - -### Phase 4: FF Validation & Floor - -**Goal**: Use faction attack data to validate predictions and act as a minimum bound. - -#### Pipeline - -``` -Recon::ValidateFromAttacksJob (periodic) - | - v -Fetch /faction/attacks (outgoing, FF < 3.0 only) - | - v -For each attack where attacker stats are known (spy report): - 1. attacker_bss = sqrt(STR) + sqrt(SPD) + sqrt(DEF) + sqrt(DEX) - 2. defender_bss = (FF - 1) * (3/8) * attacker_bss - 3. Convert to estimated total: defender_total_estimate = (defender_bss / 2)^2 - (balanced assumption -- this is a minimum bound) - 4. Store in recon_ff_observations - 5. If estimate > existing ML prediction for this defender: - update prediction with FF floor, add "ff_floor_applied" flag -``` - -**Why FF < 3.0 only**: At FF 3.0 the value is capped -- you only know "at least this strong." Below 3.0 gives exact BSS. (Confirmed by BSP: "Attacks received or given, that yielded less than 3 FF, will be used to compute your enemy bscore.") - -#### Database Table - -```ruby -create_table :recon_ff_observations do |t| - t.integer :attacker_id, null: false - t.integer :defender_id, null: false - t.float :ff_score, null: false - t.float :attacker_bss, null: false - t.float :defender_bss, null: false - t.bigint :estimated_total # Balanced assumption minimum - t.datetime :attacked_at, null: false - t.timestamps -end -add_index :recon_ff_observations, :defender_id -add_index :recon_ff_observations, :attacked_at -``` - -### Phase 5: Advanced Features - -- **Confidence intervals**: Return ranges ("8b-12b") instead of point estimates -- **Growth tracking**: Store prediction history per player, show trends -- **War scouting reports**: Auto-generate pre-war intel for enemy factions -- **Chain target finder**: Find players where predicted stats yield ~FF 3.0 -- **Model explainability**: Show top contributing features per prediction -- **Per-stat models**: Predict STR/DEF/SPD/DEX individually (needs lots of data) - ---- - -## Competitive Analysis: BSP (TDup) vs Recon - -| Aspect | BSP (TDup) | Recon (ours) | -|--------|-----------|--------------| -| Model type | Random forest (via ML.NET AutoML) | Random forest (via Rumale, pure Ruby) | -| Training labels | FF-reversed BSS (57k) + spies | Spy reports only (1000+/month, exact total stats) | -| Predicts | BSS → then converts to total stats | Total stats directly (no conversion error) | -| Features | 12 (lean, proven) | 12 core + ~10 additional (validate via importance) | -| SE handling | >250 SE: skip ML, use math formula | Let RF handle it, evaluate if shortcut needed | -| Weak players | Separate "WeakModel" (<4k bscore) | Single model, RF handles range naturally | -| FF integration | FF > ML when FF is recent + higher | FF as floor only (same pattern) | -| Caching | 5 days ML, 15 days FF | 5 days predictions, 24h personalstats | -| HoF handling | Manual insert from public HoF thread | Not yet planned | -| Architecture | C#/.NET backend + userscript | Rails API endpoint, any client can consume | - -**Our advantages:** -- Labels from spy reports (exact total stats) vs FF-derived BSS (estimated, conversion error) -- Predict total stats directly, no BSS→total conversion step -- Single model approach (simpler, RF handles range variation) - -**Their advantages:** -- 57k training data points vs our ~1k/month starting -- Battle-tested over 2+ years -- Crowdsourced FF data from opted-in users - ---- - -## API Calls Per Prediction - -Two Torn API calls per player (both use a public key): - -1. `GET /user/:id/personalstats?cat=popular` -- all 150+ stats -2. `GET /user/:id/profile` -- age, level, property, last_action - -Use `cat=popular` (not `cat=all`) to reduce Torn server load. Both responses cached 24h. - -### API Key Requirements - -| Key | Endpoint | Purpose | -|-----|----------|---------| -| Public API key | `GET /user/:id/personalstats?cat=popular` | Fetch features | -| Public API key | `GET /user/:id/profile` | Fetch age, level, property | -| Faction API key | `GET /faction/attacks?filters=outgoing` | FF observations | - -### Rate Limits - -- Torn enforces 100 API calls/min per key -- We cap at 50 calls/min (same strategy as BSP) to leave headroom -- 2 calls per uncached prediction = max 25 new predictions/min -- Caching (5 day predictions, 24h personalstats) mitigates most repeated lookups - -## External Dependencies - -| Dependency | Purpose | -|------------|---------| -| `rumale` gem | Random forest implementation (pure Ruby) | -| `numo-narray` gem | Numeric arrays for rumale | -| TornStats API | Spy report imports (existing integration) | -| Torn API v2 | personalstats, profile, faction attacks | - -## Implementation Priority - -1. **Migration**: `recon_training_samples` ✅ -2. **`Recon::TornApi::PersonalStats`** + **`Recon::TornApi::Profile`** -- fetch features for any player -3. **`Recon::FeatureExtractor`** -- turn API responses into feature hashes -4. **`Recon::TrainingSample`** model + admin import -- get spy data in -5. **`Recon::CollectTrainingSampleJob`** + **`Recon::BackfillTrainingSamplesJob`** -- pair spies with personalstats -6. **`Recon::Predictor`** -- train-per-request random forest -7. **`Api::ReconController`** -- public API endpoint (`/api/recon/:player_id`) -8. **Feature pruning** -- analyze importance after first training, drop useless features - -**Later (TODO):** -- `recon_models` + `recon_predictions` tables -- model storage + prediction caching -- `recon_ff_observations` table -- FF validation + floor logic -- `Recon::OutlierDetector` -- flag unbalanced builds - -## Open Questions - -- [ ] Minimum training samples before serving predictions? (100 seems safe for RF) -- [ ] Do we need the SE > 250 shortcut, or does RF handle it well enough? -- [ ] Should we add HoF data (manual or via API) for top players? -- [ ] How to handle old spy training data? (Player spied 6 months ago -- their personalstats also changed, so the pair is still valid as a historical data point) -- [ ] Rate limit on public API -- per user? per faction? global? diff --git a/test/integration/torn_api_budget_flow_test.rb b/test/integration/torn_api_budget_flow_test.rb index 0cc95f5..83189ad 100644 --- a/test/integration/torn_api_budget_flow_test.rb +++ b/test/integration/torn_api_budget_flow_test.rb @@ -1,11 +1,8 @@ require "test_helper" -# End-to-end composition of the three layers: a job whose key is out of budget -# is rejected before any HTTP happens and re-scheduled, while a job on a -# different key sails through the whole real pipeline (budget gate -> HTTP -> -# parse -> snapshot) at the same moment. class TornApiBudgetFlowTest < ActiveJob::TestCase setup do + freeze_time # budget windows are 60s wide; a boundary mid-test resets them Rails.stubs(:cache).returns(ActiveSupport::Cache::MemoryStore.new) @faction = Faction.create!( diff --git a/test/jobs/backfill_single_stat_job_test.rb b/test/jobs/backfill_single_stat_job_test.rb index 695c0a1..1b92573 100644 --- a/test/jobs/backfill_single_stat_job_test.rb +++ b/test/jobs/backfill_single_stat_job_test.rb @@ -1,9 +1,5 @@ require "test_helper" -# BackfillSingleStatJob currently swallows every API error and returns nil, -# which silently leaves the date missing — so the nightly gap scan re-enqueues -# it forever, compounding API load. Retryable errors must propagate to the -# job-level retry; only genuinely unrecoverable ones may skip. class BackfillSingleStatJobTest < ActiveJob::TestCase setup do @faction = Faction.create!( @@ -29,6 +25,26 @@ class BackfillSingleStatJobTest < ActiveJob::TestCase assert @user.personal_stat_snapshots.exists?(date: Date.parse(@date_str)) end + test "batch 2 completes the chain without enqueueing further work" do + TornApi::User::PersonalStats.any_instance.stubs(:fetch).returns(@stats) + + assert_no_enqueued_jobs(only: BackfillSingleStatJob) do + BackfillSingleStatJob.perform_now(@user.id, @date_str, faction_id: @faction.id, batch: 2, api_key: "FACTION_KEY_123") + end + + assert @user.personal_stat_snapshots.exists?(date: Date.parse(@date_str)) + end + + test "a missing api key skips the date without touching the API" do + TornApi::User::PersonalStats.any_instance.expects(:fetch).never + + assert_no_enqueued_jobs(only: BackfillSingleStatJob) do + BackfillSingleStatJob.perform_now(@user.id, @date_str, faction_id: @faction.id) + end + + assert_not @user.personal_stat_snapshots.exists?(date: Date.parse(@date_str)) + end + test "rate limit errors propagate so the job retries instead of losing the date" do TornApi::User::PersonalStats.any_instance.stubs(:fetch) .raises(TornApi::RateLimitError, "Too many requests") @@ -61,8 +77,7 @@ class BackfillSingleStatJobTest < ActiveJob::TestCase Discord::Notifier.expects(:notify).never job = BackfillSingleStatJob.new(@user.id, @date_str, faction_id: @faction.id, api_key: "FACTION_KEY_123") - # Pretend the shared retry budget is already spent so the next failure - # exhausts retry_on and runs the give-up block rather than re-enqueueing. + # Spend the retry budget so the next failure runs the give-up block. job.exception_executions = { "[TornApi::RateLimitError]" => 15 } assert_no_enqueued_jobs(only: BackfillSingleStatJob) do diff --git a/test/jobs/torn_api_concurrency_test.rb b/test/jobs/torn_api_concurrency_test.rb index f70a3d5..e7a5de9 100644 --- a/test/jobs/torn_api_concurrency_test.rb +++ b/test/jobs/torn_api_concurrency_test.rb @@ -1,10 +1,7 @@ require "test_helper" -# All jobs that hit the Torn API with the same api key must share ONE -# solid_queue semaphore, regardless of job class. Today FetchPersonalStatsJob -# serializes per api_key while the FactionApiCalls group serializes per -# faction_id — so several parallel streams can hammer the same key (worst on -# the kaneki key during HOF sync, where users span many faction_ids). +# Every job that hits the Torn API with the same api key must share ONE +# solid_queue semaphore, regardless of job class. class TornApiConcurrencyTest < ActiveJob::TestCase setup do @faction = Faction.create!( diff --git a/test/models/torn_api/rate_limiter_test.rb b/test/models/torn_api/rate_limiter_test.rb index 93d3d3f..b5cffe7 100644 --- a/test/models/torn_api/rate_limiter_test.rb +++ b/test/models/torn_api/rate_limiter_test.rb @@ -1,11 +1,8 @@ require "test_helper" -# Torn enforces 100 requests/minute per player across all their keys, and -# exceeding it (or spamming invalid keys) risks temporary IP bans. We -# self-impose HALF the hard limit per key, leaving headroom for the key -# owner's own usage (YATA, Torn PDA, browsing) that we can't see. class TornApi::RateLimiterTest < ActiveSupport::TestCase setup do + freeze_time # budget windows are 60s wide; a boundary mid-test resets them Rails.stubs(:cache).returns(ActiveSupport::Cache::MemoryStore.new) end @@ -29,9 +26,7 @@ class TornApi::RateLimiterTest < ActiveSupport::TestCase end test "budget resets after the window passes" do - freeze_time do - 50.times { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } - end + 50.times { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } travel 61.seconds do assert_nothing_raised do @@ -66,29 +61,30 @@ class TornApi::RateLimiterTest < ActiveSupport::TestCase end test "live traffic keeps the headroom a saturating background job leaves" do - # Background work (a backfill) fills up to its reserved threshold and yields. TornApi::RateLimiter.reserving_headroom_for_live_traffic do 42.times { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } assert_raises(TornApi::RateLimitError) { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } end - # An interactive web request may still use the reserved slice of the budget. assert_nothing_raised { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } end test "background mode does not leak across the thread after the block" do TornApi::RateLimiter.reserving_headroom_for_live_traffic { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } - # Back in interactive mode, the full per-key budget is available again. assert_nothing_raised do 49.times { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } end end - # The per-key budget alone lets 30 keys sum to ~1500/min from one server IP. - # Torn documents no aggregate limit, but error code 8 ("IP blocked for - # abuse") exists — so the aggregate ceiling must be an explicit budget, not - # an accident of the worker thread count. + test "background mode survives a nested block ending" do + TornApi::RateLimiter.reserving_headroom_for_live_traffic do + TornApi::RateLimiter.reserving_headroom_for_live_traffic { } + + 42.times { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } + assert_raises(TornApi::RateLimitError) { TornApi::RateLimiter.acquire!("FACTION_KEY_123") } + end + end test "global budget is 300 requests per minute across all keys" do assert_equal 300, TornApi::RateLimiter::GLOBAL_REQUESTS_PER_MINUTE @@ -105,10 +101,8 @@ class TornApi::RateLimiterTest < ActiveSupport::TestCase end test "global budget resets after the window passes" do - freeze_time do - 6.times do |i| - 50.times { TornApi::RateLimiter.acquire!("KEY_#{i}") } - end + 6.times do |i| + 50.times { TornApi::RateLimiter.acquire!("KEY_#{i}") } end travel 61.seconds do diff --git a/test/services/compliance_summary_test.rb b/test/services/compliance_summary_test.rb index 87dae2e..5d61321 100644 --- a/test/services/compliance_summary_test.rb +++ b/test/services/compliance_summary_test.rb @@ -101,17 +101,64 @@ class ComplianceSummaryTest < ActiveSupport::TestCase end end - test "worst_performers returns lowest compliance scores first" do + test "worst_performers puts the lowest compliance score first" do with_memory_cache do - other_user = users(:kaneki) - other_user.update!(faction: @faction, fallen: false) - create_snapshots(other_user, @start_date - 1.day, @end_date, xanax_base: 100) + slacker = users(:kaneki) + slacker.update!(faction: @faction, fallen: false) + create_snapshots(slacker, @start_date - 1.day, @end_date, xanax_step: 0) summary = ComplianceSummary.new(@faction, start_date: @start_date, end_date: @end_date) worst = summary.worst_performers(1) assert_equal 1, worst.size - assert worst.first[:compliance_score] <= summary.member_rows.map { |r| r[:compliance_score] }.max + assert_equal slacker.torn_id, worst.first[:torn_id], + "the member with zero xanax gained must rank worst" + end + end + + test "loads snapshots for the whole roster in a single query" do + with_memory_cache do + other_user = users(:kaneki) + other_user.update!(faction: @faction, fallen: false) + create_snapshots(other_user, @start_date - 1.day, @end_date) + + snapshot_queries = 0 + counter = ->(_name, _start, _finish, _id, payload) do + sql = payload[:sql].to_s + snapshot_queries += 1 if sql.start_with?("SELECT") && sql.include?("personal_stat_snapshots") + end + + ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do + summary = ComplianceSummary.new(@faction, start_date: @start_date, end_date: @end_date) + assert_equal 2, summary.member_rows.size + end + + assert_equal 1, snapshot_queries + end + end + + test "ssl users pass xanax compliance regardless of the target" do + with_memory_cache do + @user.update!(ssl_user: true) + @faction.update!(xanax_target: 100.0) + + summary = ComplianceSummary.new(@faction, start_date: @start_date, end_date: @end_date) + row = summary.member_rows.first + + assert row[:ssl_user] + assert_equal :green, row[:xanax_compliance] + end + end + + test "a member with a single snapshot is skipped rather than scored on zero days" do + with_memory_cache do + one_day_user = users(:kaneki) + one_day_user.update!(faction: @faction, fallen: false) + create_snapshots(one_day_user, @start_date, @start_date) + + summary = ComplianceSummary.new(@faction, start_date: @start_date, end_date: @end_date) + + assert_not_includes summary.member_rows.map { |r| r[:torn_id] }, one_day_user.torn_id end end @@ -138,12 +185,12 @@ class ComplianceSummaryTest < ActiveSupport::TestCase private - def create_snapshots(user, from_date, to_date, xanax_base: 0) + def create_snapshots(user, from_date, to_date, xanax_base: 0, xanax_step: 3) (from_date..to_date).each_with_index do |date, i| PersonalStatSnapshot.create!( user: user, date: date, - drugs_xanax: xanax_base + (i * 3), + drugs_xanax: xanax_base + (i * xanax_step), other_refills_energy: i * 1, other_refills_nerve: i * 1, missions_contracts_total: i * 2, From a1bed8e58229c8ef902241dea8b8487009d81dd0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:44:16 +0000 Subject: [PATCH 266/266] Bump appsignal from 4.8.6 to 4.9.1 Bumps [appsignal](https://github.com/appsignal/appsignal-ruby) from 4.8.6 to 4.9.1. - [Changelog](https://github.com/appsignal/appsignal-ruby/blob/main/CHANGELOG.md) - [Commits](https://github.com/appsignal/appsignal-ruby/compare/v4.8.6...v4.9.1) --- updated-dependencies: - dependency-name: appsignal dependency-version: 4.9.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 620ce80..50bfff7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -77,7 +77,7 @@ GEM uri (>= 0.13.1) addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) - appsignal (4.8.6) + appsignal (4.9.1) logger rack (>= 2.0.0) ast (2.4.3)